| new file mode 100644 |
| index 0000000..88a97f7 |
| --- /dev/null |
| +++ b/docs/conversation-scripts.md |
| @@ -0,0 +1,196 @@ |
| +# 🤖 Conversation scripts — a schema for Eleven's deterministic flows |
| + |
| +Status: **proposed** — design captured 2026-07-07, nothing built. Builds on |
| +`conversational-onboarding.md` (the shipped setup + welcome conversations) and |
| +exists because of one new fact: **the iOS and Mac apps are moving to fully |
| +native rendering**, so they must run the onboarding conversations natively, |
| +matching the web. Hand-porting `setup.js`/`welcome.js` to Swift means every |
| +flow tweak lands twice and drifts; keeping onboarding in a webview fights the |
| +native direction at the exact moment (first run) an app most needs to feel |
| +native. The answer is the classic split: |
| + |
| +> **Flows become data** (a script the server ships), **clients become |
| +> interpreters** (one engine per platform, written once). |
| + |
| +Scripts change often and must stay in lockstep across platforms; interpreters |
| +change rarely. Shipping the scripts in the server binary — like the changelog — |
| +means a flow change is a server deploy: no App Store release, no TestFlight |
| +lag, and web/iOS/Mac render the *same* flow version by construction. |
| + |
| +## What stays true |
| + |
| +- **Deterministic, client-executed, no AI.** Exactly as today |
| + (`conversational-onboarding.md` non-negotiables): a fresh instance has no |
| + sidecar and no key, so onboarding cannot depend on either. The interpreter |
| + runs client-side, where `setup.js`/`welcome.js` run now; the server ships the |
| + flow *definition* and never sees the answers. |
| +- **E2E rules unchanged.** The script is content + control flow — it collects |
| + nothing itself. Every side effect goes through the same host-bridge verbs the |
| + flows use today, each landing on an existing trust boundary (names encrypted |
| + under the instance key; group name/server photo/contact email are plaintext |
| + branding by design; the passkey ceremony is the existing enrolment). |
| +- **One voice.** Eleven's tone lives in the script strings. A copy edit is a |
| + script edit — one place, all platforms. |
| + |
| +## What the schema must express |
| + |
| +Everything the two shipped flows actually do — this list is derived from |
| +`setup.js` + `welcome.js` line by line, and it is the acceptance bar: |
| + |
| +1. **Steps with skip guards.** Each step re-checks on entry: skip if its |
| + recorded `steps[id].done`, or if external state already satisfies it |
| + (`myName()` set, `enrolled()` true). This is what makes reload/passkey |
| + round-trip resume work. |
| +2. **Bubbles.** One or more per step (the context-then-question pattern), with: |
| + template interpolation from a context bag (`{name}`, `{space}`, |
| + `{inviter}`…); an optional **ack prefix** folded from the previous step |
| + under a **stable key** (so a resumed run recognises the question despite the |
| + variable prefix); persisted-vs-local lines (image previews are client-only); |
| + image bubbles (`img`, `imgAlt`, round); composer hints (`hint`, |
| + `attachHint`). |
| +3. **Inputs**, one of: |
| + - **chips** — advancing (consume the step, spent past-tense label: |
| + Skip → Skipped), or non-advancing (passkey launch), with conditional |
| + presence (`Keep “{current}”` only when a name was explicitly set; |
| + `I'm {suggested}` only when the invite carried a suggestion); |
| + - **typed answer** — next composer message in the thread; |
| + - **attachment answer** — next message bearing an image; its text rides |
| + along (alt text); a non-image reply re-prompts in place and re-arms; |
| + - **probe** — a timer + predicate (the 1.5s "did the passkey ceremony |
| + navigate away?" check that routes to the stayed-in-place waiting step). |
| +4. **Effects and branching.** Side effects are *named verbs with declared |
| + result enums*, and the step branches on the result: `setGroupName → |
| + ok | invalid | error` (each with its own reply and re-ask), `adoptProfilePhoto |
| + → ok | nokey | error`. Retry loops re-ask in place, not from the top. |
| +5. **Races.** A skip chip must be able to disarm a pending answer waiter |
| + (`cancelAnswer`), and an effect can be guarded so it never runs if the step |
| + already resolved (the photo-vs-Skip race). |
| +6. **Recording.** A completed step emits its `*.step` event (label + value) — |
| + the ✓ action lines that make the transcript real history. |
| + |
| +**What the schema deliberately does NOT express: feel.** Typing beats, pacing |
| +jitter, the reply queue, persistence retry/echo-wait, spent-chip rendering, |
| +placeholder plumbing — all of that is the **engine's** job, specified once in |
| +the interpreter contract below. `eleven.js` already *is* this split on the web |
| +(scripts call `say`/`ask`/`advance`; the engine owns the rest), which is the |
| +strongest evidence the boundary is right. Keeping feel out of the schema is |
| +what keeps the schema small enough to trust. |
| + |
| +## The schema (v1 sketch) |
| + |
| +A **flow** is JSON: `{ schema: 1, flow: "setup" | "welcome", entry, steps }`. |
| +Steps are named; control flow is explicit `next` / branch targets — no |
| +implicit fallthrough. Illustrative fragments (the group-name step, with |
| +validation branching, and the passkey step, with a probe): |
| + |
| +```json |
| +{ |
| + "id": "group", |
| + "skipIf": ["step.done"], |
| + "bubbles": [ |
| + { "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" }, |
| + { "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." }, |
| + "hint": "Type a name for this space" } |
| + ], |
| + "chips": [ |
| + { "if": "space.nameSet", "label": "Keep “{space.name}”", "spent": "Kept", "goto": "email" } |
| + ], |
| + "input": { "kind": "text" }, |
| + "effect": { "verb": "setGroupName", "arg": "$answer" }, |
| + "on": { |
| + "ok": { "record": { "label": "named this space “{$answer}”", "value": "$answer" }, "goto": "email" }, |
| + "invalid": { "say": "Hmm, that name didn't stick — it needs to be 1–60 characters. Try another?", "retry": true }, |
| + "error": { "say": "Something went wrong saving that — it's not the name's fault. Give it another go in a moment.", "retry": true } |
| + } |
| +} |
| +``` |
| + |
| +```json |
| +{ |
| + "id": "keys", |
| + "skipIf": ["bridge.enrolled"], |
| + "chips": [ |
| + { "label": "Save a passkey (recommended)", "advancing": false, |
| + "effect": { "verb": "savePasskey" }, |
| + "probe": { "afterMs": 1500, "unless": "bridge.enrolled", "goto": "keys-waiting" } }, |
| + { "label": "Not now", "spent": "Skipped", "goto": "keys-confirm" } |
| + ] |
| +} |
| +``` |
| + |
| +Everything in these fragments maps 1:1 to a line of the shipped JS. The full |
| +v1 grammar is settled during the web-refactor phase (below), where each |
| +construct earns its place by being *needed* to reproduce the two flows — |
| +nothing speculative goes in. |
| + |
| +## The interpreter contract |
| + |
| +Two engines, one spec. Each platform implements: |
| + |
| +- **The bridge verbs** — the closed set of side effects a script may name: |
| + `setMyName, setGroupName, setContactEmail, adoptServerPhoto, |
| + adoptProfilePhoto, setUserInvites, savePasskey, markSetupComplete, |
| + markWelcomeDone, deferBackupReminders, openThread, startTour` — plus the |
| + read-only **context bag**: `space.*`, `suggested`, `inviterName`, |
| + `adminName`, `bridge.enrolled`, `bridge.myName`. Each verb's result enum is |
| + part of the contract. A script can *only* name these verbs; an unknown verb |
| + fails closed (see fallback). This is the capability boundary — a script is |
| + data and can never do anything the contract doesn't offer. |
| +- **The feel** — typing beat + pacing (per `eleven.js`), the reply queue, |
| + persisted `*.say` events keyed by stable key (dedupe on resume), spent-chip |
| + rendering, composer hint plumbing, answer waiters with cancel. |
| +- **Recording + resume** — `*.step` / `*.say` events into the app-thread state |
| + exactly as today, so a flow started on web and resumed native (or vice |
| + versa) folds the same transcript. The events are the state; the script is |
| + stateless. |
| + |
| +The web engine is not new code: it's `eleven.js` plus a thin step-walker |
| +replacing the hand-written step functions. The Swift engine is new, but it's |
| +the same small state machine — and the native feed must already render |
| +bot-voiced bubbles with action chips (they exist in the wire format), so the |
| +rendering half is shared with native chat work, not extra. |
| + |
| +## Delivery & versioning |
| + |
| +- Scripts are embedded in the server binary (like `changelog/`) and served at a |
| + token-gated `GET /api/scripts/{flow}` (the joiner has a token by the time the |
| + welcome runs; the setup claimer likewise). `{ schema, rev, flow, steps }`. |
| +- **Evolution is additive within a schema major.** Interpreters ignore unknown |
| + *optional* fields; an unknown **verb, input kind, or schema major fails |
| + closed**: the client abandons the script and uses its built-in fallback — |
| + the native name form / minimal join path, which every client keeps. Failing |
| + closed matters because this is onboarding: a too-old app must still let |
| + someone join, just less delightfully. (Server and web ship together, so web |
| + never lags; the rule exists for installed native apps.) |
| +- A script change is a changelog-worthy server deploy; `rev` makes "which flow |
| + version did this member see" answerable. |
| + |
| +## Sequencing |
| + |
| +1. **Freeze the schema against reality**: refactor `setup.js` + `welcome.js` |
| + into flow JSON + the web step-walker, behaviour-identical (the two-party |
| + canary harness verifies transcripts). The schema is *done* when both shipped |
| + flows are pure data and nothing else changed. This is the cheap place to |
| + discover the schema is wrong. |
| +2. **Serve the scripts** from the binary; web switches to fetching them. |
| +3. **Swift interpreter** when the native Mac/iOS surfaces land — against a spec |
| + two real flows already exercise, with the web engine as the reference |
| + implementation and the JSON as shared fixtures (the same |
| + vectors-from-the-real-thing pattern as `internal/e2ee/testdata`). |
| +4. Later, on their own merits: `/tutorial` migrates to a flow; new flows |
| + (re-onboarding after restore, feature tours) become script edits. |
| + |
| +## Boundaries — what this is not |
| + |
| +- **Not the bot-recipe schema.** `docs/bots.md`'s trigger→action recipes are |
| + the *sidecar's* declarative layer (keyword/schedule → reply/nudge), executed |
| + by the bot engine off-server. Conversation scripts are *client*-executed and |
| + serve native parity. Siblings — they share the chip/bubble message shapes — |
| + but unifying them into one behaviour language is the too-clever failure mode |
| + CLAUDE.md warns about. Two small schemas beat one grand one. |
| +- **Not a general mini-app format.** Mini-apps stay code (`apps/README.md`). |
| + A flow script cannot render arbitrary UI, only bubbles/chips/inputs — that |
| + ceiling is the point. |
| +- **Not a plain-English builder** — though flows-as-data is the substrate that |
| + would eventually make one possible (`docs/bots.md § Creating bots in plain |
| + English`). |