eleven / messenger Public

docs: conversation-scripts schema — Eleven's flows as data for native parity (#283)

With iOS/Mac moving to fully native rendering, the onboarding conversations
(setup.js, welcome.js) become flows-as-data interpreted by a per-platform
engine, so one script definition serves web + Swift and ships in the server
binary (flow changes = server deploy, no App Store lag). The schema covers
exactly what the two shipped flows do — skip guards, ack-folded bubbles with
stable keys, chips/typed/attachment/probe inputs, effect verbs with result
enums and branching, races, step recording — and deliberately excludes feel
(pacing, persistence, spent chips), which stays in the engine per eleven.js's
existing split. Fails closed to each client's built-in fallback. Cross-refs
from conversational-onboarding.md and bots.md (sibling of the recipe schema,
not one unified language).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 13c7b912c33a39cf3126f5eda6725acba688ad6c parent 4b7ef47
3 files changed, +209 −0
  • docs/bots.md +7 −0
  • docs/conversation-scripts.md +196 −0
  • docs/conversational-onboarding.md +6 −0
diff --git a/docs/bots.md b/docs/bots.md
index 45ce18a..315e327 100644
--- a/docs/bots.md
+++ b/docs/bots.md
@@ -336,6 +336,13 @@ nudge — no model call, deterministic, and often best for a nudge) or an **LLM
call** (phrase it in context). Trigger = JS; action = template or LLM. This is the
trigger→action recipe, and it's separate from Claude tool-use (the dev-bot lane).
+> Recipes have a **sibling schema**: `docs/conversation-scripts.md` — Eleven's
+> deterministic onboarding flows as data, interpreted client-side (needed for
+> the native apps). Same chip/bubble message shapes, deliberately *not* one
+> unified behaviour language. When the Go sidecar runtime is built
+> (§ Direction below), it should be recipe-shaped from day one — an engine
+> executing config — so declarative bots don't require a second rewrite.
+
## Where bots earn their keep: groups, not DMs
The moat is **shared context**, which a standalone Claude/ChatGPT structurally
diff --git a/docs/conversation-scripts.md b/docs/conversation-scripts.md
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`).
diff --git a/docs/conversational-onboarding.md b/docs/conversational-onboarding.md
index 2fad95d..a6b4717 100644
--- a/docs/conversational-onboarding.md
+++ b/docs/conversational-onboarding.md
@@ -8,6 +8,12 @@ fragility banner — phases 1–3a shipped) and the `/tutorial` mini-app
(`web/static/apps/tutorial.js`), whose client-driven, in-Eleven's-voice pattern
this design generalises.
+> **Follow-on: `conversation-scripts.md`.** With iOS/Mac moving to fully
+> native rendering, these conversations become **flows-as-data** interpreted
+> by a per-platform engine (web first, Swift when the native surfaces land),
+> so one script definition serves every client. The flows and trust
+> boundaries described here are unchanged — only where the step logic lives.
+
## Goals
Two onboarding moments, one shape for both: