| 1 | # π€ The app schema: one description, two runtimes |
| 2 | |
| 3 | A mini-app's behaviour has always been specified twice: the JS app file |
| 4 | (`web/static/apps/todo.js`) and, when native wants it, a Swift re-implementation |
| 5 | β kept honest only by the event vectors. The **app schema** collapses that for a |
| 6 | large class of apps: a single JSON file that *formally describes how the app |
| 7 | looks and behaves*, interpreted by both runtimes: |
| 8 | |
| 9 | - **JS engine + DOM renderer:** `web/static/schema-engine.js` and |
| 10 | `schema-render.js` (the app file wires `schemaEvents`/`schemaRender` into |
| 11 | defineApp and keeps only the glue a schema doesn't cover: commands, sidebar |
| 12 | preview/unread, chat wording, named parsers) |
| 13 | - **Swift engine + SwiftUI renderer:** `ios/LChatCore/Sources/LChatCore/SchemaEngine.swift` |
| 14 | and `SchemaAppView.swift` |
| 15 | |
| 16 | Canonical schemas live in **`web/static/apps/schemas/<name>.json`**. The native |
| 17 | package bundles byte-identical copies in |
| 18 | `ios/LChatCore/Sources/LChatCore/Schemas/` (SPM resources can't reach outside |
| 19 | the package); `test/schema-engine.test.mjs` fails if the copies drift β after |
| 20 | editing a schema, re-copy it: |
| 21 | |
| 22 | ```sh |
| 23 | cp web/static/apps/schemas/todo.json ios/LChatCore/Sources/LChatCore/Schemas/todo.json |
| 24 | ``` |
| 25 | |
| 26 | The payoff: **click "To-do" in the native app and the list renders with native |
| 27 | components** β SwiftUI list, sections, search field, swipe actions β assembled |
| 28 | from the schema, not a webview. And because the schema is plain, readable JSON, |
| 29 | an LLM can write or modify an app description as easily as a person can review |
| 30 | one. |
| 31 | |
| 32 | ## What the schema can (and can't) express |
| 33 | |
| 34 | It deliberately covers one archetype well β the one most personal tools are: |
| 35 | |
| 36 | - a **searchable, sectioned list** folded from typed events, |
| 37 | - **actions** that change state by emitting events (add / toggle / remove / |
| 38 | dismiss), including server-side scheduling, |
| 39 | - a **detail ("show") view** per item β definition lists, text blocks, buttons, |
| 40 | - time presentation (relative + absolute), grace-period linger, empty states. |
| 41 | |
| 42 | It is *not* a general UI language. A whiteboard, a poll's inline card, the chat |
| 43 | feed itself β anything with free-form canvas, custom gestures, or bespoke |
| 44 | layout β stays a hand-written app. When a schema can't say it, write code; |
| 45 | don't grow the schema until a second real app needs the same thing. |
| 46 | |
| 47 | The fold rules here are the **same contract** the event vectors pin |
| 48 | (`test/event-vectors/todo.json`, `remind.json`): the vectors remain the spec, |
| 49 | the schema is now the *executable* form of it, and both engines are tested |
| 50 | against both. |
| 51 | |
| 52 | ## A schema, top to bottom |
| 53 | |
| 54 | ```json |
| 55 | { |
| 56 | "schema": 1, |
| 57 | "name": "todo", |
| 58 | "title": "To-do", |
| 59 | "glyph": "β", |
| 60 | "collection": "items", |
| 61 | |
| 62 | "state": { β¦ the data shape β¦ }, |
| 63 | "events": { β¦ how events fold into state β¦ }, |
| 64 | "partition": { β¦ the grace-period buckets β¦ }, |
| 65 | "actions": { β¦ what the user can do β¦ }, |
| 66 | "views": { "main": { β¦ the list β¦ }, "detail": { β¦ the show view β¦ } }, |
| 67 | "chat": { β¦ how events project into the Chat view β¦ }, |
| 68 | "ticker": { β¦ optional per-item timed rules β¦ } |
| 69 | } |
| 70 | ``` |
| 71 | |
| 72 | `collection` names the primary list in `state` that views bind to. |
| 73 | |
| 74 | ### Values, references, conditions, templates |
| 75 | |
| 76 | Everything dynamic uses three tiny, LLM-friendly notations: |
| 77 | |
| 78 | **References** β strings starting `$`, resolved against the evaluation scope: |
| 79 | `"$event.text"`, `"$item.done"`, `"$input"` (the composer text, trimmed), |
| 80 | `"$now"` (epoch ms, render/action time only β never in folds), `"$newId"` (a |
| 81 | fresh id, minted once per action run), `"$sid"` (the last schedule step's |
| 82 | result, `""` on failure), `"$parsed.when"` (the last parse step's result), |
| 83 | `"$items"` (the primary collection), `"$buckets.active"` / `.grace` / |
| 84 | `.archived`. `"$$x"` escapes a literal leading dollar. Missing paths are null. |
| 85 | The only computed values are `{"add": [a, b]}` and `{"sub": [a, b]}`. |
| 86 | |
| 87 | **Conditions** β JSON objects, nestable: |
| 88 | |
| 89 | ```json |
| 90 | {"truthy": "$item.done"} {"not": β¦} {"all": [β¦]} {"any": [β¦]} |
| 91 | {"eq": [a, b]} {"empty": "$items"} {"gt"|"gte"|"lt"|"lte": [a, b]} |
| 92 | ``` |
| 93 | |
| 94 | **Templates** β display strings interpolating `{path}` or `{path|filter}`: |
| 95 | `"Completed ({count})"`, `"{item.when|reltime}"`. Filters: `count` (array |
| 96 | length), `reltime` (`in 2h` / `5m ago` / `now`), `abstime` (locale |
| 97 | `Wed, Jul 9, 14:30`), `number`. `{{` escapes a brace. |
| 98 | |
| 99 | ### `state` β the data shape |
| 100 | |
| 101 | ```json |
| 102 | "state": { |
| 103 | "items": { |
| 104 | "key": "id", |
| 105 | "fields": { "id": "string", "text": "string", "done": "bool", "doneAt": "time", "by": "string" } |
| 106 | } |
| 107 | } |
| 108 | ``` |
| 109 | |
| 110 | Types: `string`, `bool`, `number`, `time` (epoch ms). Folds **coerce** every |
| 111 | written field to its declared type, with missing/null becoming the zero value |
| 112 | (`""` / `false` / `0`) β this is what makes `"doneAt": "$event.ts"` on an event |
| 113 | with no `ts` fold to `0`, exactly like the hand-written `e.ts || 0`. `key` is |
| 114 | the identity field `add` dedupes on (Invariant #1 in `apps/README.md`). |
| 115 | |
| 116 | ### `events` β the fold |
| 117 | |
| 118 | The keys are the wire event types the app owns; each entry is a guard plus a |
| 119 | list of declarative ops applied in order: |
| 120 | |
| 121 | ```json |
| 122 | "todo.add": { |
| 123 | "require": ["$event.text"], |
| 124 | "fold": [ |
| 125 | { "add": { "to": "items", "item": { "id": "$event.id", "text": "$event.text", |
| 126 | "done": false, "doneAt": 0, "by": "$event.name" } } } |
| 127 | ] |
| 128 | } |
| 129 | ``` |
| 130 | |
| 131 | - `require` β every reference must be truthy or the event folds to a **no-op** |
| 132 | (the reducer-validates-everything invariant: malformed or hostile events |
| 133 | never crash or lie). |
| 134 | - `add` β append unless the key already exists (idempotent). |
| 135 | - `update` β `{ "in": "items", "id": "$event.id", "set": { β¦ } }`; a missing id |
| 136 | is a no-op (a `remind.fire` racing a dismiss must land silently). |
| 137 | - `remove` β `{ "from": "items", "id": "$event.id" }`. |
| 138 | - `move` β `{ "in": "items", "id": "$event.id", "after": "$event.after" }`: |
| 139 | reposition the item to immediately follow `after` in the collection array |
| 140 | (`""`/missing = the array's start). A missing id, or an `after` id that no |
| 141 | longer exists (removed concurrently), is a no-op. |
| 142 | - `sort` β `{ "in": "items", "by": "when", "dir": "asc" }`, stable on both |
| 143 | runtimes. |
| 144 | |
| 145 | Folds are pure and deterministic by construction: no `$now`, no randomness, no |
| 146 | platform calls β two devices folding the same events converge, and snapshots |
| 147 | stay correct. |
| 148 | |
| 149 | ### `partition` β the grace-period buckets |
| 150 | |
| 151 | ```json |
| 152 | "partition": { "flag": "done", "at": "doneAt", "graceMs": 750 } |
| 153 | ``` |
| 154 | |
| 155 | Splits the collection into `active` (flag false), `grace` (flagged, younger |
| 156 | than `graceMs`) and `archived` β **derived from the clock at render time**, |
| 157 | never by a timer mutating state. A flagged item with no timestamp (legacy |
| 158 | data) reads as long-archived. The renderer convention for `grace` rows is the |
| 159 | Reminders-app completion flourish: the completed control fills immediately |
| 160 | (checkbox goes accent), the row lingers in place for the window β still |
| 161 | undoable β then tucks (animated) into the collapsible section. No countdown |
| 162 | ring; the fill is the feedback. |
| 163 | |
| 164 | ### `actions` β what the user can do |
| 165 | |
| 166 | An action is a `require` guard plus steps, each optionally gated by `if`. The |
| 167 | scope carries the trigger's bindings: `{item}` for a row action, `{input}` for |
| 168 | the composer. |
| 169 | |
| 170 | ```json |
| 171 | "add": { |
| 172 | "require": ["$input"], |
| 173 | "steps": [ |
| 174 | { "parse": { "kind": "reminder", "text": "$input", |
| 175 | "onFail": { "hint": "When should I remind you? Try β¦" } } }, |
| 176 | { "schedule": { "at": "$parsed.when", |
| 177 | "event": { "type": "remind.fire", "id": "$newId", |
| 178 | "notice": { "title": "β° Reminder", "body": "$parsed.text" } } } }, |
| 179 | { "emit": { "type": "remind.add", "id": "$newId", "text": "$parsed.text", |
| 180 | "when": "$parsed.when", "sid": "$sid" } } |
| 181 | ] |
| 182 | } |
| 183 | ``` |
| 184 | |
| 185 | Steps: `emit` (encrypt + send an event), `schedule` (server-schedule a future |
| 186 | event; sets `$sid`, `""` on failure), `unschedule`, `parse` (run a named |
| 187 | parser; failure aborts the action and shows the `hint` inline β never a |
| 188 | floating toast). `$newId` is minted once per run, so the scheduled fire and |
| 189 | the add that records its sid share an id. The host supplies the io surface |
| 190 | (emit/schedule/unschedule/newId/parse/hint) β a schema never touches keys, |
| 191 | tokens, or the network, same as any app. |
| 192 | |
| 193 | Named parsers are the escape hatch for real parsing (natural-language time): |
| 194 | each runtime implements them (`parseReminder` in `remind.js`; its Swift twin in |
| 195 | `SchemaParsers.swift`), pinned by shared tests. |
| 196 | |
| 197 | ### `views.main` β the list |
| 198 | |
| 199 | ```json |
| 200 | "views": { |
| 201 | "main": { |
| 202 | "kind": "list", |
| 203 | "composer": { "placeholder": "Add a to-doβ¦", "button": "Add", "maxLength": 200, "action": "add" }, |
| 204 | "summary": [ |
| 205 | { "if": { "empty": "$items" }, "text": "No to-dos yet β add one above" }, |
| 206 | { "if": { "empty": "$buckets.active" }, "text": "all done π" }, |
| 207 | { "text": "{buckets.active|count} left" } |
| 208 | ], |
| 209 | "search": { "fields": ["text"] }, |
| 210 | "sections": [ |
| 211 | { "id": "open", "source": ["active", "grace"], "order": { "by": "_index", "dir": "desc" }, |
| 212 | "reorder": { "action": "move" } }, |
| 213 | { "id": "completed", "source": "archived", "title": "Completed ({count})", |
| 214 | "collapsible": true, "collapsed": true, "order": { "by": "doneAt", "dir": "desc" } } |
| 215 | ], |
| 216 | "empty": [ { "text": "No to-dos yet." } ], |
| 217 | "row": { β¦ }, |
| 218 | "refreshSeconds": 1 |
| 219 | } |
| 220 | } |
| 221 | ``` |
| 222 | |
| 223 | - `composer` β the add affordance; its `action` runs with `$input` bound. |
| 224 | `hint` renders a helper line under it. |
| 225 | - `summary` / `empty` β first-match-wins cases (`if` optional on the last). |
| 226 | `empty` shows when every non-collapsible section has no rows, rendered as a |
| 227 | quiet inline line on every platform (never a full-pane empty state β other |
| 228 | sections may still have content). |
| 229 | - `search` β substring match over the listed item fields, across sections. |
| 230 | - `sections` β each draws from one or more partition buckets (`source`), |
| 231 | ordered by an item field or `_index` (position in the collection β `desc` |
| 232 | gives newest-first for append-ordered lists). `collapsible` sections render |
| 233 | as a disclosure with a count and start `collapsed` if set. A |
| 234 | `reorder: { "action": name }` makes the section's rows draggable: the drop |
| 235 | runs the action with `$item` plus `$after` bound (the id of the item the |
| 236 | moved one now follows in the *collection array*; `""` = the start β the |
| 237 | renderer maps the visual drop by the section's order direction, and |
| 238 | suppresses reorder while a search query is active). Only meaningful on |
| 239 | `_index`-ordered sections β a field-sorted section would immediately re-sort |
| 240 | away the drop. |
| 241 | - `refreshSeconds` β re-resolve rows on a tick (live relative times). |
| 242 | |
| 243 | **`row`** describes one item: |
| 244 | |
| 245 | ```json |
| 246 | "row": { |
| 247 | "leading": { "kind": "check", "on": "$item.done", "action": "toggle" }, |
| 248 | "title": { "text": "{item.text}", "strike": { "truthy": "$item.done" }, "muted": { "truthy": "$item.done" } }, |
| 249 | "subtitle": [ |
| 250 | { "if": { "truthy": "$item.fired" }, "text": "Reminded Β· {item.firedAt|reltime}" }, |
| 251 | { "text": "{item.when|reltime}", "accent": { "gte": ["$now", "$item.when"] } } |
| 252 | ], |
| 253 | "note": [ { "if": β¦, "text": "{item.when|abstime}" } ], |
| 254 | "trailing": [ { "kind": "button", "glyph": "Γ", "label": "Remove", "role": "destructive", "action": "remove" } ], |
| 255 | "tap": { "action": "toggle" } |
| 256 | } |
| 257 | ``` |
| 258 | |
| 259 | `subtitle` and `note` are first-match-wins case lists (`accent` colours the |
| 260 | line when its condition holds). `tap` is `{ "action": name }`, |
| 261 | `{ "detail": true }` (navigate to the show view), or `{ "url": ref }` β the |
| 262 | row LINKS OUT to the resolved https URL (views, docs/views.md: each issue or |
| 263 | headline opens where it lives). `tap.url` is web-only for now: the web |
| 264 | renderer opens a new tab (`noopener`, scheme re-checked at click time; a row |
| 265 | whose ref resolves to anything but https simply isn't tappable), and the |
| 266 | native runtime ignores the key until it grows the same additive support. |
| 267 | When a `detail` view exists but tap runs an action, native exposes Details |
| 268 | via the row's context menu. Destructive `trailing` buttons double as swipe |
| 269 | actions on iOS. |
| 270 | |
| 271 | ### `views.detail` β the show view |
| 272 | |
| 273 | The click-to-view-more surface: a title plus sections, each conditional β |
| 274 | `definitions` (term/value rows), `text` (a body paragraph, article-style), and |
| 275 | `buttons` (actions; `dismiss: true` pops the view after running). |
| 276 | |
| 277 | ```json |
| 278 | "detail": { |
| 279 | "title": "{item.text}", |
| 280 | "sections": [ |
| 281 | { "kind": "definitions", "rows": [ |
| 282 | { "term": "When", "value": "{item.when|abstime}" }, |
| 283 | { "term": "Status", "value": "Upcoming", "if": { "not": { "truthy": "$item.fired" } } } |
| 284 | ] }, |
| 285 | { "kind": "text", "body": "{item.body}", "if": { "truthy": "$item.body" } }, |
| 286 | { "kind": "buttons", "items": [ |
| 287 | { "label": "Dismiss reminder", "action": "dismiss", "role": "destructive", "dismiss": true } |
| 288 | ] } |
| 289 | ] |
| 290 | } |
| 291 | ``` |
| 292 | |
| 293 | This is enough for the "list of articles" shape: a sorted, searchable list |
| 294 | whose rows open into a sectioned show view. |
| 295 | |
| 296 | ### `chat` β projection into the Chat view |
| 297 | |
| 298 | The schema form of the `chatMessage` hook: per event type, a template (bot |
| 299 | bubble) or `"action": true` (a `/me`-style line), with optional `groupText` |
| 300 | for wording that must change when many people read it. Absent types are |
| 301 | silent. |
| 302 | |
| 303 | ### `ticker` β per-item timed rules |
| 304 | |
| 305 | For behaviour that must happen while the pane is open (Reminders' client-side |
| 306 | fallback firing when server scheduling failed): |
| 307 | |
| 308 | ```json |
| 309 | "ticker": { "seconds": 1, "perItem": [ |
| 310 | { "once": true, |
| 311 | "if": { "all": [ { "not": { "truthy": "$item.fired" } }, |
| 312 | { "not": { "truthy": "$item.sid" } }, |
| 313 | { "gte": ["$now", "$item.when"] } ] }, |
| 314 | "emit": { "type": "remind.fire", "id": "$item.id", |
| 315 | "notice": { "title": "β° Reminder", "body": "$item.text" } } } |
| 316 | ] } |
| 317 | ``` |
| 318 | |
| 319 | `once` dedupes per item per session. The emitted event still goes through the |
| 320 | normal fold, which must tolerate it like any other (idempotent, race-safe). |
| 321 | |
| 322 | ## How native runs a schema |
| 323 | |
| 324 | `SchemaAppStore` (in LChatCore) owns a thread's schema state: it seeds from the |
| 325 | encrypted `apps` snapshot (`GET /api/threads/{id}/state`, decrypted with the |
| 326 | thread key, folding only events newer than the snapshot's `upTo`), folds the |
| 327 | decrypted app events the feed already sees, and applies live socket frames. |
| 328 | Actions run against the existing native transport: `emit` β |
| 329 | `ChatFeedModel.sendAppEventJSON` (the same WS blob path chat uses), `schedule`/ |
| 330 | `unschedule` β `LChatAPI.schedule`/`unschedule`. `SchemaAppView` renders the |
| 331 | resolved list/detail models with stock SwiftUI. Native **reads** snapshots but |
| 332 | doesn't write them (the web clients keep doing that); an all-native household |
| 333 | just folds the full window it has. |
| 334 | |
| 335 | Where it plugs in: iOS `ThreadListView.chatSurface` and Mac |
| 336 | `NativeChatView.feed` branch to the native surface when |
| 337 | `AppSchemaRegistry.schema(for: appId)` returns one; every other app keeps the |
| 338 | webview. On iOS the app thread gets a `Chat | Interface` toggle (the native |
| 339 | feed is the Chat view β `AppChatProjection` already renders the event lines); |
| 340 | on the Mac the existing `Chat | Both | View` modes stay, with the View pane |
| 341 | native. |
| 342 | |
| 343 | ## Keeping the contract (the checklist) |
| 344 | |
| 345 | Changing a schema-driven app touches **one JSON file** β but verify all sides: |
| 346 | |
| 347 | 1. Edit `web/static/apps/schemas/<name>.json`; copy to |
| 348 | `ios/LChatCore/Sources/LChatCore/Schemas/`. |
| 349 | 2. `npm test` β vectors, JS/hand-written parity, list/action semantics, and the |
| 350 | copy drift gate. |
| 351 | 3. iOS: `xcodebuild test -scheme LChat -only-testing:LChatCoreTests` β the |
| 352 | Swift engine runs the same vectors and schema assertions. |
| 353 | 4. A behaviour change that alters the fold is a **vector change**: update |
| 354 | `test/event-vectors/<app>.json` too, and fix both engines in the same PR β |
| 355 | never fork the contract. |
| 356 | |
| 357 | `todo.js` / `remind.js` themselves run on the schema: their events map is |
| 358 | `schemaEvents(schema)` and their render is `schemaRender(schema)` β the web |
| 359 | renderer (`schema-render.js`) consumes the same resolved list/row models |
| 360 | SwiftUI does. The parity fuzz stays as the wiring's regression net (and goes |
| 361 | back to twin-hunting if an app ever hand-writes a fold again). |
| 362 | |