+`;
+}
+
+/** Minified SLO strip — every agent is a LOOP over goals, and this card is
+ * the loop made visible: each goal is an SLI with a target, an error budget,
+ * and a trend. Incident opens on breach, closes on recovery; the agent
+ * pursues these continuously and remembers what worked. */
+function sloCard(
+ title: string,
+ rows: {
+ name: string;
+ target: string;
+ sli: string;
+ budget: string;
+ state: "ok" | "risk";
+ pts: number[];
+ }[],
+ memory: string,
+): string {
+ const COLOR = { ok: "#4ade80", risk: "#fbbf24" } as const;
+ const row = (r: (typeof rows)[number]) => {
+ const c = COLOR[r.state];
+ return `
+`;
+}
+
+/** The tasks board: every proposed action is a card — kanban, like Jira
+ * without the ceremony. Styled with Studio's REAL tokens (warm neutrals,
+ * 6px radius, ring-shadow cards) and populated with THIS session's story:
+ * the Winter Drop ship is done, the agent's queued follow-ups are moving,
+ * and the cards mirror the team threads in the sidebar. */
+function tasksKanban(): string {
+ const card = (
+ id: string,
+ chip: string,
+ chipCls: string,
+ title: string,
+ meta: string,
+ who: string,
+ agent: boolean,
+ done = false,
+ ) =>
+ `
${id}${chip}
${done ? "✓ " : ""}${title}
${meta}
${agent ? "🤖" : who.slice(0, 1)}${who}
`;
+ return `
+
Tasks
Everything from this morning's thread became a card — done, moving, or waiting for an owner.
+
+
Proposed 2
+${card("T-492", "follow-up", "follow", "Winter Drop email to subscribers", "Queued by Vela Agent after the ship", "Unassigned", false)}
+${card("T-493", "idea", "idea", "Extend Winter Drop banner to category pages", "Suggested from hero engagement", "Unassigned", false)}
+
+
+ {/* key on level+agent → each zoom crossfades like a route change */}
+
+ {level === "home" ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+// ============================================================================
+// Screenplay
+// ============================================================================
+
+const MORNING_DIGEST = `### While you were away
+
+| Team | Status | Needs you |
+| --- | --- | --- |
+| **Vela Store** | Winter Drop assets approved, hero not shipped | **Yes — ship it** |
+| **Aurora Coffee** | Subscription churn down 12% after winback flow | No |
+| **Atlas Labs** | All green — 3 PRs merged overnight | No |
+
+One thing needs you: **Vela's Winter Drop hero** is approved and waiting.`;
+
+function orgPilotCall(org: Org, summary: string, latencyMs: number) {
+ return genericTool({
+ name: "ask_team_agent",
+ input: { org: org.id, question: "morning brief" },
+ output: { result: summary },
+ latencyMs,
+ });
+}
+
+/** Ghost-type into the composer, then submit as the track's user message. */
+async function say(d: Director, t: Track, text: string) {
+ await d.type("composer", text, { cps: 30 });
+ await d.beat(350);
+ d.setInput("composer", "");
+ await t.user(text);
+}
+
+async function goodMorning(d: Director) {
+ const deco = d.track("deco").setSender({ name: "Decopilot", logo: true });
+
+ await d.caption("This is deco Studio — the home for your AI agents!");
+ await d.beat(3300);
+
+ await d.caption(
+ "Every morning, your agents report on what's new — and what needs you",
+ );
+ await say(d, deco, "Good morning!");
+ await d.beat(400);
+ await deco.think(
+ "I'll ask each team's agent for its overnight report, then rank what actually needs Gui.",
+ { cps: 85 },
+ );
+
+ await d.caption("Your deco asks each team's agent — in parallel");
+ d.setInput("dot:vela", "busy");
+ d.setInput("dot:aurora", "busy");
+ d.setInput("dot:atlas", "busy");
+ await deco.parallel(
+ [
+ orgPilotCall(ORGS[0], "Winter Drop approved · hero pending", 2300),
+ orgPilotCall(ORGS[1], "churn −12% · winback flow working", 2900),
+ orgPilotCall(ORGS[2], "all green · 3 PRs merged", 2100),
+ ],
+ 350,
+ );
+
+ d.setInput("status:vela", "Winter Drop assets approved — hero not shipped.");
+ d.setInput("dot:vela", "needs");
+ d.notify("vela");
+ d.setInput("status:aurora", "Churn down 12% after the winback flow.");
+ d.setInput("dot:aurora", "ok");
+ d.setInput("status:atlas", "All green — 3 PRs merged overnight.");
+ d.setInput("dot:atlas", "ok");
+
+ await deco.stream(MORNING_DIGEST, { instant: true });
+ // The brief ends in an ACTION, not a paragraph — the card lands WITH the
+ // digest, not after a pause: a button straight to the org that needs you.
+ deco.showCard("org_cta", {
+ orgName: "Vela Store",
+ glyph: "V",
+ tile: "bg-lime-200 text-lime-950",
+ headline: "Vela Store needs you",
+ body: "Winter Drop hero is staged and waiting on your go.",
+ button: "Take me there",
+ target: "org-cta",
+ chips: [
+ { label: "Assets approved", state: "done" },
+ { label: "QA passed", state: "done" },
+ { label: "Hero not shipped", state: "pending" },
+ ],
+ });
+ deco.endTurn();
+ await d.caption(
+ "Your agents handle the routine — you make the calls that matter",
+ );
+ await d.beat(5000); // hold: read the table + the card together
+}
+
+async function hopIntoVela(d: Director) {
+ await d.caption("The card takes you straight into the team context");
+ d.showCursor();
+ await d.beat(600);
+ await d.click("org-cta");
+ d.setInput("level", "org");
+ d.setOrg("vela");
+ d.hideCursor();
+
+ // You land in a thread where Vela's pilot has ALREADY asked the question —
+ // answer it and work proceeds. No re-explaining context.
+ const vela = d.track("vela").setSender({
+ name: "Vela Agent",
+ glyph: "V",
+ tile: "bg-lime-200 text-lime-950",
+ });
+ d.setInput("thread:vela", "1"); // the thread appears under My threads
+ // The greeting streams IMMEDIATELY on landing (over line 5's narration) so
+ // the chat column is never a blank card while the dashboard loads beside it.
+ await d.beat(300);
+ await vela.stream(
+ "Morning! The Winter Drop hero is ready — assets approved, QA passed. Ship it now?",
+ { cps: 60 },
+ );
+ vela.endTurn();
+
+ // The breadcrumb IS the context: top left always says which scope you're
+ // in and which agent you're addressing. Spotlight it while the line plays.
+ await d.caption(
+ "Top left is your context — the scope you're in, the agent you're talking to",
+ );
+ d.setInput("hl", "crumb");
+ await d.beat(4400); // hold the spotlight for the narration
+ d.setInput("hl", "");
+
+ await d.caption(
+ "You land on its operations — everything the agent watches, live",
+ );
+ await d.beat(3400); // read the dashboard + the agent's question
+
+ // Answer immediately — the urgency is the point. No sidebar detours.
+ await d.caption("You say ship it — that's the whole job");
+ await say(d, vela, "Yes — ship it.");
+ await d.beat(400);
+ await vela.think(
+ "Swap the hero, audit the page before it ships, fix anything that regressed, then deploy.",
+ { cps: 85 },
+ );
+ await vela.stream("On it — swapping the hero for the Winter Drop.", {
+ cps: 44,
+ });
+ await vela.tool(
+ genericTool({
+ name: "edit_section",
+ input: { section: "hero", variant: "winter-drop" },
+ output: { result: "hero → Winter Drop · banner enabled" },
+ latencyMs: 1800,
+ }),
+ );
+ // The preview swaps from the ops dashboard to the site to SHOW the change.
+ d.setInput("preview-url", "vela.shop");
+ d.setPreview("vela", velaPreview({ winter: true }));
+ d.setInput("shipped", "1");
+
+ // The pilot audits on its own, catches the regression, and DELEGATES the
+ // fix to its own specialist — the user never has to know what LCP is.
+ // The line narrates WHILE the audit/fix run, not after them.
+ await d.caption(
+ "It audits before shipping, finds a regression — and fixes it itself",
+ );
+ await vela.tool(
+ genericTool({
+ name: "audit_page",
+ input: { url: "vela.shop", checks: ["performance", "seo"] },
+ output: { result: "LCP 1.9s — new hero image 840KB, no preload" },
+ latencyMs: 1500,
+ }),
+ );
+ await vela.stream(
+ "The new hero slowed the page down. Sending it to Storefront Bot before we ship.",
+ { cps: 55 },
+ );
+ await vela.tool(
+ genericTool({
+ name: "subtask",
+ input: { agent: "Storefront Bot", task: "optimize the new hero asset" },
+ output: { result: "hero.webp 168KB · preload added · LCP 1.2s ✓" },
+ latencyMs: 1400,
+ }),
+ );
+ await vela.tool(
+ genericTool({
+ name: "deploy",
+ output: { result: "vela.shop · v87 live" },
+ latencyMs: 1000,
+ }),
+ );
+
+ // Shipped → who sees it: teammates' threads live in the same sidebar.
+ // Narrates over the ✅ message landing, then the sidebar click.
+ await d.caption(
+ "Your teammates see it land — their threads live right beside yours",
+ );
+ await vela.stream(
+ "✅ Live on vela.shop — Winter Drop hero, fast (LCP **1.2s**). I've queued the follow-ups on the board.",
+ { cps: 55 },
+ );
+ vela.endTurn();
+ await d.beat(400);
+ d.showCursor();
+ await d.click("team-threads");
+ await d.beat(2200);
+ d.hideCursor();
+
+ // → and where the follow-ups went: the agent's Tasks app. The cursor
+ // clicks the app switcher (top right of the preview) to make it explicit
+ // that this board is ONE of the agent's MCP apps.
+ await d.caption(
+ "Every follow-up becomes a card — assigned to an agent, or a person",
+ );
+ d.showCursor();
+ await d.click("app:tasks");
+ d.setInput("preview-url", "vela · tasks");
+ d.setPreview("vela", tasksKanban());
+ d.hideCursor();
+ await d.beat(4200);
+}
+
+const SALES_RECAP = `Yesterday on vela.shop:
+
+| | Yesterday | vs. prior day |
+| --- | --- | --- |
+| Orders | **412** | ▲ 38% |
+| Revenue | **R$96k** | ▲ 44% |
+| Avg. order | **R$233** | ▲ 5% |
+
+Top seller: the **Winter Parka** — the drop is converting at 2.1× your baseline. Want this recap every morning?`;
+
+async function zoomIntoAgent(d: Director) {
+ await d.caption(
+ "Go one level deeper — every level runs an agent loop, with goals and memory",
+ );
+ d.showCursor();
+ await d.beat(400);
+ await d.click("agent:vela-ops");
+ d.setInput("level", "agent");
+ d.setInput("agent", "vela-ops");
+ // Store Ops has its OWN app in the preview — sales data, not the site.
+ d.setInput("preview-url", "vela · sales");
+ d.setPreview("vela", storeOpsApp());
+ d.hideCursor();
+ await d.beat(900);
+
+ const ops = d.track("vela-ops").setSender({
+ name: "Store Ops",
+ glyph: "O",
+ tile: "bg-pink-200 text-pink-950",
+ });
+ await say(d, ops, "How were yesterday's sales?");
+ await d.beat(300);
+ await ops.tool(
+ genericTool({
+ name: "query_orders",
+ input: { range: "yesterday", compare: "prior_day" },
+ output: { result: "412 orders · R$96k · AOV R$233" },
+ latencyMs: 1200,
+ }),
+ );
+ await ops.stream(SALES_RECAP, { instant: true });
+ ops.endTurn();
+ await d.beat(2200); // hold the sales table + the goals card
+}
+
+async function shareAnywhere(d: Director) {
+ // Share is demonstrated where it's most visceral: THIS sales agent, in any
+ // MCP client. Every breadcrumb level mints an MCP URL for its scope.
+ await d.caption(
+ "Every scope is an MCP URL — take this exact agent to Claude Code, Codex, or anywhere",
+ );
+ d.showCursor();
+ await d.beat(400);
+ await d.click("share-button");
+ d.setInput("share", "open");
+ await d.beat(800);
+ d.hideCursor();
+ await d.beat(3600); // read the URL + client chips (narration covers this)
+ d.setInput("share", "");
+
+ // Go UP one level in the context: the SAME share pill now mints the MCP
+ // for the whole team — sharing is just picking where in the path you are.
+ await d.caption("Or go one level up — and share your whole team as one MCP");
+ d.showCursor();
+ await d.beat(300);
+ await d.click("crumb:org");
+ d.setInput("level", "org");
+ d.setInput("agent", "");
+ d.setInput("preview-url", "vela · operations");
+ d.setPreview("vela", orgDashboard());
+ await d.beat(600);
+ await d.click("share-button");
+ d.setInput("share", "open");
+ d.hideCursor();
+ await d.beat(3400); // the URL is the team's scope now
+ d.setInput("share", "");
+ await d.beat(300);
+}
+
+/** Settings is an agent, its screens are MCP apps in the preview — chat +
+ * preview is the ONE pattern; settings gets no special screen. */
+async function settingsAsAgent(d: Director) {
+ await d.caption(
+ "Even Settings is an agent — its screens are just apps in the preview",
+ );
+ d.showCursor();
+ await d.beat(500);
+ await d.click("agent:vela-settings");
+ d.setInput("level", "agent");
+ d.setInput("agent", "vela-settings");
+ d.setInput("preview-url", "vela · settings/members");
+ d.setPreview("vela", settingsApp({ rafaAdmin: false }));
+ d.hideCursor();
+ await d.beat(1000);
+
+ const st = d.track("vela-settings").setSender({
+ name: "Settings",
+ glyph: "⚙",
+ tile: "bg-muted text-muted-foreground",
+ });
+ await say(d, st, "Make Rafa an admin.");
+ await d.beat(300);
+ await st.tool(
+ genericTool({
+ name: "update_member",
+ input: { member: "rafa@vela.shop", role: "admin" },
+ output: { result: "Rafa · member → admin" },
+ latencyMs: 1200,
+ }),
+ );
+ d.setPreview("vela", settingsApp({ rafaAdmin: true }));
+ d.setInput("rafa", "1");
+ await st.stream("Done — Rafa is an **admin** now. Anything else?", {
+ cps: 44,
+ });
+ st.endTurn();
+ await d.beat(600); // let the Members app update land
+}
+
+async function backHome(d: Director) {
+ await d.caption("And the logo always takes you back home — to your agents");
+ d.showCursor();
+ await d.beat(500);
+ await d.click("crumb:home");
+ d.setInput("level", "home");
+ d.setInput("agent", "");
+ d.hideCursor();
+
+ d.setInput("status:vela", "Winter Drop live on vela.shop · LCP 1.2s ✓");
+ d.setInput("dot:vela", "ok");
+ d.setInput("metric:vela", "1.2s"); // the watched LCP square ticks down live
+ await d.beat(900);
+
+ // The share pill at the TOP scope: hand your whole deco to Claude.
+ await d.caption(
+ "You can also connect Claude to your deco — it gets all your teams at once",
+ );
+ d.showCursor();
+ await d.beat(300);
+ await d.click("share-button");
+ d.setInput("share", "open");
+ d.hideCursor();
+ await d.beat(2600); // the URL is your whole deco
+ d.setInput("share", "");
+ await d.beat(300);
+
+ const deco = d.track("deco");
+ await deco.stream(
+ "Vela's Winter Drop is live and fast. Every agent logged what it learned today. Nothing else needs you — go have your coffee. ☕",
+ { cps: 44 },
+ );
+ deco.endTurn();
+ await d.caption(
+ "It's agents all the way down — same intelligence, only the context changes",
+ );
+ // Must outlast the closing clip — markEnded() clears the caption AND
+ // stops the audio, so a short beat here crops the last line.
+ await d.beat(5400);
+}
+
+export const turtlesScenario: Scenario = {
+ id: "turtles",
+ title: "Your deco — it's agents all the way down",
+ Stage: TurtlesStage,
+ endCard: {
+ title: "It's agents all the way down",
+ subtitle:
+ "Your deco, your teams, their agents — one product, one URL per scope.",
+ },
+ run: async (d: Director) => {
+ d.setInput("level", "home");
+ // Entering an org lands on its OPERATIONS dashboard, not a site preview.
+ d.setPreview("vela", orgDashboard());
+ d.setInput("preview-url", "vela · operations");
+ await d.beat(600);
+
+ await goodMorning(d);
+ await hopIntoVela(d);
+ await zoomIntoAgent(d);
+ await shareAnywhere(d);
+ await settingsAsAgent(d);
+ await backHome(d);
+ },
+};
diff --git a/apps/mesh/src/web/demo/stage.tsx b/apps/mesh/src/web/demo/stage.tsx
new file mode 100644
index 0000000000..52dd4f2914
--- /dev/null
+++ b/apps/mesh/src/web/demo/stage.tsx
@@ -0,0 +1,212 @@
+/**
+ * Demo Mode — the stage.
+ *
+ * Full-screen surface that runs the autoplay loop and renders the ACTIVE
+ * scenario's own layout (`scenario.Stage`). The Director owns all timing
+ * outside React; the only `useEffect` here is the runner's start/abort
+ * lifecycle boundary (same documented exception pattern as
+ * `components/live-timer.tsx`).
+ */
+import { useEffect, useState } from "react";
+import { Button } from "@deco/ui/components/button.tsx";
+import { PlayCircle, VolumeX } from "@untitledui/icons";
+import { DemoProviders } from "./demo-providers";
+import { createDemoStores } from "./director-stores";
+import { Director } from "./director";
+import { runAutoplay } from "./runner";
+import { useCaption, useDemoInput } from "./use-demo-stores";
+import { GhostCursor } from "./ghost-cursor";
+import { EndCard } from "./end-card";
+import { createVoicePlayer, VoiceToggle } from "./voiceover";
+import type { DemoStores } from "./director-stores";
+import type { Scenario } from "./types";
+
+const DEFAULT_END = {
+ title: "Build this on your own data",
+ subtitle: "Spin up your first agent in minutes — free to start.",
+};
+
+/** The gate before the show: one click starts the run AND unlocks audio, so
+ * the first narration line actually narrates. */
+function StartCard({ stores, title }: { stores: DemoStores; title: string }) {
+ const started = useDemoInput(stores, "started") === "1";
+ if (started) return null;
+
+ const start = (vo: "on" | "off") =>
+ stores.ui.update((s) => ({
+ ...s,
+ inputs: { ...s.inputs, started: "1", vo: vo === "off" ? "off" : "" },
+ }));
+
+ return (
+
+
+
+
{title}
+
+ A 2-minute guided tour, narrated. Press Esc anytime to pause.
+
+
+
+
+
+
+
+ );
+}
+
+/** Esc toggles the Director's clock; this pill says so while paused. */
+function PausedPill({ stores }: { stores: DemoStores }) {
+ const paused = useDemoInput(stores, "paused") === "1";
+ if (!paused) return null;
+ return (
+
+
+ ⏸ Paused — press Esc to resume
+
+
+ );
+}
+
+function DemoCaption({ stores }: { stores: DemoStores }) {
+ const caption = useCaption(stores);
+ if (!caption) return null;
+ return (
+ // Top of the stage (clear of the breadcrumb) — the bottom sat over the
+ // composer and was easy to miss. Purple so it stands apart from the
+ // product UI: this is the narrator, not a component.
+
+ {/* key on text so each new caption fades/slides in fresh */}
+
+ {caption}
+
+
+ );
+}
+
+/**
+ * One autoplay loop per `DemoStores` instance, kept OUTSIDE React's effect
+ * lifecycle. The dev runtime (React Compiler / StrictMode) re-runs passive
+ * effects while preserving state, which would otherwise abort + restart the
+ * scenario mid-play. We key the running loop on the (mount-stable) stores
+ * instance and set it up once; teardown is deferred so a transient
+ * teardown→setup cycle (which re-sets `keep`) never kills it — only a real
+ * unmount, where no setup follows, aborts.
+ */
+const RUNNERS = new WeakMap<
+ DemoStores,
+ { controller: AbortController; keep: boolean }
+>();
+
+export function DemoStage({ scenarios }: { scenarios: Scenario[] }) {
+ const [stores] = useState(createDemoStores);
+ // Pin scenarios on mount so an unstable prop (a fresh array literal each
+ // parent render) can't influence the runner.
+ const [pinnedScenarios] = useState(() => scenarios);
+ const [activeIndex, setActiveIndex] = useState(0);
+
+ // oxlint-disable-next-line ban-use-effect/ban-use-effect, eslint-plugin-react-hooks/exhaustive-deps -- runner lifecycle (start/abort) is singleton-guarded per stores; all timing lives in the Director
+ useEffect(() => {
+ // Demo always renders in light mode (overrides the app's theme preference).
+ const root = document.documentElement;
+ const wasDark = root.classList.contains("dark");
+ root.classList.remove("dark");
+
+ let entry = RUNNERS.get(stores);
+ if (!entry) {
+ const controller = new AbortController();
+ entry = { controller, keep: true };
+ RUNNERS.set(stores, entry);
+ const director = new Director(stores, controller.signal);
+ const stopVoice = createVoicePlayer(stores);
+ controller.signal.addEventListener("abort", stopVoice, { once: true });
+ // Esc toggles pause (only mid-show — not on the start/end cards).
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== "Escape") return;
+ const { inputs, ended } = stores.ui.get();
+ if (inputs.started !== "1" || ended) return;
+ stores.ui.update((s) => ({
+ ...s,
+ inputs: {
+ ...s.inputs,
+ paused: s.inputs.paused === "1" ? "" : "1",
+ },
+ }));
+ };
+ window.addEventListener("keydown", onKey);
+ controller.signal.addEventListener(
+ "abort",
+ () => window.removeEventListener("keydown", onKey),
+ { once: true },
+ );
+ void runAutoplay(
+ director,
+ pinnedScenarios,
+ controller.signal,
+ setActiveIndex,
+ );
+ }
+ entry.keep = true; // setup cancels any pending teardown
+
+ return () => {
+ const e = RUNNERS.get(stores);
+ if (e) {
+ e.keep = false;
+ queueMicrotask(() => {
+ // A synchronous re-setup (StrictMode / spurious re-run) flips `keep`
+ // back to true before this runs → loop survives. Real unmount leaves
+ // it false → abort.
+ if (!e.keep) {
+ e.controller.abort();
+ RUNNERS.delete(stores);
+ }
+ });
+ }
+ if (wasDark) root.classList.add("dark");
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const active = pinnedScenarios[activeIndex] ?? pinnedScenarios[0]!;
+ const ActiveStage = active.Stage;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mesh/src/web/demo/types.ts b/apps/mesh/src/web/demo/types.ts
new file mode 100644
index 0000000000..ec5b8164df
--- /dev/null
+++ b/apps/mesh/src/web/demo/types.ts
@@ -0,0 +1,18 @@
+import type { ComponentType } from "react";
+import type { Director } from "./director";
+import type { DemoStores } from "./director-stores";
+
+/**
+ * A demo screenplay. `run` drives the Director top-to-bottom; `Stage` is the
+ * React layout that renders this scenario's tracks/chrome (single chat, a
+ * multi-agent grid, a terminal + panes, …). The autoplay loop calls `run`
+ * repeatedly, resetting state between runs — keep it idempotent.
+ */
+export interface Scenario {
+ id: string;
+ title: string;
+ Stage: ComponentType<{ stores: DemoStores }>;
+ run: (d: Director) => Promise;
+ /** Copy for the end card shown after a full play-through. */
+ endCard?: { title: string; subtitle: string };
+}
diff --git a/apps/mesh/src/web/demo/use-demo-stores.ts b/apps/mesh/src/web/demo/use-demo-stores.ts
new file mode 100644
index 0000000000..180a100f5f
--- /dev/null
+++ b/apps/mesh/src/web/demo/use-demo-stores.ts
@@ -0,0 +1,65 @@
+/**
+ * Demo Mode — React subscription hooks for the Director's external stores.
+ * All reads go through `useSyncExternalStore` (no `useEffect`).
+ */
+import { useSyncExternalStore } from "react";
+import type { DemoStores } from "./director-stores";
+
+export function useCaption(stores: DemoStores): string | null {
+ return useSyncExternalStore(
+ stores.ui.subscribe,
+ () => stores.ui.get().caption,
+ () => stores.ui.get().caption,
+ );
+}
+
+export function useDemoInput(stores: DemoStores, id: string): string {
+ return useSyncExternalStore(
+ stores.ui.subscribe,
+ () => stores.ui.get().inputs[id] ?? "",
+ () => stores.ui.get().inputs[id] ?? "",
+ );
+}
+
+export function useCurrentOrg(stores: DemoStores): string | null {
+ return useSyncExternalStore(
+ stores.ui.subscribe,
+ () => stores.ui.get().currentOrg,
+ () => stores.ui.get().currentOrg,
+ );
+}
+
+/** Whether an agent has an unseen completion (sidebar notification dot). */
+export function useNotified(stores: DemoStores, agentId: string): boolean {
+ return useSyncExternalStore(
+ stores.ui.subscribe,
+ () => stores.ui.get().notified.includes(agentId),
+ () => stores.ui.get().notified.includes(agentId),
+ );
+}
+
+/** True when a given org's agent track is mid-stream — drives the "working in
+ * the background" pulse on inactive org tabs. */
+export function useTrackBusy(stores: DemoStores, orgId: string): boolean {
+ const store = stores.getChat(orgId);
+ return useSyncExternalStore(
+ store.subscribe,
+ () =>
+ store.get().status === "streaming" || store.get().status === "submitted",
+ () =>
+ store.get().status === "streaming" || store.get().status === "submitted",
+ );
+}
+
+/** True once a track has any messages — drives hero → thread view swaps. */
+export function useTrackHasMessages(
+ stores: DemoStores,
+ trackId: string,
+): boolean {
+ const store = stores.getChat(trackId);
+ return useSyncExternalStore(
+ store.subscribe,
+ () => store.get().messages.length > 0,
+ () => store.get().messages.length > 0,
+ );
+}
diff --git a/apps/mesh/src/web/demo/voiceover.tsx b/apps/mesh/src/web/demo/voiceover.tsx
new file mode 100644
index 0000000000..5d124ba182
--- /dev/null
+++ b/apps/mesh/src/web/demo/voiceover.tsx
@@ -0,0 +1,174 @@
+/**
+ * Demo Mode — narration.
+ *
+ * The captions ARE the script: every pill line has a pre-recorded voice-over
+ * clip (Maya1 TTS — Apache 2.0, Indian-English voice design, generated
+ * on-device → AAC in public/demo/vo/), keyed by the exact caption text. Narration is ON by default; browsers block audio
+ * until a user gesture, so the player retries the current line on the first
+ * pointer/keydown anywhere (the standard unlock pattern). The toggle mutes.
+ *
+ * The player is a plain store subscriber created inside the stage's
+ * sanctioned lifecycle effect — when the caption changes and narration is
+ * on, the matching clip plays (any previous clip stops first).
+ */
+import { useSyncExternalStore } from "react";
+import { VolumeMax, VolumeX } from "@untitledui/icons";
+import type { DemoStores } from "./director-stores";
+
+/** caption text → clip. Keys must EXACTLY match the script's captions. */
+const VO_FILES: Record = {
+ "This is deco Studio — the home for your AI agents!": "/demo/vo/vo-01.m4a",
+ "Every morning, your agents report on what's new — and what needs you":
+ "/demo/vo/vo-02.m4a",
+ "Your deco asks each team's agent — in parallel": "/demo/vo/vo-03.m4a",
+ "Your agents handle the routine — you make the calls that matter":
+ "/demo/vo/vo-04.m4a",
+ "The card takes you straight into the team context": "/demo/vo/vo-05.m4a",
+ "Top left is your context — the scope you're in, the agent you're talking to":
+ "/demo/vo/vo-06.m4a",
+ "You land on its operations — everything the agent watches, live":
+ "/demo/vo/vo-07.m4a",
+ "You say ship it — that's the whole job": "/demo/vo/vo-08.m4a",
+ "It audits before shipping, finds a regression — and fixes it itself":
+ "/demo/vo/vo-09.m4a",
+ "Your teammates see it land — their threads live right beside yours":
+ "/demo/vo/vo-10.m4a",
+ "Every follow-up becomes a card — assigned to an agent, or a person":
+ "/demo/vo/vo-11.m4a",
+ "Go one level deeper — every level runs an agent loop, with goals and memory":
+ "/demo/vo/vo-12.m4a",
+ "Every scope is an MCP URL — take this exact agent to Claude Code, Codex, or anywhere":
+ "/demo/vo/vo-13.m4a",
+ "Or go one level up — and share your whole team as one MCP":
+ "/demo/vo/vo-14.m4a",
+ "Even Settings is an agent — its screens are just apps in the preview":
+ "/demo/vo/vo-15.m4a",
+ "And the logo always takes you back home — to your agents":
+ "/demo/vo/vo-16.m4a",
+ "You can also connect Claude to your deco — it gets all your teams at once":
+ "/demo/vo/vo-17.m4a",
+ "It's agents all the way down — same intelligence, only the context changes":
+ "/demo/vo/vo-18.m4a",
+};
+
+/** Clip length (ms) per file — the Director holds each pill at least this
+ * long so narration NEVER gets cropped by the next line. Clips generated
+ * with Maya1 (Apache 2.0, Indian-English voice design) on-device; durations
+ * are the sample-accurate wav lengths from the generation run. */
+export const VO_MS: Record = {
+ "/demo/vo/vo-01.m4a": 2738,
+ "/demo/vo/vo-02.m4a": 3382,
+ "/demo/vo/vo-03.m4a": 3202,
+ "/demo/vo/vo-04.m4a": 3047,
+ "/demo/vo/vo-05.m4a": 2281,
+ "/demo/vo/vo-06.m4a": 4234,
+ "/demo/vo/vo-07.m4a": 4097,
+ "/demo/vo/vo-08.m4a": 2464,
+ "/demo/vo/vo-09.m4a": 4333,
+ "/demo/vo/vo-10.m4a": 4132,
+ "/demo/vo/vo-11.m4a": 4456,
+ "/demo/vo/vo-12.m4a": 4657,
+ "/demo/vo/vo-13.m4a": 6783,
+ "/demo/vo/vo-14.m4a": 4906,
+ "/demo/vo/vo-15.m4a": 3521,
+ "/demo/vo/vo-16.m4a": 3896,
+ "/demo/vo/vo-17.m4a": 3833,
+ "/demo/vo/vo-18.m4a": 4482,
+};
+
+/** Milliseconds the caption must stay up for its clip (0 if unnarrated). */
+export function captionHoldMs(caption: string): number {
+ const src = VO_FILES[caption];
+ return src ? (VO_MS[src] ?? 0) + 300 : 0;
+}
+
+/** ON unless explicitly muted — narration leads the tale by default. */
+function isOn(stores: DemoStores): boolean {
+ return stores.ui.get().inputs.vo !== "off";
+}
+
+/** Subscribe to caption changes and narrate. Returns a cleanup fn. */
+export function createVoicePlayer(stores: DemoStores): () => void {
+ let lastCaption: string | null = null;
+ let current: HTMLAudioElement | null = null;
+ let unlocked = false;
+
+ const playCaption = (caption: string | null) => {
+ current?.pause();
+ current = null;
+ if (!caption || !isOn(stores)) return;
+ const src = VO_FILES[caption];
+ if (!src) return;
+ current = new Audio(src);
+ current.play().then(
+ () => {
+ unlocked = true;
+ },
+ () => {
+ /* blocked until a user gesture — the unlock listener retries */
+ },
+ );
+ };
+
+ let lastPaused = false;
+ const unsub = stores.ui.subscribe(() => {
+ const { caption, inputs } = stores.ui.get();
+ // Esc pauses/resumes the clip in flight along with the Director's clock.
+ const paused = inputs.paused === "1";
+ if (paused !== lastPaused) {
+ lastPaused = paused;
+ if (paused) current?.pause();
+ else current?.play().catch(() => {});
+ }
+ if (caption === lastCaption) return;
+ lastCaption = caption;
+ playCaption(caption);
+ });
+
+ // Autoplay unlock: on the FIRST gesture anywhere, re-speak the line that's
+ // currently on screen so narration starts mid-show instead of never.
+ const unlock = () => {
+ if (unlocked) return cleanupUnlock();
+ unlocked = true;
+ cleanupUnlock();
+ playCaption(stores.ui.get().caption);
+ };
+ const cleanupUnlock = () => {
+ window.removeEventListener("pointerdown", unlock);
+ window.removeEventListener("keydown", unlock);
+ };
+ window.addEventListener("pointerdown", unlock);
+ window.addEventListener("keydown", unlock);
+
+ return () => {
+ unsub();
+ cleanupUnlock();
+ current?.pause();
+ };
+}
+
+export function VoiceToggle({ stores }: { stores: DemoStores }) {
+ const on = useSyncExternalStore(
+ stores.ui.subscribe,
+ () => stores.ui.get().inputs.vo !== "off",
+ () => stores.ui.get().inputs.vo !== "off",
+ );
+ return (
+
+ );
+}
diff --git a/apps/mesh/src/web/demo/work-plan.tsx b/apps/mesh/src/web/demo/work-plan.tsx
new file mode 100644
index 0000000000..5faa249c60
--- /dev/null
+++ b/apps/mesh/src/web/demo/work-plan.tsx
@@ -0,0 +1,389 @@
+/**
+ * Demo Mode — inline work-plan (sprint) + pull-request cards.
+ *
+ * Rendered INLINE in the chat flow (registered via the part-renderer registry,
+ * see register-parts.tsx) from the message part's `output`. Pure + data-driven:
+ * the Director mutates the part output to tick tasks / approve / merge. Buttons
+ * carry `data-demo-target` so the ghost cursor can aim at them; they're visual
+ * only (the Director performs the action).
+ */
+import { Button } from "@deco/ui/components/button.tsx";
+import { cn } from "@deco/ui/lib/utils.ts";
+import {
+ ArrowRight,
+ Bell01,
+ Check,
+ GitBranch01,
+ GitPullRequest,
+ Target04,
+} from "@untitledui/icons";
+import type {
+ DigestState,
+ PlanState,
+ PlanTaskStatus,
+ PRState,
+} from "./director-stores";
+
+function TaskIcon({ status }: { status: PlanTaskStatus }) {
+ if (status === "done") {
+ return (
+
+
+
+ );
+ }
+ if (status === "active") {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+function StatusLabel({ status }: { status: PlanTaskStatus }) {
+ const map = {
+ done: { text: "Done", cls: "text-primary" },
+ active: { text: "In progress", cls: "text-foreground" },
+ queued: { text: "Queued", cls: "text-muted-foreground" },
+ } as const;
+ const { text, cls } = map[status];
+ return {text};
+}
+
+export function WorkPlanCard({ plan }: { plan: PlanState }) {
+ const done = plan.tasks.filter((t) => t.status === "done").length;
+ return (
+
+ );
+}
diff --git a/apps/mesh/src/web/hooks/use-my-threads.ts b/apps/mesh/src/web/hooks/use-my-threads.ts
new file mode 100644
index 0000000000..635b789073
--- /dev/null
+++ b/apps/mesh/src/web/hooks/use-my-threads.ts
@@ -0,0 +1,186 @@
+/**
+ * useMyThreads — the cross-org aggregate that powers the "MY deco" home.
+ *
+ * Threads are strictly org-scoped server-side (COLLECTION_THREADS_LIST requires
+ * an org), so there is no single "all my threads" endpoint. We fan out instead:
+ * one `COLLECTION_THREADS_LIST { where: { created_by: "me" } }` per org the user
+ * belongs to, each over that org's self-MCP client, then merge and sort client
+ * side. No new backend tool, no migration — every call is membership-checked by
+ * the org-scoped route.
+ *
+ * Clients are reused from the shared react-query cache (same key as
+ * `useMCPClient`), so this doesn't open N fresh transports per render.
+ */
+import { useQueries, useQueryClient } from "@tanstack/react-query";
+import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
+import { mcpClientQueryOptions, SELF_MCP_ALIAS_ID } from "@decocms/mesh-sdk";
+import { useActiveOrganizations } from "@/web/lib/auth-client";
+import { KEYS } from "@/web/lib/query-keys";
+import type { Task } from "@/web/components/chat/task/types";
+import type { StatusKey } from "@/web/lib/task-status";
+
+/** How many recent threads to pull per org. The home is a "what's moving now"
+ * surface, not an archive — recent-per-org merged is plenty for v1. */
+const PER_ORG_LIMIT = 25;
+
+/** Attention order: what needs me first, done last. */
+const ATTENTION_RANK: Record = {
+ requires_action: 0,
+ in_progress: 1,
+ failed: 2,
+ expired: 3,
+ completed: 4,
+};
+
+export interface MyThreadOrg {
+ id: string;
+ slug: string;
+ name: string;
+ logo?: string | null;
+}
+
+export interface MyThreadAgent {
+ title: string;
+ icon: string | null;
+}
+
+export interface MyThread {
+ thread: Task;
+ org: MyThreadOrg;
+ /** Resolved from the org's VIRTUAL_MCP collection so cards can show the agent
+ * without an org-scoped context (the home is org-less). */
+ agent: MyThreadAgent | null;
+}
+
+export interface UseMyThreadsResult {
+ threads: MyThread[];
+ /** True while the first results are still loading and nothing is shown yet. */
+ isLoading: boolean;
+ /** True once at least one org has resolved (progressive reveal). */
+ hasAny: boolean;
+ /** Orgs whose fetch failed — surfaced non-fatally so the rest still render. */
+ erroredOrgs: MyThreadOrg[];
+ /** The user's orgs, for filter chips. */
+ orgs: MyThreadOrg[];
+}
+
+function rankOf(status: Task["status"]): number {
+ return ATTENTION_RANK[(status ?? "completed") as StatusKey] ?? 5;
+}
+
+/** requires_action → in_progress → … → completed, then most-recent first. */
+function compareThreads(a: MyThread, b: MyThread): number {
+ const byRank = rankOf(a.thread.status) - rankOf(b.thread.status);
+ if (byRank !== 0) return byRank;
+ return (b.thread.updated_at ?? "").localeCompare(a.thread.updated_at ?? "");
+}
+
+function unwrap(result: unknown): T {
+ if ((result as { isError?: boolean }).isError) {
+ throw new Error("tool call failed");
+ }
+ return ((result as { structuredContent?: unknown }).structuredContent ??
+ result) as T;
+}
+
+/** Map of virtual_mcp_id → agent display info, so cards render the agent name
+ * and icon without the org-scoped `useVirtualMCP` context. */
+async function fetchOrgAgents(
+ client: Client,
+): Promise