web+ios: setup asks about the extras ā AI, bots, apps (one tap or granular) (#716)
A new 'extras' step between invites and keys: 'Keep everything on' (one tap, writes nothing ā the #712 gates already default on) or 'Let me choose', which walks AI ā bots ā apps as quick chip questions, each writing its gate via the new whitelisted setFeature bridge verb (web shell bridge + native setAdminSettings twin). The AI copy carries the reassurance: everything works without it, you just lose the natural-language help. Resume-safe: skips past the trio once answered (extras skipTo, entry closes the step after a granular run). Twin regenerated; 19/19 flow tests incl. new granular-path + resume coverage; both native apps build.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
7 files changed,
+346
−8
changelog/2026-07-11-setup-extras.md+1 −0ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift+7 −0ios/LChatCore/Sources/LChatCore/Flows/setup.json+171 −1test/flow.test.mjs+47 −5web/static/apps/setup.flow.js+106 −2web/static/apps/setup.js+3 −0web/static/shell.js+11 −0
diff --git a/changelog/2026-07-11-setup-extras.md b/changelog/2026-07-11-setup-extras.md| new file mode 100644 |
| index 0000000..bb62825 |
| --- /dev/null |
| +++ b/changelog/2026-07-11-setup-extras.md |
| @@ -0,0 +1 @@ |
| +š¤ Setting up a space now covers the extras. Eleven asks one new question ā keep AI, bots, and apps on, or choose exactly which your space gets. Everything essential works either way, and you can change your mind anytime in Admin. |
diff --git a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift| index e29a2c1..2a02aac 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift |
| @@ -343,6 +343,13 @@ extension ElevenConversationModel: FlowIO { |
| let on = args.first.map { $0.truthy } ?? true |
| _ = await api.setAdminSettings(["user_invites": on]) |
| return .null |
| + case "setFeature": |
| + // The extras steps' gates ("ai" | "bots" | "apps" ā ai_enabled etc.). |
| + // Whitelisted so flow data can't name an arbitrary settings key. |
| + guard let key = args.first?.stringValue, ["ai", "bots", "apps"].contains(key) else { return .null } |
| + let enabled = args.count > 1 ? args[1].truthy : true |
| + _ = await api.setAdminSettings(["\(key)_enabled": enabled]) |
| + return .null |
| case "markSetupComplete": |
| _ = await api.setAdminSettings(["setup_complete": true]) |
| return .null |
diff --git a/ios/LChatCore/Sources/LChatCore/Flows/setup.json b/ios/LChatCore/Sources/LChatCore/Flows/setup.json| index feca77d..8ec2e48 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| +++ b/ios/LChatCore/Sources/LChatCore/Flows/setup.json |
| @@ -9,6 +9,7 @@ |
| "done.email", |
| "done.photo", |
| "done.invites", |
| + "done.extras", |
| "done.keys" |
| ] |
| }, |
| @@ -288,7 +289,7 @@ |
| "label": "decided members can invite people", |
| "value": "members" |
| }, |
| - "goto": "keys" |
| + "goto": "extras" |
| }, |
| { |
| "label": "Only me", |
| @@ -302,6 +303,175 @@ |
| "label": "decided only the admin invites people", |
| "value": "admin only" |
| }, |
| + "goto": "extras" |
| + } |
| + ] |
| + }, |
| + { |
| + "id": "extras", |
| + "skipIf": [ |
| + "done" |
| + ], |
| + "skipTo": "keys", |
| + "entries": [ |
| + { |
| + "if": "done.extras-apps", |
| + "recordIfNew": { |
| + "label": "chose this space's extras", |
| + "value": "chosen" |
| + }, |
| + "goto": "keys" |
| + } |
| + ], |
| + "bubbles": [ |
| + { |
| + "text": "Nearly there. Your space comes with a few extras:\n\n⢠AI ā ask Eleven things in plain language and it helps out.\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\nEverything essential works with any of these off, and you can change your mind anytime in Admin." |
| + } |
| + ], |
| + "chips": [ |
| + { |
| + "label": "Keep everything on", |
| + "spent": "Kept", |
| + "record": { |
| + "label": "kept all the extras on", |
| + "value": "all-on" |
| + }, |
| + "goto": "keys" |
| + }, |
| + { |
| + "label": "Let me choose", |
| + "goto": "extras-ai" |
| + } |
| + ] |
| + }, |
| + { |
| + "id": "extras-ai", |
| + "skipIf": [ |
| + "done" |
| + ], |
| + "bubbles": [ |
| + { |
| + "text": "AI first. With it on, members can ask Eleven things in plain language and it answers, builds, and helps out. Everything works without it ā you just won't have the natural-language help." |
| + } |
| + ], |
| + "chips": [ |
| + { |
| + "label": "Enable AI", |
| + "effect": { |
| + "verb": "setFeature", |
| + "args": [ |
| + "ai", |
| + true |
| + ] |
| + }, |
| + "record": { |
| + "label": "enabled AI", |
| + "value": "on" |
| + }, |
| + "goto": "extras-bots" |
| + }, |
| + { |
| + "label": "No AI here", |
| + "spent": "Off", |
| + "effect": { |
| + "verb": "setFeature", |
| + "args": [ |
| + "ai", |
| + false |
| + ] |
| + }, |
| + "record": { |
| + "label": "switched AI off", |
| + "value": "off" |
| + }, |
| + "goto": "extras-bots" |
| + } |
| + ] |
| + }, |
| + { |
| + "id": "extras-bots", |
| + "skipIf": [ |
| + "done" |
| + ], |
| + "bubbles": [ |
| + { |
| + "text": "Bots next ā 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": "keys" |
| + }, |
| + { |
| + "label": "No apps here", |
| + "spent": "Off", |
| + "effect": { |
| + "verb": "setFeature", |
| + "args": [ |
| + "apps", |
| + false |
| + ] |
| + }, |
| + "record": { |
| + "label": "switched apps off", |
| + "value": "off" |
| + }, |
| "goto": "keys" |
| } |
| ] |
diff --git a/test/flow.test.mjs b/test/flow.test.mjs| index cd26c4b..ded5377 100644 |
| --- a/test/flow.test.mjs |
| +++ b/test/flow.test.mjs |
| @@ -187,9 +187,13 @@ await test("setup: the full first-run transcript, happy path with detours", asyn |
| await h.tap("Keep this photo"); |
| assert.deepEqual(h.records[3], { step: "photo", label: "set the space photo", value: "Our logo" }); |
| - // Invites ā keys: the warning speaks, passkey chip launches the ceremony. |
| + // Invites ā extras ā keys: the one-tap common path records and moves on. |
| await h.tap("Only me"); |
| assert.ok(h.verbLog.find((v) => v[0] === "setUserInvites" && v[1] === false)); |
| + assert.ok(h.transcript.find((t) => /comes with a few extras/.test(t.text))); |
| + await h.tap("Keep everything on"); |
| + assert.ok(h.records.find((r) => r.step === "extras" && r.value === "all-on")); |
| + assert.equal(h.verbLog.some((v) => v[0] === "setFeature"), false, "keeping defaults writes nothing"); |
| assert.ok(h.transcript.find((t) => /Now the most important thing of all/.test(t.text))); |
| await h.tap("Sign in"); |
| assert.ok(h.verbLog.find((v) => v[0] === "savePasskey")); |
| @@ -240,6 +244,7 @@ await test("setup: resumed run ā skips, Keep chip, skip-everything path", asyn |
| await h.tap("Skip for now"); // photo ā must disarm the waiter |
| assert.equal(h.cancelled, 1, "photo skip cancels the pending answer"); |
| await h.tap("Members can invite people"); |
| + await h.tap("Keep everything on"); |
| // Keys: decline twice through the plain-words confirm. |
| await h.tap("Not now"); |
| @@ -259,7 +264,7 @@ await test("setup: already enrolled ā keys short-circuits with the once-only a |
| const h = harness({ |
| myName: "Paul", |
| enrolled: true, |
| - done: { name: true, group: true, email: true, photo: true, invites: true }, |
| + done: { name: true, group: true, email: true, photo: true, invites: true, extras: true }, |
| }); |
| runFlow(SETUP_FLOW, h.env, h.io); |
| await tick(); |
| @@ -269,7 +274,7 @@ await test("setup: already enrolled ā keys short-circuits with the once-only a |
| assert.ok(h.verbLog.find((v) => v[0] === "markSetupComplete")); |
| // Second entry with keys done: no duplicate record, straight to finish. |
| - const h2 = harness({ myName: "Paul", enrolled: true, done: { name: true, group: true, email: true, photo: true, invites: true, keys: true } }); |
| + const h2 = harness({ myName: "Paul", enrolled: true, done: { name: true, group: true, email: true, photo: true, invites: true, extras: true, keys: true } }); |
| runFlow(SETUP_FLOW, h2.env, h2.io); |
| await tick(); |
| // No duplicate keys record ā only finish stamping its completion marker. |
| @@ -277,11 +282,47 @@ await test("setup: already enrolled ā keys short-circuits with the once-only a |
| assert.equal(h2.transcript.some((t) => /key is safe. ā /.test(t.text)), false); |
| }); |
| +await test("setup: extras ā 'Let me choose' walks the trio and writes each gate", async () => { |
| + const h = harness({ |
| + myName: "Paul", |
| + done: { name: true, group: true, email: true, photo: true, invites: true }, |
| + }); |
| + runFlow(SETUP_FLOW, h.env, h.io); |
| + await tick(); |
| + assert.ok(h.transcript.find((t) => /comes with a few extras/.test(t.text))); |
| + |
| + await h.tap("Let me choose"); |
| + assert.ok(h.transcript.find((t) => /AI first/.test(t.text))); |
| + await h.tap("No AI here"); |
| + assert.deepEqual(h.verbLog.at(-1), ["setFeature", "ai", false]); |
| + await h.tap("Enable bots"); |
| + assert.deepEqual(h.verbLog.at(-1), ["setFeature", "bots", true]); |
| + await h.tap("No apps here"); |
| + assert.deepEqual(h.verbLog.at(-1), ["setFeature", "apps", false]); |
| + assert.ok(h.records.find((r) => r.step === "extras-ai" && r.value === "off")); |
| + assert.ok(h.records.find((r) => r.step === "extras-apps" && r.value === "off")); |
| + // The trio lands on the keys pitch like the one-tap path. |
| + assert.ok(h.transcript.find((t) => /Now the most important thing of all/.test(t.text))); |
| + |
| + // Resume after the granular path: the extras entry closes the step out and |
| + // the trio never replays. |
| + const h2 = harness({ |
| + myName: "Paul", |
| + enrolled: true, |
| + done: { ...h.doneSteps }, |
| + }); |
| + runFlow(SETUP_FLOW, h2.env, h2.io); |
| + await tick(); |
| + assert.equal(h2.transcript.some((t) => /AI first/.test(t.text)), false, "granular steps don't replay"); |
| + assert.ok(h2.records.find((r) => r.step === "extras" && r.value === "chosen"), "the entry closes extras"); |
| + assert.ok(h2.records.find((r) => r.step === "keys"), "resume reaches keys"); |
| +}); |
| + |
| await test("setup: from-home key step + settle auto-detects the enrolment return", async () => { |
| const h = harness({ |
| myName: "Paul", |
| fromHome: true, // arrived from the Eleven sign-in page ā the keys-home variant |
| - done: { name: true, group: true, email: true, photo: true, invites: true }, |
| + done: { name: true, group: true, email: true, photo: true, invites: true, extras: true }, |
| }); |
| runFlow(SETUP_FLOW, h.env, h.io); |
| await tick(); |
| @@ -322,6 +363,7 @@ await test("setup: native creator ā greeting shows, name pre-set, passkey neve |
| await h.tap("Skip for now"); // email; photo auto-skips under native |
| assert.equal(h.transcript.some((t) => t.extra.key === "q-photo"), false); |
| await h.tap("Members can invite people"); |
| + await h.tap("Keep everything on"); |
| // Keys: never pitched ā recorded as covered by the Eleven account. |
| assert.equal(h.transcript.some((t) => /Now the most important thing of all/.test(t.text)), false); |
| @@ -552,7 +594,7 @@ await test("welcome: enrolment confirmed while on access-waiting settles through |
| await test("setup: enrolment confirmed while on keys-waiting settles through", async () => { |
| const h = harness({ |
| myName: "Paul", |
| - done: { name: true, group: true, email: true, photo: true, invites: true }, |
| + done: { name: true, group: true, email: true, photo: true, invites: true, extras: true }, |
| }); |
| runFlow(SETUP_FLOW, h.env, h.io); |
| await tick(); |
diff --git a/web/static/apps/setup.flow.js b/web/static/apps/setup.flow.js| index 3b0aeeb..2586517 100644 |
| --- a/web/static/apps/setup.flow.js |
| +++ b/web/static/apps/setup.flow.js |
| @@ -10,7 +10,7 @@ export default { |
| // 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.keys"] }, |
| + 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"] }, |
| @@ -183,12 +183,116 @@ export default { |
| label: "Members can invite people", |
| effect: { verb: "setUserInvites", args: [true] }, |
| record: { label: "decided members can invite people", value: "members" }, |
| - goto: "keys", |
| + goto: "extras", |
| }, |
| { |
| label: "Only me", |
| effect: { verb: "setUserInvites", args: [false] }, |
| record: { label: "decided only the admin invites people", value: "admin only" }, |
| + goto: "extras", |
| + }, |
| + ], |
| + }, |
| + { |
| + // The extras trio (AI / bots / apps ā the #712 gates) as ONE question |
| + // with a one-tap common path; "Let me choose" opens the granular three. |
| + // Defaults are already on server-side, so keeping everything on writes |
| + // nothing ā the granular chips write the setting either way. |
| + id: "extras", |
| + skipIf: ["done"], |
| + skipTo: "keys", // past the granular trio, 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: "keys", |
| + }, |
| + ], |
| + bubbles: [ |
| + { |
| + text: |
| + "Nearly there. Your space comes with a few extras:\n\n" + |
| + "⢠AI ā ask Eleven things in plain language and it helps out.\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 any of these off, and you can change your mind anytime in Admin.", |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Keep everything on", |
| + spent: "Kept", |
| + record: { label: "kept all the extras on", value: "all-on" }, |
| + goto: "keys", |
| + }, |
| + { label: "Let me choose", goto: "extras-ai" }, |
| + ], |
| + }, |
| + { |
| + id: "extras-ai", |
| + skipIf: ["done"], |
| + bubbles: [ |
| + { |
| + text: "AI first. With it on, members can ask Eleven things in plain language and it answers, builds, and helps out. Everything works without it ā you just won't have the natural-language help.", |
| + }, |
| + ], |
| + chips: [ |
| + { |
| + label: "Enable AI", |
| + effect: { verb: "setFeature", args: ["ai", true] }, |
| + record: { label: "enabled AI", value: "on" }, |
| + goto: "extras-bots", |
| + }, |
| + { |
| + label: "No AI here", |
| + spent: "Off", |
| + effect: { verb: "setFeature", args: ["ai", false] }, |
| + record: { label: "switched AI off", value: "off" }, |
| + goto: "extras-bots", |
| + }, |
| + ], |
| + }, |
| + { |
| + id: "extras-bots", |
| + skipIf: ["done"], |
| + bubbles: [ |
| + { text: "Bots next ā 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: "keys", |
| + }, |
| + { |
| + label: "No apps here", |
| + spent: "Off", |
| + effect: { verb: "setFeature", args: ["apps", false] }, |
| + record: { label: "switched apps off", value: "off" }, |
| goto: "keys", |
| }, |
| ], |
diff --git a/web/static/apps/setup.js b/web/static/apps/setup.js| index 11c17d0..4aaa164 100644 |
| --- a/web/static/apps/setup.js |
| +++ b/web/static/apps/setup.js |
| @@ -69,6 +69,9 @@ function verb(name, args, env) { |
| return bridge.setContactEmail(args[0]); |
| case "setUserInvites": |
| return bridge.setUserInvites(args[0]); |
| + case "setFeature": |
| + // The extras steps' gates ("ai" | "bots" | "apps" ā the #712 settings). |
| + return bridge.setFeature(args[0], args[1]); |
| case "adoptServerPhoto": |
| return bridge.adoptServerPhoto(env.threadId, args[0], args[1]); |
| case "savePasskey": |
diff --git a/web/static/shell.js b/web/static/shell.js| index ac43f17..7d415a9 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -4066,6 +4066,17 @@ setSetupBridge({ |
| return false; |
| } |
| }, |
| + // The setup extras steps write the feature gates ("ai" | "bots" | "apps" ā |
| + // ai_enabled etc., #712). The whitelist keeps the flow data from naming an |
| + // arbitrary settings key. |
| + setFeature: async (key, on) => { |
| + if (!["ai", "bots", "apps"].includes(key)) return false; |
| + try { |
| + return (await fetch("/api/admin/settings", { method: "POST", headers: adminAuth(), body: JSON.stringify({ [`${key}_enabled`]: !!on }) })).ok; |
| + } catch { |
| + return false; |
| + } |
| + }, |
| markSetupComplete: async () => { |
| try { |
| return (await fetch("/api/admin/settings", { method: "POST", headers: adminAuth(), body: JSON.stringify({ setup_complete: true }) })).ok; |