eleven / ocho Public

web,agent: /tutorial — Eleven's guided tour (#212)

* web,agent: /tutorial — Eleven's guided tour

A client-driven, Eleven-voiced onboarding tour: a sequence of private
"only visible to you" notices with tap-to-advance chips, rendered in place.
It's the interactive successor to the static welcome DM — the welcome now
carries a "Take the tour" chip that starts it (a new harmless open_tutorial
chip type), and /tutorial runs it anytime, in any thread.

Client-side by design: it must work with no AI and even no bot, it's stateful
and interactive, and advancing/ending are client concerns. Eleven owns the
voice (notices are attributed to it); a member with AI on can still just ask.

Flow: intro → this space (group name + optional administrator identity, read
member-gated; degrades to "privately run") → To-do → Reminders (each with an
"Open …" side-trip) → privacy, with a "Tell me about security" branch → an
AI section shown only where a conversational helper is present → a 🎉 ending.

Plumbing: a floor-command registry in apps.js (client-answered, never sent to
a bot, listed in the "/" typeahead everywhere) + small exports (defaultBot,
openAppThread, spaceInfo); chat.js gains appendNotice so the welcome chip can
answer in place; shell.js wires the open_tutorial runner + the space-info read.

Tested: test/tutorial.test.mjs drives the flow (steps, security branch, AI
gating, tap-twice idempotency, /tutorial routing); ui.test.mjs covers the new
chip in the whitelist. Full npm test + go build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: run the tour in the Eleven DM + a way back after wandering off

Two bits of feedback from trying it:

