# Building App

> Builds a Robomotion App - a real React web app plus a robot backend - by conversation with a non-technical person. Owns the full lifecycle: clarify → contract (app.json) → screens with sample data → live preview in ~90s → backend actions → publish. Use when the user wants an app, dashboard, portal, form, approval queue, a board of things that move through stages, or any screens people will click.

- Skill: `robomotionio/building-app` (Agent Skill, multi-file: 11 files)
- Install (CLI): `npx skillmds@latest add robomotionio/building-app`
- Raw SKILL.md: https://api.skillmd.com/api/skills/robomotionio/building-app/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: robomotionio (https://skillmd.com/u/robomotionio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/robomotionio/building-app

---


# 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 Action` trigger 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 `JsonInput` parses 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 `JsonView` renders 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.

0. **Create the app first.** Call `create_app` with a short human name and, WHEN YOU ARE ALREADY IN A FLOW, its id as `flowId` - 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 returns `app_id`, `flow_id` and the local paths, and clones both working copies. Then `sync_app` before you read or write anything. Continuing an existing app instead? `list_apps`, then `sync_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.
1. **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."
2. **Write `app.json`** - read `./docs/contract.md` first. Every `description` line 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 from `src/generated/actions.gen`. Your `app.json` deletes those types, so **any leftover file fails `tsc` inside `validate_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.tsx` tells 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.

3. **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.md` components, and fill tables and cards with realistic sample rows declared as a `SAMPLE_*` 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:

   ```tsx
   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:

   ```tsx
   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):

   ```tsx
   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.

4. **`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 call `push_app` to 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 call `app_dev_server start` for it: the tool answers "already running", and every such call is one more row on the person's screen that did nothing. Call `app_dev_server status` only 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 codegen`** whenever `app.json` changes, before writing code against it. Run it from the app folder; it regenerates both typed clients and prints the contract hash.

5. **Build the flow backend, one action at a time**, in the order the user will click them. For each action: `App Action` trigger → the real work → `App Respond` on EVERY path (an unresponded call only ends by timeout, which the user experiences as a hung button). Long work sends `App Progress`. The generated `flow/src/generated/actions.gen.ts` gives you the param/result types. Flow SDK mechanics (node grammar, browser, credentials) are the `creating-flow` skill - 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:

```ts
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:

```ts
  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:

```ts
.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:

```ts
.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:

```ts
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
"<action>": missing required property "<field>". It sent: <the fields that
arrived>*. 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:

```tsx
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 `cd` in one command does not carry to the next, so
  `pwd && ls && cat src/generated/actions.gen.ts` finds nothing and the next
  call goes hunting. Begin every command with `cd` into the folder you mean,
  with the absolute path `create_app` gave you. **The same for the write and
  edit tools**: the robot's steps live at `<flow_path>/main.ts` from
  `create_app`'s result, and a bare `main.ts` lands 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.ts` walks 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 -e` or `jq` to do it.** Run `robomotion app codegen` in the app folder:
  it writes both `actions.gen.ts` files from `app.json` and prints the hash.
  `robomotion app hash` prints just the hash. Hand-hashing gets a different
  answer than the server's canonicalisation, which blocks the app from
  connecting with `contract_mismatch` - and python is not installed on most
  people's machines. `robomotion` is the tool that is always present; do not
  reach for `bun run`, `npm run` or `npx` to do a job it already does.
- Prefer the app tools over raw shell generally: `sync_app`, `save_app`,
  `validate_app`, `app_dev_server` each 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 edit
  `tsconfig.json` to 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.yaml` or any other secret. To prove an action end to end,
  ask them to press it and watch with `poll_logs` on the `studio_id` that
  `start_app_session` returned - a Debug or Log step in the flow arrives there
  as a `debug` event, with the value in it.
- **When something fails, read the robot's error BEFORE explaining it.**
  `poll_logs` on the app session's `studio_id` carries 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_session` mints a
  NEW `studio_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_error` is a
  `poll_logs` event 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.

0. **The harness installs the packages.** `create_app` and `sync_app` place `@robomotion/app-kit` and `@robomotion/apps-runtime` beside the app and run the install; their result carries a `packages_warning` if that did not work. Never symlink, copy or `bun install` packages by hand, and never borrow them from another app's checkout - if something looks missing, run `sync_app` and read its warning.
1. **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 `action` prop (`<Button action={greet} params={{ name }}>`), or spreads `bindAction(greet)` when it must keep its own handler. Never write `onClick={() => 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`.
1. **Kit-only.** Compose `@robomotion/app-kit` components plus Tailwind classes for layout. Never write a new UI primitive, never add an npm dependency, never edit `vite.config.ts` or the dependency list. The allowlist is exactly: `react`, `react-dom`, `@robomotion/app-kit`, `@robomotion/apps-runtime`, and the dev toolchain - `validate_app` fails 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.
2. **Actions only through the generated typed stubs.** `src/generated/actions.gen.ts` exports one hook per action, `use<Action>()` (for `greet`: `const greet = useGreet()`), plus `<Action>Params` / `<Action>Result` types; `greet.data` is typed and `<Form action={greet}>` / `<Button action={greet}>` link the control. Use those. Never write `useAction("name")` yourself - untyped, its `data` is `{}` and `tsc` fails on the first field you read. Events use `useEvent` with the generated payload types. Never hand-write transport, never invent a message format, never call `app.call` from 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 break `tsc` instead of a person.
3. **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.
4. **Never hand-edit generated files.** Anything under `src/generated/` is regenerated from `app.json`; edit `app.json` and regenerate. Reason: the next regeneration silently erases your edit, and an edited file no longer matches `contract_hash`, which blocks the app from connecting at all.
5. **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`, never `Core.Flow.End`**, and never a "finish", "cleanup" or "done" step that reaches one. Every path finishes at its `App Respond` or `App Respond Error` and 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 `Stop` node, so
   every app backend needs `Core.Trigger.Catch` wired to an `App 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.

6. **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.SQLite` for something local to this app
   and this robot, or whichever database package already holds the person's
   data - `searching-packages` names it, and the screens read it back through
   an ordinary action. When the person asks for a file, the nodes are
   `Core.FileSystem.PathExists` (ask first), `Core.FileSystem.Create`,
   `Core.FileSystem.ReadFile` and `Core.FileSystem.WriteFile` for a JSON
   file, and `Core.CSV.ReadCSV` / `Core.CSV.WriteCSV` / `Core.CSV.AppendCSV`
   for 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 fails `validate_app` when its Catch passes
   `error.message` through - the first press before the file exists would
   show a raw path.

7. **Saving is `save_flow` and `save_app`. Never git by hand.** `git add`,
   `git commit`, `git push`, `git reset` and 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_server` is 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 a `127.0.0.1` address 

…(truncated)
