# 🤖 The app schema: one description, two runtimes

A mini-app's behaviour has always been specified twice: the JS app file
(`web/static/apps/todo.js`) and, when native wants it, a Swift re-implementation
— kept honest only by the event vectors. The **app schema** collapses that for a
large class of apps: a single JSON file that *formally describes how the app
looks and behaves*, interpreted by both runtimes:

- **JS engine + DOM renderer:** `web/static/schema-engine.js` and
  `schema-render.js` (the app file wires `schemaEvents`/`schemaRender` into
  defineApp and keeps only the glue a schema doesn't cover: commands, sidebar
  preview/unread, chat wording, named parsers)
- **Swift engine + SwiftUI renderer:** `ios/LChatCore/Sources/LChatCore/SchemaEngine.swift`
  and `SchemaAppView.swift`

Canonical schemas live in **`web/static/apps/schemas/<name>.json`**. The native
package bundles byte-identical copies in
`ios/LChatCore/Sources/LChatCore/Schemas/` (SPM resources can't reach outside
the package); `test/schema-engine.test.mjs` fails if the copies drift — after
editing a schema, re-copy it:

```sh
cp web/static/apps/schemas/todo.json ios/LChatCore/Sources/LChatCore/Schemas/todo.json
```

The payoff: **click "To-do" in the native app and the list renders with native
components** — SwiftUI list, sections, search field, swipe actions — assembled
from the schema, not a webview. And because the schema is plain, readable JSON,
an LLM can write or modify an app description as easily as a person can review
one.

## What the schema can (and can't) express

It deliberately covers one archetype well — the one most personal tools are:

- a **searchable, sectioned list** folded from typed events,
- **actions** that change state by emitting events (add / toggle / remove /
  dismiss), including server-side scheduling,
- a **detail ("show") view** per item — definition lists, text blocks, buttons,
- time presentation (relative + absolute), grace-period linger, empty states.

It is *not* a general UI language. A whiteboard, a poll's inline card, the chat
feed itself — anything with free-form canvas, custom gestures, or bespoke
layout — stays a hand-written app. When a schema can't say it, write code;
don't grow the schema until a second real app needs the same thing.

The fold rules here are the **same contract** the event vectors pin
(`test/event-vectors/todo.json`, `remind.json`): the vectors remain the spec,
the schema is now the *executable* form of it, and both engines are tested
against both.

## A schema, top to bottom

```json
{
  "schema": 1,
  "name": "todo",
  "title": "To-do",
  "glyph": "✓",
  "collection": "items",

  "state":     { … the data shape … },
  "events":    { … how events fold into state … },
  "partition": { … the grace-period buckets … },
  "actions":   { … what the user can do … },
  "views":     { "main": { … the list … }, "detail": { … the show view … } },
  "chat":      { … how events project into the Chat view … },
  "ticker":    { … optional per-item timed rules … }
}
```

`collection` names the primary list in `state` that views bind to.

### Values, references, conditions, templates

Everything dynamic uses three tiny, LLM-friendly notations:

