# Building an app, end to end

The other references here describe **contracts** — what a query may express, what a workflow body
may say, which props a component takes. This one describes the **sequence**: the order the steps go
in, and why that order and not another. Read it once for the shape, then reach for the area doc
(`lotics docs`) whenever you need the detail.

Everything below is deploy-free until the last step. That is the point: a deploy is a release, and
using one to find out whether something works is the slowest possible way to learn it.

---

## 1 — Scaffold, or pull what exists

```
lotics app create "<name>"          # new: a real Vite + React + TS project, deps installed
lotics app create "<name>" --from model.json#<app>   # new, from a checked plan: one screen per entry
lotics app create "<name>" --api    # new, no screens: its declarations are the whole surface
cd <dir> && lotics app regenerate   # existing + generated: fold a later generation into it
cd <dir> && lotics app pull <app_id>   # existing: refresh to the latest first, then read its README
lotics workspace build model.json      # the whole plan: check, apply, then every app in it
```

`lotics workspace build` is the four lines above in one, for a model that declares several apps:
`scaffold check`, `scaffold apply` where this workspace differs from the model, then per app
`create --from` into `<dir-of-model>/<alias-with-dashes>` when that directory does not exist and
`regenerate` there when it does, then `app check` — and `app deploy` under `--deploy`. An app that
conflicts or checks red is named and the rest still run, because the one you are going to fix is
not a reason to leave the others in a state nobody knows. `--dry-run` writes nothing and says which
apps it would create and which it would regenerate.

`--from` takes the model file `lotics scaffold check` approved and the app in it, and scaffolds
each screen as the registry shape over the live table its entity became — a `LifecycleDesk`, a
`PartyRegister`, … from `@lotics/ui`, its slots reading the fields the plan bound. The tables
have to exist (`lotics scaffold apply` first); a table or field the workspace lacks is refused by
name. A `custom` screen arrives as a register of the cells its roles name, cut to what still reads
at a phone's width — its slot list is a checklist of what the rows know, never a layout. The
project's `README.md` is the plan in prose: the app, its screens with their routes and shapes, and
the tables behind them.

`--api` scaffolds the app something OUTSIDE Lotics calls (§ 9): the manifest, `src/workflows/`, a
CI job and the two briefs, with no `index.html`, no `src/App.tsx`, no Vite config and no kit. Its
one devDependency is `typescript`, because `app workflow check` runs the project's own compiler.
Nothing is built and nothing is deployed, so `current_version_id` stays null and the surface goes
live through `app query set` / `app workflow set` instead. It cannot be combined with `--from`,
which describes screens; `app dev` and `app check --screens` refuse a project with no Vite
config, `app deploy` one with no `build` script, and `app pull` names version control as where
its project lives. Steps 7 and 8 below do not apply to
it, and step 9 ships it without a build: `npm run typecheck` (it declares no `lint` and no `test`
script), `lotics app check` on its own, then `lotics app api publish` where a screens app deploys.
Every other step applies unchanged.

A pull writes more than source: one `src/workflows/<alias>.ts` per bound workflow, one
`src/agents/<alias>.md` per bound agent, and the `.lotics/` type companions — so an existing app
arrives fully editable rather than as an archive you have to reconstruct.

**The plan changes after the app is built, and `lotics app regenerate` is how it lands.** Run it
inside the app; the plan is the one `package.json#lotics.plan` remembers, unless `--from` names
another. Each generation leaves a copy of what it wrote under `.lotics/generated/`, so the next one
is a three-way merge: a generated file you have not touched is overwritten, one you have edited
keeps your edit and takes the generator's, and a clash writes conflict markers, names the file and
ends non-zero once every other file is folded. A file you wrote yourself is never touched; one the
generator retired goes only if you left it alone. Every file it leaves as yours is named with
the reason, so the next author can tell a hand dialog from a generated one. The manifest is reconciled
by the same rule — the generator's aliases are replaced, retired ones removed, yours kept — then
codegen and `app check` run.