- "Show me around" now moves the tour into your DM with Eleven and continues
  there (find-or-create the DM, open it, render the rest into it). The intro
  still answers in place; the tap is what navigates — the app's rule. apps.js
  gains an injected elevenTour() the shell implements from the roster + DM
  create/open, and chat.js's appendNotice queues a notice until the target
  thread mounts (so the first post-navigation step isn't lost to the feed load).

- The To-do / Reminders steps now nudge you to try the command yourself
  (/todo buy milk, /remind …) and reassure you won't lose your place: the tour
  stays in the chat with Eleven, so you can wander off and come back to this
  conversation to continue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: one affirmative security chip; friendlier skip label

The privacy step had two chips ("Tell me about security" + "Yes, tell me
more") that did the same thing — collapse to one. Rename the skip from
"No thanks" to "Sounds secure enough to me!".

* web: code-style command examples; richer AI step

- Notice bodies now render the same safe inline formatting as chat messages
  (via richTokens → text nodes + a single wrapper each, never innerHTML), so a
  command example in `backticks` shows as monospace. Command examples in the
  tour are backticked; added .local-notice-body code/link CSS.

- AI step: invite a free-form question ("try asking me anything about the
  app!"), add a "Tell me about Eleven" chip that sends a REAL question to
  Eleven so the AI actually replies (elevenTour now returns { notice, ask };
  shell.js sendChatText posts as the member into the Eleven DM), and rename
  "Finish" to "End tutorial".

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 9391c86c46f6b1b508a426e3f04026a8ee9c748d parent d71dda8
11 files changed, +495 −11
  • agent/eleven.mjs +10 −7
  • changelog/2026-07-05-eleven-tutorial.md +1 −0
  • package.json +1 −1
  • test/tutorial.test.mjs +111 −0
  • test/ui.test.mjs +2 −1
  • web/static/apps.js +86 −1
  • web/static/apps/chat.js +29 −0
  • web/static/apps/tutorial.js +191 −0
  • web/static/shell.js +55 −1
  • web/static/style.css +8 −0
  • web/static/ui.js +1 −0
diff --git a/agent/eleven.mjs b/agent/eleven.mjs
index 1ac48d6..7395c6d 100644
--- a/agent/eleven.mjs
+++ b/agent/eleven.mjs
@@ -83,13 +83,16 @@ export async function loadElevenSession() {
// actually run — so the welcome never promises what this instance can't do.
const welcomeText = (name, aiEnabled) =>
`Hi ${name || "there"}, welcome! 👋 I'm ${ELEVEN_NAME}, your guide to this app.\n\n` +
- `A few things worth knowing:\n` +
- `• Everything here is end-to-end encrypted — only the people in a chat can read it.\n` +
- `• Tap ➕ to start a chat or a group. Helpers (like me) have their own section there.\n` +
- `• Double-tap a message to react, swipe right to reply.\n\n` +
+ `Everything here is end-to-end encrypted — only the people in a chat can read it, never the server.\n\n` +
+ `Tap “Take the tour” below for a quick guided walkthrough (or type /tutorial anytime). ` +
(aiEnabled
- ? `Ask me anything about how the app works — I'm happy to help!`
- : `Try /help to see what I can do.`);
+ ? `You can also just ask me anything about how the app works.`
+ : `And /help lists what I can do.`);
+
+// The welcome offers a one-tap chip that starts the client-side guided tour
+// (tutorial.js). It's a harmless client action, so it rides even on an
+// admin-customised welcome — the tour is useful either way.
+const WELCOME_ACTIONS = [{ type: "open_tutorial" }];
// Member display names are encrypted under the instance key (the server is
// blind to them). Fetch + unwrap our copy; null if a holder hasn't granted it to
@@ -182,7 +185,7 @@ export async function welcomeTick() {
}
const name = await memberName(person, instanceKey);
const text = welcomeTmpl ? welcomeTmpl.replaceAll("{name}", name || "there") : welcomeText(name, AI_ENABLED);
- const blob = await encrypt(key, { name: ELEVEN_NAME, text, ts: Date.now() });
+ const blob = await encrypt(key, { name: ELEVEN_NAME, text, ts: Date.now(), actions: WELCOME_ACTIONS });
const pr = await fetch(`${BASE}/api/messages?token=${encodeURIComponent(session.token)}`, {
method: "POST",
headers: { "content-type": "application/json" },
diff --git a/changelog/2026-07-05-eleven-tutorial.md b/changelog/2026-07-05-eleven-tutorial.md
new file mode 100644
index 0000000..3358a32
--- /dev/null
+++ b/changelog/2026-07-05-eleven-tutorial.md
@@ -0,0 +1 @@
+New: a guided tour from Eleven. Type /tutorial anytime — or tap “Take the tour” when Eleven first welcomes you — for a quick, tappable walkthrough: what this space is and who runs it, how To-dos and Reminders work (with a nudge to try each), and a gentle explainer of how everything stays private and encrypted. It ends with a little 🎉.
diff --git a/package.json b/package.json
index c98c741..81847ef 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/apps.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node home/test/crypto.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/apps.test.mjs && node test/tutorial.test.mjs && node test/actions.test.mjs && node test/commands.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node test/connect.test.mjs && node home/test/crypto.test.mjs"
}
}
diff --git a/test/tutorial.test.mjs b/test/tutorial.test.mjs
new file mode 100644
index 0000000..f136e70
--- /dev/null
+++ b/test/tutorial.test.mjs
@@ -0,0 +1,111 @@
+// Tests for Eleven's guided tour (client-side flow). node test/tutorial.test.mjs
+import assert from "node:assert/strict";
+import { startTour } from "../web/static/apps/tutorial.js";
+import { runCommand, commandCatalog, setDefaultBot } from "../web/static/apps.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log(`PASS ${name}`);
+ } catch (e) {
+ failures++;
+ console.log(`FAIL ${name} — ${e && e.message}`);
+ }
+}
+
+const tick = () => new Promise((r) => setTimeout(r, 0));
+const lastChip = (spec) => spec.actions[spec.actions.length - 1]; // the advancing chip
+const textOf = (steps) => steps.map((s) => s.text).join("\n");
+
+// Walk the tour, tapping one chip per step (chooser picks which). Stops at the
+// first step with no chips (the ending). Awaits a tick after each tap since an
+// advance can be async (e.g. "Show me around" navigates to the Eleven DM first).
+async function walk(chooser = lastChip) {
+ const steps = [];
+ startTour((spec) => steps.push(spec));
+ await tick(); // spaceInfo().finally → first step
+ for (let guard = 0; guard < 20; guard++) {
+ const cur = steps[steps.length - 1];
+ if (!cur.actions || !cur.actions.length) break; // the ending
+ const before = steps.length;
+ chooser(cur).run();
+ await tick();
+ if (steps.length === before) break; // tapped a non-advancing chip; stop
+ }
+ return steps;
+}
+
+await test("every step is attributed to Eleven and the tour ends with a 🎉", async () => {
+ setDefaultBot(() => null); // no bot → speaks as "Eleven", AI section off
+ const steps = await walk();
+ assert.ok(steps.length >= 5, `expected a multi-step tour, got ${steps.length}`);
+ for (const s of steps) assert.equal(s.sender.name, "Eleven");
+ assert.match(steps[0].text, /I'm Eleven/); // intro introduces itself
+ assert.match(steps[steps.length - 1].text, /🎉/); // celebratory ending
+});
+
+await test("the default path skips the security deep-dive; picking it shows it", async () => {
+ setDefaultBot(() => null);
+ const plain = textOf(await walk()); // taps "No thanks" at the privacy step
+ assert.doesNotMatch(plain, /never leaves your device/);
+
+ // Now tap the FIRST chip at the privacy step ("Tell me about security").
+ const security = textOf(
+ await walk((spec) => (/technical version/.test(spec.text) ? spec.actions[0] : lastChip(spec))),
+ );
+ assert.match(security, /never leaves your device/);
+});
+
+await test("the AI section appears only where a conversational helper is present", async () => {
+ setDefaultBot(() => null);
+ assert.doesNotMatch(textOf(await walk()), /AI helpers switched on/);
+
+ setDefaultBot(() => ({ name: "Eleven", kind: "ai" }));
+ const withAi = textOf(await walk());
+ assert.match(withAi, /AI helpers switched on/);
+ assert.match(withAi, /asking me anything/i); // invites a free-form question
+ setDefaultBot(() => null);
+});
+
+await test("command examples are wrapped in backticks so they render as code", async () => {
+ setDefaultBot(() => null);
+ const all = textOf(await walk());
+ assert.match(all, /`\/todo buy milk`/);
+ assert.match(all, /`\/help`/);
+});
+
+await test("tapping an advancing chip twice can't double-advance", async () => {
+ setDefaultBot(() => null);
+ const steps = [];
+ startTour((spec) => steps.push(spec));
+ await tick();
+ const chip = lastChip(steps[0]); // "Show me around" — fires once even if tapped twice
+ chip.run();
+ chip.run(); // stale tap
+ await tick();
+ assert.equal(steps.length, 2, "a second tap on the same chip appended another step");
+});
+
+await test("the Todo/Reminder steps invite trying the command and reassure you can come back", async () => {
+ setDefaultBot(() => null);
+ const all = textOf(await walk());
+ assert.match(all, /\/todo /); // shows how to try it yourself
+ assert.match(all, /come back to this conversation/i); // reassurance that you won't lose your place
+});
+
+await test("/tutorial is a client-answered command, listed everywhere in the typeahead", async () => {
+ const entry = commandCatalog().find((c) => c.name === "/tutorial");
+ assert.ok(entry, "/tutorial missing from the command catalog");
+ assert.equal(entry.bot, false); // client-answered → shown in every thread
+
+ setDefaultBot(() => null);
+ const steps = [];
+ const handled = runCommand("/tutorial", "thread-1", (spec) => steps.push(spec));
+ assert.equal(handled, true); // caught, not broadcast
+ await tick();
+ assert.match(steps[0].text, /I'm Eleven/);
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/test/ui.test.mjs b/test/ui.test.mjs
index 892225b..5bdd2a6 100644
--- a/test/ui.test.mjs
+++ b/test/ui.test.mjs
@@ -209,9 +209,10 @@ await test("sanitizeActions: whitelists types and validates params", () => {
{ type: "set_appearance", appearance: "dark", label: " Go dark " },
{ type: "delete_everything" }, // unknown type
{ type: "open_profile" },
+ { type: "open_tutorial" }, // the welcome DM's "Take the tour" chip
"junk",
]);
- assert.deepEqual(chips.map((c) => c.type), ["set_theme", "set_appearance", "open_profile"]);
+ assert.deepEqual(chips.map((c) => c.type), ["set_theme", "set_appearance", "open_profile", "open_tutorial"]);
assert.equal(chips[0].theme, "plum");
assert.equal(chips[1].label, "Go dark");
});
diff --git a/web/static/apps.js b/web/static/apps.js
index ee8ca73..5a9e853 100644
--- a/web/static/apps.js
+++ b/web/static/apps.js
@@ -22,6 +22,7 @@
// reducer shape here is deliberately snapshot-ready.
import { BOT_COMMANDS } from "./bot-commands.js";
+import { richTokens } from "./format.js";
const apps = [];
const typeOwner = new Map(); // event type → owning app
@@ -526,6 +527,46 @@ let hostBridge = null;
export function setHostBridge(b) {
hostBridge = b;
}
+// Space info for Eleven's guided tour: a promise of { name, adminName, adminEmail }
+// — the group name plus the optional administrator identity (a member-gated read).
+// shell.js injects the fetch; absent or failing → the tour degrades to just a name.
+let spaceInfoFn = null;
+export function setSpaceInfo(fn) {
+ spaceInfoFn = fn;
+}
+export function spaceInfo() {
+ if (!spaceInfoFn) return Promise.resolve({});
+ return Promise.resolve()
+ .then(() => spaceInfoFn())
+ .catch(() => ({}));
+}
+// The default bot (Eleven), for modules that speak in Eleven's voice or need to
+// know whether conversational AI is on here (kind === "ai"). Null when no bot.
+export function defaultBot() {
+ return defaultBotFn ? defaultBotFn() : null;
+}
+// Open a personal app's own thread (created on first use) — for a chip that invites
+// someone to try To-do / Reminders. Leaving the current context is always their tap.
+export async function openAppThread(name) {
+ if (!resolveAppThread || !openThreadFn) return;
+ const id = await resolveAppThread(name);
+ if (id) openThreadFn(id);
+}
+// The guided tour lives in your DM with Eleven. When "Show me around" is tapped
+// from anywhere else, this navigates there (finding or creating the DM) and
+// returns a notice sink bound to it, so the rest of the tour renders in the DM.
+// Returns null when there's no Eleven to DM (the tour then just stays in place).
+// shell.js injects the implementation (roster + DM create + open live there).
+let elevenTourFn = null;
+export function setElevenTour(fn) {
+ elevenTourFn = fn;
+}
+export function elevenTour() {
+ if (!elevenTourFn) return Promise.resolve(null);
+ return Promise.resolve()
+ .then(() => elevenTourFn())
+ .catch(() => null);
+}
function makeCtx(threadId, app) {
return {
@@ -588,6 +629,16 @@ function makeCtx(threadId, app) {
// commandCatalog flags them non-bot so the typeahead shows them everywhere.
const CLIENT_ANSWERED = new Set(["/help"]);
+// Floor commands the CLIENT answers in place, owned by no app and never sent to a
+// bot (like /help, but contributed by a feature module — e.g. /tutorial). Each is
+// run(args, threadId, reply), rendering private notices via reply. Kept apart from
+// BOT_COMMANDS so the sidecar never sees a command it has no handler for, and
+// listed in the "/" typeahead everywhere (bot:false), like the client-answered set.
+const floorCommands = new Map(); // name -> { desc, args, run(args, threadId, reply) }
+export function registerFloorCommand(name, spec) {
+ floorCommands.set(name, { desc: (spec && spec.desc) || "", args: (spec && spec.args) || "", run: spec && spec.run });
+}
+
// helpNotice lists the commands you can use, as the private "/help" reply. Mirrors
// the typeahead: the bot's commands (/theme, …) show only where a bot can answer
// them, so /help never advertises a command that would do nothing here.
@@ -634,6 +685,12 @@ export function runCommand(text, activeThreadId, respond) {
}
};
if (!cmd.app) {
+ // A registered floor command (e.g. /tutorial) answers itself, in place.
+ const floor = floorCommands.get(cmd.name);
+ if (floor && floor.run) {
+ floor.run(cmd.args, activeThreadId, reply);
+ return true;
+ }
// /help (and any other CLIENT_ANSWERED floor command) is answered right here
// with a private notice — no bot needed, so it works in every thread.
if (cmd.name === "/help") {
@@ -675,6 +732,8 @@ export function commandCatalog() {
args: (app.commands[name] && app.commands[name].args) || "",
bot: false,
}));
+ // Floor commands (e.g. /tutorial) are client-answered → shown in every thread.
+ for (const [name, c] of floorCommands) out.push({ name, desc: c.desc, args: c.args, bot: false });
// bot:false for the ones the client answers itself (/help) → the typeahead lists
// them in every thread, not only where a bot is present.
for (const [name, desc] of Object.entries(BOT_COMMANDS)) out.push({ name, desc, args: "", bot: !CLIENT_ANSWERED.has(name) });
@@ -735,6 +794,32 @@ function motionOK() {
// name row), the reply text is a chat-style bubble, and any trusted action chips
// ({ label, run } — minted locally, so they bypass the untrusted-payload
// whitelist) sit below.
+//
+// Notice bodies render the same safe inline formatting as chat messages, so a
+// command example in `backticks` shows as monospace code (and **bold**, links,
+// etc. work too). Built from richTokens as text nodes + a single wrapper element
+// each — never innerHTML, so untrusted text (a to-do name in a confirmation)
+// stays inert.
+const NOTICE_MARK_TAG = { b: "strong", i: "em", s: "s", code: "code" };
+function renderNoticeBody(text, el) {
+ for (const tok of richTokens(text)) {
+ if (tok.href) {
+ const a = document.createElement("a");
+ a.href = tok.href;
+ a.target = "_blank";
+ a.rel = "noopener noreferrer";
+ a.textContent = tok.text;
+ el.appendChild(a);
+ } else if (tok.mark) {
+ const w = document.createElement(NOTICE_MARK_TAG[tok.mark]);
+ w.textContent = tok.text;
+ el.appendChild(w);
+ } else {
+ el.appendChild(document.createTextNode(tok.text));
+ }
+ }
+}
+
function fillFullNotice(row, spec = {}) {
const { sender, text, actions } = spec;
const head = document.createElement("div");
@@ -767,7 +852,7 @@ function fillFullNotice(row, spec = {}) {
bubble.className = "local-notice-bubble";
const body = document.createElement("div");
body.className = "local-notice-body";
- body.textContent = text || "";
+ renderNoticeBody(text || "", body);
bubble.appendChild(body);
row.appendChild(bubble);
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 1c22317..c309157 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -69,8 +69,26 @@ const refreshTyping = new Map(); // threadId → fn redrawing the typing row
// it. render() folds these into its keyedList (see the notice branch in renderItem).
const localNotices = new Map(); // threadId → [{ id, seq, sender, text, actions }]
const refreshFeed = new Map(); // threadId → fn re-rendering this chat pane
+const mountedCtx = new Map(); // threadId → live app ctx (for shell-injected notices)
+const pendingNotices = new Map(); // threadId → [spec] queued until that thread mounts
let noticeSeq = 0; // monotonic: unique ids + a stable tie-break within a thread
+// appendNotice injects a private "only visible to you" notice into a thread's feed
+// from OUTSIDE a command (the shell uses it when an action chip answers in place —
+// the welcome's "Take the tour", and the tour continuing after it navigates you to
+// the Eleven DM). If the target thread isn't mounted yet (we just navigated to it
+// and its feed is still loading), the notice is queued and flushed when it mounts.
+export function appendNotice(threadId, spec) {
+ const ctx = mountedCtx.get(threadId);
+ if (ctx) {
+ addLocalNotice(ctx, spec);
+ return;
+ }
+ const q = pendingNotices.get(threadId) || [];
+ q.push(spec);
+ pendingNotices.set(threadId, q);
+}
+
// addLocalNotice records a command reply for `threadId` and re-renders the pane so
// it appears immediately. Anchored just above the current newest message so it
// sits where you typed it and never jumps around as the conversation continues.
@@ -373,8 +391,19 @@ export const app = defineApp({
// (like the hotkeys/native re-points above), self-removed on teardown.
const doRefresh = () => renderApp(ctx.threadId, "chat", el);
refreshFeed.set(ctx.threadId, doRefresh);
+ // Keep the live mount ctx so the shell can inject a private notice into this
+ // thread (e.g. the welcome DM's "Take the tour" chip starting Eleven's tour).
+ mountedCtx.set(ctx.threadId, ctx);
+ // Flush any notices that were queued for this thread before it mounted (the
+ // tour navigating to the Eleven DM appends its next step while the feed loads).
+ const queued = pendingNotices.get(ctx.threadId);
+ if (queued) {
+ pendingNotices.delete(ctx.threadId);
+ for (const spec of queued) addLocalNotice(ctx, spec);
+ }
ctx.onCleanup(() => {
if (refreshFeed.get(ctx.threadId) === doRefresh) refreshFeed.delete(ctx.threadId);
+ if (mountedCtx.get(ctx.threadId) === ctx) mountedCtx.delete(ctx.threadId);
});
const feed = root.querySelector(".chatapp-feed");
diff --git a/web/static/apps/tutorial.js b/web/static/apps/tutorial.js
new file mode 100644
index 0000000..97e9d8f
--- /dev/null
+++ b/web/static/apps/tutorial.js
@@ -0,0 +1,191 @@
+// /tutorial — Eleven's guided tour. A client-driven, in-Eleven's-voice onboarding
+// flow: a sequence of private "only visible to you" notices with tap-to-advance
+// chips, rendered right where you are. It's the interactive successor to the
+// static welcome DM (the welcome now offers a "Take the tour" chip that starts it).
+//
+// Why client-side rather than a bot conversation: the tour must work with NO AI
+// (it's the app basics) and even with no bot at all; it's stateful and interactive;
+// and two of its behaviours — advancing on a tap, ending with a flourish — are
+// inherently client concerns. Eleven still owns the voice (the notices are
+// attributed to it), and a member with conversational AI on can always just ask.
+//
+// Nothing here is sent to the server or to other members — notices are local and
+// ephemeral (see buildLocalNotice). The tour reads the group name + optional
+// administrator identity via spaceInfo(), and shows the AI section only when a
+// conversational helper is present.
+import { registerFloorCommand, defaultBot, openAppThread, spaceInfo, elevenTour } from "../apps.js";
+
+// The tour is entered two ways — typing /tutorial, and the welcome DM's "Take the
+// tour" chip — so registration lives here and startTour is exported for the chip.
+registerFloorCommand("/tutorial", {
+ desc: "Take a quick guided tour",
+ run: (_args, _threadId, reply) => startTour(reply),
+});
+
+// startTour(respond): respond(spec) appends one Eleven notice to the current feed.
+// Both entry points supply their own respond bound to the thread you're in.
+export function startTour(respond) {
+ const bot = defaultBot();
+ const env = {
+ respond,
+ // Speak as Eleven (with its AI/BOT badge) when it's a member here; otherwise
+ // still speak as "Eleven" the guide — the tour is a client feature, not a bot.
+ sender: bot || { name: "Eleven", kind: "bot" },
+ ai: !!bot && bot.kind === "ai",
+ space: {},
+ };
+ // Fetch the space name + administrator identity, then open — but never block the
+ // tour on it: if the read fails, we degrade to a nameless "this space".
+ spaceInfo()
+ .then((info) => {
+ env.space = info || {};
+ })
+ .catch(() => {})
+ .finally(() => stepWelcome(env));
+}
+
+// A per-step guard: the first advancing tap wins, later taps are ignored. The
+// notice (and its chips) are rebuilt on every feed refresh, so without this a
+// second tap on a stale "Next" would append the same step twice.
+function onceStep() {
+ let done = false;
+ return (fn) => {
+ if (done) return;
+ done = true;
+ fn();
+ };
+}
+
+// advance = a chip that moves the tour on (guarded); side = a chip that does
+// something without advancing (open an app), so it stays tappable if you return.
+const advance = (go, label, fn) => ({ label, run: () => go(fn) });
+const side = (label, fn) => ({ label, run: fn });
+
+function say(env, text, actions) {
+ env.respond({ sender: env.sender, text, actions });
+}
+
+function stepWelcome(env) {
+ const go = onceStep();
+ say(
+ env,
+ "Hi — I'm Eleven, your guide to this app (yes, I'm named after it 🙂).\n\n" +
+ "Eleven is a private group chat: every message is end-to-end encrypted, so only the people in a chat can read it — never the server, and never whoever runs it. " +
+ "A few things you can do anytime: type `/help` to see the commands, `/theme` to change the colours, or just ask me a question.\n\n" +
+ "Fancy a quick tour? I'll run it here in our chat.",
+ [advance(go, "Show me around →", () => toElevenDm(env, () => stepSpace(env)))],
+ );
+}
+
+// The tour belongs in your DM with Eleven. "Show me around" moves it there (from
+// wherever it was started) and continues; from then on every step renders in that
+// chat, so you can wander off to try something and come back to it. If there's no
+// Eleven to DM, we just carry on in place.
+function toElevenDm(env, next) {
+ elevenTour()
+ .then((tour) => {
+ if (tour) {
+ env.respond = tour.notice; // render the rest of the tour into the Eleven DM
+ env.ask = tour.ask; // and let the AI step ask Eleven a real question there
+ }
+ })
+ .catch(() => {})
+ .finally(next);
+}
+
+function stepSpace(env) {
+ const go = onceStep();
+ const name = env.space.name || "this space";
+ let text = `You're in ${name}. `;
+ if (env.space.adminName) {
+ text += `It's looked after by ${env.space.adminName}`;
+ text += env.space.adminEmail ? ` — you can reach them at ${env.space.adminEmail}.` : ".";
+ } else {
+ text += "It's privately run — no company in the middle, just the people here.";
+ }
+ text +=
+ "\n\nEvery space is its own separate, isolated world: its own members, its own keys, nothing shared with anywhere else.";
+ say(env, text, [advance(go, "Next →", () => stepTodo(env))]);
+}
+
+function stepTodo(env) {
+ const go = onceStep();
+ say(
+ env,
+ "Let me show you two handy built-ins.\n\n" +
+ "First — To-do, your own private checklist. Go ahead and try it now: type `/todo buy milk` in any chat (or tap “Open To-do” below). It files into your own list — encrypted, and synced across your devices.\n\n" +
+ "Don't worry about losing your place: our tour stays right here in this chat with me. When you're ready to come back, just come back to this conversation — or tap “Next” to carry on.",
+ [side("Open To-do", () => openAppThread("todo")), advance(go, "Next →", () => stepRemind(env))],
+ );
+}
+
+function stepRemind(env) {
+ const go = onceStep();
+ say(
+ env,
+ "And Reminders — for nudging yourself later. Try typing `/remind call mum in 10 minutes` (or `/remind standup at 9:00`). " +
+ "It'll message you at the right moment, on every device — and even the reminder's text and time stay private to you.\n\n" +
+ "Same as before: give it a go, then pop back to this chat with me whenever you're ready, or tap “Next”.",
+ [side("Open Reminders", () => openAppThread("remind")), advance(go, "Next →", () => stepPrivacy(env))],
+ );
+}
+
+function stepPrivacy(env) {
+ const go = onceStep();
+ say(
+ env,
+ "Now the part that matters most: privacy.\n\n" +
+ "Everything here — messages, photos, names, files — is end-to-end encrypted. The server only ever stores scrambled data it can't unlock, so even whoever runs this space can never read your chats.\n\n" +
+ "Want the slightly more technical version?",
+ [
+ side("Tell me about security", () => go(() => stepSecurity(env))),
+ side("Sounds secure enough to me!", () => go(() => afterPrivacy(env))),
+ ],
+ );
+}
+
+function stepSecurity(env) {
+ const go = onceStep();
+ say(
+ env,
+ "Here's how it works:\n\n" +
+ "• You have a personal key that never leaves your device.\n" +
+ "• Every chat has its own key, locked to each member's key — so only its members can open it.\n" +
+ "• Photos are shrunk and stripped of location data before they're encrypted and uploaded.\n" +
+ "• Invite links carry a one-time secret in the part of the address your browser never sends to the server.\n\n" +
+ "The one thing the server sees in the clear is a small preview card when someone posts a web link — a deliberate, single exception. Everything else, it simply can't read.",
+ [advance(go, "Got it →", () => afterPrivacy(env))],
+ );
+}
+
+// After privacy, the AI section only appears where a conversational helper is on.
+function afterPrivacy(env) {
+ if (env.ai) stepAI(env);
+ else stepEnd(env);
+}
+
+function stepAI(env) {
+ const go = onceStep();
+ const chips = [];
+ // Where we can post as the member (the tour's in the Eleven DM), offer a chip
+ // that actually asks Eleven — a real question, so the AI genuinely replies.
+ if (env.ask) chips.push(side("Tell me about Eleven", () => env.ask("Tell me about Eleven — what makes this app different?")));
+ chips.push(advance(go, "End tutorial", () => stepEnd(env)));
+ say(
+ env,
+ "One more thing — this space has AI helpers switched on. 🤖\n\n" +
+ "Beyond the quick commands, you can actually chat with a helper like me: ask questions, think something through, get a hand. Try asking me anything about the app!\n\n" +
+ "Soon you'll be able to give helpers skills, and even coordinate a few of them together — that part's just getting started. 👀",
+ chips,
+ );
+}
+
+function stepEnd(env) {
+ const name = env.space.name || "here";
+ say(
+ env,
+ `That's the tour! 🎉\n\n` +
+ `You're all set. Type \`/help\` whenever you'd like a reminder of what I can do — or just say hi. Enjoy ${name}!`,
+ // A gentle ending — no more chips to tap.
+ );
+}
diff --git a/web/static/shell.js b/web/static/shell.js
index 45c7d5b..0ce5c88 100644
--- a/web/static/shell.js
+++ b/web/static/shell.js
@@ -52,6 +52,8 @@ import {
setThreadHasBot,
setThreadOpener,
setHostBridge,
+ setSpaceInfo,
+ setElevenTour,
runCommand,
commandCatalog,
buildLocalNotice,
@@ -82,7 +84,8 @@ import "/static/apps/bookmarks.js";
import "/static/apps/poll.js";
import "/static/apps/board.js";
import "/static/apps/connect.js";
-import { setHost as setChatAppHost, setConnected as setChatAppConnected } from "/static/apps/chat.js";
+import { startTour } from "/static/apps/tutorial.js"; // also registers the /tutorial floor command
+import { setHost as setChatAppHost, setConnected as setChatAppConnected, appendNotice } from "/static/apps/chat.js";
import { parseInviteFragment, buildInviteFragment } from "/static/invite.js";
import { qrCanvas } from "/static/qrcode.js";
import { initBackupBanner, canRestore, launchRestore, checkRestoreReturn } from "/static/backup.js";
@@ -171,6 +174,10 @@ const CHIP_RUNNERS = {
},
open_theme_picker: () => (showThemePicker(), null),
open_profile: () => (editProfile(), null),
+ // Start Eleven's guided tour in the thread the chip was tapped in (the welcome
+ // DM). The tour renders as private notices via chat.js's appendNotice; the chip
+ // stays as-is (returns null), since the tour appears below it in the feed.
+ open_tutorial: () => (startTour((spec) => appendNotice(activeId, spec)), null),
};
const chipLabel = (a) =>
a.label ||
@@ -179,6 +186,7 @@ const chipLabel = (a) =>
set_appearance: { light: "Switch to light", dark: "Switch to dark", system: "Follow my device" }[a.appearance],
open_theme_picker: "Open theme picker",
open_profile: "Edit my profile",
+ open_tutorial: "Take the tour",
}[a.type];
function buildActionChips(actions) {
@@ -2397,6 +2405,52 @@ setDefaultBot(() => {
// Lets /help list the bot's commands only where a bot can answer them (same gate
// as the "/" typeahead).
setThreadHasBot((id) => threadHasBot(id));
+// Space info for Eleven's guided tour: the group name plus the optional
+// administrator identity. The admin fields are member-gated, so read /api/group
+// with our token; the tour degrades to just the name if it's unset or fails.
+setSpaceInfo(async () => {
+ try {
+ const g = await (await fetch(`/api/group?token=${encodeURIComponent(session.token)}`)).json();
+ return { name: g.name || "", adminName: g.admin_name || "", adminEmail: g.admin_email || "" };
+ } catch {
+ return {};
+ }
+});
+// The tour lives in your DM with Eleven: when "Show me around" is tapped elsewhere,
+// find-or-create that DM, open it, and hand the tour a sink bound to it so the rest
+// renders there. createThread dedupes DMs server-side, so this never spawns a
+// second one. Null if there's no Eleven to DM → the tour just stays put.
+setElevenTour(async () => {
+ const eleven = roster.find((x) => x.is_agent && x.public_key && /eleven/i.test(x.label || ""));
+ if (!eleven) return null;
+ const dm = existingDM(eleven.user_id);
+ const id = dm ? dm.id : await createThread([eleven.user_id], { group: false });
+ if (!id) return null;
+ if (!dm) {
+ await loadThreads();
+ renderThreadList();
+ }
+ await openThread(id);
+ // notice: append a private tour step here. ask: send a real chat message as the
+ // user, so Eleven's AI actually replies (the tour's "Tell me about Eleven" chip).
+ return {
+ notice: (spec) => appendNotice(id, spec),
+ ask: (text) => sendChatText(id, text),
+ };
+});
+
+// Send a normal chat message (name + text) into a thread, as if typed. Same
+// envelope + encryption as the composer and the app-event transport; used by the
+// tour to ask Eleven a question on the member's behalf.
+async function sendChatText(threadId, text) {
+ const key = threadKeys[threadId];
+ if (!key || !ws || ws.readyState !== WebSocket.OPEN) return false;
+ const payload = { name: session.name, text, ts: Date.now() };
+ if (myUserId) payload.uid = myUserId;
+ const blob = await encrypt(key, payload);
+ ws.send(JSON.stringify({ type: "msg", thread_id: threadId, blob }));
+ return true;
+}
// createAppThread makes a personal app's single-member thread: a `self` thread
// whose name_blob is the ENCRYPTED app id, wrapped only to us. The server sees
diff --git a/web/static/style.css b/web/static/style.css
index 452b810..432e30f 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -798,6 +798,14 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
max-width: 90%; padding: 10px 12px; border-radius: 14px; background: var(--bubble-them);
}
.local-notice-body { font-size: 15px; color: var(--ink); white-space: pre-wrap; }
+.local-notice-body code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.92em;
+ background: color-mix(in srgb, var(--ink) 8%, transparent);
+ padding: 1px 5px;
+ border-radius: 5px;
+}
+.local-notice-body a { color: var(--accent); }
.local-notice .chip-row { margin-top: 8px; }
/* A confirmation is loud only for a while: after ~30s it "blinds up" (its height
animates down — see collapseNotice) to this compact one-line record
diff --git a/web/static/ui.js b/web/static/ui.js
index 268f952..b6d726f 100644
--- a/web/static/ui.js
+++ b/web/static/ui.js
@@ -172,6 +172,7 @@ export const CHIP_TYPES = {
set_appearance: { param: "appearance", valid: (v) => APPEARANCES.includes(v) },
open_theme_picker: {},
open_profile: {},
+ open_tutorial: {}, // starts Eleven's guided tour (e.g. the welcome DM's "Take the tour")
};
// sanitizeActions coerces an untrusted payload `actions` array to renderable