Mobile apps · .NET MAUI

Docs › Mobile apps

.NET MAUI

iOS, Android and Mac Catalyst on .NET 10 and later.

Figma-style comments for a running .NET MAUI app. Tap an element, write a note, and it reaches your coding agent (Claude Code, Codex, Cursor, Gemini CLI, Copilot and others) over MCP with a screenshot, the XAML file and line the element was written at, its page, its view model, and the app's recent log. It is the MAUI client of the same Notato server every other Notato SDK uses: the notes, the board, the MCP tools and the status loop (open, acknowledged, resolved, revert) are the same.

Works on iOS, Android and Mac Catalyst with .NET 10 and later. Windows builds, but has no overlay yet.

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. See screenshot storage.

Quick start#

npx notato dev           # the server your agent reads, on http://localhost:4747

Add the package to the app (dotnet add package Notato.Maui), and in MauiProgram.cs:

using Notato.Maui;

var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
#if DEBUG
builder.UseNotato(options => options.Project = "checkout-app");
#endif

Leaving UseNotato out of a build ships nothing of Notato in it. Run the app: a small dark toolbar appears in the corner. Tap Annotate, tap what you want to comment on, write a note, press Send. The number on Annotate is how many notes the screen has; the chevron folds the toolbar into a round button, and ⋯ opens Notato's menu (notes, settings, packaging in test mode, hiding the toolbar), whose header shows the server and whether it is connected. Ask your agent to "watch Notato and fix what comes in".

The app has to be allowed to talk plain http to the development machine:

The iOS simulator shares the Mac's network, so localhost works there as it is.

On a phone#

A phone cannot reach your machine's localhost (and iOS has no adb reverse). Start the server with a dev tunnel, notato dev --tunnel (see Phones: a dev tunnel; one devtunnel user login first), and there is nothing else to do: it writes the tunnel's https URL and a device token to .notato/device.json at the repository root, and this package's build targets record them in debug builds. When Server is not set, an iPhone, an Android phone and the Android emulator then use the tunnel, sending the device token, and the iOS simulator and Mac Catalyst the server's local address (its port included). Build again after the tunnel's URL changes; Visual Studio's up-to-date check knows the file.

Configuration#

UseNotato() binds the Notato section of builder.Configuration when there is one, then applies the delegate. Any configuration source works; the sample embeds an appsettings.json and adds environment variables over it:

