A Robomotion App is two halves in ONE git repository, built by talking to you:
- the screens: a React app under
app/, composed only from@robomotion/app-kit - the robot's side: a Robomotion flow (
main.ts,subflows/) that answers every button, oneApp Actiontrigger per action
They are joined by one file, app.json, and talk over a typed action-RPC. The robot is the differentiator: this app can open a browser, drive a desktop program, read a PDF, keep its own database.
Two things decide whether this goes well: how fast the person sees the first screen, and whether your words leak jargon. Everything below serves those two. This skill is a thin index over ./docs/ - read the relevant doc when the topic comes up.
What you need: the robomotion CLI on PATH (it brings robomotion-sdk-mcp, robomotion-api-mcp and robomotion-browser-mcp with it), bun, git, and a Robomotion login (robomotion auth login). Nothing else: no Designer, no other MCP server. Under Robomotion's own Build with AI the same steps are tools instead of commands - read ./docs/build-view.md there and only there.
The loop below is robomotion 26.9.8 or later - check robomotion version once. An older CLI still builds the app, but: app screen / app try read localhost instead of the preview link, app start --restart runs the last published build instead of your save once the app has been published, and app publish serves the screens without publishing the backend or printing the app's address. On an older CLI, ask the person to update Robomotion (robomotion.io/downloads) before you publish, and do not send them to the Designer to finish it.
The contract is the spine
app.json at the project root is the single source of truth for actions, events, types, and screens. The project is one folder - the flow's - and the screens live in app/ inside it. Typegen ripples every change into both halves:
app/src/generated/actions.gen.ts ← SPA: typed client + CONTRACT_HASH
src/generated/actions.gen.ts ← flow: param/result types per action
Any behavior change starts in app.json, then regenerate, then touch screens and flow. Changing app.json without regenerating leaves both sides referencing types that no longer exist, so tsc fails inside robomotion app validate before anything ships. Drift is a compile error, never a runtime surprise. The contract_hash is embedded in the SPA at build time and computed by the robot at startup; a mismatch renders a blocking "this app was updated, reload" state, never silent talking-past-each-other. Authoring guide: ./docs/contract.md.
When the shape is open, say so
Not every payload has fields you can name, and inventing some is worse than
admitting it. Two spellings are legal in app.json and both generate a usable
type:
| Written as | Means | The screen gets |
|---|---|---|
"params": { "type": "object" } |
any object | Record<string, unknown> |
"result": { "type": "object" } |
any object | Record<string, unknown> |
"result": {} |
any value at all | unknown |
Reach for one when the payload really is open:
- A pass-through payload the screen assembles and the flow hands straight on - to a webhook, a spreadsheet row, another system's API.
- A dynamic form, where the fields are not known when you write the app.
The kit's
JsonInputparses what the person typed and hands the form a real object, so<Form action={run}>sends it as the params. - A result the flow decides: a report, a lookup against a system whose
response shape is not yours. The kit's
JsonViewrenders it without the screen knowing its shape.
Still write the description - it is all the person, the Designer and an MCP
client have to go on. The call site is unchecked, so run({ ...payload })
compiles; that is the point, and also the cost, so type what you know, open
what you don't. An action with three known fields and one free-form bag
declares the three and puts the bag in a property; it does not open the whole
thing. additionalProperties is not in the subset and is not needed: the open
object is exactly { "type": "object" }.
The surface: the robomotion CLI, and git
One project, one folder, one save. The layout:
app.jsonat the project root - the contract, where the robot reads it.mcp.jsonsits beside it once you write it in step 2c; a new project has none.main.tsandsubflows/at the root - the robot's side.app/holds the screens:app/package.json,app/src/, the two Robomotion packages underapp/vendor/.src/generated/on both sides (src/generated/actions.gen.tsfor the flow,app/src/generated/actions.gen.tsfor the screens) is codegen-owned; never hand-edit it..robomotion/holds this machine's session files (never committed): the robot, the dev server,checks.json, and yourrequest-checks.md.
| Command | Does |
|---|---|
robomotion auth login --workspace <host> |
signs THIS PROJECT in (the login is kept in .robomotion/session.json at the project root, never committed): a code and a link, approved in the person's browser. Each project folder has its own login, so several assistants can build apps in different workspaces side by side. Run robomotion auth whoami in the project folder first; if it says not logged in, see step 0 - you run the login, the person only approves it. Never ask for a key. |
robomotion create app "<name>" |
the start of a NEW app: makes the project folder (a slug of the name; --dir <path> to choose), signs it in when it is not (the browser approval below; --workspace <host> when the person named one), creates the flow and the app, pulls the seeded app/, places the packages, installs. Then cd into it. |
robomotion app create "<name>" |
the same, in place, on an EXISTING flow checkout (a git clone from its Home card) that has no app yet. |
robomotion app sync |
for an app that already exists: pull, place the packages, install |
robomotion app codegen (from app/) |
regenerates both typed clients from app.json and prints the contract hash |
robomotion app dev |
runs the screens live and prints two addresses: localhost, and the app's preview link (https://runs.<domain>/preview/<id>/, also in robomotion app status). Every screen change is on the preview at once; it reaches the robot through the same serving tier the published app uses. Develop and test on it. Start it in the background; it keeps running. |
robomotion app link |
a link that opens the preview in any browser, for members of the workspace (it expires in minutes; the browser keeps the preview after that) |
robomotion app validate |
every check: schema, tsc on both halves, contract hash, the flow's wiring, the kit rules, and whether the flow is saved (flow-committed). It writes no files: the canvas positions (.designer.ts) come from robomotion build (step 5b) |
git add -A && git commit -m "..." && git push |
the save, at the project root - screens and flow together |
robomotion app robot |
gives the app its own robot (only after the person said yes) |
robomotion app start [--restart] |
brings that robot up on this computer and starts the app on it; --restart after a save that changed the flow |
robomotion app smoke |
presses every button once through the app's own door and reports what each answered |
robomotion app press <action> --params '{...}' |
presses one button with values you choose |
robomotion app try --screen "<label>" --fill "Label=value" --select "Label=option" --click "Button" --expect "text" |
uses the app the way a person does, on the live preview (--local for localhost, --url for another address), in a browser signed in as them, and prints what the screen shows and what the robot did |
robomotion app screen "<label>" |
reads what a screen shows on the live preview (text and console errors). A one-screen app has no navigation to pick a label from: robomotion app screen / |
robomotion app logs [-f] |
the robot's output: every step it ran, and the error when one failed |
robomotion app status |
the app, the screens, the robot, what was last checked |
robomotion app publish |
the last step, when the app is finished and the person asked: builds the screens, uploads them, publishes the flow version the app runs in production, and prints the production link (https://runs.<domain>/<id>). Never part of the development loop |
Results come on stdout; progress and complaints on stderr.
The loop
Narrate progress with the task list, in the person's language ("Design the review screen", "Teach the robot to read invoices") - never internal steps ("run typegen", "start dev server"). Ask questions with the question tool (AskUserQuestion in Claude Code), one per turn, with short options.
-
Create the project. A new app starts with one command, in the folder the person wants the project under (never inside another project):
robomotion create app ""
Run it in the background: when the folder is not signed in yet, it opens the person's browser on the approval page, prints a short code, and waits for the approval - a foreground run would hold your turn. Tell them in one sentence: "A Robomotion sign-in page has opened in your browser - approve it (the code is XXXX-XXXX)." Which workspace: the one their browser is signed in to, so ask nothing - unless the person named one, then pass
--workspace <host>(the full host, likeacme.robomotion.io). When it returns,cdinto the folder it made (it says which); everything after runs there, and the folder's own.robomotion/session.jsonis its login.Only if the sign-in cannot open a browser - the output says no browser was opened (a server with no desktop, a remote shell) or the person says nothing opened - run it again with
--no-browserand guide them: give the address and the code it prints, to open in any browser where they are signed in to Robomotion.Never write app or flow files before the command has returned: there is nothing to write into until then. An existing flow checkout that has no app yet:
robomotion app create "<name>"inside it (same sign-in rule,robomotion auth whoamifirst). Continuing an existing app:robomotion app sync.
0b. Clarify - at most 3 questions, total. ONE question per turn. Worth asking: who uses this, what is the one main job, where does the data live today. Never ask about technology, hosting, colours or frameworks. If the request already answers a question, don't ask it.
0c. Find the robot's pieces before you design anything. For every external system or capability the person named - their CRM, their shared drive, a spreadsheet, a mailbox, a database, a website with no API - use the searching-packages skill BEFORE you choose an archetype or write a line of app.json. The Robomotion library is 229 packages deep and the flow behind an app can reach all of it. A package beats raw HTTP every time. Name what you found in your reply, in the person's words ("I can talk to your Google Sheet directly"), never as a package list.
0d. Write the promises down. Before any screen, write what the person asked for as short checkable lines in .robomotion/request-checks.md (their language is fine): counts, order, what an empty case shows, what is refused and what stays unchanged when it is, what each screen shows - naming each screen by the label its navigation will carry. Step 7 checks the screens against it. Never mention the file to the person.
-
Pick an archetype silently: dashboard / approval-queue / form-and-table / document-review / gallery-review / board. Match by what the person wants to DO - the chooser is
./docs/archetypes/(one file per archetype). Never say the archetype's name; say what you're building: "I'll make you an app with two screens: a queue of waiting invoices, and a page to approve each one." Layout follows the screen count: two or more screens get the kit's sidebar (give each screen anicon, and agroupwhen there are more than five); a one-screen app the top bar. "Tabs at the top" istheme.layout: "topbar"inapp.json. -
Write
app.json- read./docs/contract.mdfirst. Everydescriptiondoubles as UI copy, so write it for the end user. Thenrobomotion app codegenfromapp/.
2b. Clear out the demo the app arrived with. A new app already renders something, written against the seed's contract. Your app.json deletes those types, so any leftover demo file fails tsc, including one nothing imports. After writing your screens, ask the checkout what still points at the contract:
grep -rl "generated/actions.gen" app/src/
Anything listed that you did not write is debris: delete it. `app/src/screens.tsx` says which screens the app mounts; anything unreachable from there goes too.
2c. Write mcp.json beside app.json - read ./docs/mcp.md. Every app is also an MCP server and has an assistant in its corner; mcp.json is how both understand it. Presentation only: a name, an instructions paragraph, a sentence per tool, "read_only": true or false on EVERY tool (robomotion app validate fails one without it; run robomotion app codegen again after it changes), and the destructive / idempotent hints. Keep an action an agent must never run out of it with "enabled": false. Never put schemas in it.
- Generate the screens from the archetype, with sample data baked in. The
app's name belongs to the shell's title and nowhere else on a screen: a
screen's title is what that screen does, a card's title is what the card
holds. A one-screen app that repeats its name on the shell, the screen and
the card reads as a template, not as somebody's app. Copy the archetype's screen structure, compose it from
./docs/app-kit-reference.mdcomponents, and fill tables and cards with realistic sample rows declared as aSAMPLE_*const at the top of each screen file. The screens must render fully before any backend exists - a person who sees their app in the first minutes stays in the conversation; one who waits for a backend leaves.
3a. Sample data is a FALLBACK, never a switch. Every button is wired to its real action from the first draft. Write the sample rows as what the table shows when there is no answer yet:
const rows = search.data?.matches ?? SAMPLE_MATCHES; // yes
<Form action={search} ...> // always the real one
Never a mode constant, and never anything that makes the action conditional:
const SAMPLE_MODE = true; // NO
<Form action={SAMPLE_MODE ? undefined : search} ...> // NO - dead button
A mode flag leaves a button that submits nothing for ever, and tsc passes
it, because nothing about an unwired button is a type error. robomotion app validate
reports the shape (screens-wired); the person finds it sooner, by pressing
it, and by then it is their app.
There is nothing for a mode flag to do. Before a session exists the kit already shows "Not connected to your robot yet. The screens below show sample data." at the top of the app, and an action that cannot reach a robot fails honestly and says so. A wired button on a draft app is correct; an unwired one is a mockup you will tell somebody is an app.
That banner is the kit's, so do not write it a second time. An
<Alert title="Sample data">The screens below show sample data until the robot is connected.</Alert> on the screen says exactly what the banner
two inches above it already says, and the person reads the same sentence
twice before they read anything about their app (2026-09-09, the
earthquake app's first screen). Say nothing; the banner has it.
And the sample answer is only for an app that has never had a
backend. That is one connection state, "unconfigured", the only one
whose banner says the screens show sample data. In every other state the
app is real: connected with no answer yet, a stopped session, a published
app whose robot is off. Invented rows under "This app isn't running" read
as the app's own data, and a sample result on a connected app reads as a
real answer to a form nobody has filled in. Gate the fallback on that one
state, which useConnection() reports (robomotion app validate's
sample-gated check fails a fallback that is not gated):
const { state } = useConnection();
const rows = search.data?.matches ?? (state === "unconfigured" ? SAMPLE_MATCHES : []);
Anything but "unconfigured" and no answer yet is the EMPTY state ("Type
a topic and press Search"), never the sample one. (The older
state === "ready" ? [] : SAMPLE_MATCHES passes the validator too, but
shows invented rows on a stopped or published app; don't write it.)
3b. Every view that waits on the robot renders THREE states, always: loading,
empty, and failed. Not two. The runtime times a call out after 30s and
rejects the promise; if the screen has nowhere to put that rejection, the
person is left with a spinner that means "broken" and reads as "nearly
there" - no message, no retry, nothing to say that what they are waiting
for is never coming.
Use the kit's ErrorState for the failure and EmptyState for "nothing
here yet" (./docs/app-kit-reference.md), and give the failure a button
that tries again. A screen where the loading branch is the only branch is
not finished.
The failure sits BESIDE the table, never in its place. ErrorState
renders nothing when the connection banner has already said the same thing
(the robot is not connected), so {list.error ? <ErrorState/> : <DataTable/>} leaves a form with nothing under it the moment the robot
is unreachable - no table, no sample rows, no error. Write
{list.error && <ErrorState/>} and then the DataTable unconditionally.
3c. A screen asks for what it shows when it opens, and a row button is a column. A table with source={{ action }} loads itself; every other list, counter or detail runs its read in one useEffect on open, and a form that adds a record refetches the list that shows it. A button the person asked for on every row is rendered as a column, not folded into the table's "More" menu. Both are under DataTable in ./docs/app-kit-reference.md ("Loading a screen", "A button on every row").
- Start the screens and look.
robomotion app devin the background, once; it prints the preview link. You develop on the preview link from here to the end: a screen edit is there as soon as the file is saved, with no build and no publish. After every batch of screen edits,robomotion app screen "<label>"on the screen you touched (robomotion app screen /on a one-screen app; it reads the preview): it prints the text and the console errors, and a screen that throws on first render is caught here, not by the person. Give the person the preview link when they ask where the app is. A change to the flow is not live the same way: save it (step 5b), thenrobomotion app start --restart.
4b. robomotion app codegen whenever app.json or a read_only mark in mcp.json changes, before writing code against it. Run it from app/.
- Build the flow, one action at a time, in the order the person will press them. For each action:
App Actiontrigger → the real work →App Respondon EVERY path (an unresponded call only ends by timeout, which the person experiences as a hung button). Long work sendsApp Progress.src/generated/actions.gen.tsat the project root gives you the param/result types. Flow SDK mechanics (node grammar, browser, credentials) are thecreating-flowskill - use it. The exact shape of an app's flow is the next section; read it before the first node.
5b. Check, save, check again. The robot fetches the flow from git, so an uncommitted flow is a flow the robot has never seen, and robomotion app validate's flow-committed check fails until the flow is saved. So, in this order:
robomotion app validate, and fix every problem it names exceptflow-committed.- When the flow's nodes changed:
robomotion buildat the project root (the canvas positions, below). - Save:
git add -A && git commit -m "<what changed>" && git pushat the project root. robomotion app validateagain.flow-committedpasses now; if anything else fails, fix it and save again.
The canvas positions the Designer reads live in a .designer.ts beside main.ts and each subflow. Validate never writes them; robomotion build does, and merges: a position already saved is kept, a new node is placed, a removed one is dropped. They are part of the flow, so git add -A takes them with the rest.
A subflow the app arrived with keeps the demo's .designer.ts until a build touches it, and keeps the demo's positions for every node id you reused. After rewriting a starter subflow, delete its .designer.ts, then robomotion build, so the person's canvas shows your subflow laid out rather than piled where the demo's nodes were.
6a. When the app has no robot of its own yet - a brand-new app never does -
ask before anything gives it one, and the question is a CARD, not a
sentence. On an app with no robot, robomotion app start says the app has
no robot yet and points at robomotion app robot; that is your cue to ask,
never to run app robot straight away, because it spends one of a small
number of robots in their workspace. This is the last question of the build and it
arrives at the end of a long summary, where a sentence ending in a question
mark leaves the person nothing to press. Call the question tool
(AskUserQuestion in Claude Code). Exactly this shape:
header: "Robot"
question: "Your app needs a robot to run on. Shall I set one up?"
options: "Yes, set it up" / "Not now"
The header is a word the person reads too: "Robot", not "App robot" or
"Robot slot". Writing the same words into your reply instead is not a
different spelling of the same thing, it is the fault. The person owns
robots; slots and types are our bookkeeping. Say what it costs only if they
ask. On yes: robomotion app robot, then robomotion app start, in the
same turn. On no: stop there and say the screens show sample data until
then. If the person already said yes in their request, that is the answer:
do not ask again.
6b. Press every button, then read the report before you say anything works. After
every robomotion app start (and every --restart), run robomotion app smoke
yourself - nothing runs it for you: every button pressed once through the app's
own MCP door - the same action_call a press in the screens sends - with
stand-in values built from each action's schema (a description's own
example first, then an enum's first member, today for a date, "smoke test"
/ 1 / true otherwise). The report has one row per button, and the row's word is the
whole diagnosis. Classify, then act - never explain:
| Row says | It means | Do this |
|---|---|---|
answered |
The path reached an App Respond (or App Respond Error). result holds what came back. |
Read result. An answer that says the stand-in values were not understood ("that doesn't look like a country code") proved only the rejection path - press it yourself with robomotion app press and real values before you call the button working. |
refused |
invalid_params: the stand-ins were the wrong shape; the path behind the check is untested. |
robomotion app press with values you choose, and say which case you proved. |
dead_end |
The robot's watchdog fired: the path from that App Action never reaches an App Respond. last_node is the last step that started and never finished. |
Open the flow, find last_node, wire the path from it through to an App Respond (a port that goes nowhere, a chain nothing enters, a branch with no answer). Save, robomotion app start --restart, robomotion app smoke again; read that report too. |
unwired |
No App Action node serves this action. |
Add Robomotion.Apps.Action with this action name, wire its path to an App Respond, save. |
stale |
contract_mismatch / unknown_action: the running backend is older than app.json. |
Save the flow and call robomotion app start again. |
dead_after_first |
The first press answered and the second found nothing running: the flow ended itself. | Hard rule 5. Remove the Stop/End, or the path that reaches one; wire Core.Trigger.Catch to an App Respond Error. Save. |
step_failed |
A step failed inside the robot before the path reached an App Respond or App Respond Error. |
robomotion app logs names the step and its error. Fix that step, save, robomotion app start --restart, robomotion app smoke again. |
declined |
The app said no on purpose: its App Respond Error answered with a message and no step failed (a duplicate name, a missing choice). Not a fault, and not proof of the good path either. |
robomotion app press with values it should accept, and say which case you proved. |
not_exposed |
The action is in app.json but mcp.json keeps it off the app's door ("enabled": false), so the pass did not press it, and robomotion app press cannot either. |
Nothing to fix. Prove it the way a person would, with robomotion app try on its screen (step 7). |
untried |
The pass ran out of time before this button, or it needed a record the pass had not made. | robomotion app press it yourself if it matters. |
not_running / still_starting |
The flow is not up, or never loaded its contract. | robomotion app start once more; if it says the session is up, read robomotion app logs for why the flow ended, say so, and end your turn. |
busy / slow / error / skipped |
Nothing proven either way. | Say nothing alarming about the buttons; robomotion app press the one that matters if you need to know. |
Never say a button works that the report says did not answer - and
never say the app is ready over a failed verdict. Write actions ARE
pressed, for real, and the pass takes its own creates away again through
the app's delete when there is one. left_behind names a create it could
not remove: add a delete for that record (contract, flow, screen), save,
restart, smoke again. Take away only what the pass or your own presses
made (created_ids, the ids your press calls returned) - never a record
you read from a list, never one the person entered. An update
(updated_existing) is not a leftover; leave it. Reading last_node beats
every theory you have about why a press timed out (the twenty-sixth pass
spent twenty-one minutes on a confident wrong one).
6d. Prove the paths the stand-ins could not, with real values. robomotion app press <action> --params '{...}' for the refusal ("not enough left"), the second page, the empty case. Read robomotion app logs when a press does not answer as you expect, BEFORE explaining anything: the failing step and its message are there in the robot's own words.
-
Use the app the way the person will, and check every promise. With
robomotion app devrunning and the robot up,app trydrives the live preview; for each screen.robomotion/request-checks.mdnames, drive it as a person:robomotion app try --screen "Items" --fill "Name=Rice" --fill "Unit=kg" --click "Add item" --expect "Rice" robomotion app try --screen "Movements" --select "Item=Rice (kg)" --select "In or out=Out" --fill "Quantity=8" --click "Save" --expect "only"
A button that opens a dialog. While a dialog is open,
--clicklooks for the button inside it first. A confirm dialog's button often carries the same words as the row button that opened it (a row's "Remove" opens "Remove this item?" with its own "Remove"), so a delete is the same word twice:--click "Remove" --click "Remove"- the first opens the dialog, the second confirms it, rather than pressing the row button behind it again. A confirm that is worded differently is named by its own words (--click "Remove" --click "Yes, remove it").It fills fields by their labels, presses buttons by their words, then prints the screen's text, the page's console errors, and every step the robot ran meanwhile. Check every line of
request-checks.mdagainst what the screen shows - reading a screen is not checking it. A fix to what a screen shows is confirmed by reading that screen again, never only by pressing the action. Fix any mismatch, save, restart when the flow changed, try again. Take away the records your tries made before you hand over, through the app's own delete. -
Hand over, honestly. Say what the app does and what to press, in the person's words; give them the preview link (
robomotion app status; members of the workspace open it signed in) or a link fromrobomotion app link; and tell them they can also open it in Robomotion: opening the flow there opens Build with AI with the app, and whilerobomotion app devis running the screens show in its panel. Save first (git add -A && git commit && git push) so what they open is what you built. Say nothing about ids, files or test data unless something was left behind. Offer to publish; never publish unasked. Publishing is the end of development, not a way to test: when they say yes (or asked for it up front),robomotion app publish, and give them the production link it prints. Publishing does not touch the session already running on the app's robot: it keeps running the saved flow ([master]inrobomotion app logs) until a production start - the app's card, or its auto-start - which runs the published version.robomotion app start --restarton this computer still runs the saved flow, for development. Every later change goes back to the preview loop and is published again only when it is done.
The flow side, exactly
One subflow per screen. main.ts is a table of contents, nothing else.
This is a hard rule, and it is about the person, not about tidiness. An app's backend grows one chain per action, all on one canvas: four screens came to ten unconnected chains stacked down a single surface, thirty nodes wide, and nothing on it said which row belonged to which screen (thirty-sixth pass). The person WILL open that canvas - it is their robot - and what they see decides whether they believe they own this app or are merely allowed to use it. Every screen you add makes it worse, and nobody ever goes back and splits it up.
So, every time:
main.tsholds onlyCore.Flow.SubFlownodes - one per screen - and the app's singleCore.Trigger.Catch. Nothing else lives there. Read top to bottom,main.tsis the app's screen list.- Every action a screen owns lives in that screen's subflow file
(
subflows/<6-hex>.ts): itsApp Actiontrigger, its work, itsApp Respond, its refusal branch, its ownLabel/GoTopairs. - Name the subflow exactly what the person sees in the sidebar -
subflow.create('Problems', ...), notsubflow.create('listIssues', ...). The canvas then labels each box with their own word for it. - Every screen's
Core.Flow.SubFlownode is written with{ optHidePorts: true }-f.node('b2d4e1', 'Core.Flow.SubFlow', 'Problems', { optHidePorts: true }), never{}. A screen is entered byApp Actionand left byApp Respond, so the node's input and output ports and theBegin/Endinside are never wired; this option tells the Designer not to draw them, and the person's canvas shows one clean box per screen instead of ports that go nowhere. It is cosmetic and Designer-only: the robot ignores it, andBegin/Endare still written (below).
Screens inside screens nest the same way. When pressing a row opens a
detail screen, that screen is a subflow called from its parent's subflow, not
from main.ts, and its name carries the path:
main.ts
├── SubFlow 'Check' → subflows/a1c3f0.ts
├── SubFlow 'Problems' → subflows/b2d4e1.ts
│ └── SubFlow 'Problems / Detail' → subflows/c3e5f2.ts
│ └── SubFlow 'Problems / Detail / History' → subflows/d4f6a3.ts
├── SubFlow 'Pages' → subflows/e5a7b4.ts
└── Catch 'Say What Went Wrong'
A person on the Detail screen looking for what it does finds one box called 'Problems / Detail'. That is the whole point: the flow reads as the app's own navigation.
The cost, which you pay without complaining. A subflow file's name IS its calling node's id, so one subflow is called from exactly one place - which is precisely what one-screen-one-subflow needs, so the rule and the runtime agree. But it also means a step several screens share (open the database, make sure the tables exist) CANNOT be one subflow called from five: write those four nodes into each screen's subflow. Do not try to call a subflow twice, do not flatten the app back onto one canvas to avoid repeating them. Four repeated nodes are cheaper than a canvas nobody can read.
This overrides creating-flow's "do not use a subflow for fewer than three
nodes". There, the unit is the node; here it is the screen. A screen with one
small action still gets its own subflow.
The general node grammar belongs to creating-flow, but these seven types ship
only in this package, and hunting for them costs a search round every build.
f.node takes the type, never the display name:
| Type | Shows as | What you actually set |
|---|---|---|
Robomotion.Apps.Action |
App Action | optActionName - the action's name in app.json |
Robomotion.Apps.Respond |
App Respond | nothing; it answers with msg.result |
Robomotion.Apps.RespondError |
App Respond Error | optCode, optRetryable, inMessage - the sentence the person reads, see below |
Robomotion.Apps.Progress |
App Progress | optPercent |
Robomotion.Apps.EmitEvent |
App Emit Event | optEventName, optAudience |
Robomotion.Apps.GetFile |
App Get File | optDownloadDir |
Robomotion.Apps.SaveFile |
App Save File | nothing |
One complete action, start to finish. It is always two files: main.ts names
the screen, and the screen's subflow file holds the action. Both open with
creating-flow's Required First Line, copied whole even where the file uses
one helper or none (subflow in place of flow in a subflow file): a helper
you add later is then already imported, and a missing one is a
ReferenceError on the robot, not a build error.
// main.ts - the package, one SubFlow node per screen, the catch-all (below)
import { flow, Message, Custom, JS, Global, Flow, Credential, AI } from '@robomotion/sdk';
flow.create('<flowId>', '<Flow Name>', (f) => {
f.addDependency('Robomotion.Apps', '<version>'); // the one main.ts already has
f.node('b2d4e1', 'Core.Flow.SubFlow', 'Problems', { optHidePorts: true });
}).start();
// subflows/b2d4e1.ts - the file name is the SubFlow node's id
import { subflow, Message, Custom, JS, Global, Flow, Credential, AI } from '@robomotion/sdk';
subflow.create('Problems', (f) => {
f.node('e1a7c3', 'Core.Flow.Begin', 'Begin', {});
f.node('f2b8d4', 'Core.Flow.End', 'End', { sfPort: 0 });
f.node('a3c1f9', 'Robomotion.Apps.Action', 'Search Call', { optActionName: 'search' })
.then('b8e274', 'Core.Programming.Function', 'Do The Work', {
func: 'msg.result = { hits: [] };\nreturn msg;',
})
.then('c4d952', 'Robomotion.Apps.Respond', 'Send Results', {});
});
Every subflow MUST have its Core.Flow.Begin and Core.Flow.End, and in a
screen's subflow they stand alone, connected to nothing. creating-flow's
subflow rule stands: write both. What differs in an app is that nothing needs
to enter through Begin. App Action is already a trigger: it has no input
port (0 inputs), so it is always the FIRST node of its chain, written with
f.node(...), and nothing is ever .then()ed into it. Begin chained into
App Action does not compile: Cannot chain to node '<id>' (Robomotion.Apps.Action): it has 0 inputs. So:
BeginandEndare two lonef.node(...)lines at the top of the file. Do not wireBeginto the action, and do not wire the action's path toEnd: the path ends at itsApp Respond.- A screen with two actions has two
f.node(...)chains in its subflow file, each starting at its ownApp Action. - Steps an action needs first (open the database, make sure the tables exist)
go AFTER its
App Action, never before it. - A sub-screen's
Core.Flow.SubFlownode sits in its parent's subflow file the same way it sits inmain.ts: its ownf.node(...), chained to nothing. - Every screen's
Core.Flow.SubFlownode gets{ optHidePorts: true }, inmain.tsand in a parent screen's file alike. It is a Designer-only cosmetic option: the node's input/output ports and the loneBegin/Endinside are not drawn, because nothing is ever wired to them in a screen. The robot ignores it, and theBegin/Endlines above are still written.
f.addDependency('Robomotion.Apps', '<version>') goes in main.ts, once. The
subflow files use the main flow's packages, and main.ts needs it anyway for
the catch-all's App Respond Error. The version is already there: robomotion create app writes the latest published one. Keep it; never type one from
memory. A newer one is a deliberate change: robomotion describe package Robomotion.Apps names what is published, and the flow is saved and restarted
after the bump.
The caller's arguments arrive as msg.params.<field>; the answer is whatever
sits on msg.result when App Respond runs. Both shapes are already typed
for you in src/generated/actions.gen.ts at the project root.
An action that makes a record answers with that record's id.
{ id, ...whatever else the screen needs } - { id: 'mv17...', balance: 17 },
never { balance: 17 } alone. The id is how the screen points at the row it
just added, how the checks after a save know which row the press made, and
how that same row is taken away again; a create that answers without it
leaves a row nobody can name. Declare id in the action's result in
app.json.
And the catch-all, in main.ts, every time. An unhandled error ends
the flow and the app with it (hard rule 5), so every backend has a
Core.Trigger.Catch wired to an App Respond Error. This is the whole of it -
there is nothing to look up in creating-flow for it:
f.node('d5e061', 'Core.Trigger.Catch', 'Say What Went Wrong', {
optNodes: { all: true, ids: [], type: 'catch' },
})
.then('e7f2a8', 'Robomotion.Apps.RespondError', 'Tell Them The Problem', {
optRetryable: true,
inMessage: Message('error.message'),
continueOnError: true,
});
continueOnError: true on that answer step is not optional. The Catch
takes EVERY step's failure, its own answer step's included. A failure on a
path that is not an app call - the start-up, a background job after its
{ id } was answered - has no call to answer, so App Respond Error fails
too, the Catch catches that, and it goes round for ever: the log fills with
unknown call_id and the robot answers no button at all.
Catch is a second trigger beside your App Action nodes (a separate
f.node(...) chain, never .then()ed after anything), optNodes as written
catches every node, and msg.error.message is the thrown error's own text.
Once, in main.ts, and not again in the screens. The robot scopes a
Catch to the file it sits in: a catch-all in main.ts covers the main flow
and every error a screen's subflow leaves uncaught, because an uncaught error
climbs to the parent until a Catch takes it, and stops there. So the one in
main.ts is the safety net for the whole app, and a screen's subflow file
does NOT get a copy of it. A second "Say What Went Wrong" pair in every screen
says nothing the net does not already say, and it is noise on the person's
canvas. A screen adds its own Catch only when it has something of its own to
say: different words or a non-retryable answer for that screen (all: true
inside the screen's file covers that screen's nodes only), or a Catch on the
ids of one lookup so the action can answer with words about that lookup.
The main.ts catch-all stays either way.
App Action is a trigger, so it has no input port, and App Respond and App Respond Error end a path. The validator knows both: it reports neither as
unreachable or as a dead end. A path from an App Action that never reaches an
App Respond shows up at run time instead, as dead_end in robomotion app smoke.
App Respond Error needs a message. Its message input defaults to empty,
and an empty one puts a title and a Try again button on screen with nothing
between them - a person told that something failed and never told what, while
the reason sits in the robot's log. Give it the reason, in the words the
person would use:
.then('f9a4c6', 'Robomotion.Apps.RespondError', 'Say What Went Wrong', {
optRetryable: true,
inMessage: Message('error.message'), // the caught error, or better:
})
Better still on a branch you can predict, write the sentence yourself -
inMessage: Custom('Those percentages are the wrong way round.') - because the
error text was written for you and the message is read by them. Never pass a
raw stack: Error: x at main (main.js) on a screen is a bug report, not an
answer.
A refusal is a branch, never a throw. "Not enough left in the tin",
"no copies on the shelf", "that date is in the past" - anything the flow can
foresee is the app WORKING, and it goes: a Function node that sets a flag
(msg.refused = 'The tin only has £5.00 in it.') → a Core.Flow.Switch on it
→ App Respond Error with that sentence on the refused side, the real work on
the other.
When a Function decides by itself (outputs: 2), the path that goes on is
output 0. .then() always continues from output 0, so write the check as
return [msg, null] for "carry on" and return [null, msg] for "refuse", chain
the real work with .then(), and send the refusal from output 1:
f.edge('<check id>', 1, '<App Respond Error or GoTo id>', 0). The other way
round - refusing on output 0 and chaining the work with .then() - puts both on
output 0 and wires the real path to nothing, so every good press is dropped
and the screen waits until it times out. robomotion build refuses that shape
(a Function sends a message out of an output that goes nowhere).
A throw new Error(...) caught by the Catch gets the same words to
the screen, but it also paints a red "Node Execution Error" on the person's
canvas and an error line in the robot's log every time somebody is told no -
and they will open that canvas and ask whether their app is broken. Reserve
throw for what you did not foresee.
A wire that crosses other rows is a GoTo, not an edge. An app flow is
one row of nodes per action, stacked. The refused side of an action's Switch
goes to that action's App Respond Error, and there are usually two or three
branch points feeding it from different places in the chain. Written as
f.edge(...) into a node declared after the chain, the layout puts that node
rows away and draws a wire diagonally across every action in between - once
per branch. Open a four-action app built that way and the canvas is a cat's
cradle; the person WILL open it, and they will ask whether their app is broken.
Core.Flow.Label and Core.Flow.GoTo are the answer, and they are not only a
loop construct. A GoTo has no outgoing wire at all - it jumps - so the long
edge stops existing rather than being redrawn shorter:
// Beside the node it guards. Nothing is ever wired INTO a Label: it has no
// input port, and a GoTo is how you arrive.
f.node('7a1b0b', 'Core.Flow.Label', 'Refused', {});
f.node('7a1b07', 'Robomotion.Apps.RespondError', 'Say Why', REFUSE_ERR);
f.edge('7a1b0b', 0, '7a1b07', 0);
// On each branch point, in its own row, a short hop to a GoTo sitting there.
f.node('7a1b0c', 'Core.Flow.GoTo', 'Say Why', { optNodes: { ids: ['7a1b0b'], type: 'goto', all: false } });
f.edge('7a1b06', 1, '7a1b0c', 0);
The rule: if an edge would span more than about two rows, make it a GoTo to
a Label. Name the GoTo after where it goes ("Say Why"), so the row still
reads left to right and nothing is hidden. Core.Flow.Goto with a lowercase T
is not a registered type - the capital in GoTo is load-bearing.
The same holds inside one action when its chain is too long for its row. End the
row with a GoTo and start the next row with a Label; never draw a wire back
across the canvas. On a node with several exits, put each exit's target at the
height of its port: ports are drawn top to bottom in index order. Layout detail is
in the creating-flow skill's docs/patterns/comments-and-layout.md.
An action that calls a website uses Core.Net.HttpRequest, which is not in this
package and is the one node worth naming here so you do not spend a search
round on it.
outBody is only parsed when the server says application/json. Plenty of
real services return JSON under another content type (text/javascript,
text/plain), and then msg.response is a string, msg.response.items
is undefined, and your not-found branch fires on every single query. Nothing
errors: the robot's log shows every step finished, the screen politely says
nothing matched, and the person believes the search is broken rather than the
app. Parse defensively, always:
.then('d8f317', 'Core.Programming.Function', 'Build The List', {
func: `var data = msg.response;
if (typeof data === 'string') { try { data = JSON.parse(data); } catch (e) { data = null; } }
// ... now read data.results
return msg;`
})
And when a search legitimately finds nothing, say which it was: an empty answer from the service and an answer you could not read are the same screen otherwise.
A not-found is a status code you can name, not "anything but 200". Some
services answer a miss with a 404 and a JSON body rather than an empty list,
so the not-found branch has to read the status - and the moment it does, the
easy shape is if (status === 200) { ...rows } else { ...nothing matched },
which quietly tells the person their word, their part number or their postcode
does not exist every time the call times out, the service is down, or the key
is wrong. Those are three different sentences and only one of them is theirs
to fix. Name the codes that mean not-found and let the rest be a failure:
func: `var status = msg.httpStatus;
msg.failed = '';
if (status === 404) { msg.result = { found: false, message: 'Nothing matched that.' }; return msg; }
if (status < 200 || status >= 300) {
msg.failed = 'The service is not answering just now (' + status + '). Try again in a minute.';
return msg;
}
// ... 2xx: read the body, and an empty list is ALSO found:false
return msg;`
Both of those are branches, not throws, by the rule above: msg.failed goes to
a Core.Flow.Switch and out through App Respond Error, so the person gets a
sentence and the canvas stays green.
Give the call room, too. optTimeout is in seconds and a public service on the
other side of the world is regularly slower than it looks from here: a timeout
set to about what the call takes today is a coin flip, and the side it lands on
is a wrong answer on the person's screen. Sixty seconds costs nothing when the
answer arrives in two.
A row that lacks what the person asked for is not a match. Public indexes mix kinds of record - datasets and books beside papers, comments beside stories, albums beside songs - and the first rows a search returns are often not the kind the person named. Two things, both every time:
- Ask the service for the kind the person named when it can be asked - a type filter, a tag, an entity parameter - and prefer an index whose records are that kind over one that mixes them.
- Drop a row that is missing a column the person asked for, and ask for more rows than you show so the ten on screen are ten real ones. A cell reading "Not listed" in every row is this rule skipped, and a person reads a table whose first rows are blanks as a search that does not work.
Remembering something: the flow owns the storage
An app that has to remember - a list somebody adds to, a queue that survives a
reload, last month's numbers - keeps that data in a database its own FLOW owns.
Robomotion.SQLite is the default when the data belongs to this app and this
robot; when it is bigger, or shared with something else, or already lives
somewhere, it is one of the database packages (Postgres, MySQL, MongoDB, Google
Sheets and the rest) and searching-packages is what finds it - the same step
0c you ran before writing app.json. An app is bounded by exactly two lists of
what it may use - @robomotion/app-kit on the screens, the Robomotion package
library behind them - and storage is not a third one: it is one more package the
flow calls, like every other system the flow reaches.
Open a SQLite database by a full path built from the home folder, in a
Function, and put the app's own id in the file name:
msg.db = 'Data Source=' + global.get('$Home$') + '/<app name>-<first 8 of app_id>.db;Version=3;',
then optConnectionString: Message('db') on every SQLite node (Query, NonQuery and Insert all take it, so no Connect node and no inConnectionId). The id is what keeps two apps
apart: a person who builds "Pantry Stock" twice gets two apps, and a file
named only after the app hands the second one the first one's tables -
CREATE TABLE IF NOT EXISTS sees them, keeps them, and every query then
fails on a column the old table never had (seen live, 2026-09-14). Never a
relative Data Source=app.db either: the robot runs the package inside its
own versioned folder, so that file is lost on the next package update and
the app starts over empty. The validator refuses the relative form.
The SQL is written in the SQL node, in SQL. Robomotion.SQLite.Query /
NonQuery take the statement in their func property with {{{field}}}
placeholders filled from msg. A paged list is two queries: the page, and
how many rows match altogether, because DataTable's pager needs the count
across every page (total), not the length of this one:
.then('a4b005', 'Core.Programming.Function', 'What was asked', {
func: `var p = msg.params || {};
msg.filter = '%' + String(p.filter || '') + '%';
msg.offset = Number(p.offset) || 0;
msg.limit = Number(p.limit) || 25;
if (p.limit === 0) {
msg.limit = -1;
}
return msg;` })
.then('a4b006', 'Robomotion.SQLite.Query', 'Read the page', {
optConnectionString: Message('db'), outResult: Message('page'),
func: `SELECT v.id, v.name, v.email, v.terms_days,
(SELECT COUNT(*) FROM invoices i WHERE i.vendor_id = v.id AND i.status = 'waiting') AS waiting
FROM vendors v
WHERE v.name LIKE '{{{filter}}}' OR v.email LIKE '{{{filter}}}'
ORDER BY v.name COLLATE NOCASE
LIMIT {{{limit}}} OFFSET {{{offset}}}` })
.then('a4b008', 'Robomotion.SQLite.Query', 'Count what matches', {
optConnectionString: Message('db'), outResult: Message('matching'),
func: `SELECT COUNT(*) AS n
FROM vendors v
WHERE v.name LIKE '{{{filter}}}' OR v.email LIKE '{{{filter}}}'` })
.then('a4b007', 'Core.Programming.Function', 'Hand the page over', {
func: `var total = Number(msg.matching.rows[0].n);
msg.result = { rows: msg.page.rows, total: total };
return msg;` })
The count query repeats the page's WHERE exactly and nothing else: no
ORDER BY, no LIMIT. limit: 0 is the table's export asking for every
row, and SQLite spells "no limit" LIMIT -1, hence the if.
Text that can hold an apostrophe: let the node quote it. The SQLite nodes
(Robomotion.SQLite 1.7.1) double a text value's single quotes themselves when
every placeholder for that key sits directly between single quotes:
WHERE name = '{{{name}}}' with O'Brien runs as 'O''Brien'. Anywhere else
the value goes in as it is, and an apostrophe ends the SQL string early: the
statement fails, and a crafted value runs SQL of its own. So:
- A write that carries the person's text goes through
Robomotion.SQLite.Insert, which takes rows, not SQL, so nothing is quoted and nothing needs to be. Build the row in a Function (msg.row = { columns: ['id', 'name', 'email'], rows: [{ id: id, name: p.name, email: p.email }] };), then{ optConnectionString: Message('db'), inDatabaseTable: Custom('vendors'), inTable: Message('row'), optReplace: true }.optReplacemakes it the insert-or-replace a whole-row write is (below). - An id that goes into a
WHEREis checked first, in the Function that reads it:if (!/^[A-Za-z0-9-]+$/.test(String(p.id || '')))is a refusal ("That item no longer exists"), never a query. - Text in a
WHEREis written'{{{name}}}', quotes hugging the placeholder. Never double its quotes by hand as well: the node doubles them again,O'Brienis searched for asO''Brien, and nothing matches. - A search filter builds the whole
LIKEpattern in the Function,%signs included (msg.filter = '%' + p.filter + '%'), and the SQL saysLIKE '{{{filter}}}', as above. NeverLIKE '%{{{filter}}}%': there the placeholder sits between%signs, the node does not quote it, and an apostrophe breaks the query. - One key, one way. A key written
'{{{x}}}'in one place and{{{x}}}in another is not quoted anywhere. Use two keys. - A piece of SQL assembled in a Function (avoid it, below) goes in
verbatim as
{{{where}}}: that Function makes every value in it safe itself (ids checked against a pattern, text with its quotes doubled - here, and only here, by hand).
Never a q() helper that quotes values, never a WHERE built by concatenation,
never rowsOf() / vendorOf() shapers, never a money() or pad() in the
flow (the screen formats), and never a block of helper functions at the top
of every Function node. The program is the flow; a Function is glue code,
written only where a step needs it, and formatted for a person (one
statement per line, blocks on their own lines). robomotion validate
refuses crammed code and the same block opening three or more Functions;
the rule and the table of where each urge belongs is creating-flow hard
rule 7 and its docs/reference/function-nodes.md. Reason: an app whose
fifty-three Function nodes each began with fourteen helper functions, one
crammed line each, was a minified JavaScript program wearing a flow's
clothes; the person who opened one node to change a line could not find
the line.
So there is no storage half of the contract. A screen reads stored data the way it reads anything else: by calling an ACTION, and the flow answers it out of the database. Reason: the flow is the side that holds the credentials, writes the query and can be corrected when the person changes their mind about what "waiting" means. A screen that could reach the store on its own would be a second place where those rules live, and the two would disagree inside a week.
A table of stored rows is a paged action. DataTable's
source={{ action: listThings, pageSize: 25 }} calls the action with
{filter, sort, offset, limit} and reads {rows, total} back, so the filtering
and the ordering happen in the query, where the rows already are, and the screen
never holds more than a page. The exact shape both sides must keep is in
./docs/app-kit-reference.md; do not invent a different one.
The same table does the other two things people ask of stored rows, and neither
needs a new contract shape. Working them in batches is selectable plus
bulkActions: one more action, whose params take either the ticked ids or
{all_matching, filter} when the person chose "select all N" - so a job over
forty thousand rows is the flow's job and not the browser's. Taking them
away is exportable, which asks the SAME paged action with limit: 0
("no paging: all of them"), so an export costs no second action at all. Never
hand-roll a checkbox column, a ticked-ids array or a CSV string in a screen.
A moment in time is stored as a number: Date.now(), milliseconds since
1970, in an INTEGER column. Never as a string you put into a package
node's {{{template}}}. Many packages (the database ones among them) read a
message value that looks like an ISO date as a date, and write it back in
their own format: 2026-09-14T11:25:14Z goes in and 09/14/2026 11:25:14
lands in the table, UTC with the zone gone, so every screen shows it hours off
for anyone not on UTC. A number has no format to lose, sorts correctly and
the screen turns it into the person's own time with new Date(ms). When the
time is taken inside SQL instead, keep the zone in it
(strftime('%Y-%m-%dT%H:%M:%fZ','now') in SQLite). A calendar date with no
time of day (a due date, a birthday) stays a plain YYYY-MM-DD string.
A write through an action reaches every open screen by itself. From
Robomotion.Apps 0.6.0, when an action that writes finishes - pressed on a page,
run by the assistant, or called over MCP - the robot tells every other open page,
and their tables and read hooks ask again and show the change in place. That
needs read_only on every read action in mcp.json, and nothing else.
When a change made outside the actions has to reach a screen, emit an event.
App Emit Event plus useEvent or useLive on the screen: a long job
finishing, a scheduled branch writing, a number crossing its limit.
And hard rule 6 applies hardest here: the flow creates its table before the first write, on every path that reads or writes it.
From Robomotion.Apps 0.1.8 a page whose contract does not match is told
WHICH kind of mismatch it is. One local robot runs one app session at a time,
so on a machine with several apps the ordinary answer is "this robot is
running something else" - not "your app was updated". The screen says so, in
amber, and offers to start this app rather than a Reload that cannot help.
From Robomotion.Apps 0.1.7 a call the robot refuses for the wrong
parameters says which ones: the screen sent the wrong details for
"": missing required property "". It sent: . If you ever see a bare "invalid parameters" on a screen, the app is pinned to
an older version.
A write REPLACES the row. Half a row destroys it.
A flow that stores a record normally writes it whole - an insert-or-replace, a rewritten spreadsheet row, a document put back. It does not merge. So a button that changes one field of an existing row has to send every field that row has, or the fields it left out are gone.
So when app.json declares an action with every field of the record and the
screen sends two of them, the fields it left out are written as nothing:
params: (row: Item) => ({ id: row.id, done: true }), // NO - the other fields die
params: (row: Item) => ({ ...row, done: true }), // yes - the whole row
One press turns a full row into dashes and zeros, it survives a reload, and nothing fails: the action returns ok, no node errors, no log line looks wrong, because the robot did exactly what it was told.
So: a row action that toggles or edits a field spreads the row. And when
you change what an action takes, change all three halves in the same breath -
app.json, the flow's write step, and every screen that calls it; the screen is
the half most easily forgotten. robomotion app validate reports a call site that passes
fewer fields than app.json declares (action-params); do not wave that
through.
The other way out is a write that only touches the columns it was handed (an
UPDATE ... SET of those fields alone), which is right when the action is
honestly a one-field change - setStatus, not saveItem. What is never right
is an action that declares the whole record and is called with half of it.
When somebody says their app is losing what they saved, look at the write step and the call site before you touch anything else: a whole-row write handed half a row is the usual answer, and both halves are one read away.
Note what the example does not have: an ending. No Core.Flow.Stop, no
Core.Flow.End. The flow is the app's backend and stays up forever behind the
screens (hard rule 5) - the last node on every path is its App Respond or
App Respond Error. This is the single easiest way to ship an app that works
exactly once, so check for it before you save.
Stay inside your own app, and use the tools
The project is the folder robomotion create app made (or robomotion app create ran in): main.ts, app.json, subflows/ at its root, the screens under app/. Work only in it.
-
Your shell starts in the FLOW's folder, not the app's, and every command starts there again. A
cdin one command does not carry to the next, sopwd && ls && cat src/generated/actions.gen.tsfinds nothing and the next call goes hunting. Begin every command withcdinto the folder you mean, with the absolute path of the project. The same for the write and edit tools: the robot's steps live at<flow_path>/main.tsof the project, and a baremain.tslands wherever the shell happens to be - one build wrote its whole backend into the wrong folder that way, saved it, and the next save replaced it with the empty skeleton. Always the absolute path. -
Never read, glob or grep another app's folder. The flows directory holds every project on this machine. A pattern like
*/main.tswalks all of them, wastes the whole turn, and risks copying one person's app into another's. Anchor every path at the two you were given. -
Never compute the contract hash yourself, and never shell out to
python,node -eorjqto do it. Runrobomotion app codegenin the app folder: it writes bothactions.gen.tsfiles fromapp.jsonand prints the hash.robomotion app hashprints just the hash. Hand-hashing gets a different answer than the server's canonicalisation, which blocks the app from connecting withcontract_mismatch- and python is not installed on most people's machines.robomotionis the tool that is always present; do not reach forbun run,npm runornpxto do a job it already does. -
Prefer the
robomotion appverbs over raw shell: each does one job properly. -
archetypes/in the app repo is reference material. It is not compiled and not checked; leave it where it is. Never delete it and never edittsconfig.jsonto work around it. -
Press the buttons yourself, both ways. Every app is also an MCP server, and a
tools/callthrough it is the very sameaction_calla press sends:robomotion app smokepresses every button once (step 6b),robomotion app pressone button with values you choose. Androbomotion app trypresses them the way the person will, in a browser signed in as them (step 7). That is the whole of how an action runs for real from here. Never hand-write a websocket message, never read the runtime's compiled source to work out the wire format, and never opencredentials.yamlor any other secret. To watch a press - yours or theirs -robomotion app logs -f: a Debug or Log step in the flow arrives there with its value. -
A press of yours never deletes something the person put there. The rows in their app are their work, and everything on their screens is derived from them. Testing a delete by deleting one of their records is the one press you must never make: on 2026-09-09 a build checked its own fix with six presses of
removeMatchNighton real ids, and the two match nights the person had entered - the rows every number on the last screen came from - were gone, unsaid and unrecovered. To test a delete, press the matching create first, delete THAT, and say what you tried.robomotion app smokerefuses the other case outright; if the person has asked for one of their own records to go, say plainly that you will not remove their data from your side and point them at the button that does it. The same care applies to a create: a row you add to try something out is taken away again before you hand the app back, or named in what you say. -
When something fails, read the robot's error BEFORE explaining it.
robomotion app logscarries the step that failed and why, in the robot's own words. Diagnosing from the shape of the symptom instead produces confident fiction - "your press never reached the robot" about a press that reached it and failed three steps in, on a reason the robot's log had stated in one line. A wrong explanation is worse than none: it spends their trust and sends them back into the same failure, now believing it was fixed once already. If the logs say nothing, say that, and say what you are going to try next.Read the logs BEFORE restarting anything. The failure the person is describing is in the log now; restart first and you are reading a fresh one, which reads exactly like "the press never arrived".
-
Never show identifiers. App ids, flow ids, commit shas, contract hashes, file names and node property names are yours, not the person's. "The app is created" - not "The app was created (id
0cfd...)".
Hard rules
Each rule carries its reason. The reason is why you don't route around the rule when it feels inconvenient.
-
robomotion app createandrobomotion app syncinstall the packages. They place@robomotion/app-kitand@robomotion/apps-runtimeunderapp/vendor/and run the install. Never symlink, copy orbun installpackages by hand, and never borrow them from another app's checkout - if something looks missing, runrobomotion app syncand read what it says. -
Kit-only. Compose
@robomotion/app-kitcomponents plus Tailwind classes for layout. Never write a new UI primitive, never add an npm dependency, never editvite.config.ts,tailwind.config.ts,src/index.cssor the dependency list. The allowlist is exactly:react,react-dom,@robomotion/app-kit,@robomotion/apps-runtime, and the dev toolchain -robomotion app validatefails on anything else. Colours, tables, spinners and pictures drawn by hand are reported too (robomotion app validate'skit-onlycheck, by file and line): a Tailwind palette colour (text-gray-500), adark:variant, a hex in brackets or an inlinestylecolour; an inline<svg>; an inline<table>; ananimate-spindiv. The kit's preset gives a screen the colours it may name (text-muted-foreground,bg-card,border-border,text-primary),Icongives it every picture,DataTableevery table andSpinnerthe one spinner; the "Design language" section of./docs/app-kit-reference.mdis the whole vocabulary. Every nav item carries aniconinsrc/screens.tsx, and every file undersrc/pages/opens with<Screen>. Reason: a prompt-built app that can pull arbitrary packages becomes a codebase nobody can review; the kit is also what keeps every screen themed, dark-mode aware, and accessible without you doing anything, and a colour chosen by hand is the one thing on the screen that does not follow the theme. -
Actions only through the generated typed stubs.
src/generated/actions.gen.tsexports one hook per action,use<Action>()(forgreet:const greet = useGreet()), plus<Action>Params/<Action>Resulttypes;greet.datais typed and<Form action={greet}>/<Button action={greet}>link the control. Use those. Never writeuseAction("name")yourself - untyped, itsdatais{}andtscfails on the first field you read. Events useuseEventwith the generated payload types. Never hand-write transport, never invent a message format, never callapp.callfrom screen code. Reason: the old app system died because clients hand-invented protocols over a raw channel and drift was discovered by users in production; the stubs make a contract change breaktscinstead of a person. 2b. Every control that runs an action declares it. A button, upload zone or form that makes the robot do something takes the action through the kit'sactionprop (<Button action={greet} params={{ name }}>), or spreadsbindAction(greet)when it must keep its own handler. Never writeonClick={() => greet.run(...)}on its own: the Build view then cannot link the control to its step, the connections map reports the action as unlinked, and the person is told the button they can see does not exist. See./docs/app-kit-reference.md. -
One component per file, flat directories, no barrel files.
src/pages/Review.tsx,src/components/InvoiceCard.tsx- that's the whole depth. Reason: "make that button green" must resolve to exactly one file from the route context; barrels and deep nesting break targeted edits and make hot reload touch more than it should. 3b. The flow files must read back in the Flow Designer. The person opens the app's flow on the canvas and saves it from there; the Designer parses eachsubflows/<screen>.tsstatically and regenerates it on save. So nosrc/helpers.tsimported into subflows, noopenDb(f)helpers that make nodes, no{ ...SQL_FROM_MSG }spreads, nofunc: \${IMPORTED}...`. Declare the SQL helper string as aconstat the top of EACH subflow that needs it and write every node out.robomotion validate(and soapp validate) refuses the rest with "the Flow Designer cannot read it". The rule and the table:creating-flowhard rule 6 and itsdocs/reference/project-format.md`. Reason: an app whose flow used an imported helper ran perfectly and showed "return msg;" in all 53 Function nodes on the canvas; one Designer save would have written that into git. -
Never hand-edit generated files. Anything under
src/generated/is regenerated fromapp.json; editapp.jsonand regenerate. Reason: the next regeneration silently erases your edit, and an edited file no longer matchescontract_hash, which blocks the app from connecting at all. -
An app flow never ends. It is the backend, not a script. It comes up with the app session and stays up for as long as the app lives, serving every press of every button by every person. So no path may end it: never
Core.Flow.Stop, neverCore.Flow.End, and never a "finish", "cleanup" or "done" step that reaches one. Every path finishes at itsApp RespondorApp Respond Errorand goes no further; anything that has to happen after answering (closing a browser, deleting a temp file) belongs before that node, not after a stop. Reason: a flow that stops once it has answered leaves an app that looks perfect and is dead on the second press. The first person to try it gets their results; everyone after that is told "The robot for this app is not connected", which blames the robot for something the flow did to itself, and the screen keeps the previous results under the new question so the failure even reads as a success.An unhandled error ends the flow just as surely as a
Stopnode, so every app backend needsCore.Trigger.Catchwired to anApp Respond Error. Without it the first node that throws takes the whole app down - not that action, the app: every node closes, the caller is never answered, and the screen sits on its loading state for ever with nothing to say why. One typo in one query, and an app that has just been built is permanently dead. Catch turns that into a message on the one action that failed, with the app still serving every other button. -
If the flow stores anything, it creates its own storage first. Whatever holds the data - a table, a file, a folder - is created on a path that runs before the first write and is safe to run again (
CREATE TABLE IF NOT EXISTS, a directory check). An app whose first save is its first crash never gets a second chance from the person who just built it.On every path that touches it, not one of them. A person adds their first item before they ever run a report, so the path they reach first is the one that has to be ready - a setup step wired into the read path alone leaves the write path failing exactly as before. Either put the setup at the start of every path that reads or writes, or run it once where the flow comes up, before any trigger can be served.
And when a first save does nothing, read the flow before you read the session. A button that answers nothing on a brand-new app is a setup question until proved otherwise. Open the flow, follow the path that button runs, and check the storage it writes to is created on THAT path. That costs one read. Restarting the session costs the person another round trip and tells you nothing you did not already know.
Where records live, so you do not go looking. A database the flow owns is the default:
Robomotion.SQLitefor something local to this app and this robot, or whichever database package already holds the person's data -searching-packagesnames it, and the screens read it back through an ordinary action. When the person asks for a file, the nodes areCore.FileSystem.PathExists(ask first),Core.FileSystem.Create,Core.FileSystem.ReadFileandCore.FileSystem.WriteFilefor a JSON file, andCore.CSV.ReadCSV/Core.CSV.WriteCSV/Core.CSV.AppendCSVfor a spreadsheet-shaped one. Read the node cards for their properties; do not tour the catalogue for them. A flow that reads a file and never asks whether it is there failsrobomotion app validatewhen its Catch passeserror.messagethrough - the first press before the file exists would show a raw path. -
The save is
git add -A && git commit && git pushat the project root. The robot fetches the flow from git, so a flow that is not pushed is a flow the robot has never seen, androbomotion app validatesays so. (Under Build with AI the save is a tool and git is refused:./docs/build-view.md.)
Say it like a person
Jargon leaking into narration is the single most common quality failure on this surface. The person sees screens, buttons, and a robot. Talk about those.
| Never say | Say instead |
|---|---|
| node, trigger node | a step, or name the work: "the robot reads the PDF" |
| flow | what the robot does: "the robot's side", "the automation" |
| component, widget | the actual thing: "the button", "the table", "the form" |
| endpoint, API, RPC | "where the data comes from", "the connection to the robot" |
| instance | "your app" |
| websocket, socket | "the connection" |
| validate, validation | "check that everything fits together" |
| headless | "in the background" |
| frontend, backend, SPA | "your app" / "the robot" |
| repo, commit, push | "saved" |
| database, table, query, schema, contract | "where your app remembers things", "your list of X", "the plan of your app" (or say nothing) |
| mock data | "sample data" |
| deploy | "make it live", "publish" |
| scaffold, template, demo files | "what the app started with" (or say nothing) |
| typed client, generated types, typegen, regenerate | say nothing - it is your bookkeeping |
| checkout, working copy, flow checkout | "the robot's side", "your app" |
| contract hash, app.json | "the plan of your app" (or say nothing) |
Wrong: "I added a trigger node for the approve endpoint and validated the flow." Right: "The Approve button works now. When you press it, the robot records the decision."
Wrong: "The websocket dropped so the component can't reach the backend instance." Right: "Your app lost its connection to the robot. Reconnecting now."
Wrong: "I'll regenerate the contract types and restart the dev server." Right: "One moment, I'm updating your app to match the change."
Between steps, say only what you DID or are ABOUT TO DO. Never think aloud to the person: no "Let me check the pspec", no weighing of options, no quoting your own rules back at them ("that satisfies 'renders fully before any backend exists'"), and no plan items read out as narration. That reasoning belongs in your thinking, which they never see. A line they cannot act on reads as something having gone wrong.
Never predict what the robot will answer. The figures a screen shows before the robot connects are sample data, and nobody computed them - so a worked example in your reply ("with these sizes it should come out around 900 litres and 23 bags") is that sample read back as a promise. When the robot's answer differs, and it will, the person is left to decide which of the two is wrong. Say what to press and what kind of thing comes back ("the litres, the bags and the cost"), never the numbers. The robot's answer is the answer.
One answer on screen at a time. When an action fails, the previous result
goes; when it succeeds, the previous error goes. The hooks do this for you -
data and error from an action hook are mutually exclusive - so render
whichever is set and never keep your own copy of the last result beside them.
A wrong answer sitting under a red error card is worse than no answer.
Ask vs decide
Ask ONLY when the request genuinely matches more than one thing: two screens both have a "Send" button, "the report" could be either of two tables, an "approve" could mean one item or all filtered items. Then the question tool with 2-4 quick replies, one question per turn. Everything else: pick the sensible default and say what you picked in one line ("I put the newest items at the top - tell me if you'd rather sort by amount"). A person asked three questions in a row stops answering; a person told what was chosen corrects you for free.
When things fail
| Situation | Do this |
|---|---|
robomotion app screen or app try reports a console error, or app dev's output a build error |
Fix it, re-check, only then reply. Never paste a stack trace at the user; say "fixing a mistake I made on the review screen". |
| An action times out | The call ALWAYS terminates (robot-side watchdog), so a hung button means a path that never reaches App Respond, or a timeout_ms too short for the work. Do not theorise: robomotion app smoke (or robomotion app press on that one button) presses it from here and answers dead_end with last_node, the last step that started and never finished - fix the wiring from there (step 6b). Long robot work (browser, PDF): raise timeout_ms in app.json and set progress: true, then send App Progress from the flow so the wait is visible. |
Robot is offline (robot_offline state or error) |
It's retryable and the kit's ConnectionBanner already shows it. Tell the person plainly: "Your robot is offline - start it and the buttons will work again." Do NOT rebuild or edit anything. |
| The buttons do nothing and the app says "The robot for this app is not connected" - about a robot that IS connected and running the flow | The chat path and the app path are different transports, and this message comes from the app one. Do not rebuild anything and do not blame the robot. The two causes seen live: the flow stopped itself (see rule 5 - an app flow never ends), or the robot's app connection was churning while the page's key exchange was in flight, in which case the robot's log says dropping <type> for unknown conn ... (no key exchange yet) and a reload of the app gets a fresh key. Say what you found; if it is the second, say the connection dropped and ask them to reload the app. |
robomotion app start says the app has no robot of its own |
Expected on a brand-new app. Ask with the question tool, then act (step 6a). On yes: robomotion app robot and then robomotion app start in the same turn. On no: stop there and say the screens show sample data until then. Never run robomotion app robot without the yes: it spends one of a small number of robots in their workspace. |
robomotion app robot says the workspace is full |
Give them the numbers it returns and the two ways forward, in plain words: they can delete an app they no longer use to free a slot, or add more robots to their plan. Both are theirs to do in Robomotion's own pages. Do not delete anything yourself and do not retry. |
robomotion app start says the session was already running |
This app's own backend is up. Run robomotion app smoke to know whether the buttons answer; --restart only when the flow changed. |
robomotion app start did not start (the robot did not connect within its wait, or did not take the run) |
Read .robomotion/robot.log (robomotion app logs) once, say in one sentence what it says, and try robomotion app start once more. Do NOT inspect packages, the package server or the network. |
queue_full / concurrency_rejected |
Backpressure, both retryable. If it recurs, the action's concurrency is wrong for how it's used - see ./docs/contract.md. |
robomotion app validate fails with type errors naming generated types |
You changed app.json without regenerating, or a generated file was hand-edited. Regenerate; never patch the generated file. |
robomotion app validate fails on a dependency |
Something outside the allowlist crept into package.json. Remove it and compose from the kit instead. |
robomotion app validate fails on the schema |
A field in app.json breaks a rule (naming, limits, banned words in descriptions). Fix per ./docs/contract.md. |
Docs
| Topic | Doc |
|---|---|
| Composing screens: every kit component with a usage example | ./docs/app-kit-reference.md |
Authoring app.json: naming, action vs event, timeouts, concurrency, descriptions |
./docs/contract.md |
mcp.json: the app as an MCP server and the assistant in its corner |
./docs/mcp.md |
| Under Robomotion's Build with AI: the tools that stand in for the commands, and what its harness does for you | ./docs/build-view.md |
| Dashboard archetype | ./docs/archetypes/dashboard.md |
| Approval-queue archetype | ./docs/archetypes/approval-queue.md |
| Form-and-table archetype | ./docs/archetypes/form-and-table.md |
| Document-review archetype | ./docs/archetypes/document-review.md |
| Gallery-review archetype (pictures a person approves or marks up) | ./docs/archetypes/gallery-review.md |
| Board archetype (things that move through stages) | ./docs/archetypes/board.md |
Related skills
creating-flow- the flow SDK grammar for the robot's side (node IDs, wiring, browser, credentials, data tables)exploring-browser- map a live website before the robot automates itsearching-packages- find the right package/node for an action
Dates on screen
Format a date for the person's own locale, never by hand:
new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" }) (and
toLocaleString when the time matters). A hand-built M/D/YYYY reads as
the wrong day to most of the world, and a raw ISO string reads as nothing.
new Date(value) is only right when the value carries its zone: epoch
milliseconds, or an ISO string ending in Z or +03:00. A string with no
zone (09/14/2026 11:25:14, 2026-09-14 11:25:14) is read as the viewer's
local time, which is the wrong time whenever the flow wrote UTC. Store times
as the section on remembering something says, and the screen stays right.