**Nothing is pushed.** What the live app RUNS changes at `lotics app deploy` and nowhere else: the
bundle production serves was built against the bindings it has, so a regeneration that replaced a
live workflow body left a deployed dialog posting inputs that workflow no longer declared. The
summary names what a deploy will add, change and leave bound instead. `--bind-new` binds the
aliases the app does not have YET — `lotics app dev` forwards queries to production, so a new alias
cannot be tried before something binds it — and refuses, naming them, to touch one that already
exists. `--dry-run` prints the whole fold and writes nothing. The alternative
is diffing two trees by hand and re-applying every edit, which is where generated apps stop being
regenerated.

## 2 — Clarify what is being asked, before modelling it

**A metric name is not a definition.** "Revenue", "in stock", "active", "overdue" — each is a
business rule the person asking owns, and the cost of guessing is a screen that is confidently
wrong. Ask until there is no ambiguity left:

- Which rows count, keyed off which field — a date, a status, a flag?
- Does the same metric need a **different rule per table**? One table may key off a date and
  another off a status; one rule rarely covers both.
- Snapshot or flow? "Current stock" is as-of-now; "revenue this month" is a window. They compile
  to different filters.
- If the data cannot support the definition asked for — the field simply is not there — **say so
  and show the options.** Silently substituting a near-miss produces a number nobody can trace.

This is the step that gets skipped under time pressure, and it is the only one whose mistakes are
invisible in review: every later artifact is correct with respect to the wrong definition.

## 3 — The data model, before any code

Get this wrong and nothing above it can be precise. Each entity is its own table with
`record_link`s into the spine; attributes and evidence are fields on their owner. A single table
with a `type` column standing in for three entities collapses the distinctions every later query
needs.

**Verify real VALUES, never just that a field exists.** `lotics run query_records` a sample and
look at fill rates — a field that is present and empty on 90% of rows will not support the screen
you are about to design.

**That includes imagery.** If the entity has a likeness — a product, a property, a vehicle, a
person — its picture is the strongest identifier a register row can carry, and an empty image field
is a data gap to fill before you design around it (see `lotics docs composition`, §Character comes
from the DATA). Fill it the same way you fill any other field: put the file on the record. Where the
images do not exist yet, generate them out of band and `lotics file upload` + `update_records` them
on — and keep one style across the whole set, because a catalogue whose shots disagree about
lighting and background reads worse than one with no pictures at all.

**One fact, one column — and check before you add one.** Read the table's existing fields before
adding any, because the fact is often already there in another shape: a place written as text
beside a `select_record_link` to the place record, a status word beside the select that decides it,
a total beside the formula that computes it. Two columns for one fact never stay equal — some
writer sets only one of them, and nothing reports the divergence because both rows look correct
alone. Prefer the link, the select, or the formula, and compose the text when you READ. Renaming or
re-pointing the existing field beats adding a second one; a field is addressed by key, so a rename
breaks nothing. Superseding a field means deleting it, not leaving it beside its replacement.

**Match on ids and option keys, never on rendered text.** A reader that compares display strings
treats "Acme" and "Acme Ltd" as different records, and a value spelled `Net 30` as different from
the option labelled `Net 30 days` — so an import creates a duplicate every time it runs, silently,
because each row looks right on its own. Resolve a name to its `rec_…` or `opt_…` once, at the boundary, and
compare those. Treat an unresolved name as UNKNOWN, never as a wildcard.

**How the tables RELATE is `lotics docs data_model`** — one entity per table and the overlap probe
that says when a split has broken, one vocabulary wherever values are copied between tables, a copy
boundary that accounts for every source field, provenance as a link, a declared natural key, and why
derived depth costs more than row count. Read it before designing a schema; those decisions outlive
any one app, and most of them are unfixable once a second screen depends on the copy.