{
  "Notato": {
    "Project": "checkout-app",
    "Mode": "Dev",
    "Server": "http://localhost:4747"
  }
}
builder.Configuration.AddJsonStream(appsettingsStream);
builder.UseNotato();                                                   // the "Notato" section
builder.UseNotato(builder.Configuration.GetSection("Feedback"));       // or another section
builder.UseNotato(o => o.AppVersion = "2.1-beta");                     // the section, then code
OptionDefault
Project(required)Project id on the server. Letters, digits and . _ - @
ModeDevDev, Test or Agent (see below)
Serverhttp://localhost:4747 (none in test mode)An empty string means no server. NOTATO_SERVER wins over it (below)
TokenA project token (notato_…) for a shared notato serve, sent only to the server configured here. A build for testers: a capture-only token (below)
EnabledtrueWhether Notato is on at start-up. The app can switch it at runtime. NOTATO_ENABLED wins over both (below)
ShowToolbar, ToolbarPosition, ToolbarCollapsedtrue, BottomRight, falseThe floating toolbar. People can drag it anywhere and fold it into one round button (it opens by itself while annotating); where they leave it, and how, is remembered
ShakeToToggletrueShaking the device shows or hides the toolbar (real devices). It shares the accelerometer if the app reads it too, and only stops it if it started it
AuthorThe name on this person's notes. They can change it in the toolbar's settings
ScreenshotstrueWith a server set (test mode's too), none is taken until it says it takes them, and its "off" wins either way
CaptureSecureWindowsfalseScreenshot Android windows marked FLAG_SECURE (one-time codes, card entry) too. Off, a note made on one goes without a screenshot
MaskInputson in test and agent modeCover inputs (Entry, Editor, SearchBar) in screenshots and leave what is typed out of notes. Password entries always are
RememberRuntimeStatetrueKeep runtime choices (on or off, toolbar, name, a server typed in) across launches
XamlSourceInfotrueTurn on MAUI's XAML source info, so notes say which file and line (Debug builds)
ProjectPathworked out at build time (Debug builds)The app project's folder from the repository root, put in front of XAML paths
CaptureLogs, LogLimittrue, 50Attach the app's recent ILogger messages and unhandled exceptions
AppName, AppVersionfrom the appRecorded on every note
MaxScreenshotScale2Phones are 3x; 2x is plenty to read and half the size

The configuration is read through IOptionsMonitor, so a source that reloads takes effect without a restart.

For one launch: NOTATO_ENABLED and NOTATO_SERVER#

Two environment variables are read whatever the configuration is, after it and after the code in MauiProgram, so whoever launches the app decides for that launch without changing it: Aspire sets them for an app it runs (WithReference(notato)), and so can an IDE's launch profile, or xcrun simctl launch with the SIMCTL_CHILD_ prefix.

SIMCTL_CHILD_NOTATO_ENABLED=false xcrun simctl launch booted com.example.checkout
NOTATO_ENABLEDtrue, 1, yes or on, or false, 0, no or off, in any case. Wins over Enabled, and over on or off chosen at runtime in an earlier launch. Anything else is ignored
NOTATO_SERVERThe server, such as http://localhost:4747, over Server. A phone or the Android emulator reaches a localhost server through the dev tunnel the build recorded for it (above), so Aspire's address works there too. Blank is ignored

Enable() and Disable() still work during the launch and are remembered as before, but the next launch with NOTATO_ENABLED set goes by it again. A server typed into the toolbar's settings still wins over NOTATO_SERVER, as it does over Server. They only matter where UseNotato is in the build: behind #if DEBUG, a release build has nothing of Notato for them to switch on.

At runtime: INotato#

Take INotato from DI (or Feedback.Current where there is no DI) to switch Notato on and off, and to select things:

public partial class DeveloperSettingsPage(INotato notato) : ContentPage
{
    void OnNotatoToggled(object? sender, ToggledEventArgs e)
    {
        if (e.Value) notato.Enable();   // remembered across launches
        else notato.Disable();          // removes the overlay and closes the connection
    }
}
Enable(), Disable(), IsEnabledOn and off. A choice made here wins over Enabled in configuration until ResetRuntimeState(); a launch with NOTATO_ENABLED set goes by that
ShowToolbar(), HideToolbar(), IsToolbarVisibleThe toolbar. Off still lets the app drive Notato from code
StartAnnotating(), StopAnnotating()The next tap selects what is under it
SelectAsync(element)Select an element as if it had been tapped, and open the note for it
AnnotateAsync(element, comment, options)Make a note with no UI, as a person (PeopleOnly keeps it from the agent) or (with AgentName) an agent
AnnotateAsync("#SignIn", comment, options)The same, finding the element with a selector
Annotations, PendingCount, Connection, ConnectionDetail, ChangedWhat Notato knows, for a status line or a badge. Safe to read from any thread
PackageAsync()Test mode: the device's notes as a bundle zip

The sample's Feedback tab does each of these.

What a note carries#

Everything the web SDK sends, in the same schema (environment.platform is maui), so the board, the Markdown and the MCP tools need nothing new:

Mark things up in XAML with xmlns:notato="clr-namespace:Notato.Maui;assembly=Notato.Maui": notato:Feedback.Mask="True" makes an element private, and notato:Feedback.Ignore="True" makes the picker look through it.

Feedback is private to its creator by default in Dev mode. The agent starts with that person's notes; choose Shared or All to include project feedback (see the feedback visibility guide). People only notes and asides remain hidden from agents:

@name calls one of the server's mention plugins. None is built in and none is needed to reach the agent, so the composer and the reply box show no chips unless the server adds a plugin.

What a note never carries:

Selectors#

A selector is a path through the visual tree, written like CSS so it reads at a glance and the agent can turn it into a search: LoginPage VerticalStackLayout#Form > Button:nth-of-type(2).

ButtonThe control's type (* for any)
#SignInIts AutomationId (or [AutomationId="Sign in"])
[x:Name=Email]Its x:Name
.primaryA StyleClass
:nth-of-type(2)The second child of its parent with that type
:text("Sign in")Its text contains this, ignoring case (for agents; never generated)
space, >Somewhere inside, directly inside

Notato writes the shortest selector that finds only that element on its page, and uses selectors to put pins back on elements after a restart. An AutomationId makes the steadiest one.

Pins#

A pin sits on the element its note was made on, follows it as the page scrolls, and goes faint where the element cannot be found (at the place the note was made). Only notes made with this package get a pin: a note from a web page or another SDK is in the Notes list but has no pin, because its selector means nothing here. A screen shows at most the newest 150 pins (as the React Native and Flutter SDKs do); the Notes list has every note. Pins are looked for together, in one pass over the page about once a second while one is missing, and drawn again only when one moved or changed.

Modes#

How the overlay works#

Notato draws its UI with MAUI controls, in a layer of its own above the app, so it needs nothing from the app's pages and works over Shell, tabs, navigation, modal pages, popups and alerts:

Notato's controls carry explicit styles, so the app's implicit styles do not reach them, and its handler customisations apply only to the app's own controls.

The picker hit-tests the visual tree with the native views' real positions (scroll views and clipping included), topmost first. Parent in the note widens the selection to the element around it.

Physical devices and shared servers#

notato dev listens on loopback only. Your own phones reach it through a dev tunnel (On a phone), and a USB-connected Android phone also with adb reverse. For testers elsewhere, run a shared server (notato serve, see the main README) and give the app its address and a project token in configuration. The token only ever goes to that server (its scheme, host and port): a server typed into the toolbar's settings gets no token, so a note sent there goes without one.

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 capture-only token, which can send notes and nothing else, so one read out of the app cannot read, change or delete anyone's feedback:

npx notato token create checkout-app --purpose app --capture-only --expires 30

Notato asks the server what the token may do before it connects. With a capture-only token it sends notes (and those kept on the device) without following the project, the notes' cards point to the board for replies, status and revert, and a test-mode package uploads its captured notes when a server is configured.

Known limits#

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.