| 1 | # π€ Conversation scripts β a schema for Eleven's deterministic flows |
| 2 | |
| 3 | Status: **phase 1 complete β schema v1 is frozen against reality.** Both |
| 4 | shipped conversations are pure data (`setup.flow.js`, `welcome.flow.js`) |
| 5 | interpreted by the engine (`web/static/flow.js`); `test/flow.test.mjs` |
| 6 | drives full transcripts of both in Node with a fake io and enforces JSON |
| 7 | purity, so the flow files are shippable fixtures for the Swift interpreter. |
| 8 | Next: phase 2, serving the scripts from the binary. Design captured |
| 9 | 2026-07-07. Builds on |
| 10 | `conversational-onboarding.md` (the shipped setup + welcome conversations) and |
| 11 | exists because of one new fact: **the iOS and Mac apps are moving to fully |
| 12 | native rendering**, so they must run the onboarding conversations natively, |
| 13 | matching the web. Hand-porting `setup.js`/`welcome.js` to Swift means every |
| 14 | flow tweak lands twice and drifts; keeping onboarding in a webview fights the |
| 15 | native direction at the exact moment (first run) an app most needs to feel |
| 16 | native. The answer is the classic split: |
| 17 | |
| 18 | > **Flows become data** (a script the server ships), **clients become |
| 19 | > interpreters** (one engine per platform, written once). |
| 20 | |
| 21 | Scripts change often and must stay in lockstep across platforms; interpreters |
| 22 | change rarely. Shipping the scripts in the server binary β like the changelog β |
| 23 | means a flow change is a server deploy: no App Store release, no TestFlight |
| 24 | lag, and web/iOS/Mac render the *same* flow version by construction. |
| 25 | |
| 26 | ## What stays true |
| 27 | |
| 28 | - **Deterministic, client-executed, no AI.** Exactly as today |
| 29 | (`conversational-onboarding.md` non-negotiables): a fresh instance has no |
| 30 | sidecar and no key, so onboarding cannot depend on either. The interpreter |
| 31 | runs client-side, where `setup.js`/`welcome.js` run now; the server ships the |
| 32 | flow *definition* and never sees the answers. |
| 33 | - **E2E rules unchanged.** The script is content + control flow β it collects |
| 34 | nothing itself. Every side effect goes through the same host-bridge verbs the |
| 35 | flows use today, each landing on an existing trust boundary (names encrypted |
| 36 | under the instance key; group name/server photo/contact email are plaintext |
| 37 | branding by design; the passkey ceremony is the existing enrolment). |
| 38 | - **One voice.** Eleven's tone lives in the script strings. A copy edit is a |
| 39 | script edit β one place, all platforms. |
| 40 | |
| 41 | ## What the schema must express |
| 42 | |
| 43 | Everything the two shipped flows actually do β this list is derived from |
| 44 | `setup.js` + `welcome.js` line by line, and it is the acceptance bar: |
| 45 | |
| 46 | 1. **Steps with skip guards.** Each step re-checks on entry: skip if its |
| 47 | recorded `steps[id].done`, or if external state already satisfies it |
| 48 | (`myName()` set, `enrolled()` true). This is what makes reload/passkey |
| 49 | round-trip resume work. |
| 50 | 2. **Bubbles.** One or more per step (the context-then-question pattern), with: |
| 51 | template interpolation from a context bag (`{name}`, `{space}`, |
| 52 | `{inviter}`β¦); an optional **ack prefix** folded from the previous step |
| 53 | under a **stable key** (so a resumed run recognises the question despite the |
| 54 | variable prefix); persisted-vs-local lines (image previews are client-only); |
| 55 | image bubbles (`img`, `imgAlt`, round); composer hints (`hint`, |
| 56 | `attachHint`). |
| 57 | 3. **Inputs**, one of: |
| 58 | - **chips** β advancing (consume the step, spent past-tense label: |
| 59 | Skip β Skipped), or non-advancing (passkey launch), with conditional |
| 60 | presence (`Keep β{current}β` only when a name was explicitly set; |
| 61 | `I'm {suggested}` only when the invite carried a suggestion); |
| 62 | - **typed answer** β next composer message in the thread; |
| 63 | - **attachment answer** β next message bearing an image; its text rides |
| 64 | along (alt text); a non-image reply re-prompts in place and re-arms; |
| 65 | - **probe** β a timer + predicate (the 1.5s "did the passkey ceremony |
| 66 | navigate away?" check that routes to the stayed-in-place waiting step). |
| 67 | 4. **Effects and branching.** Side effects are *named verbs with declared |
| 68 | result enums*, and the step branches on the result: `setGroupName β |
| 69 | ok | invalid | error` (each with its own reply and re-ask), `adoptProfilePhoto |
| 70 | β ok | nokey | error`. Retry loops re-ask in place, not from the top. |
| 71 | 5. **Races.** A skip chip must be able to disarm a pending answer waiter |
| 72 | (`cancelAnswer`), and an effect can be guarded so it never runs if the step |
| 73 | already resolved (the photo-vs-Skip race). |
| 74 | 6. **Recording.** A completed step emits its `*.step` event (label + value) β |
| 75 | the β action lines that make the transcript real history. A step may also |
| 76 | carry a top-level `record` that persists ON ARRIVAL (once β skipped when the |
| 77 | step is already recorded): welcome's `finish` records its completion marker |
| 78 | this way, so a host with no run-local state (the native embed has no |
| 79 | localStorage join marker) can tell a finished conversation from a half-done |
| 80 | one purely from recorded state. |
| 81 | |
| 82 | **What the schema deliberately does NOT express: feel.** Typing beats, pacing |
| 83 | jitter, the reply queue, persistence retry/echo-wait, spent-chip rendering, |
| 84 | placeholder plumbing β all of that is the **engine's** job, specified once in |
| 85 | the interpreter contract below. `eleven.js` already *is* this split on the web |
| 86 | (scripts call `say`/`ask`/`advance`; the engine owns the rest), which is the |
| 87 | strongest evidence the boundary is right. Keeping feel out of the schema is |
| 88 | what keeps the schema small enough to trust. |
| 89 | |
| 90 | ## The schema (v1 sketch) |
| 91 | |
| 92 | A **flow** is JSON: `{ schema: 1, flow: "setup" | "welcome", entry, steps }`. |
| 93 | Steps are named; control flow is explicit `next` / branch targets β no |
| 94 | implicit fallthrough. Illustrative fragments (the group-name step, with |
| 95 | validation branching, and the passkey step, with a probe): |
| 96 | |
| 97 | ```json |
| 98 | { |
| 99 | "id": "group", |
| 100 | "skipIf": ["step.done"], |
| 101 | "bubbles": [ |
| 102 | { "text": "{ack?}Next, this space needs a name. It's the one everyone sees β on the sign-in page, in invites, on the app icon.", "key": "q-group" }, |
| 103 | { "text": { "ifSet": "space.nameSet", "then": "It's currently β{space.name}β β keep that, or reply with a new name.", "else": "What should we call it? Reply with a name." }, |
| 104 | "hint": "Type a name for this space" } |
| 105 | ], |
| 106 | "chips": [ |
| 107 | { "if": "space.nameSet", "label": "Keep β{space.name}β", "spent": "Kept", "goto": "email" } |
| 108 | ], |
| 109 | "input": { "kind": "text" }, |
| 110 | "effect": { "verb": "setGroupName", "arg": "$answer" }, |
| 111 | "on": { |
| 112 | "ok": { "record": { "label": "named this space β{$answer}β", "value": "$answer" }, "goto": "email" }, |
| 113 | "invalid": { "say": "Hmm, that name didn't stick β it needs to be 1β60 characters. Try another?", "retry": true }, |
| 114 | "error": { "say": "Something went wrong saving that β it's not the name's fault. Give it another go in a moment.", "retry": true } |
| 115 | } |
| 116 | } |
| 117 | ``` |
| 118 | |
| 119 | ```json |
| 120 | { |
| 121 | "id": "keys", |
| 122 | "skipIf": ["bridge.enrolled"], |
| 123 | "chips": [ |
| 124 | { "label": "Save a passkey (recommended)", "advancing": false, |
| 125 | "effect": { "verb": "savePasskey" }, |
| 126 | "probe": { "afterMs": 1500, "unless": "bridge.enrolled", "goto": "keys-waiting" } }, |
| 127 | { "label": "Not now", "spent": "Skipped", "goto": "keys-confirm" } |
| 128 | ] |
| 129 | } |
| 130 | ``` |
| 131 | |
| 132 | Everything in these fragments maps 1:1 to a line of the shipped JS. The full |
| 133 | v1 grammar is settled during the web-refactor phase (below), where each |
| 134 | construct earns its place by being *needed* to reproduce the two flows β |
| 135 | nothing speculative goes in. |
| 136 | |
| 137 | ## The interpreter contract |
| 138 | |
| 139 | Two engines, one spec. Each platform implements: |
| 140 | |
| 141 | - **The bridge verbs** β the closed set of side effects a script may name: |
| 142 | `setMyName, setGroupName, setContactEmail, adoptServerPhoto, |
| 143 | adoptProfilePhoto, setUserInvites, savePasskey, markSetupComplete, |
| 144 | markWelcomeDone, deferBackupReminders, openThread, startTour` β plus the |
| 145 | read-only **context bag**: `space.*`, `suggested`, `inviterName`, |
| 146 | `adminName`, `bridge.enrolled`, `bridge.myName`. Each verb's result enum is |
| 147 | part of the contract. A script can *only* name these verbs; an unknown verb |
| 148 | fails closed (see fallback). This is the capability boundary β a script is |
| 149 | data and can never do anything the contract doesn't offer. |
| 150 | - **The feel** β typing beat + pacing (per `eleven.js`), the reply queue, |
| 151 | persisted `*.say` events keyed by stable key (dedupe on resume), spent-chip |
| 152 | rendering, composer hint plumbing, answer waiters with cancel. |
| 153 | - **Recording + resume** β `*.step` / `*.say` events into the app-thread state |
| 154 | exactly as today, so a flow started on web and resumed native (or vice |
| 155 | versa) folds the same transcript. The events are the state; the script is |
| 156 | stateless. |
| 157 | |
| 158 | The web engine is not new code: it's `eleven.js` plus a thin step-walker |
| 159 | replacing the hand-written step functions. The Swift engine is new, but it's |
| 160 | the same small state machine β and the native feed must already render |
| 161 | bot-voiced bubbles with action chips (they exist in the wire format), so the |
| 162 | rendering half is shared with native chat work, not extra. |
| 163 | |
| 164 | ## Delivery & versioning |
| 165 | |
| 166 | - Scripts are embedded in the server binary (like `changelog/`) and served at a |
| 167 | token-gated `GET /api/scripts/{flow}` (the joiner has a token by the time the |
| 168 | welcome runs; the setup claimer likewise). `{ schema, rev, flow, steps }`. |
| 169 | - **Evolution is additive within a schema major.** Interpreters ignore unknown |
| 170 | *optional* fields; an unknown **verb, input kind, or schema major fails |
| 171 | closed**: the client abandons the script and uses its built-in fallback β |
| 172 | the native name form / minimal join path, which every client keeps. Failing |
| 173 | closed matters because this is onboarding: a too-old app must still let |
| 174 | someone join, just less delightfully. (Server and web ship together, so web |
| 175 | never lags; the rule exists for installed native apps.) |
| 176 | - A script change is a changelog-worthy server deploy; `rev` makes "which flow |
| 177 | version did this member see" answerable. |
| 178 | |
| 179 | ## Sequencing |
| 180 | |
| 181 | 1. **Freeze the schema against reality**: refactor `setup.js` + `welcome.js` |
| 182 | into flow JSON + the web step-walker, behaviour-identical (the two-party |
| 183 | canary harness verifies transcripts). The schema is *done* when both shipped |
| 184 | flows are pure data and nothing else changed. This is the cheap place to |
| 185 | discover the schema is wrong. |
| 186 | 2. **Serve the scripts** from the binary; web switches to fetching them. |
| 187 | 3. **Swift interpreter** when the native Mac/iOS surfaces land β against a spec |
| 188 | two real flows already exercise, with the web engine as the reference |
| 189 | implementation and the JSON as shared fixtures (the same |
| 190 | vectors-from-the-real-thing pattern as `internal/e2ee/testdata`). |
| 191 | 4. Later, on their own merits: `/tutorial` migrates to a flow; new flows |
| 192 | (re-onboarding after restore, feature tours) become script edits. |
| 193 | |
| 194 | ## Boundaries β what this is not |
| 195 | |
| 196 | - **Not the bot-recipe schema.** `docs/bots.md`'s triggerβaction recipes are |
| 197 | the *sidecar's* declarative layer (keyword/schedule β reply/nudge), executed |
| 198 | by the bot engine off-server. Conversation scripts are *client*-executed and |
| 199 | serve native parity. Siblings β they share the chip/bubble message shapes β |
| 200 | but unifying them into one behaviour language is the too-clever failure mode |
| 201 | CLAUDE.md warns about. Two small schemas beat one grand one. |
| 202 | - **Not a general mini-app format.** Mini-apps stay code (`apps/README.md`). |
| 203 | A flow script cannot render arbitrary UI, only bubbles/chips/inputs β that |
| 204 | ceiling is the point. |
| 205 | - **Not a plain-English builder** β though flows-as-data is the substrate that |
| 206 | would eventually make one possible (`docs/bots.md Β§ Creating bots in plain |
| 207 | English`). |
| 208 | |