# 🤖 Conversation scripts — a schema for Eleven's deterministic flows

Status: **phase 1 complete — schema v1 is frozen against reality.** Both
shipped conversations are pure data (`setup.flow.js`, `welcome.flow.js`)
interpreted by the engine (`web/static/flow.js`); `test/flow.test.mjs`
drives full transcripts of both in Node with a fake io and enforces JSON
purity, so the flow files are shippable fixtures for the Swift interpreter.
Next: phase 2, serving the scripts from the binary. Design captured
2026-07-07. 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. A step may also
   carry a top-level `record` that persists ON ARRIVAL (once — skipped when the
   step is already recorded): welcome's `finish` records its completion marker
   this way, so a host with no run-local state (the native embed has no
   localStorage join marker) can tell a finished conversation from a half-done
   one purely from recorded state.

**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`).
