eleven / messenger Public
π€ 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.jsandschema-render.js(the app file wiresschemaEvents/schemaRenderinto 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.swiftandSchemaAppView.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:
cp web/static/apps/schemas/todo.json ios/LChatCore/Sources/LChatCore/Schemas/todo.jsonThe 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
{
"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:
{"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
"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:
"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 (aremind.fireracing 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 followafterin the collection array (""/missing = the array's start). A missing id, or anafterid 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
"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.
"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
"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; itsactionruns with$inputbound.hintrenders a helper line under it.summary/emptyβ first-match-wins cases (ifoptional on the last).emptyshows 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 βdescgives newest-first for append-ordered lists).collapsiblesections render as a disclosure with a count and startcollapsedif set. Areorder: { "action": name }makes the section's rows draggable: the drop runs the action with$itemplus$afterbound (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:
"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).
"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):
"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:
- Edit
web/static/apps/schemas/<name>.json; copy toios/LChatCore/Sources/LChatCore/Schemas/. npm testβ vectors, JS/hand-written parity, list/action semantics, and the copy drift gate.- iOS:
xcodebuild test -scheme LChat -only-testing:LChatCoreTestsβ the Swift engine runs the same vectors and schema assertions. - A behaviour change that alters the fold is a vector change: update
test/event-vectors/<app>.jsontoo, 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).