**Empty is not the same as redundant.** A field nothing fills may still be the only home for a real
distinction, and a column whose values are all `1` may be the volume band nobody has needed yet.
Read what a field MEANS before you remove it; "unused in this data" is not evidence it is wrong.

## 4 — Typed field access

```
lotics app codegen        # no deploy, no version bump
```

Regenerates `.lotics/`: the three `.d.ts` companions that type `useQuery` / `useWorkflow` /
`useAgentRun`, and — when credentials resolve — `app_fields.ts`, exporting four maps keyed by
display-name aliases: `F` (table → field → `"fld_…"`), `OPT` (table → select field → option →
`"opt_…"`), `TBL` (table → `"tbl_…"`) and `GRP` (member group → `"grp_…"`).

Address every id by alias, never by a pasted one — a table id and a member group included:

```tsx
row.opt(r[F.SHIPMENT.direction]) === OPT.SHIPMENT.direction.export
useCommentCounts({ table_id: TBL.SHIPMENT });
useMembers({ group: GRP.sale });
```

An alias is slugified from the display name, so a rename on the platform MOVES it: re-run `codegen`
and every call site on the old alias fails `tsc` until it names the new one. **Re-run after any
schema change** — local typecheck is only honest if the generated ids are current, and you never
deploy to refresh types.

## 5 — Named queries

Author them as `kind: "project"` with a `filter`. A bare `from_table` over-exposes columns and
degrades at scale. Scope per-user reads with `is_current_member` **inside the template** — a
`member_id` passed from the client is an IDOR, since the caller chooses it.

Name each projected column — `{ "source": "fld_…", "output": "total" }` — and the row is then read
as `r.total`, with no field map on the read path; `lotics app create --from` emits exactly that.
Write one `description` per alias too: it is the line a chat or MCP caller chooses between them by,
and `lotics app check` exits 1 naming any alias that has none.

Decode cells with the `row.*` helpers (`row.text`, `row.opt`, `readSelect`, `readLinks`), never by
reaching into the raw shape: a select cell is `[{key,label}]`, and a hand-rolled reader silently
returns the wrong half.

Iterating a query needs no deploy either:

```
lotics app query set <alias>     # pushes package.json#lotics.queries.<alias>, server-validated
```

Details: `lotics docs queries`.

## 6 — Workflows, the only way an app writes

**A workflow binding is SOURCE, and it deploys with the app.** Its two halves are
`package.json#lotics.workflows.<alias>` and `src/workflows/<alias>.ts`; `lotics app pull` writes
both, and `lotics app deploy` pushes whatever differs from what it last saw live — through
`set_app_workflow`, before the bundle ships, so a version that shipped always reproduces what runs.
Nothing is bound by hand after a deploy, and nothing needs to be: an alias whose body and
declaration are in the repo is an alias the next clone can build. Three consequences worth knowing
before you write one:

- **A push refuses when the live body moved past the copy you edited.** The baseline rides as a
  precondition, so two people editing one alias is a refusal naming it, never a silent clobber.
- **An unchanged alias is not pushed.** An update is a diff, so a deploy that changes one screen
  does not re-push nine workflows.
- **Deleting the declaration and the file does not unbind it.** The binding keeps serving, and
  keeps being published to chat and to MCP. `lotics app deploy --prune` unbinds what the project no
  longer names, and it is opt-in because an alias can be invoked from outside the bundle.

A workflow body is a file you open and edit. The new-alias path is typed from the first line:

1. Declare it in `package.json#lotics.workflows.<alias>` — its `inputs`, and `outputs` only to
   narrow beyond what the body infers.
2. Write `src/workflows/<alias>.ts`.
3. `lotics app codegen` — the dts is generated **from your declaration**, so the body gets real
   types (`trigger.app_workflow.inputs.*`, the tool globals) before the alias is bound at all.
