eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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):

{
  "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 }
  }
}
{
  "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).