eleven / messenger Public

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>
Paul Campbell 067ad3ec076a7960c86be8cd58cd1b17adcffbd8 parent 23f2351
7 files changed, +346 −8
  • changelog/2026-07-11-setup-extras.md +1 −0
  • ios/LChatCore/Sources/LChatCore/ElevenConversationModel.swift +7 −0
  • ios/LChatCore/Sources/LChatCore/Flows/setup.json +171 −1
  • test/flow.test.mjs +47 −5
  • web/static/apps/setup.flow.js +106 −2
  • web/static/apps/setup.js +3 −0
  • web/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;