Robomotion App Builder
A Robomotion App is two artifacts a non-technical person creates by talking to you:
- a frontend: a real React SPA in its own repo (
app-<appID>), composed only from@robomotion/app-kit - a backend: a Robomotion flow running on their robot, one
App 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. Lovable can't; Power Apps can't.
Two things decide whether this conversation goes well: how fast the person sees the first pixel, 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.
The contract is the spine
app.json at the repo root is the single source of truth for actions, events, types, and screens. Typegen ripples every change into both projects:
app/src/generated/actions.gen.ts ← SPA: typed client + CONTRACT_HASH
flow/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 validate_app 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" }.
Workflow (tuned for time-to-first-pixel)
Narrate progress through todo_write, with items phrased in the user's language ("Design the review screen", "Teach the robot to read invoices") - never internal steps ("run typegen", "start dev server").
The tools, in the order you need them: create_app (once, first) -> sync_app -> save_app -> create_app_robot (only with a yes, see below) -> start_app_session -> validate_app -> publish_app. There is no push_app step: the app half of every save is sent to Robomotion when your turn ends, whether or not you ask, so calling it yourself only makes the person wait twice. list_apps finds an existing app; app_dev_server controls the preview process. Before the contract, searching-packages (step 0c) is what tells you what the robot can already do. Never write app or flow files before create_app has returned - there is no working copy to write into until it has.
- Create the app first. Call
create_appwith a short human name and, WHEN YOU ARE ALREADY IN A FLOW, its id asflowId- in the Build view you always are, and omitting it binds the app to a different flow than the one on the user's screen. It returnsapp_id,flow_idand the local paths, and clones both working copies. Thensync_appbefore you read or write anything. Continuing an existing app instead?list_apps, thensync_app.
0b. Clarify - at most 3 questions, total. Use ask_user_question with quick replies, 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, colors, or frameworks. If the request already answers a question, don't ask it.
0c. Find the backend 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, so the shape of the app follows what is actually there: which systems have a package, what those packages can do, and what has to be done by driving a browser or a desktop program instead. A package beats raw HTTP every time - it carries the authentication, the paging and the error handling you would otherwise write and get wrong. 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. Skipping this is how an app gets built around what you guessed the robot could do rather than what it can.
Pick an archetype silently: dashboard / approval-queue / form-and-table / document-review / board. Match by what the person wants to DO, not the words they used - the chooser table is in
./docs/archetypes/(one file per archetype). Never say the archetype name to the user; 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."Write
app.json- read./docs/contract.mdfirst. Everydescriptionline doubles as the Designer's UI copy, so write it for the end user. 2b. Clear out the demo the app arrived with. A new app already renders something, and every file of that demo is written against the seed's contract - it imports types fromsrc/generated/actions.gen. Yourapp.jsondeletes those types, so any leftover file failstscinsidevalidate_app, including one that nothing imports any more. Deleting the screens you noticed is not enough. After you have written your own screens, ask the checkout what is still pointing at the contract:grep -rl "generated/actions.gen" src/
Anything it lists that you did not write is debris from the demo: delete it.
src/screens.tsxtells you which screens the app actually mounts, so anything unreachable from there goes too, whether or not it still compiles.
2c. Write mcp.json beside app.json - read ./docs/mcp.md. Every app is also an MCP server (its actions are tools, at https://mcp.robomotion.io/<b58>) and every app has an assistant in its corner; mcp.json is how both understand the app. It is presentation only: a server name, an instructions paragraph that says what the app is for and who uses it, a sentence per tool on when to reach for it, and the destructive / idempotent hints. Keep any action an agent must never run out of it with "enabled": false. Never put schemas in it - app.json owns those. Write it in the same pass as app.json and update it when an action changes meaning.
- 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. validate_app
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.
And the sample answer goes the moment the robot is connected. The
banner that explained it goes with the connection, so a sample result
left on a connected app reads as a real answer to a form nobody has
filled in. Gate the fallback on the connection, which useConnection()
reports (validate_app's sample-gated check fails a fallback that is
not):
const { state } = useConnection();
const rows = search.data?.matches ?? (state === "ready" ? [] : SAMPLE_MATCHES);
Connected and no answer yet is the EMPTY state ("Type a topic and press Search"), never the sample one. 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.
save_app. It records the app's working copy and saves the flow behind it - the half the robot actually runs. The app's own copy goes to Robomotion when the turn ends, on its own; do not callpush_appto make that happen sooner, because nothing between here and the end of the turn reads it. The preview comes up on its own a few seconds after this first save - the harness starts it and it appears in the person's preview panel - so do not callapp_dev_server startfor it: the tool answers "already running", and every such call is one more row on the person's screen that did nothing. Callapp_dev_server statusonly when you have a reason to think the preview is down. Tell the person to look at the preview, and say that the numbers are sample data until their robot is connected. 4b.robomotion app codegenwheneverapp.jsonchanges, before writing code against it. Run it from the app folder; it regenerates both typed clients and prints the contract hash.Build the flow backend, one action at a time, in the order the user will click them. For each action:
App Actiontrigger → the real work →App Respondon EVERY path (an unresponded call only ends by timeout, which the user experiences as a hung button). Long work sendsApp Progress. The generatedflow/src/generated/actions.gen.tsgives you the param/result types. Flow SDK mechanics (node grammar, browser, credentials) are thecreating-flowskill - use it.
The flow side, exactly
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:
import { flow, Message } from '@robomotion/sdk';
flow.create('<flowId>', '<Flow Name>', (f) => {
f.addDependency('Robomotion.Apps', '0.3.1');
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', {});
}).start();
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 flow/src/generated/actions.gen.ts.
And the catch-all, in the same file, 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'),
});
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.
App Action is a trigger, so it has no input port and the validator reports it
as an unreachable node. App Respond and App Respond Error end a path, so it
reports them as dead ends. Both warnings are expected on every app. Never
restructure the flow to silence either.
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. 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.
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.
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.
When a change has to reach a screen that is not asking, emit an event.
App Emit Event plus useEvent on the screen: a decision somebody else made, a
long job finishing, a number crossing its limit. A table re-asks by itself after
a run of its own action, so the event is for the screens that would otherwise sit
there showing yesterday.
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. validate_app 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.
6. start_app_session. On a brand-new app, ask before you call it: create_app has already told you the app has no robot of its own, and calling start_app_session only to be refused puts a failed step on the person's screen one row above the question that follows it. Ask first (6a), then call start_app_session after the yes. An app that already has its robot needs no question - call it straight away. It brings up the app's OWN robot on this computer and starts the flow on it; the preview's buttons now hit a real robot. An app runs on its own robot and on no other - you never pick a robot for it, and you never run it on the person's Development or Production robot. Delete each SAMPLE_* const as its backend action comes alive. The buttons need no change, because step 3a wired them to the real action from the start; if changing one is what makes it work, the app was a mockup until now and you have just found that out later than the person would have.
6a. When the app has no robot of its own yet - a brand-new app never does,
and start_app_session says so if you call it anyway - the question is a
CARD, not a sentence. 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 ask_user_question. Exactly
this shape:
ask_user_question
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. It is "Robot". Not "App robot", not "App-robot", not "Robot slot".
Writing the same words into your reply instead is not a different spelling
of the same thing, it is the fault. And the word is "robot" - never
"app-robot", never "one of your app-robot slots", never "application_lc".
The person owns robots; slots and types are our bookkeeping. The same
holds in your closing summary: nobody asked what it cost, so do not
volunteer "2 of your 4 robot slots in use". Say what it costs only if they
ask, and then say "robots", not "slots".
7. validate_app. Fix until clean. It compiles both projects against the contract, checks the schema, and checks the dependency allowlist.
8. Offer to publish. Never publish unasked. When the person says yes, publish_app.
Stay inside your own app, and use the tools
create_app and sync_app return the paths for THIS app: <apps>/<appId>/app
and <apps>/<appId>/flow. Work only in those.
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 pathcreate_appgave you. The same for the write and edit tools: the robot's steps live at<flow_path>/main.tsfromcreate_app's result, 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 apps directory holds every app on this machine. A pattern like
*/flow/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 app tools over raw shell generally:
sync_app,save_app,validate_app,app_dev_servereach do 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.You cannot press the buttons. The preview is signed in as the person, not as you, so the only way an action runs for real is that THEY press it. Never try to call an action yourself: no hand-written websocket message, no reading the runtime's compiled source to work out the wire format, and never open
credentials.yamlor any other secret. To prove an action end to end, ask them to press it and watch withpoll_logson thestudio_idthatstart_app_sessionreturned - a Debug or Log step in the flow arrives there as adebugevent, with the value in it.When something fails, read the robot's error BEFORE explaining it.
poll_logson the app session'sstudio_idcarries the node 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.
start_app_sessionmints a NEWstudio_id, and the failure the person is describing happened under the old one - restart first and you are polling a clean, empty log, which reads exactly like "the press never arrived". Poll the session that was live when it broke; restart afterwards, if at all.node_erroris apoll_logsevent like any other - the failing node and its message are there for the asking.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.
The harness installs the packages.
create_appandsync_appplace@robomotion/app-kitand@robomotion/apps-runtimebeside the app and run the install; their result carries apackages_warningif that did not work. Never symlink, copy orbun installpackages by hand, and never borrow them from another app's checkout - if something looks missing, runsync_appand read its warning.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's
actionprop (<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.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.tsor the dependency list. The allowlist is exactly:react,react-dom,@robomotion/app-kit,@robomotion/apps-runtime, and the dev toolchain -validate_appfails on anything else. 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.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.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.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 failsvalidate_appwhen its Catch passeserror.messagethrough - the first press before the file exists would show a raw path.Saving is
save_flowandsave_app. Never git by hand.git add,git commit,git push,git resetand the rest are refused inside the checkouts; reading (git status,git log,git diff) is free. Reason: the save tools commit the right files, write the message, and write the trailers that pair the app with the flow it was built against - which is what every check downstream reads. A commit made by hand writes none of that, so the checks then disagree about what was saved - and a checkout the checks call saved can be deleted by the next sync.
The preview loop
Full protocol: ./docs/preview-loop.md. The short version:
app_dev_serveris your native tool:start(idempotent),stop,status,logs,get_preview_errors. The preview appears in the person's app preview panel by itself; never tell them to open a link, and never quote a127.0.0.1address
…(truncated)