4. `lotics app workflow check` — runs the server's own parse and typecheck locally. It catches the
   JS-subset rejections that read as ordinary TypeScript: a `function` declaration, a typed
   parameter, `push` on a const. Those otherwise cost a full push round trip.
5. `lotics app workflow set <alias>` — the server re-verifies (parse → typecheck → resolve names →
   lint → structural validate).

`outputs` are declared, else **derived** from `return({ status, message, data })` — so a workflow
that returns an id must keep its `data` clause or the app receives nothing. When derived, `set`
writes the schema back into the manifest and refreshes that alias's types in place.

The alias's `description` rides along from the manifest. It is the one line an agent reads when
choosing between your workflows, so write it rather than leaving the generated placeholder — and
keep it inside 300 characters, the cap a push holds both a workflow's and a query's `description`
to, because both ride the capability catalog into the agent's prompt on every run. `app check`
names every alias over it and exits 1, and `workflow set` / `query set` refuse before sending
anything, so a long line costs one edit rather than a failed push per alias.

**Every workflow you declare is also the chat agent's write surface.** So the alias's *shape* is an
agent-facing decision, not only a screen-facing one — take a list where one job covers many
records; say in an optional input's own `description` what omitting it means, since the agent
cannot see the default your body applies; make the write survive running twice on the same input;
and gate anything irreversible with `wait_for_approval` inside the body. `lotics docs ai` and
`lotics docs workflows` carry each of those.

**Static green is not a run.** `check` and `set` prove parse, types, name resolution and lint —
they evaluate nothing. Rehearse before the first live run:

```
lotics run dry_run_workflow '{"trigger_type":"app_workflow","trigger_payload":{…},"live_reads":true}'
```

It walks the real step tree and returns the resolved plan plus expression and tool-input errors,
dispatching no write. Pass the raw body — the file carries a wrapper that `set` strips and this
tool does not. **`live_reads: true` matters whenever the body reads anything**: without it every
read returns a stub, so a duplicate check finds no duplicate and every data-gated branch takes the
empty path — green, and proving nothing about the branch you care about.

Then prove it end to end without a screen:

```
lotics run run_app_workflow '{"app_id":"app_…","alias":"…","inputs":{…}}'
# exits non-zero on error, so it is assertable
```

A workflow is also how an app **produces a document** — an invoice, a debit note, a shipping
label. The `generate_*_from_template` tools fill a template you registered once, and the file
comes back to the app on `result.files[]`, **not** through `return({ data })`; that channel
carries values, never files. Registering the template is a CLI job, not an app one:
`lotics docs document_templates`.

Authoring rules for the body itself: `lotics docs workflows`.

## 7 — Screens

**Before any JSX**, read `lotics docs ui` — the catalog and the composition grammar. A screen the
plan named is a registry shape, and the shape is a kit component (`@lotics/ui/lifecycle_desk`,
`@lotics/ui/party_register`, …) that owns the strip, the columns, the fit at a phone's width and
the record's door: give it the rows and the slots, and swap a slot's device through its `render`
rather than rebuilding the frame. For a screen no shape covers, the kit's `examples/` are whole
screens as source; if the pattern is genuinely missing, build it as a kit component rather than
a local one-off, or the next screen re-derives it differently.

**The record half is the same deal.** A screen is `[tabs] + list → record`, and the record is one
frame too: `@lotics/ui/record_page` over the section bodies the record's field roles decide. Which
roles become which sections is `lotics scaffold docs` § Apps and screens; which component each
section kind names is `node_modules/@lotics/ui/docs/templates.md` § The record. A drawer and a page
draw the same list of sections; `lotics scaffold check` prints it per screen, and `--from` emits it.
A section's ADD act rides its heading row, never inside its body.

**A screen no shape fits** is declared in the plan as `"shape": "custom"` with its slots as roles
(`lotics scaffold docs` § Apps and screens) — never a shape bent to fit — and built from the kit
like any other. Then file `lotics report` with `wanted` opening `shape <name>`: a custom slot set
that recurs becomes a shape, and the report is how the next build gets it.

