Mobile apps · Flutter

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.

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. 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 and the device token as token, from the build (--dart-define=NOTATO_SERVER=…). A sandboxed macOS app needs the com.apple.security.network.client entitlement in its debug profile, as the example has.

Options#

OptionDefault
project(required)Project id on the server. Letters, digits and . _ - @, not only dots
modeNotatoMode.devdev, test or agent (see below)
serverhttp://localhost:4747 (none in test mode)'' for no server: notes stay on the device
tokenA project token (notato_…) for a shared notato serve
routethe top named route navigatorObserver seesA function that says the screen: notes are filed under it, and its pins shown on it
enableddebug buildsWhether Notato is on at launch. The app can switch it at runtime. Picking a widget needs a debug build either way
showToolbar, toolbarPositiontrue, bottomRightThe toolbar and the corner it starts in. Where people drag it is remembered
authorThe name on this person's notes. They can change it in the settings
screenshotstrueA server that has screenshots off wins either way
maskInputson in test and agent modeCover text fields in screenshots and leave their values out of notes (password fields always are)
rememberRuntimeStatetrueKeep runtime choices across launches
captureLogs, logLimittrue, 50Attach the app's recent errors (FlutterError, uncaught ones) and debugPrint messages
appName, appVersionFlutter appRecorded on every note
maxScreenshotScale2Pixels per point screenshots are kept at. Phones are 2x to 3.5x; 2x is plenty
storagethe app's support folderWhere 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 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.

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:

What is never recorded#

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:

#saveThe widget's key (ValueKey('save'), ValueKey(42), ValueKey(Tab.home)), or its Semantics identifier
Text, buttonThe 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#

People only and asides#

Everything people write reaches the agent: new notes and every reply. Two switches keep something between people instead:

Known limits#