// The first-run setup conversation as DATA (docs/conversation-scripts.md) — // every bubble, chip, branch, and side effect of the flow Eleven walks a fresh // instance's admin through. The engine (flow.js) interprets it; setup.js binds // it to the host bridge. This object must stay JSON-pure (a test enforces it): // it is the exact document the server will ship to native interpreters. export default { schema: 1, flow: "setup", entry: "hello", // Resumed = a step was actually recorded in an earlier session. (A name // alone doesn't count: the native creator arrives with one pre-set from the // create screen, and that first run must still get the greeting.) resumedIf: { any: ["done.name", "done.group", "done.email", "done.photo", "done.invites", "done.extras", "done.keys"] }, // The passkey RETURN leg gets its own richer ack in the keys step's entry, // so it skips the generic re-greeting (mirrors welcome.flow.js). resumeUnless: { all: ["bridge.enrolled", "!done.keys"] }, resumeSay: "Welcome back — let's pick up where we left off.", steps: [ { id: "hello", skipIf: ["resumed"], bubbles: [ { text: "Hi — I'm Eleven! 👋 This is your space now: everything in it is end-to-end encrypted, and you're its administrator.\n\n" + "Let's take two minutes to set things up.", }, ], auto: "name", }, { id: "name", // A device that already knows this member's name (an add-device link // ships it in the fragment; a native shell carries the rail's) must // not skip PAST this step with a bare skipIf: that writes nothing to // the log, so every client that does NOT know the name stays parked // here forever while this one moves on — two composers armed on // different steps of the same thread (the 2026-07-19 web-vs-Mac // divergence). The entry RECORDS the settled name, so done.name folds // to every client and they all derive the same position from the log. entries: [ { if: "bridge.myName", recordIfNew: { label: "noted the name you go by", value: "known" }, goto: "group", }, ], skipIf: ["done"], // Explicit, because the confirm step (name-email) now sits between this // one and "group": a skip falls through in ARRAY order, and landing a // resumed client on a confirmation for an answer it never gave would // park the whole conversation there. skipTo: "group", bubbles: [ { text: "First — what should everyone here call you? Reply with your name.", label: "Your name:", hint: "e.g. Finn Wolfhard" }, ], // The fast path for admins in a hurry: one form with the whole setup at // once, ending in the same sign-in (the keys step's settle picks the // ceremony's return up from the fast-filled records). chips: [ { label: "Fill in a form instead", trailing: true, advancing: false, effect: { verb: "openQuickForm" }, }, ], // An email address typed here would become the name the whole space // sees, silently. Confirm it first (flow.js input.confirm) — everything // else is accepted as typed, because people are called all sorts. input: { kind: "text", required: true, maxLen: 32, clean: "name", confirm: { when: "email", goto: "name-email" } }, effect: { verb: "setMyName", args: ["$answer"] }, on: { ok: { record: { label: "noted: you're {vars.answer}", value: "$answer" }, ack: "Lovely to meet you, {vars.answer}! 🤝", goto: "group", }, }, }, { // The confirm step for an email answered as a name. Nothing has been // recorded yet, so both chips are live choices: keeping it runs the // effect + record the name step would have, and changing it re-asks. id: "name-email", bubbles: [ { text: "That's an email address — is that what you'd like to be called here? It's the name everyone in this space sees.", key: "q-name-email", }, ], chips: [ { label: "Use it anyway", spent: "Kept", effect: { verb: "setMyName", args: ["$answer"] }, record: { step: "name", label: "noted: you're {vars.answer}", value: "$answer" }, ack: "Righto — {vars.answer} it is. 🤝", goto: "group", }, { label: "Type a different name", spent: "Changed", goto: "name" }, ], }, { id: "group", skipIf: ["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: { if: "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.", }, label: "Space name:", hint: "e.g. The Book Club", }, ], chips: [{ if: "space.nameSet", label: "Keep “{space.name}”", spent: "Kept", goto: "email" }], input: { kind: "text", required: true }, effect: { verb: "setGroupName", args: ["$answer"] }, on: { ok: { set: { "space.name": "$answer" }, record: { label: "named this space “{vars.answer}”", value: "$answer" }, goto: "email", }, invalid: { say: "Hmm, that name didn't stick — it needs to be 1–60 characters. Try another?", retry: "step" }, error: { say: "Something went wrong saving that — it's not the name's fault. Give it another go in a moment.", retry: "step", }, }, }, { id: "email", skipIf: ["done"], bubbles: [ { text: "Your contact email is shared with members — useful if someone's locked out, or something needs a human. It's optional.", }, { text: "How can people here reach you outside the chat? Reply with an email address, or skip it.", label: "Contact email:", hint: "e.g. finn@example.com", }, ], // The skip RECORDS the step: an unrecorded skip replays on every resume — // notably the passkey return leg, which re-asked the email (and photo) // instead of carrying on. A skip is an answer. chips: [ { label: "Skip for now", spent: "Skipped", record: { label: "skipped the contact email", value: "skipped" }, goto: "photo" }, ], input: { kind: "text" }, effect: { verb: "setContactEmail", args: ["$answer"] }, on: { ok: { record: { label: "noted a contact email", value: "$answer" }, goto: "photo" }, invalid: { ask: "That doesn't look like an email address — try again, or skip it." }, error: { ask: "Something went wrong saving that — try again in a moment, or skip it." }, }, }, { id: "photo", // Native v1: no attachment flow in the conversation composer — the space // photo lives in the Admin app there. skipIf: ["done", "native"], skipTo: "invites", bubbles: [ { text: "Want to give this space a face? The photo shows on the welcome page and when people join — you can always change it later.", }, { text: "Send me a photo — tap the + next to the message box, or just drag a picture into the chat, and hit send. Type a message with it if you want to include alt text.", attachHint: "Optional: type alt text for the photo", }, ], // Recorded for the same resume reason as the email skip. chips: [ { label: "Skip for now", spent: "Skipped", cancelInput: true, record: { label: "skipped the space photo", value: "skipped" }, goto: "invites" }, ], input: { kind: "photo", reject: { text: "Send it as a picture — tap the + next to the message box or drag one in — or tap Skip." }, }, effect: { verb: "adoptServerPhoto" }, on: { ok: { set: { "vars.photoAlt": "$answerText" }, goto: "photo-preview" }, error: { say: "Hmm, I couldn't use that one — try another?", ask: "Send another photo when you're ready, or skip it.", }, }, }, { id: "photo-preview", bubbles: [ { text: [ "Here's how it looks.", { if: "vars.photoAlt", text: "\n\nIts alt text is the message you sent — “{vars.photoAlt}” — and like the photo itself it's PUBLIC: anyone with an invite link can see both.", }, "\n\nHappy with it, or want to try another?", ], img: "$serverAvatar", imgAlt: "{vars.photoAlt|The space photo}", local: true, }, ], chips: [ { label: "Keep this photo", spent: "Kept", record: { step: "photo", label: "set the space photo", value: "{vars.photoAlt}" }, goto: "invites", }, { label: "Use a different photo", gotoAsk: { step: "photo", text: "Of course! Send me another one whenever you're ready — and pop a message with it if you'd like alt text. 📷", }, }, ], }, { id: "invites", skipIf: ["done"], bubbles: [ { text: "Who gets to bring people in? You can always invite people. The question is whether members can too, or whether new faces only arrive through you.", }, ], chips: [ { label: "Members can invite people", effect: { verb: "setUserInvites", args: [true] }, record: { label: "decided members can invite people", value: "members" }, goto: "extras", }, { label: "Only me", effect: { verb: "setUserInvites", args: [false] }, record: { label: "decided only the admin invites people", value: "admin only" }, goto: "extras", }, ], }, { // Bots + apps (two of the #712 gates) as ONE question with a one-tap // common path; "Let me choose" opens the granular pair. Defaults are // already on server-side, so keeping both on writes nothing — the // granular chips write the setting either way. AI is deliberately NOT // in this bundle: it gets its own explicit opt-in step below // (docs/ai-provisioning.md — saying yes provisions a real helper, so // it's never a side effect of "keep everything on"). id: "extras", skipIf: ["done"], skipTo: "ai-optin", // past the granular pair, which only "Let me choose" enters entries: [ // A resume that already finished the granular path: close this out. { if: "done.extras-apps", recordIfNew: { label: "chose this space's extras", value: "chosen" }, goto: "ai-optin", }, ], bubbles: [ { text: "Nearly there. Your space comes with a couple of extras:\n\n" + "• Bots — little helpers members can create for reminders and jobs.\n" + "• Apps — to-dos, polls, whiteboards, and a studio for building your own.\n\n" + "Everything essential works with these off, and you can change your mind anytime in Admin.", }, ], chips: [ { label: "Keep both on", spent: "Kept", record: { label: "kept bots and apps on", value: "all-on" }, goto: "ai-optin", }, { label: "Let me choose", goto: "extras-bots" }, ], }, { id: "extras-bots", skipIf: ["done"], bubbles: [ { text: "Bots — helpers that live in chats and do jobs: reminders, summaries, little tools. Members can create their own." }, ], chips: [ { label: "Enable bots", effect: { verb: "setFeature", args: ["bots", true] }, record: { label: "enabled bots", value: "on" }, goto: "extras-apps", }, { label: "No bots here", spent: "Off", effect: { verb: "setFeature", args: ["bots", false] }, record: { label: "switched bots off", value: "off" }, goto: "extras-apps", }, ], }, { id: "extras-apps", skipIf: ["done"], bubbles: [ { text: "And apps — the built-in extras like to-dos, polls, and whiteboards, plus the App Studio where members build their own." }, ], chips: [ { label: "Enable apps", effect: { verb: "setFeature", args: ["apps", true] }, record: { label: "enabled apps", value: "on" }, goto: "ai-optin", }, { label: "No apps here", spent: "Off", effect: { verb: "setFeature", args: ["apps", false] }, record: { label: "switched apps off", value: "off" }, goto: "ai-optin", }, ], }, { // AI is opt-in with the honest note (docs/ai-provisioning.md): saying // yes flips ai_enabled on, and on a cloud space the server asks the // provisioner for the sidecar — Eleven joins moments later. Declining // writes an explicit off. Either way the choice is the admin's, never // a default's. id: "ai-optin", skipIf: ["done"], bubbles: [ { text: "Last choice — AI. Say yes and Eleven, the built-in helper, joins this space: members can ask it things in plain language and it answers, builds, and helps out.\n\n" + "An honest note before you decide: Eleven's AI runs on Eleven Messenger's machines, and it can read the chats it's invited to — that's what lets it help. It never sees chats it isn't in, and you can switch it off anytime in Admin.", }, ], chips: [ { label: "Bring Eleven in", spent: "On", effect: { verb: "setFeature", args: ["ai", true] }, record: { label: "invited Eleven's AI in", value: "on" }, goto: "plan", }, { label: "Not now", spent: "Off", effect: { verb: "setFeature", args: ["ai", false] }, record: { label: "kept AI off", value: "off" }, goto: "plan", }, ], }, { // The plan question, moved HERE from the create flow (#1374): creating // a space is never pay-gated — the conversation is where "who is this // space for?" gets asked, with real prices from the price book, and a // paid answer becomes an upgrade chip on the finish line (the space // already exists, free, and stays free until that tap). Web-only, like // the photo: native wrappers must not surface an external purchase // (App Store rules — the same gate as the ⋮ Upgrade item), and with no // reachable price book (self-hosted, billing off, already paid) the // step vanishes. Whichever client asks, the record lands on THIS step. id: "plan", skipIf: ["done", "native"], skipTo: "keys", entries: [{ if: "!bridge.planPrices", goto: "keys" }], bubbles: [ { text: "One more question — who is this space for?\n\n" + "Every space starts free, and free is a real home — kept that way by the spaces that pay. " + "If this one's for a team, a community, or family, there's a plan that backs it (and the free spaces too).", key: "q-plan", }, ], chips: [ { if: "bridge.planTeam", label: "A team", goto: "plan-team" }, { if: "bridge.planCommunity", label: "A community", goto: "plan-community" }, { if: "bridge.planFamily", label: "Family & friends", goto: "plan-family" }, { label: "Keep it free", spent: "Free", trailing: true, record: { label: "kept this space free", value: "free" }, goto: "keys", }, ], }, { // The three per-audience price cards. Names and prices interpolate from // the price book (config, never code — docs/tier-economics.md); the // record's value is the AUDIENCE, which openPlanCheckout maps back to // the book's tier key at tap time. id: "plan-team", bubbles: [ { text: "For a team that's {bridge.planNameTeam}: {bridge.planPriceTeam} — it scales with the team, and bots are free.\n\n" + "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.", key: "q-plan-team", }, ], chips: [ { label: "Choose {bridge.planNameTeam}", spent: "Chosen", set: { "vars.planTier": "team" }, record: { step: "plan", label: "chose the {bridge.planNameTeam} plan — payment waits at the finish line", value: "team" }, goto: "keys", }, { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" }, ], }, { id: "plan-community", bubbles: [ { text: "For a community that's {bridge.planNameCommunity}: {bridge.planPriceCommunity}.\n\n" + "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.", key: "q-plan-community", }, ], chips: [ { label: "Choose {bridge.planNameCommunity}", spent: "Chosen", set: { "vars.planTier": "community" }, record: { step: "plan", label: "chose the {bridge.planNameCommunity} plan — payment waits at the finish line", value: "community" }, goto: "keys", }, { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" }, ], }, { id: "plan-family", bubbles: [ { text: "For family and friends that's {bridge.planNameFamily}: {bridge.planPriceFamily}.\n\n" + "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.", key: "q-plan-family", }, ], chips: [ { label: "Choose {bridge.planNameFamily}", spent: "Chosen", set: { "vars.planTier": "family" }, record: { step: "plan", label: "chose the {bridge.planNameFamily} plan — payment waits at the finish line", value: "family" }, goto: "keys", }, { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" }, ], }, { id: "keys", entries: [ { // Native shells (iOS/Mac) WITH a live Eleven session: claiming a // space pushes it — key included — straight into that account's // vault (addInstance → HomeSync.pushSoon), so the pitch is moot. if: { all: ["native", "bridge.homeSignedIn"] }, set: { "vars.backedUp": true }, recordIfNew: { label: "Your space's key is saved with your Eleven account", value: "native" }, goto: "finish", }, { if: "bridge.enrolled", set: { "vars.backedUp": true }, recordIfNew: { label: "Signed in — your space's key is safe", value: "passkey" }, sayIfNew: "You're signed in — your space's key is safe. ✅", goto: "finish", }, // Answered in an earlier session, but this browser isn't enrolled // ("Continue" after a detour, "I understand the risk", a lost local // flag): the step is DONE — never re-pitch it. Without this entry the // flow re-asked on every resume and never reached finish, so // markSetupComplete never ran (#1377). backedUp stays false, so the // finish line still carries the honest reminder. { if: "done", goto: "finish" }, // Nowhere to sign in: a self-hosted space with no home server (#1377), // or a native host that wired no sign-in. The pitch would be a // dead-end chip — record where the key actually is and move on. { if: "!bridge.canEnrol", recordIfNew: { label: { if: "native", then: "This device holds this space's key", else: "This browser holds this space's key" }, value: "local", }, goto: "finish", }, // Someone who created this space from their Eleven sign-in page already // holds an account — don't pitch signing in from scratch, offer to add // this space's key to it (keys-home). { if: "bridge.fromHome", goto: "keys-home" }, ], // The sign-in returns asynchronously — a redirect back from the home // server on web, a sheet or Settings window on native — so watch both // signals and advance without a manual tap. settle: { when: { any: ["bridge.enrolled", "bridge.homeSignedIn"] } }, // Member-facing language is "sign in" — never the mechanism (passkeys, // backups). The lock-and-key framing carries the stakes instead. bubbles: [ { // Segments, so only the noun changes between surfaces: a native app // holds the key on the DEVICE, a browser in the browser. text: [ "Now the most important thing of all. 🔑\n\n", "Everything here is locked with a key that exists only on your devices. There's no password reset and no support desk — not even whoever runs the server can read your messages or recover that key. That's not a flaw; it's the whole point.\n\n", "But it means: right now, ", { if: "native", text: "this device holds" }, { if: "!native", text: "this browser holds" }, " the only key to your space. If it's lost — cleared, broken, stolen — your space is gone with it, and nobody can get it back.", ], }, { text: { if: "native", then: "Sign in with Eleven and your key stays safe — it's saved to your account, and every device you sign in on can open this space.", else: "Sign in with Eleven and your key stays safe — one tap, your face or fingerprint, and you'll be brought straight back.", }, }, ], chips: [ { label: "Sign in", advancing: false, effect: { verb: "savePasskey" }, probe: { afterMs: 1500, unless: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys-waiting" }, }, { label: "Not now", spent: "Skipped", goto: "keys-confirm" }, ], }, { // The from-home variant of "keys": they signed in with Eleven to create // this space, so the ask is one addition, not a new concept. Mechanics // are identical (the enrol popup signs into their existing account and // merges this space's key + name over the address-only stub). id: "keys-home", entries: [ { if: "bridge.enrolled", set: { "vars.backedUp": true }, recordIfNew: { label: "Signed in — your space's key is safe", value: "passkey" }, sayIfNew: "You're signed in — your space's key is safe. ✅", goto: "finish", }, ], // Auto-detect the return from the home server's sign-in ceremony (same as // the keys step above) so the user is carried on without tapping Continue. settle: { when: "bridge.enrolled" }, bubbles: [ { text: "Last thing — the key. 🔑\n\n" + "This space is locked with a key that was just created here, in this browser. Your Eleven sign-in doesn't hold it yet — so if this browser is lost, the space goes with it.", }, { text: "Sign in and it's added to your account — one tap, and you'll be brought straight back." }, ], chips: [ { label: "Sign in", advancing: false, effect: { verb: "savePasskey" }, probe: { afterMs: 1500, unless: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys-waiting" }, }, { label: "Not now", spent: "Skipped", goto: "keys-confirm" }, ], }, { id: "keys-waiting", // The ceremony may confirm while we sit here (the completion poll flips // the flag) — re-enter keys so its entry records + acks, no tap needed. settle: { when: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys" }, bubbles: [{ text: "I'll wait here while you finish signing in. When you're done, tap Continue." }], chips: [ { label: "Continue", record: { step: "keys", label: { if: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, then: "Signed in — your space's key is safe", else: "Signed in (in this browser)", }, value: "passkey", }, set: { "vars.backedUp": true }, goto: "finish", }, { label: "It didn't work — skip for now", spent: "Skipped", record: { step: "keys", label: "Skipped signing in for now", value: "skipped" }, set: { "vars.backedUp": false }, goto: "finish", }, ], }, { id: "keys-confirm", // Same late-confirmation catch as keys-waiting: a ceremony finished in // another context can land while the warning is up — advance over it. settle: { when: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys" }, bubbles: [ { text: "I have to be straight with you: if you don't sign in, one lost or cleared browser means this space and everything in it is gone — permanently, for everyone, and no one can help. I'll remind you again in a day or so.", }, ], chips: [ { label: "Go back and sign in", advancing: false, effect: { verb: "savePasskey" }, probe: { afterMs: 1500, unless: { any: ["bridge.enrolled", "bridge.homeSignedIn"] }, goto: "keys-waiting" }, }, { label: "I understand the risk", spent: "Understood", record: { step: "keys", label: "Skipped signing in for now", value: "skipped" }, set: { "vars.backedUp": false }, goto: "finish", }, ], }, { id: "finish", // The completion marker: hosts without the server flag's context (the // native embed gates on conversation state, not setup_complete — native // sets that eagerly at claim as the reaper's not-abandoned signal) read // this recorded step to know the conversation is over. record: { label: "Your space is ready", value: "done" }, effects: [{ if: "!vars.backedUp", verb: "deferBackupReminders" }, { verb: "markSetupComplete" }], bubbles: [ { text: [ "That's it — {space.name|your space} is ready! 🎉\n\n", "Everything we just set up lives in your Admin panel — invite links, the space's name and photo, who can invite. Type `/admin` anywhere to open it; it's yours alone as this space's admin.", // The one honest parting warning for an admin who chose "Not now": // plain consequences, no mechanism talk, and the way out right // there. Gated on canEnrol — a self-hosted admin has no sign-in // to be reminded of. { if: { all: ["!vars.backedUp", "bridge.canEnrol"] }, text: "\n\nOne more reminder before you go: you haven't signed in, so this browser holds the only key to this space. If it's lost or cleared, the space is gone with it — for everyone. Sign in any time and it's safe.", }, // The plan they chose mid-conversation (#1374): the payment is a // chip below, never a side effect — and the space stays free (and // fully working) until that tap. { if: "vars.planTier", text: "\n\nAnd the plan you picked — tap below whenever you're ready to set up the payment. Your space runs free until then.", }, ], // A stable dedup key (welcome has q-finish too): without one the // dedupe rides the rendered text, and a renamed space or a flipped // backup state persisted a second finish bubble (#1377). key: "q-finish", }, { text: "Where would you like to start?" }, ], chips: [ // The parting reminder's action — finish-line chips stay live, and the // completion poll picks the sign-in up even after the flow has ended. { if: { all: ["!vars.backedUp", "bridge.canEnrol"] }, label: "Sign in", advancing: false, effect: { verb: "savePasskey" } }, // The chosen plan's payment (#1374): hands off to the home page's // upgrade picker with the tier pre-highlighted. Same tab — setup is // complete, and home brings you back to your list. { if: "vars.planTier", label: "Set up your plan", advancing: false, effect: { verb: "openPlanCheckout" } }, // Native hops threads too (the conversation model's openAdmin verb). { label: "Open admin", advancing: false, effect: { verb: "openAdmin" } }, // The tour is the web's guided overlay — web-only for now. { if: "!native", label: "Take the tour", advancing: false, effect: { verb: "startTour" } }, // "Where would you like to start?" needs an answer that ISN'T a // destination: most people are finished, and every chip pointing // somewhere else made leaving feel like the one thing on offer. // Advancing (so it spends itself), and it says the thing out loud — // you're set up, nothing is outstanding, Eleven is here if you want it. { label: "Done for now", spent: "Done", say: "You're all set. 👍 I'm here whenever you want me — just say hello, or type `/` to see what I can do." }, ], end: true, }, ], };