Two rules that cause most of the rework:

- **Never copy server data into `useState`.** Derive from `useQuery` / `useWorkflow` with
  `useMemo`; a copy goes stale the moment anything else writes.
- **Design the loading, empty and error states.** Reserve their space so the layout does not jump.

**The kit is a strong recommendation, not a requirement.** `@lotics/app-sdk` is data and RPC only —
its peers are `react`, `react-dom` and `react-router`, nothing else — so an app can be plain DOM
React with your own CSS and still use every hook, deploy the same way, and run the same server-side.
What the scaffold buys you is the part that is hard to get right by hand: a screen that looks
deliberate, states that are already designed, and behaviour that matches the rest of the product.
Building without it means owning all of that, so reach for it unless you have a specific reason not
to — and if you do, the ONLY thing you give up is the components.

## 8 — Run it locally, and prove it

```
lotics app dev
```

Vite plus an RPC-forwarding server, in a sandboxed iframe matching production, with real data and
auth and HMR. File flows work too — the dev server relays the bytes, so upload, preview and
download are all exercisable locally.

**Navigate with `waitUntil: "domcontentloaded"`.** A dev app never goes network-quiet — the Vite
client and the app's own cross-origin frame each keep a connection open — so a driver that opts
into `networkidle` waits out its timeout on an app that rendered fine, and the timeout reads as the
app being broken. Any in-app path opens directly (`http://localhost:<port>/lo/rec_…`); the wrapper
serves every path that is not one of its own `/_…` routes.

**Drive it with a browser, in this order** — each step's failure means something different:

1. **Does it render at all?** A blank iframe is almost always a bundling problem, not your code.
2. **Read the console before the DOM.** A React error boundary shows a blank region; the reason is
   only in the console.
3. **Does the data arrive?** Check the query result before blaming the layout — an empty list and a
   broken list look identical.
4. **Then interact.** Click the real control rather than calling the handler: an element that is
   covered, disabled, or outside the viewport fails only under a real click.

**Everything inside the app is a separate frame.** The app renders in a sandboxed iframe served
from a different port, so it is cross-origin to the wrapper: parent-page JavaScript cannot reach
`contentDocument`, and a selector run against the page finds nothing. Address it through the frame
— `page.frameLocator("iframe")`, or the frame refs an accessibility snapshot gives you — and run
any injected script in the frame's own context, or its `window` and coordinates are the wrong ones.

Three kit anatomies then need driving deliberately rather than clicked: a pressable row's named
button always intercepts pointer events, overlays portal to the top of the DOM, and custom pointer
drag ignores `dragTo`. All three are by design and all three read as bugs — `lotics docs testing`.

Read a failure by what it *cannot* be. A control that takes its value while its list never appears
is not a wiring bug — the list is rendered somewhere the harness cannot see. Assert what the DOM
actually carries, not what the source says it should.

## 9 — Verify, then ship

```
npm run typecheck && npm run lint && npm test
lotics app check --screens --shots shots/
                                     # every pre-flight a deploy runs, without building or shipping,
                                     # plus the portability gate a library publish applies, plus
                                     # every screen rendered at 1280 and 375, measured, and written
                                     # to shots/ as a PNG per screen and per record it opened
lotics app deploy -m "<what changed + why>"
```

**A green suite says nothing about how the screen LOOKS**, and that half starts with
`--screens`: it renders the app over its real data (Chrome needed), walks every tab at both
widths, and refuses what a review would; what it measures is in `lotics docs cli_reference`. It
is a READ, enforced at the network rather than by what the walk presses — the CLI serves the app
an allowlist of read ops and refuses everything else before the call leaves your machine, and a
refused call heads the report and fails the run. What
it cannot measure is in `lotics docs reviewing` — which is why `--shots <dir>` is on the same
line: it photographs the frame each probe read, so LOOKING at the app is the same run rather than
a dev server and a browser pass per screen. Open the 375 shots first. Both before the deploy, not
as an audit someone schedules after a complaint.