**References** — strings starting `$`, resolved against the evaluation scope:
`"$event.text"`, `"$item.done"`, `"$input"` (the composer text, trimmed),
`"$now"` (epoch ms, render/action time only — never in folds), `"$newId"` (a
fresh id, minted once per action run), `"$sid"` (the last schedule step's
result, `""` on failure), `"$parsed.when"` (the last parse step's result),
`"$items"` (the primary collection), `"$buckets.active"` / `.grace` /
`.archived`. `"$$x"` escapes a literal leading dollar. Missing paths are null.
The only computed values are `{"add": [a, b]}` and `{"sub": [a, b]}`.

**Conditions** — JSON objects, nestable:

```json
{"truthy": "$item.done"}   {"not": …}   {"all": […]}   {"any": […]}
{"eq": [a, b]}   {"empty": "$items"}   {"gt"|"gte"|"lt"|"lte": [a, b]}
```

**Templates** — display strings interpolating `{path}` or `{path|filter}`:
`"Completed ({count})"`, `"{item.when|reltime}"`. Filters: `count` (array
length), `reltime` (`in 2h` / `5m ago` / `now`), `abstime` (locale
`Wed, Jul 9, 14:30`), `number`. `{{` escapes a brace.

### `state` — the data shape

```json
"state": {
  "items": {
    "key": "id",
    "fields": { "id": "string", "text": "string", "done": "bool", "doneAt": "time", "by": "string" }
  }
}
```

Types: `string`, `bool`, `number`, `time` (epoch ms). Folds **coerce** every
written field to its declared type, with missing/null becoming the zero value
(`""` / `false` / `0`) — this is what makes `"doneAt": "$event.ts"` on an event
with no `ts` fold to `0`, exactly like the hand-written `e.ts || 0`. `key` is
the identity field `add` dedupes on (Invariant #1 in `apps/README.md`).

### `events` — the fold

The keys are the wire event types the app owns; each entry is a guard plus a
list of declarative ops applied in order:

```json
"todo.add": {
  "require": ["$event.text"],
  "fold": [
    { "add": { "to": "items", "item": { "id": "$event.id", "text": "$event.text",
               "done": false, "doneAt": 0, "by": "$event.name" } } }
  ]
}
```

- `require` — every reference must be truthy or the event folds to a **no-op**
  (the reducer-validates-everything invariant: malformed or hostile events
  never crash or lie).
- `add` — append unless the key already exists (idempotent).
- `update` — `{ "in": "items", "id": "$event.id", "set": { … } }`; a missing id
  is a no-op (a `remind.fire` racing a dismiss must land silently).
- `remove` — `{ "from": "items", "id": "$event.id" }`.
- `move` — `{ "in": "items", "id": "$event.id", "after": "$event.after" }`:
  reposition the item to immediately follow `after` in the collection array
  (`""`/missing = the array's start). A missing id, or an `after` id that no
  longer exists (removed concurrently), is a no-op.
- `sort` — `{ "in": "items", "by": "when", "dir": "asc" }`, stable on both
  runtimes.

Folds are pure and deterministic by construction: no `$now`, no randomness, no
platform calls — two devices folding the same events converge, and snapshots
stay correct.

### `partition` — the grace-period buckets

```json
"partition": { "flag": "done", "at": "doneAt", "graceMs": 750 }
```

Splits the collection into `active` (flag false), `grace` (flagged, younger
than `graceMs`) and `archived` — **derived from the clock at render time**,
never by a timer mutating state. A flagged item with no timestamp (legacy
data) reads as long-archived. The renderer convention for `grace` rows is the
Reminders-app completion flourish: the completed control fills immediately
(checkbox goes accent), the row lingers in place for the window — still
undoable — then tucks (animated) into the collapsible section. No countdown
ring; the fill is the feedback.

### `actions` — what the user can do

An action is a `require` guard plus steps, each optionally gated by `if`. The
scope carries the trigger's bindings: `{item}` for a row action, `{input}` for
the composer.

```json
"add": {
  "require": ["$input"],
  "steps": [
    { "parse": { "kind": "reminder", "text": "$input",
        "onFail": { "hint": "When should I remind you? Try …" } } },
    { "schedule": { "at": "$parsed.when",
        "event": { "type": "remind.fire", "id": "$newId",
                   "notice": { "title": "⏰ Reminder", "body": "$parsed.text" } } } },
    { "emit": { "type": "remind.add", "id": "$newId", "text": "$parsed.text",
                "when": "$parsed.when", "sid": "$sid" } }
  ]
}
```

Steps: `emit` (encrypt + send an event), `schedule` (server-schedule a future
event; sets `$sid`, `""` on failure), `unschedule`, `parse` (run a named
parser; failure aborts the action and shows the `hint` inline — never a
floating toast). `$newId` is minted once per run, so the scheduled fire and
the add that records its sid share an id. The host supplies the io surface
(emit/schedule/unschedule/newId/parse/hint) — a schema never touches keys,
tokens, or the network, same as any app.

Named parsers are the escape hatch for real parsing (natural-language time):
each runtime implements them (`parseReminder` in `remind.js`; its Swift twin in
`SchemaParsers.swift`), pinned by shared tests.

### `views.main` — the list

```json
"views": {
  "main": {
    "kind": "list",
    "composer": { "placeholder": "Add a to-do…", "button": "Add", "maxLength": 200, "action": "add" },
    "summary": [
      { "if": { "empty": "$items" }, "text": "No to-dos yet — add one above" },
      { "if": { "empty": "$buckets.active" }, "text": "all done 🎉" },
      { "text": "{buckets.active|count} left" }
    ],
    "search": { "fields": ["text"] },
    "sections": [
      { "id": "open", "source": ["active", "grace"], "order": { "by": "_index", "dir": "desc" },
        "reorder": { "action": "move" } },
      { "id": "completed", "source": "archived", "title": "Completed ({count})",
        "collapsible": true, "collapsed": true, "order": { "by": "doneAt", "dir": "desc" } }
    ],
    "empty": [ { "text": "No to-dos yet." } ],
    "row": { … },
    "refreshSeconds": 1
  }
}
```

- `composer` — the add affordance; its `action` runs with `$input` bound.
  `hint` renders a helper line under it.
- `summary` / `empty` — first-match-wins cases (`if` optional on the last).
  `empty` shows when every non-collapsible section has no rows, rendered as a
  quiet inline line on every platform (never a full-pane empty state — other
  sections may still have content).
- `search` — substring match over the listed item fields, across sections.
- `sections` — each draws from one or more partition buckets (`source`),
  ordered by an item field or `_index` (position in the collection — `desc`
  gives newest-first for append-ordered lists). `collapsible` sections render
  as a disclosure with a count and start `collapsed` if set. A
  `reorder: { "action": name }` makes the section's rows draggable: the drop
  runs the action with `$item` plus `$after` bound (the id of the item the
  moved one now follows in the *collection array*; `""` = the start — the
  renderer maps the visual drop by the section's order direction, and
  suppresses reorder while a search query is active). Only meaningful on
  `_index`-ordered sections — a field-sorted section would immediately re-sort
  away the drop.
- `refreshSeconds` — re-resolve rows on a tick (live relative times).

**`row`** describes one item:

```json
"row": {
  "leading": { "kind": "check", "on": "$item.done", "action": "toggle" },
  "title": { "text": "{item.text}", "strike": { "truthy": "$item.done" }, "muted": { "truthy": "$item.done" } },
  "subtitle": [
    { "if": { "truthy": "$item.fired" }, "text": "Reminded · {item.firedAt|reltime}" },
    { "text": "{item.when|reltime}", "accent": { "gte": ["$now", "$item.when"] } }
  ],
  "note": [ { "if": …, "text": "{item.when|abstime}" } ],
  "trailing": [ { "kind": "button", "glyph": "×", "label": "Remove", "role": "destructive", "action": "remove" } ],
  "tap": { "action": "toggle" }
}
```

`subtitle` and `note` are first-match-wins case lists (`accent` colours the
line when its condition holds). `tap` is `{ "action": name }`,
`{ "detail": true }` (navigate to the show view), or `{ "url": ref }` — the
row LINKS OUT to the resolved https URL (views, docs/views.md: each issue or
headline opens where it lives). `tap.url` is web-only for now: the web
renderer opens a new tab (`noopener`, scheme re-checked at click time; a row
whose ref resolves to anything but https simply isn't tappable), and the
native runtime ignores the key until it grows the same additive support.
When a `detail` view exists but tap runs an action, native exposes Details
via the row's context menu. Destructive `trailing` buttons double as swipe
actions on iOS.

### `views.detail` — the show view

The click-to-view-more surface: a title plus sections, each conditional —
`definitions` (term/value rows), `text` (a body paragraph, article-style), and
`buttons` (actions; `dismiss: true` pops the view after running).

```json
"detail": {
  "title": "{item.text}",
  "sections": [
    { "kind": "definitions", "rows": [
      { "term": "When", "value": "{item.when|abstime}" },
      { "term": "Status", "value": "Upcoming", "if": { "not": { "truthy": "$item.fired" } } }
    ] },
    { "kind": "text", "body": "{item.body}", "if": { "truthy": "$item.body" } },
    { "kind": "buttons", "items": [
      { "label": "Dismiss reminder", "action": "dismiss", "role": "destructive", "dismiss": true }
    ] }
  ]
}
```

This is enough for the "list of articles" shape: a sorted, searchable list
whose rows open into a sectioned show view.

### `chat` — projection into the Chat view

The schema form of the `chatMessage` hook: per event type, a template (bot
bubble) or `"action": true` (a `/me`-style line), with optional `groupText`
for wording that must change when many people read it. Absent types are
silent.

### `ticker` — per-item timed rules

For behaviour that must happen while the pane is open (Reminders' client-side
fallback firing when server scheduling failed):

```json
"ticker": { "seconds": 1, "perItem": [
  { "once": true,
    "if": { "all": [ { "not": { "truthy": "$item.fired" } },
                     { "not": { "truthy": "$item.sid" } },
                     { "gte": ["$now", "$item.when"] } ] },
    "emit": { "type": "remind.fire", "id": "$item.id",
              "notice": { "title": "⏰ Reminder", "body": "$item.text" } } }
] }
```

`once` dedupes per item per session. The emitted event still goes through the
normal fold, which must tolerate it like any other (idempotent, race-safe).

## How native runs a schema

`SchemaAppStore` (in LChatCore) owns a thread's schema state: it seeds from the
encrypted `apps` snapshot (`GET /api/threads/{id}/state`, decrypted with the
thread key, folding only events newer than the snapshot's `upTo`), folds the
decrypted app events the feed already sees, and applies live socket frames.
Actions run against the existing native transport: `emit` →
`ChatFeedModel.sendAppEventJSON` (the same WS blob path chat uses), `schedule`/
`unschedule` → `LChatAPI.schedule`/`unschedule`. `SchemaAppView` renders the
resolved list/detail models with stock SwiftUI. Native **reads** snapshots but
doesn't write them (the web clients keep doing that); an all-native household
just folds the full window it has.

Where it plugs in: iOS `ThreadListView.chatSurface` and Mac
`NativeChatView.feed` branch to the native surface when
`AppSchemaRegistry.schema(for: appId)` returns one; every other app keeps the
webview. On iOS the app thread gets a `Chat | Interface` toggle (the native
feed is the Chat view — `AppChatProjection` already renders the event lines);
on the Mac the existing `Chat | Both | View` modes stay, with the View pane
native.

## Keeping the contract (the checklist)

Changing a schema-driven app touches **one JSON file** — but verify all sides:

1. Edit `web/static/apps/schemas/<name>.json`; copy to
   `ios/LChatCore/Sources/LChatCore/Schemas/`.
2. `npm test` — vectors, JS/hand-written parity, list/action semantics, and the
   copy drift gate.
3. iOS: `xcodebuild test -scheme LChat -only-testing:LChatCoreTests` — the
   Swift engine runs the same vectors and schema assertions.
4. A behaviour change that alters the fold is a **vector change**: update
   `test/event-vectors/<app>.json` too, and fix both engines in the same PR —
   never fork the contract.

`todo.js` / `remind.js` themselves run on the schema: their events map is
`schemaEvents(schema)` and their render is `schemaRender(schema)` — the web
renderer (`schema-render.js`) consumes the same resolved list/row models
SwiftUI does. The parity fuzz stays as the wiring's regression net (and goes
back to twin-hunting if an app ever hand-writes a fold again).
