Docs › Mobile apps
Flutter
iOS, Android, macOS and the web: wrap the app, tap a widget, and the note has its file and line.
People tap a widget in your running Flutter app and write what should change, and the note reaches your coding agent (Claude Code, Codex, Cursor and others) over MCP through a Notato server. Each note has a screenshot with the widget outlined and numbered, a crop of it, its text and key, the app's recent errors, and exactly where it is written: the widget you wrote (Text('£${product.price}')), the file, line and column (lib/main.dart:95:11), and your widgets around it (SpudShop › ShopScreen › ProductCard).
It is the same client as the Swift, Android and .NET MAUI SDKs: the toolbar and its ⋯ menu, the three modes, threads and replies, People only and asides, revert, an offline queue, masking, and a runtime API. Variants are web-only.
Flutter 3.38.1 and later, on iOS, Android and macOS. On the web, notes are kept in memory and a package cannot be shared, only uploaded.
Screenshot size. New captures prefer WebP at quality 85 when it is smaller than PNG, after privacy masking and at the same dimensions. PNG remains a fallback, and existing queues and bundles stay readable. Run flutter pub get and rebuild to include the encoder; unsupported platforms keep PNG. See screenshot storage.
Set up#
flutter pub add notato
npx notato # the server, the board and MCP for your agent
Wrap your app in it, in main.dart:
import 'package:notato/notato.dart';
void main() {
runApp(const Notato(project: 'shop', appName: 'Shop', child: MyApp()));
}
And let it know the screens, so notes are filed under the route they were made on (a dialog, or a page pushed without a name, keeps the name of the screen under it):
MaterialApp(navigatorObservers: [Notato.navigatorObserver], ...)
A small dark toolbar appears in the corner. Drag it anywhere; it stays where you leave it, and its chevron folds it into a round button. Tap Annotate, tap a widget, write the note, and press Send. A numbered pin marks it, and turns amber when the agent is on it and green when it is resolved; tap a pin for the note's card. Then ask your agent to "watch Notato and fix what comes in". Everything else is in the toolbar's ⋯ sheet: pins, the notes list, settings, hiding the toolbar or turning Notato off, and the server's state, with Retry when it cannot be reached.
Notes not sent yet, test-mode notes, their screenshots and people's choices (on or off, the toolbar's place, a name) are kept in the app's support folder (path_provider), so they survive a restart; on Android, in the no-backup folder beside it, so a tester's screenshots stay out of Google's backups. On iOS and macOS the support folder is in the device's backups, and Dart cannot mark a folder to be left out: pass a storage that writes elsewhere to keep them out. A package is shared through the share sheet (share_plus).
Reaching the server. The default is http://localhost:4747. The iOS simulator and desktop apps reach it as it is. The Android emulator, and a phone plugged in over USB, reach it once the port is forwarded:
adb reverse tcp:4747 tcp:4747
A phone on Wi-Fi needs the server's dev tunnel: pass its URL as server, or from the build (--dart-define=NOTATO_SERVER=…, below), and the device token as token, in debug builds only. The device token opens notato dev on your machine to whoever has it, and whatever a build is given with --dart-define can be read out of it: give it to flutter run from a file your repository ignores, never to a build you hand out, and never commit it.
Notato(project: 'shop', token: const String.fromEnvironment('NOTATO_TOKEN'), child: MyApp())
flutter run --dart-define-from-file=notato.local.json # {"NOTATO_SERVER": "https://…devtunnels.ms", "NOTATO_TOKEN": "notato_device_…"}
Notato sends a token only from a debug build unless you set allowTokenInRelease, and never the device token, or any token to a dev tunnel, from a profile or release build, whatever you set. A sandboxed macOS app needs the com.apple.security.network.client entitlement in its debug profile, as the example has.
Builds for testers. A build you hand out (TestFlight, an internal track, a device farm) holds whatever is in it, and anyone with it can read that out. Give it a shared server (notato serve) and a capture-only project token, and set allowTokenInRelease:
npx notato token create shop --purpose app --capture-only --expires 30
A capture-only token can send notes and nothing else: one read out of the app cannot read, change or delete anyone's feedback. Notato asks the server what the token may do before it connects, then sends notes (and those kept on the device) without following the project; replies, status and revert are on the board, and a note's card says so. A test-mode package uploads its captured notes to the configured server too; it grants no access to existing feedback.
Options#
| Option | Default | |
|---|---|---|
project | (required) | Project id on the server. Letters, digits and . _ - @, not only dots |
mode | NotatoMode.dev | dev, test or agent (see below) |
server | http://localhost:4747 (none in test mode) | '' for no server: notes stay on the device |
token | A project token (notato_…) for a shared notato serve. Debug builds only, unless allowTokenInRelease | |
allowTokenInRelease | false | Send token from profile and release builds: a capture-only token only, never a device token |
route | the top named route navigatorObserver sees | A function that says the screen: notes are filed under it, and its pins shown on it |
enabled | debug builds | Whether Notato is on at launch. The app can switch it at runtime. Picking a widget needs a debug build either way |
showToolbar, toolbarPosition | true, bottomRight | The toolbar and the corner it starts in. Where people drag it is remembered |
author | The name on this person's notes. They can change it in the settings | |
screenshots | true | With a server set (test mode's too), none until it says it takes them; its "off" wins either way |
maskInputs | on in test and agent mode | Cover text fields in screenshots and leave their values out of notes (password fields always are) |
rememberRuntimeState | true | Keep runtime choices across launches |
captureLogs, logLimit | true, 50 | Attach the app's recent errors (FlutterError, uncaught ones) and debugPrint messages |
appName, appVersion | Flutter app | Recorded on every note |
maxScreenshotScale | 2 | Pixels per point screenshots are kept at. Phones are 2x to 3.5x; 2x is plenty |
storage | the app's support folder | Where notes and choices are kept: (_) async => MemoryStorage() keeps them for this run only |
In a release build Notato is your app and nothing else, unless enabled or NOTATO_ENABLED is true when it mounts (that is decided once, so the app is never built again from scratch because Notato came or went). A Notato that takes the place of another (a parent rebuilt it with a new key) leaves Notato running.
From the build#
Two declarations win over the options in code, so a build can switch Notato or send its notes elsewhere without a change to the app. Give them with --dart-define or --dart-define-from-file:
NOTATO_ENABLED:true,1,yesoron, orfalse,0,nooroff, in any case. It wins overenabledand over the choice remembered from an earlier launch:trueputs Notato in a profile or release build (picking still needs a debug build), andfalsekeeps it off in debug. The app and the ⋯ menu still switch it for the rest of the launch, and that choice is remembered for a launch that declares nothing;resetRuntimeState()goes back to the declaration.NOTATO_SERVER: the server, overserver. It counts as a server set in code, so thetokengoes to it, as from code (one typed into the settings never gets the token).
A blank value, or any other, is ignored, and the option in code stands. A release build that does not declare NOTATO_ENABLED decides from enabled alone, as before.
flutter run --dart-define=NOTATO_ENABLED=false # off, even in debug
flutter run --profile --dart-define=NOTATO_ENABLED=true --dart-define=NOTATO_SERVER=https://… # on in a profile build
At runtime: notato#
notato (a Listenable) | Listen to it for its state: isEnabled, isToolbarVisible, isAnnotating, mode, connection, agents, notes, pendingCount |
enable(), disable(), setEnabled(…) | On and off. A choice made here wins over enabled until resetRuntimeState() |
showToolbar(), hideToolbar() | The toolbar. Off still lets the app drive Notato from code |
startAnnotating(), stopAnnotating() | The next tap selects what is under it |
select(key), select('#save') | Select a widget (a GlobalKey, an Element, or a selector) as if it had been tapped, and open the note for it |
annotate('#save', comment, …) | Make a note with no UI, as a person or (with agentName) an agent. peopleOnly: true keeps a person's note from the agent |
packageNotes(upload: …) | Test mode: the device's notes as a bundle zip |
recordRequest(…) | Add an HTTP request to the network context of later notes |
ListenableBuilder(
listenable: notato,
builder: (context, _) => Switch(value: notato.isEnabled, onChanged: (on) => notato.setEnabled(on)),
)
The example's Feedback card switches Notato and its toolbar, and makes a note from code.
What the agent gets#
A tap hit-tests your app's render tree, as Flutter's own widget inspector does, and follows the render object it lands on back to the widgets that made it. Parent in the composer steps out to the widget around it. The note says:
- The widget: the nearest one written in your code (the
Textyou wrote, not theRichTextFlutter builds inside it), its type, text, key (as the test id: aValueKeyof a string, a number or an enum, at most 100 letters, digits and_ - . @ /, which a selector can name),Semanticsidentifier and label,Tooltipmessage, and whether it is a button, a field or a switch. Nothing of a private widget's text or key (below). - Where it is written: the file, line and column of that widget, from Flutter's widget creation tracking, which debug builds have by default. The path is the one on your machine, where the agent works.
- The widgets around it: your own widget classes, outermost first. Flutter's (
Card,Padding) and builders that only hand on your widgets (Builder,StreamBuilder, providers) are left out. - A selector made of those:
ProductCard > FilledButton#add-to-basket > Text. - Screenshots: the screen as the person saw it, with the widget outlined and numbered, the toolbar left out and private widgets covered, and a crop of the widget.
- Context: the platform (
flutter), OS and version, Dart's version, the window's size and pixel ratio, the app's recent errors (context.console), and requests the app recorded (context.network). Credentials are blanked out of the messages (Bearer …, cookies, API keys, and tokens and passwords as a printed map or a form writes them), and a request's address is kept without its query string or a user name and password.
What is never recorded#
- Password fields (
obscureText), always: covered in screenshots, and their value is never recorded. - Text fields when
maskInputsis on (by default in test and agent mode): covered, and their value left out. Their hint is still recorded, so a note can say which field it is. - Screens the app keeps out of screenshots (Android's
FLAG_SECURE: a one-time code, a card's number) only when they are insideNotatoMask. Flutter draws Notato's screenshots from its own layers, which the flag does not reach, and Dart cannot see it: wrap such a screen inNotatoMask. - Anything inside
NotatoMask: covered in screenshots, and none of its text is recorded anywhere: not as its text or label, not in the text of a widget around it, and not in its selector. Its key is left out too (an app may key a widget with what it shows:ValueKey(email)). A selector cannot find it by its text or its key either.
NotatoMask(child: Text(user.email)) // private: this and everything in it
NotatoMask(private: false, child: TextField(decoration: InputDecoration(hintText: 'Search'))) // recorded even with maskInputs
Password fields stay masked whatever the mark, and a private mark around a private: false one wins over it. NotatoMask paints its child and nothing else. It is the web SDK's data-notato-mask.
Selectors#
What a note's selector looks like, and what an agent passes to notato_annotate or notato.annotate:
#save | The widget's key (ValueKey('save'), ValueKey(42), ValueKey(Tab.home)), or its Semantics identifier |
Text, button | The widget's type, its role (button, textbox, switch, img…), or your widget class; * for any |
[label="Pay now"] | The semantics label, exactly |
:text("Add to cart") | The text or label contains this, ignoring case |
:nth(2) | The second match, in painting order |
ProductCard > … | Leading names are your widgets around it; one the app does not have (a screen's name) is ignored |
Modes#
- dev: notes go live to
notato dev, pins turn amber when the agent acknowledges them and green when it resolves them, and replies arrive in the pin's card. On a resolved pin, Ask the agent to revert asks for the change to be undone. A note made while the server is down is kept on the device and sent when it comes back; one the server refuses for good (too large, say) is marked failed and kept, and the notes after it still go. - test: feedback defaults to shared and, when a server is configured, is sent live into a review session. Notes also stay on the device for Package and share, which exports the bundle zip
notato_import_bundlereads and uploads it when requested. Without a server, testing stays offline. - agent: like dev, and the app also takes
notato_annotaterequests:notato_annotate { target: "ProductCard > #add-to-basket:nth(2)", comment: "…" }is relayed to the app, which finds the widget, takes the screenshot and files the note as the agent. Requests are taken one at a time. The agent hears the note is filed only once the server has it; one kept on the device (the server could not be reached, or would not take it) is reported as not filed, with the reason.
People only and asides#
Replies reach the agent within its active feedback scope. Dev feedback is private to its creator by default; Test feedback is Shared. Change a note's visibility in the board to share it with the project. People only and Aside are additional ways to keep feedback between people:
- People only, for a whole note: the composer's switch ("Keep this between people: the agent won't see it."), and the same switch on the note's card, at any time, for anyone on the thread. Each change is recorded in the thread. A note still on the device is changed on the device, with the same entry, so a package carries the history.
- Aside, for one reply: under the reply field ("Just for people: the agent won't see this reply."). Asides show in the thread outlined and labelled Aside.
Known limits#
- Picking needs a debug build: release and profile builds do not record where widgets come from. In one, the toolbar says so.
- A widget is what the hit test finds under the finger, so a tap on a button's label picks the label. Parent selects the button; the note's selector and component say which button either way.
- Pins are placed again twice a second by finding their widgets with their selectors; of several alike (a list's rows), the one nearest where the note was made. An app at rest draws no frames, and its pins are not looked for again until it does. A widget scrolled out of sight takes its pin with it; one that cannot be found keeps its pin where it was, dimmed. At most 150 pins are drawn on one screen (the newest); the Notes list has every note on the screen, newest first.
- Only notes made in a Flutter app get pins. A note from another platform filed under the same screen (the web's, iOS's) is in the Notes list, with its number, and no pin: its selector names something this app does not have.
- The screen is read as Notato draws, and checked twice a second besides, so moving to a screen with no pins is noticed too, whether
routeornavigatorObserversays it. - The connection to the server is watched: the server says something at least every 15 seconds, and a stream silent for 45 is closed and opened again. Every request has a time limit on all of it, the wait for the answer to start included.
- Notes not sent yet and people's choices are written to the support folder once a burst of changes is over (after 0.3 seconds), off the UI thread, whole, then moved over the old file, so a crash part way never leaves half a file.
- There is no shake to bring the toolbar back (it would need a sensor plugin): the app brings it back with
notato.showToolbar(). - Platform views (a map, a web view) are one widget, with nothing inside them.
- Verified on the iOS simulator (iPhone 17 Pro, iOS 26.5) with Flutter 3.47: picking, Parent, masking, both screenshots, the widget's file and line, the composer, the ⋯ menu, live status and the agent's replies, People only, the agent relay through
notato_annotate, annotating from code, and test mode's package uploaded and shared. Not yet run on Android, a physical device, macOS or the web.
Hiding completed annotations#
On a resolved or reverted note, open More options → Hide from this app to hide its pin and card on this device. In Settings, Hide completed annotations hides completed notes across every screen at once, including notes beyond the 150 drawn pins. Restore hidden annotations brings them back.
Hiding keeps the feedback, screenshots and thread in the dashboard and exports. It never changes the note on the server. Reopened tasks and new agent questions remain visible. The preference is scoped to the server, project, mode, credential and name, and survives app restarts when runtime preferences are remembered.