eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The first-run setup conversation as DATA (docs/conversation-scripts.md) โ€”
2// every bubble, chip, branch, and side effect of the flow Eleven walks a fresh
3// instance's admin through. The engine (flow.js) interprets it; setup.js binds
4// it to the host bridge. This object must stay JSON-pure (a test enforces it):
5// it is the exact document the server will ship to native interpreters.
6export default {
7 schema: 1,
8 flow: "setup",
9 entry: "hello",
10 // Resumed = a step was actually recorded in an earlier session. (A name
11 // alone doesn't count: the native creator arrives with one pre-set from the
12 // create screen, and that first run must still get the greeting.)
13 resumedIf: { any: ["done.name", "done.group", "done.email", "done.photo", "done.invites", "done.extras", "done.keys"] },
14 // The passkey RETURN leg gets its own richer ack in the keys step's entry,
15 // so it skips the generic re-greeting (mirrors welcome.flow.js).
16 resumeUnless: { all: ["bridge.enrolled", "!done.keys"] },
17 resumeSay: "Welcome back โ€” let's pick up where we left off.",
18 steps: [
19 {
20 id: "hello",
21 skipIf: ["resumed"],
22 bubbles: [
23 {
24 text:
25 "Hi โ€” I'm Eleven! ๐Ÿ‘‹ This is your space now: everything in it is end-to-end encrypted, and you're its administrator.\n\n" +
26 "Let's take two minutes to set things up.",
27 },
28 ],
29 auto: "name",
30 },
31 {
32 id: "name",
33 // A device that already knows this member's name (an add-device link
34 // ships it in the fragment; a native shell carries the rail's) must
35 // not skip PAST this step with a bare skipIf: that writes nothing to
36 // the log, so every client that does NOT know the name stays parked
37 // here forever while this one moves on โ€” two composers armed on
38 // different steps of the same thread (the 2026-07-19 web-vs-Mac
39 // divergence). The entry RECORDS the settled name, so done.name folds
40 // to every client and they all derive the same position from the log.
41 entries: [
42 {
43 if: "bridge.myName",
44 recordIfNew: { label: "noted the name you go by", value: "known" },
45 goto: "group",
46 },
47 ],
48 skipIf: ["done"],
49 bubbles: [
50 { text: "First โ€” what should everyone here call you? Reply with your name.", label: "Your name:", hint: "e.g. Finn Wolfhard" },
51 ],
52 // The fast path for admins in a hurry: one form with the whole setup at
53 // once, ending in the same sign-in (the keys step's settle picks the
54 // ceremony's return up from the fast-filled records).
55 chips: [
56 {
57 label: "Fill in a form instead",
58 trailing: true,
59 advancing: false,
60 effect: { verb: "openQuickForm" },
61 },
62 ],
63 input: { kind: "text", required: true, maxLen: 32, clean: "name" },
64 effect: { verb: "setMyName", args: ["$answer"] },
65 on: {
66 ok: {
67 record: { label: "noted: you're {vars.answer}", value: "$answer" },
68 ack: "Lovely to meet you, {vars.answer}! ๐Ÿค",
69 goto: "group",
70 },
71 },
72 },
73 {
74 id: "group",
75 skipIf: ["done"],
76 bubbles: [
77 {
78 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.",
79 key: "q-group",
80 },
81 {
82 text: {
83 if: "space.nameSet",
84 then: "It's currently โ€œ{space.name}โ€ โ€” keep that, or reply with a new name.",
85 else: "What should we call it? Reply with a name.",
86 },
87 label: "Space name:",
88 hint: "e.g. The Book Club",
89 },
90 ],
91 chips: [{ if: "space.nameSet", label: "Keep โ€œ{space.name}โ€", spent: "Kept", goto: "email" }],
92 input: { kind: "text", required: true },
93 effect: { verb: "setGroupName", args: ["$answer"] },
94 on: {
95 ok: {
96 set: { "space.name": "$answer" },
97 record: { label: "named this space โ€œ{vars.answer}โ€", value: "$answer" },
98 goto: "email",
99 },
100 invalid: { say: "Hmm, that name didn't stick โ€” it needs to be 1โ€“60 characters. Try another?", retry: "step" },
101 error: {
102 say: "Something went wrong saving that โ€” it's not the name's fault. Give it another go in a moment.",
103 retry: "step",
104 },
105 },
106 },
107 {
108 id: "email",
109 skipIf: ["done"],
110 bubbles: [
111 {
112 text: "Your contact email is shared with members โ€” useful if someone's locked out, or something needs a human. It's optional.",
113 },
114 {
115 text: "How can people here reach you outside the chat? Reply with an email address, or skip it.",
116 label: "Contact email:",
117 hint: "e.g. finn@example.com",
118 },
119 ],
120 // The skip RECORDS the step: an unrecorded skip replays on every resume โ€”
121 // notably the passkey return leg, which re-asked the email (and photo)
122 // instead of carrying on. A skip is an answer.
123 chips: [
124 { label: "Skip for now", spent: "Skipped", record: { label: "skipped the contact email", value: "skipped" }, goto: "photo" },
125 ],
126 input: { kind: "text" },
127 effect: { verb: "setContactEmail", args: ["$answer"] },
128 on: {
129 ok: { record: { label: "noted a contact email", value: "$answer" }, goto: "photo" },
130 invalid: { ask: "That doesn't look like an email address โ€” try again, or skip it." },
131 error: { ask: "Something went wrong saving that โ€” try again in a moment, or skip it." },
132 },
133 },
134 {
135 id: "photo",
136 // Native v1: no attachment flow in the conversation composer โ€” the space
137 // photo lives in the Admin app there.
138 skipIf: ["done", "native"],
139 skipTo: "invites",
140 bubbles: [
141 {
142 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.",
143 },
144 {
145 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.",
146 attachHint: "Optional: type alt text for the photo",
147 },
148 ],
149 // Recorded for the same resume reason as the email skip.
150 chips: [
151 { label: "Skip for now", spent: "Skipped", cancelInput: true, record: { label: "skipped the space photo", value: "skipped" }, goto: "invites" },
152 ],
153 input: {
154 kind: "photo",
155 reject: { text: "Send it as a picture โ€” tap the + next to the message box or drag one in โ€” or tap Skip." },
156 },
157 effect: { verb: "adoptServerPhoto" },
158 on: {
159 ok: { set: { "vars.photoAlt": "$answerText" }, goto: "photo-preview" },
160 error: {
161 say: "Hmm, I couldn't use that one โ€” try another?",
162 ask: "Send another photo when you're ready, or skip it.",
163 },
164 },
165 },
166 {
167 id: "photo-preview",
168 bubbles: [
169 {
170 text: [
171 "Here's how it looks.",
172 {
173 if: "vars.photoAlt",
174 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.",
175 },
176 "\n\nHappy with it, or want to try another?",
177 ],
178 img: "$serverAvatar",
179 imgAlt: "{vars.photoAlt|The space photo}",
180 local: true,
181 },
182 ],
183 chips: [
184 {
185 label: "Keep this photo",
186 spent: "Kept",
187 record: { step: "photo", label: "set the space photo", value: "{vars.photoAlt}" },
188 goto: "invites",
189 },
190 {
191 label: "Use a different photo",
192 gotoAsk: {
193 step: "photo",
194 text: "Of course! Send me another one whenever you're ready โ€” and pop a message with it if you'd like alt text. ๐Ÿ“ท",
195 },
196 },
197 ],
198 },
199 {
200 id: "invites",
201 skipIf: ["done"],
202 bubbles: [
203 {
204 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.",
205 },
206 ],
207 chips: [
208 {
209 label: "Members can invite people",
210 effect: { verb: "setUserInvites", args: [true] },
211 record: { label: "decided members can invite people", value: "members" },
212 goto: "extras",
213 },
214 {
215 label: "Only me",
216 effect: { verb: "setUserInvites", args: [false] },
217 record: { label: "decided only the admin invites people", value: "admin only" },
218 goto: "extras",
219 },
220 ],
221 },
222 {
223 // Bots + apps (two of the #712 gates) as ONE question with a one-tap
224 // common path; "Let me choose" opens the granular pair. Defaults are
225 // already on server-side, so keeping both on writes nothing โ€” the
226 // granular chips write the setting either way. AI is deliberately NOT
227 // in this bundle: it gets its own explicit opt-in step below
228 // (docs/ai-provisioning.md โ€” saying yes provisions a real helper, so
229 // it's never a side effect of "keep everything on").
230 id: "extras",
231 skipIf: ["done"],
232 skipTo: "ai-optin", // past the granular pair, which only "Let me choose" enters
233 entries: [
234 // A resume that already finished the granular path: close this out.
235 {
236 if: "done.extras-apps",
237 recordIfNew: { label: "chose this space's extras", value: "chosen" },
238 goto: "ai-optin",
239 },
240 ],
241 bubbles: [
242 {
243 text:
244 "Nearly there. Your space comes with a couple of extras:\n\n" +
245 "โ€ข Bots โ€” little helpers members can create for reminders and jobs.\n" +
246 "โ€ข Apps โ€” to-dos, polls, whiteboards, and a studio for building your own.\n\n" +
247 "Everything essential works with these off, and you can change your mind anytime in Admin.",
248 },
249 ],
250 chips: [
251 {
252 label: "Keep both on",
253 spent: "Kept",
254 record: { label: "kept bots and apps on", value: "all-on" },
255 goto: "ai-optin",
256 },
257 { label: "Let me choose", goto: "extras-bots" },
258 ],
259 },
260 {
261 id: "extras-bots",
262 skipIf: ["done"],
263 bubbles: [
264 { text: "Bots โ€” helpers that live in chats and do jobs: reminders, summaries, little tools. Members can create their own." },
265 ],
266 chips: [
267 {
268 label: "Enable bots",
269 effect: { verb: "setFeature", args: ["bots", true] },
270 record: { label: "enabled bots", value: "on" },
271 goto: "extras-apps",
272 },
273 {
274 label: "No bots here",
275 spent: "Off",
276 effect: { verb: "setFeature", args: ["bots", false] },
277 record: { label: "switched bots off", value: "off" },
278 goto: "extras-apps",
279 },
280 ],
281 },
282 {
283 id: "extras-apps",
284 skipIf: ["done"],
285 bubbles: [
286 { text: "And apps โ€” the built-in extras like to-dos, polls, and whiteboards, plus the App Studio where members build their own." },
287 ],
288 chips: [
289 {
290 label: "Enable apps",
291 effect: { verb: "setFeature", args: ["apps", true] },
292 record: { label: "enabled apps", value: "on" },
293 goto: "ai-optin",
294 },
295 {
296 label: "No apps here",
297 spent: "Off",
298 effect: { verb: "setFeature", args: ["apps", false] },
299 record: { label: "switched apps off", value: "off" },
300 goto: "ai-optin",
301 },
302 ],
303 },
304 {
305 // AI is opt-in with the honest note (docs/ai-provisioning.md): saying
306 // yes flips ai_enabled on, and on a cloud space the server asks the
307 // provisioner for the sidecar โ€” Eleven joins moments later. Declining
308 // writes an explicit off. Either way the choice is the admin's, never
309 // a default's.
310 id: "ai-optin",
311 skipIf: ["done"],
312 bubbles: [
313 {
314 text:
315 "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" +
316 "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.",
317 },
318 ],
319 chips: [
320 {
321 label: "Bring Eleven in",
322 spent: "On",
323 effect: { verb: "setFeature", args: ["ai", true] },
324 record: { label: "invited Eleven's AI in", value: "on" },
325 goto: "plan",
326 },
327 {
328 label: "Not now",
329 spent: "Off",
330 effect: { verb: "setFeature", args: ["ai", false] },
331 record: { label: "kept AI off", value: "off" },
332 goto: "plan",
333 },
334 ],
335 },
336 {
337 // The plan question, moved HERE from the create flow (#1374): creating
338 // a space is never pay-gated โ€” the conversation is where "who is this
339 // space for?" gets asked, with real prices from the price book, and a
340 // paid answer becomes an upgrade chip on the finish line (the space
341 // already exists, free, and stays free until that tap). Web-only, like
342 // the photo: native wrappers must not surface an external purchase
343 // (App Store rules โ€” the same gate as the โ‹ฎ Upgrade item), and with no
344 // reachable price book (self-hosted, billing off, already paid) the
345 // step vanishes. Whichever client asks, the record lands on THIS step.
346 id: "plan",
347 skipIf: ["done", "native"],
348 skipTo: "keys",
349 entries: [{ if: "!bridge.planPrices", goto: "keys" }],
350 bubbles: [
351 {
352 text:
353 "One more question โ€” who is this space for?\n\n" +
354 "Every space starts free, and free is a real home โ€” kept that way by the spaces that pay. " +
355 "If this one's for a team, a community, or family, there's a plan that backs it (and the free spaces too).",
356 key: "q-plan",
357 },
358 ],
359 chips: [
360 { if: "bridge.planTeam", label: "A team", goto: "plan-team" },
361 { if: "bridge.planCommunity", label: "A community", goto: "plan-community" },
362 { if: "bridge.planFamily", label: "Family & friends", goto: "plan-family" },
363 {
364 label: "Keep it free",
365 spent: "Free",
366 trailing: true,
367 record: { label: "kept this space free", value: "free" },
368 goto: "keys",
369 },
370 ],
371 },
372 {
373 // The three per-audience price cards. Names and prices interpolate from
374 // the price book (config, never code โ€” docs/tier-economics.md); the
375 // record's value is the AUDIENCE, which openPlanCheckout maps back to
376 // the book's tier key at tap time.
377 id: "plan-team",
378 bubbles: [
379 {
380 text:
381 "For a team that's {bridge.planNameTeam}: {bridge.planPriceTeam} โ€” it scales with the team, and bots are free.\n\n" +
382 "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.",
383 key: "q-plan-team",
384 },
385 ],
386 chips: [
387 {
388 label: "Choose {bridge.planNameTeam}",
389 spent: "Chosen",
390 set: { "vars.planTier": "team" },
391 record: { step: "plan", label: "chose the {bridge.planNameTeam} plan โ€” payment waits at the finish line", value: "team" },
392 goto: "keys",
393 },
394 { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" },
395 ],
396 },
397 {
398 id: "plan-community",
399 bubbles: [
400 {
401 text:
402 "For a community that's {bridge.planNameCommunity}: {bridge.planPriceCommunity}.\n\n" +
403 "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.",
404 key: "q-plan-community",
405 },
406 ],
407 chips: [
408 {
409 label: "Choose {bridge.planNameCommunity}",
410 spent: "Chosen",
411 set: { "vars.planTier": "community" },
412 record: { step: "plan", label: "chose the {bridge.planNameCommunity} plan โ€” payment waits at the finish line", value: "community" },
413 goto: "keys",
414 },
415 { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" },
416 ],
417 },
418 {
419 id: "plan-family",
420 bubbles: [
421 {
422 text:
423 "For family and friends that's {bridge.planNameFamily}: {bridge.planPriceFamily}.\n\n" +
424 "A paid space never counts against your space limit, is never reclaimed as unused, and carries a bigger storage allowance.",
425 key: "q-plan-family",
426 },
427 ],
428 chips: [
429 {
430 label: "Choose {bridge.planNameFamily}",
431 spent: "Chosen",
432 set: { "vars.planTier": "family" },
433 record: { step: "plan", label: "chose the {bridge.planNameFamily} plan โ€” payment waits at the finish line", value: "family" },
434 goto: "keys",
435 },
436 { label: "Keep it free", spent: "Free", record: { step: "plan", label: "kept this space free", value: "free" }, goto: "keys" },
437 ],
438 },
439 {
440 id: "keys",
441 entries: [
442 {
443 // Native shells (iOS/Mac): the space was created from a signed-in
444 // Eleven account โ€” its key is already saved to the creator's vault,
445 // so the whole passkey pitch is moot.
446 if: "native",
447 set: { "vars.backedUp": true },
448 recordIfNew: { label: "Your space's key is saved with your Eleven account", value: "native" },
449 goto: "finish",
450 },
451 {
452 if: "bridge.enrolled",
453 set: { "vars.backedUp": true },
454 recordIfNew: { label: "Signed in โ€” your space's key is safe", value: "passkey" },
455 sayIfNew: "You're signed in โ€” your space's key is safe. โœ…",
456 goto: "finish",
457 },
458 // Answered in an earlier session, but this browser isn't enrolled
459 // ("Continue" after a detour, "I understand the risk", a lost local
460 // flag): the step is DONE โ€” never re-pitch it. Without this entry the
461 // flow re-asked on every resume and never reached finish, so
462 // markSetupComplete never ran (#1377). backedUp stays false, so the
463 // finish line still carries the honest reminder.
464 { if: "done", goto: "finish" },
465 // No home server to sign in with (self-hosted): the pitch would be a
466 // dead-end chip โ€” record the honest state and move on (#1377).
467 {
468 if: "!bridge.canEnrol",
469 recordIfNew: { label: "This browser holds this space's key", value: "local" },
470 goto: "finish",
471 },
472 // Someone who created this space from their Eleven sign-in page already
473 // holds an account โ€” don't pitch signing in from scratch, offer to add
474 // this space's key to it (keys-home).
475 { if: "bridge.fromHome", goto: "keys-home" },
476 ],
477 // The sign-in ceremony returns asynchronously (a redirect back from the
478 // home server); auto-detect that return and advance without a manual tap.
479 settle: { when: "bridge.enrolled" },
480 // Member-facing language is "sign in" โ€” never the mechanism (passkeys,
481 // backups). The lock-and-key framing carries the stakes instead.
482 bubbles: [
483 {
484 text:
485 "Now the most important thing of all. ๐Ÿ”‘\n\n" +
486 "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" +
487 "But it means: right now, 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.",
488 },
489 { text: "Sign in with Eleven and your key stays safe โ€” one tap, your face or fingerprint, and you'll be brought straight back." },
490 ],
491 chips: [
492 {
493 label: "Sign in",
494 advancing: false,
495 effect: { verb: "savePasskey" },
496 probe: { afterMs: 1500, unless: "bridge.enrolled", goto: "keys-waiting" },
497 },
498 { label: "Not now", spent: "Skipped", goto: "keys-confirm" },
499 ],
500 },
501 {
502 // The from-home variant of "keys": they signed in with Eleven to create
503 // this space, so the ask is one addition, not a new concept. Mechanics
504 // are identical (the enrol popup signs into their existing account and
505 // merges this space's key + name over the address-only stub).
506 id: "keys-home",
507 entries: [
508 {
509 if: "bridge.enrolled",
510 set: { "vars.backedUp": true },
511 recordIfNew: { label: "Signed in โ€” your space's key is safe", value: "passkey" },
512 sayIfNew: "You're signed in โ€” your space's key is safe. โœ…",
513 goto: "finish",
514 },
515 ],
516 // Auto-detect the return from the home server's sign-in ceremony (same as
517 // the keys step above) so the user is carried on without tapping Continue.
518 settle: { when: "bridge.enrolled" },
519 bubbles: [
520 {
521 text:
522 "Last thing โ€” the key. ๐Ÿ”‘\n\n" +
523 "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.",
524 },
525 { text: "Sign in and it's added to your account โ€” one tap, and you'll be brought straight back." },
526 ],
527 chips: [
528 {
529 label: "Sign in",
530 advancing: false,
531 effect: { verb: "savePasskey" },
532 probe: { afterMs: 1500, unless: "bridge.enrolled", goto: "keys-waiting" },
533 },
534 { label: "Not now", spent: "Skipped", goto: "keys-confirm" },
535 ],
536 },
537 {
538 id: "keys-waiting",
539 // The ceremony may confirm while we sit here (the completion poll flips
540 // the flag) โ€” re-enter keys so its entry records + acks, no tap needed.
541 settle: { when: "bridge.enrolled", goto: "keys" },
542 bubbles: [{ text: "I'll wait here while you finish signing in. When you're done, tap Continue." }],
543 chips: [
544 {
545 label: "Continue",
546 record: {
547 step: "keys",
548 label: {
549 if: "bridge.enrolled",
550 then: "Signed in โ€” your space's key is safe",
551 else: "Signed in (in this browser)",
552 },
553 value: "passkey",
554 },
555 set: { "vars.backedUp": true },
556 goto: "finish",
557 },
558 {
559 label: "It didn't work โ€” skip for now",
560 spent: "Skipped",
561 record: { step: "keys", label: "Skipped signing in for now", value: "skipped" },
562 set: { "vars.backedUp": false },
563 goto: "finish",
564 },
565 ],
566 },
567 {
568 id: "keys-confirm",
569 // Same late-confirmation catch as keys-waiting: a ceremony finished in
570 // another context can land while the warning is up โ€” advance over it.
571 settle: { when: "bridge.enrolled", goto: "keys" },
572 bubbles: [
573 {
574 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.",
575 },
576 ],
577 chips: [
578 {
579 label: "Go back and sign in",
580 advancing: false,
581 effect: { verb: "savePasskey" },
582 probe: { afterMs: 1500, unless: "bridge.enrolled", goto: "keys-waiting" },
583 },
584 {
585 label: "I understand the risk",
586 spent: "Understood",
587 record: { step: "keys", label: "Skipped signing in for now", value: "skipped" },
588 set: { "vars.backedUp": false },
589 goto: "finish",
590 },
591 ],
592 },
593 {
594 id: "finish",
595 // The completion marker: hosts without the server flag's context (the
596 // native embed gates on conversation state, not setup_complete โ€” native
597 // sets that eagerly at claim as the reaper's not-abandoned signal) read
598 // this recorded step to know the conversation is over.
599 record: { label: "Your space is ready", value: "done" },
600 effects: [{ if: "!vars.backedUp", verb: "deferBackupReminders" }, { verb: "markSetupComplete" }],
601 bubbles: [
602 {
603 text: [
604 "That's it โ€” {space.name|your space} is ready! ๐ŸŽ‰\n\n",
605 "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.",
606 // The one honest parting warning for an admin who chose "Not now":
607 // plain consequences, no mechanism talk, and the way out right
608 // there. Gated on canEnrol โ€” a self-hosted admin has no sign-in
609 // to be reminded of.
610 {
611 if: { all: ["!vars.backedUp", "bridge.canEnrol"] },
612 text:
613 "\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.",
614 },
615 // The plan they chose mid-conversation (#1374): the payment is a
616 // chip below, never a side effect โ€” and the space stays free (and
617 // fully working) until that tap.
618 {
619 if: "vars.planTier",
620 text: "\n\nAnd the plan you picked โ€” tap below whenever you're ready to set up the payment. Your space runs free until then.",
621 },
622 ],
623 // A stable dedup key (welcome has q-finish too): without one the
624 // dedupe rides the rendered text, and a renamed space or a flipped
625 // backup state persisted a second finish bubble (#1377).
626 key: "q-finish",
627 },
628 { text: "Where would you like to start?" },
629 ],
630 chips: [
631 // The parting reminder's action โ€” finish-line chips stay live, and the
632 // completion poll picks the sign-in up even after the flow has ended.
633 { if: { all: ["!vars.backedUp", "bridge.canEnrol"] }, label: "Sign in", advancing: false, effect: { verb: "savePasskey" } },
634 // The chosen plan's payment (#1374): hands off to the home page's
635 // upgrade picker with the tier pre-highlighted. Same tab โ€” setup is
636 // complete, and home brings you back to your list.
637 { if: "vars.planTier", label: "Set up your plan", advancing: false, effect: { verb: "openPlanCheckout" } },
638 // Native hops threads too (the conversation model's openAdmin verb).
639 { label: "Open admin", advancing: false, effect: { verb: "openAdmin" } },
640 // The tour is the web's guided overlay โ€” web-only for now.
641 { if: "!native", label: "Take the tour", advancing: false, effect: { verb: "startTour" } },
642 ],
643 end: true,
644 },
645 ],
646};
647