Docs › Mobile apps
SwiftUI
iOS 17 and later, as a Swift package with native WebP compression.
Figma-style comments for a running iOS 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 Swift file and line it was written at (for views you mark), what VoiceOver would call it, and the app's recent log. It is the iOS client of the same Notato server the web, Android, .NET MAUI, React Native and Flutter SDKs use: the notes, the board, the MCP tools and the status loop (open, acknowledged, resolved, revert) are the same.
SwiftUI and UIKit apps on iOS 17 and later (and Mac Catalyst), as a Swift package, with libwebp for screenshot compression.
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: in Xcode, File › Add Package Dependencies… with https://github.com/notatorg/notato, or in a Package.swift:
.package(url: "https://github.com/notatorg/notato", from: "0.3.0"),
with .product(name: "Notato", package: "notato") in your target's dependencies. Then start it as early as the app starts:
import SwiftUI
import Notato
@main
struct ShopApp: App {
init() {
#if DEBUG
Notato.start(NotatoConfiguration(project: "shop-ios"))
#endif
}
var body: some Scene {
WindowGroup { RootView() }
}
}
Leaving Notato.start out of a build (as #if DEBUG does here) 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. Ask your agent to "watch Notato and fix what comes in". In Dev mode, your feedback is private to you and your agent. To share a note with the project, change its visibility in the board. See the feedback visibility guide and People only and asides.
The app has to be allowed plain http to your machine: in Info.plist, NSAppTransportSecurity → NSAllowsLocalNetworking = YES. The simulator shares the Mac's network, so localhost works there as it is.
Where it is in the code#
Mark the views you care about, and notes about them (or anything inside them) say exactly where they are written:
struct ProductList: View {
var body: some View {
List { … }
.notatoScreen() // a screen: its name is the note's route
}
}
struct ProductCard: View {
var body: some View {
HStack { … }
.notato("ProductCard") // ProductList.swift:52, filled in by the compiler
}
}
The file and line come from #filePath and #line, so there is nothing to keep in step. Paths are given from the repository root (found by looking for .git above the source file, which the simulator and a Mac can read; set sourceRoot for a device). .notatoMask() makes a view private (see What is never recorded); .notatoIgnore() makes the picker look through it.
Configuration#
In code, or from the app's Info.plist (a Notato dictionary) or a JSON file, with NOTATO_* environment variables over either (an Xcode scheme, or SIMCTL_CHILD_NOTATO_SERVER=… with xcrun simctl launch):
Notato.start() // reads Info.plist and the environment
<key>Notato</key>
<dict>
<key>Project</key><string>shop-ios</string>
<key>Mode</key><string>dev</string>
<key>Server</key><string>http://localhost:4747</string>
</dict>
Key (NotatoConfiguration) | Default | |
|---|---|---|
Project (project) | (required) | Project id on the server. Letters, digits and . _ - @, not only dots |
Mode (mode) | dev | dev, test or agent (see below) |
Server (server) | http://localhost:4747 (none in test mode) | An empty string means no server. NOTATO_SERVER wins over it (see below) |
Token (token) | A project token (notato_…) for a shared notato serve. Sent only to the configured server (the same scheme, host and port); a server typed into Settings gets no token. In a build you hand out, use a capture-only one (see Tokens in builds you hand out) | |
Enabled (enabled) | true | Whether Notato is on at launch. The app can switch it at runtime; NOTATO_ENABLED wins over both (see below) |
ShowToolbar, ToolbarPosition | true, bottomTrailing | The floating toolbar. People can drag it, and fold it into one round button with its chevron (it opens again while they annotate); where they leave it, and whether it is folded, is remembered |
ShakeToToggle | true | Shaking the device shows or hides the toolbar (real devices) |
Author | The name on this person's notes. They can change it in the toolbar's settings | |
Screenshots | true | A server that has screenshots off wins either way. With a server set (test mode's too), none is taken until it has said it takes them (see Screenshots and the server) |
MaskInputs | on in test and agent mode | Cover text fields in screenshots and leave what is typed in them out of notes. Secure fields always are; .notatoMask(false) opts a field out |
RememberRuntimeState | true | Keep runtime choices (on or off, toolbar, name, a server typed in) across launches |
ReadAccessibility | true | Read the accessibility tree to describe what was tapped (see below) |
CaptureLogs, LogLimit | true, 50 | Attach the app's own recent log messages (Logger, os_log) |
SourceRoot | found from .git | The folder source paths are given from |
AppName, AppVersion | from the bundle | Recorded on every note |
MaxScreenshotScale | 2 | Phones are 3x; 2x is plenty to read and half the size |
For one launch: NOTATO_ENABLED and NOTATO_SERVER#
These two are read however Notato is started, from code too, so whoever launches the app decides for that launch without changing it: in an Xcode scheme (Run › Arguments › Environment Variables), with the SIMCTL_CHILD_ prefix for xcrun simctl launch, or from a launcher such as Aspire.
SIMCTL_CHILD_NOTATO_ENABLED=false xcrun simctl launch booted com.example.shop
NOTATO_ENABLED | true, 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_SERVER | The server, such as http://localhost:4747. Wins over Server, and counts as the configured server, so Token goes to it. Blank is ignored (it never means no server) |
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; resetRuntimeState() goes back to it when it is set, else to the configuration. A server typed into Settings still wins over NOTATO_SERVER, as it does over Server.
Tokens in builds you hand out#
Anyone with a copy of an app can take a token out of it. In a build that leaves your machine (TestFlight, an ad hoc or enterprise build), use a capture-only app token for the one project, set to expire, made on the board or with:
npx notato token create shop-ios --capture-only --expires 30
A capture-only token can send new notes and nothing else: whoever takes it out of the app cannot read, change or delete anyone's notes or screenshots. Notato asks the server what its token may do (/config) before it connects, and with a capture-only one it only sends: notes go as they are made (or as soon as the server can be reached), the toolbar's menu says Send only, and there are no live updates, replies, revert requests or deletions from the app. A note's card says to read and answer it on the board. Test mode sends feedback live when a server is configured and also supports exporting and uploading a package. Keep a token without --capture-only for builds that stay with you.
Screenshots and the server#
The server decides whether screenshots are taken (notato config). With a server set, in test mode too, Notato asks it (/config) as it starts and again before each screenshot (unless it asked in the last few seconds, and waiting at most a second and a half), and takes none until the server has said it takes them: not while it has not answered yet, nor after it could not be asked again. A package leaves out the screenshots of its notes unless the server says it takes them. With no server set (Server empty, or test mode without one) nothing stops them but Screenshots.
At runtime: Notato.shared#
Notato is @Observable, so a view can show and change its state directly:
struct DeveloperSettings: View {
@State private var notato = Notato.shared
var body: some View {
Toggle("Notato", isOn: Binding(get: { notato.isEnabled }, set: { $0 ? notato.enable() : notato.disable() }))
}
}
enable(), disable(), isEnabled | On and off. A choice made here wins over enabled in the configuration until resetRuntimeState(); a launch with NOTATO_ENABLED set goes by that |
showToolbar(), hideToolbar(), isToolbarVisible | The toolbar. Off still lets the app drive Notato from code |
startAnnotating(), stopAnnotating() | The next tap selects what is under it |
select("#AddToCart") | Select an element as if it had been tapped, and open the note for it |
annotate("#AddToCart", comment:, options:) | Make a note with no UI, as a person or (with agentName) an agent. Throws if the server refuses it. AnnotateOptions(peopleOnly: true) keeps a person's note from the agent |
annotations, pendingCount, connection, connectionDetail | What Notato knows, for a status line or a badge |
packageNotes(upload:) | Test mode: the device's notes as a bundle zip, uploaded when a server is set |
The sample's Feedback tab does each of these.
What a note carries#
The same schema as every Notato SDK (environment.platform is ios):
- Which element, from the accessibility tree: its role (
button,heading,text,textbox,switch, …), its label, its accessibility identifier (as the test id), its value, and its frame. SwiftUI puts every control there with no work from the app;.accessibilityIdentifier("AddToCart")makes the steadiest selector. - Where it is in the code: the marked view it is, or the nearest one around it (
"nearest": true), asswift/App/ProductList.swift:52:17; the component path (ProductList › ProductCard); the screen and its file. - What was on screen: the window with the element outlined and the pin's number drawn on, and a crop around it. Notato's own window is never in them.
- Context (
context.ios): the screen and the screens showing, the view controllers, the device, iOS version, idiom, orientation, colour scheme, text size and locale. - The app's log (
context.console, the web SDK's shape): the app's ownLogger/os_logmessages since launch (from info up), read back from the unified log. Apple's frameworks are left out. - Network (
context.network, the latest 50 requests) when the app addsNotatoNetworkRecorderto a session'sprotocolClasses(see below). URLs are recorded without their query strings, fragments, or a user name and password written into them. Each request is made again through Notato's own session, which hands every part back to the app's session as it comes: the response, the body a piece at a time, redirects (which the app's session and its delegate follow or not, as they would without Notato) and authentication challenges, server trust included, which the app's delegate answers (so certificate pinning still applies). What a protocol cannot carry over is the app's session configuration: Notato's session is ephemeral (what it loads is cached in memory only, and no credential is kept), with each request's own headers, timeout and cache policy. It uses the system's shared cookie store, as a session of the system's defaults does: a session adds none of its own cookies to a request a protocol loads, nor keeps those the response sets, so a recorded session with a cookie store of its own (an ephemeral one) has its cookies read from and kept in the shared store while it is recorded. Upload tasks (upload(for:from:), whose body a protocol cannot read), streams and web sockets are not recorded and go to the network untouched.
To record a session's requests, put the recorder in front of its protocols when the session is made:
let configuration = URLSessionConfiguration.default
#if DEBUG
configuration.protocolClasses = [NotatoNetworkRecorder.self] + (configuration.protocolClasses ?? [])
#endif
let session = URLSession(configuration: configuration)
What is never recorded#
- Secure fields (
SecureField), always: covered in every screenshot, and what is in them is never read. - Text fields, with
MaskInputs(on in test and agent mode): covered in screenshots, and what is typed in them left out of notes; the field's label still says which field it is..notatoMask(false)on a field (or a view holding fields) opts it out, a search box say, so it is shown and recorded. - Fields are found in the app's views as well as in the accessibility tree, which can leave some out: every
UITextField(SwiftUI'sTextFieldandSecureFieldare ones), every editableUITextView(a verticalTextField, aTextEditor) and any view whose entry is secure is covered wherever it is drawn, under a sheet or an alert, in a view hidden from VoiceOver or one that lists its own accessibility elements. What is typed in one is also taken out of the text of the element around it, such as a row combined with.accessibilityElement(children: .combine). A screen is read, and a picture taken, only once the accessibility tree has been built. - Anything marked
.notatoMask(), a customer's name, an address, a card number: covered in every screenshot, and neither its text nor the text of anything inside it goes into a note, whether as the element's text, its name, its selector, part of the label of a button around it, or a match for an agent's:text()selector. The note still says which element it is (its role, accessibility identifier, frame and where it is written), and its selector falls back to#identifieror the role and:nth. A private view wins over a field inside it that opts out.
TextField("Card number", text: $card).notatoMask() // private in every mode
TextField("Search", text: $query).notatoMask(false) // recorded even when MaskInputs is on
Reading the accessibility tree#
iOS builds an app's accessibility tree only when an assistive technology or a UI test asks for it. To read it, Notato does what UI-testing tools do: it loads UIKit's and SwiftUI's accessibility bundles into the app and switches on iOS's application accessibility. That setting belongs to the system, not the app, so Notato remembers what it was and puts it back when it is switched off or the app goes to the background (and on the next launch, if the app was killed first). It uses private system calls, which is fine for a debug tool and one more reason to keep Notato out of release builds. With ReadAccessibility off, Notato leaves the system alone and knows elements only through .notato() marks.
Selectors#
What a note's selector looks like, and what an agent passes to notato_annotate or annotate(_:):
#AddToCart | The accessibility identifier, or the name of a .notato("AddToCart") mark |
button | The role (* for any) |
:text("Add to cart") | The label or value contains this, ignoring case |
:nth(2) | The second match on screen, in reading order |
ProductDetail … | A leading screen name, for people; ignored when matching. A name that is not one capitalised word is quoted: "My cart" button |
People only and asides#
Replies reach the agent within its active feedback scope. In Dev mode, the agent starts with the creator's private feedback; Test mode feedback is Shared by default. People only and Aside are additional ways to keep shared feedback between people:
- People only, on a note: the note and its whole thread are between people, and the agent never gets them. Switch it on in the note before sending it ("Keep this between people: the agent won't see it."), or turn it on or off at any time on the note's card, which shows a People only badge while it is on. Each change is recorded in the thread as an automatic entry ("Made this people only: the agent won't see it.", "Shared this with the agent."); turning it off hands the note to the agent. A note not sent yet (the server is down, or test mode) is changed on the device, with the same entry in its thread, so it is sent or packaged that way.
- Aside, on a reply: that one reply is for the people on the thread and kept from the agent ("Just for people: the agent won't see this reply."). Switch it on in the reply box before sending; it is off again for the next reply. An aside is marked in the thread. It is offered on a People only note too: an aside stays kept from the agent if the note is shared with it later.
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. So is one the server will not take yet (a project a sharednotato servedoes not have, a token that may not write): the server's reason is shown, and it is sent again on the next connection or launch. A note the server refuses for good (it is malformed or too large) is marked failed with the server's reason and kept on the device to read or delete; the notes after it are still sent. On each connection the notes waiting go first, then the project's notes are read again (without their context and steps, which the overlay never shows). A note not sent yet keeps its screenshots in files on the device, not in memory: in the app's Application Support folder, left out of iCloud and computer backups, and protected so they can be read only while the device is unlocked (a note waiting while it is locked goes once it is unlocked). - 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: "button:text(\"Add to cart\")", comment: "…" }is relayed to the app, which finds the element, takes the screenshot and files the note as the agent.
The toolbar#
The bar holds a grip (drag it, or the bar anywhere, to move it), Annotate with the count of notes on this screen, ⋯, and a chevron that folds it into a round button showing the Notato potato, with the count on its corner. Everything else is in the ⋯ sheet: Annotate, hide or show pins, the notes list, Package and share and Clear notes (test mode), Settings, Hide toolbar and Turn Notato off. Notes lists the newest 50 on this screen in pin order, and says how many older ones are here and how many are on other screens (the board has them all). A note's card shows the last 4 entries of its thread, with how many earlier ones are on the board. The ⋯ sheet's header shows the mode, the server and whether it is connected; when the server cannot be reached a banner says so, and Retry tries again at once instead of waiting for the next attempt. A dot on ⋯ (or on the round button) shows only while connecting or offline. The sheets follow the app's light or dark appearance; the bar is always dark.
Pins#
A pin is drawn for each note on the screen at its element (found again as the screen changes, or where the note was made, faded, when it is not there). Only notes made on iOS are pinned: a web or Android note whose route has the same name is about another app's screen, and is in the Notes list but not on this one. A screen shows the pins of its newest 150 notes at most, as the React Native and Flutter SDKs do; the Notes list and the board have every note.
How the overlay works#
Notato draws its UI in SwiftUI, in a window of its own above the app's window in the same scene. Touches that are not on Notato's controls fall through to the app; while annotating, or while one of its sheets is open, Notato's window takes them. Being a separate window keeps it above sheets, full-screen covers, alerts and popovers, and out of the screenshots, which are taken of the app's window only. While a note is being written Notato's window has the keyboard, and gives it back after.
Known limits#
- An element is what VoiceOver sees: a
NavigationLinkor aButtonthat holds several texts is one element with all of them in its label, so tapping the price inside a card selects the card. Mark the view (.notato()) or give it an accessibility identifier to tell such places apart; Parent in the note widens to the marked view around. - Without marks, a note has the screen's file at best. Swift has no runtime record of where an unmarked view was written.
- An accessibility element is not a view, so what
.notatoMask()hides is told from frames: an element that lies mostly inside a private view, or holds one (a button whose label reads it out). Something that only partly overlaps a private view keeps its text, so mark the whole of what is private. - iPad multi-window works per scene (each window's overlay reads only its own window's marks) but has had less testing. Shaking does not reach the simulator (use
Notato.shared.showToolbar()). - Verified on the iPhone 17 Pro simulator (iOS 26.5) against
notato dev, with XCUITest driving real taps (Example/ShopSampleUITests). Not yet run: a physical device, Mac Catalyst, anotato serveserver with a token.
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.