Every check above reads the SOURCE; none of them renders it. So the entire class of defect that
lives in the pixels — wrong form for the subject, a treatment that contradicts what an element
means, a surface that measures clean and still tells the reader nothing — passes all of them
silently, and arrives later as "it looks bad", which is a report about a cause the reporter cannot
name. That is what the review is for, and why it belongs here rather than in whoever remembers to
ask for it.

`-m` is optional; the deploy derives a message from what it pushed. Write one when the *why* is
worth keeping.

Then set the icon, the colour and the app's own `description` — the most-forgotten step, which is
why `deploy` and `check` both warn while any of them is unset:

```
lotics app rename "<the app's name>" --icon <lucide-name> --theme blue \
  --description "<what the app is for, and the standing job it does>"
```

The name is repeated because it is the same act: `app rename` is the one verb that sets an app's
display metadata, and it also folds the new name into `package.json#name`, which nothing else
does. Renaming to the name it already has changes nothing on the server.

The `description` is not a label. It heads the capability listing the member's chat agent reads on
**every** turn, so a standing process the app expects that agent to carry out belongs there and
nowhere else — not in workspace instructions and not in a knowledge doc, both of which apply to
every app at once (`lotics docs ai`).

**A caller outside the team gets its own app.** Sharing an app publicly, or giving an API key
*Only selected ones* → that app, reaches every alias the app declares; no alias can be held back.
So whatever outsiders may call is a SECOND app over the same tables: only the queries they may
read, each projecting only the columns they may see, and only the workflows they may run — a form
anyone can reach is workflows and no queries at all. It needs no screens, so scaffold it with
`lotics app create "<name>" --api` (§ 1): push its aliases with `app query set` /
`app workflow set`, share it or give a key to it, and `app api publish` once a caller depends on
it — there is no deploy anywhere in that loop. The desk the team works in stays a separate,
private app.

Finally, update the app's own `README.md` on any model or behaviour change and redeploy, so the
brief travels with the app rather than living in whoever built it.

---

## The inner loop

Once scaffolded, everything below happens locally:

```
edit src/workflows/<alias>.ts        # or a screen, or a query
lotics app regenerate --dry-run      # when the PLAN moved: what the new generation would do
lotics app codegen                   # after any schema change
npm run typecheck                    # honest, because codegen is current
lotics run run_app_workflow '{"app_id":…,"alias":…}'   # prove the mutation path
lotics app dev                       # prove the screen
lotics app check --screens --shots shots/   # measure it AND photograph it, before anyone looks
…
lotics app workflow set <alias>      # push the body; the server verifies
lotics app deploy -m "…"             # pushes pending bindings, then ships
```

**Proving a change to `@lotics/ui` or `@lotics/app-sdk` before it is published** is
`lotics app kit <path-to-that-package-in-your-checkout>`: it builds the package, packs it into this
app's `.lotics/kit/`, installs it by file specifier and hashes one built file on both sides to
prove the install took — a repack under the same name is otherwise served from the lockfile's first
tarball, and every other signal reads as success. `app check` then says the app depends on a build
that exists on one machine, and `app deploy` refuses it (the tarball is not in the source archive,
so a clone could not install) unless you pass `--allow-local-kit`. `lotics app kit <path>
--published` puts the registry version back.

A deploy pushes any workflow body, agent prose or query that is ahead of the app **before** it
ships the bundle, so a forgotten `set` cannot ship a bundle typed against a binding that does not
exist. It then regenerates the `.lotics` types and runs `npm run typecheck` before building — Vite
strips types, so that run is what makes them a gate. `lotics app check` reports the same set, and
runs the same typecheck, without pushing.

Keep the CLI current: an old one silently drops manifest fields it does not model.
