Multi-session AI chat built on the sub-agent routing primitive. A
single Inbox Durable Object owns the chat list + per-user shared
memory; each chat is a facet of that inbox — its own
AIChatAgent DO, colocated on the same machine, with isolated
SQLite storage. Chat facets can use the normal Agents scheduling and
durable-execution APIs; the parent owns the physical alarm, but
callbacks and recovery run inside the chat facet.
This is the pattern the proposed Chats base class in
design/rfc-think-multi-session.md
will codify as sugar. When that RFC lands, most of Inbox becomes
extends Chats<Env> and the client-side wiring collapses to a
single useChats() hook — but the mechanics underneath are already
shipped and demonstrated here.
npm install
npm startOpen the dev URL. Click New to create a chat. Start chatting.
The assistant has three tools it can choose to call during a turn:
rememberFact(fact)— saves a fact to the user's shared memory (persisted on the parentInbox, visible to every chat on the next turn). Try: "Remember I prefer TypeScript over JavaScript."recallMemory()— reads the full shared memory.getCurrentTime()— returns the server's current ISO time.
Each tool call renders in-line as a collapsible panel with state, input, and output; reasoning traces (if the model emits any) show up as dimmed "Thinking" blocks. Text, reasoning, and tool parts stream in order as the model produces them.
You can also type a fact in Shared memory at the bottom of the sidebar and hit Save memory to set it manually — useful when you want to seed the assistant with context without a tool call.
┌─────────────────────────────────────────────┐
│ Inbox (top-level DO, "demo-user") │
│ - chats: [ ... ] (broadcast via state) │
│ - memory: "…" (shared context) │
│ - onBeforeSubAgent → strict-registry gate │
│ - @callable: create/rename/deleteChat, │
│ get/setSharedMemory, ... │
└──┬────────────┬──────────────┬──────────────┘
│ subAgent(Chat, id) — facets, one per chat
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Chat abc │ │ Chat def │ │ Chat ghi │
│ AIChatAgent│ │ AIChatAgent│ │ AIChatAgent│
│ parentPath │ │ parentPath │ │ parentPath │
│ → Inbox │ │ → Inbox │ │ → Inbox │
└────────────┘ └────────────┘ └────────────┘
URL shapes the client connects to:
/agents/inbox/demo-user— the sidebar / Inbox RPC surface./agents/inbox/demo-user/sub/chat/{chatId}— a specific chat. The Inbox parent gatekeeps viaonBeforeSubAgent, then the WebSocket is upgraded straight to theChatfacet.
Key things worth looking at in src/server.ts:
Inbox.onBeforeSubAgent— a strict-registry gate. A chat becomes reachable only aftercreateChathas calledthis.subAgent(Chat, id)once.hasSubAgentreads the framework-maintained registry thatsubAgent/deleteSubAgentpopulate. Unknown chat ids get a 404 before any facet is woken.Inbox._refreshStatereads the chat list fromlistSubAgents(Chat)(the framework-owned registry) and joins in app-owned metadata (title, preview) from a tinychat_metatable. Existence lives with the framework; decoration lives with the app.Inbox.createChat/deleteChatare thin wrappers overthis.subAgent(Chat, id)/this.deleteSubAgent(Chat, id)that insert / remove the matching meta row.Chat.getInbox()uses the framework'sparentAgent(Inbox)helper — pass the parent's class, get back a typed RPC stub with the right identity baked in. No hardcoded user id, nogetAgentByNameplumbing inside the facet.- Each
Chatowns its own SQLite database, stream state, and recovery state. If you build this pattern withThink,chatRecoveryandrunFiber()work from inside the chat facet; the root parent's alarm drives recovery checks back into idle children, and reconnecting to the/sub/chat/{chatId}URL attaches directly to that child. - The worker entry is a one-liner:
routeAgentRequest(request, env). It already knows how to walk/agents/inbox/.../sub/chat/...— no custom routing needed.
And in src/client.tsx:
- The sidebar connection:
useAgent({ agent: "Inbox", name: DEMO_USER }). - The active chat connection:
useAgent({ agent: "Inbox", name: DEMO_USER, sub: [{ agent: "Chat", name: chatId }] }). Thesubarray builds the nested URL;useAgentChatwraps the resulting socket unchanged.
- One Durable Object per chat means two chats for the same user run in parallel. If all chats lived inside a single DO (a "session map" pattern), inference would serialize — DOs are single-threaded.
- The Inbox keeps a single source of truth. Chat creation,
deletion, and shared memory all go through the parent. The registry
hasSubAgentgate prevents orphaned chats from accidentally being woken by speculative client requests.
parentPathreplaces hardcoded parent lookups. A child Chat doesn't need to know the user id — it knows its parent from the chain the framework gave it at facet-init time.- Shared memory lives on the parent, not inside each chat. This
is what makes "facts the assistant learns about you" persist across
chats. A more ambitious app could bump this up to Session context
blocks + search (see
Think+ theRemoteContextProviderproposal).
- Single-user demo — the Inbox name is hardcoded to
demo-user. In a real app, authenticate first and use the user's id. - Titles default to
Chat — YYYY-MM-DD. LLM-generated titles are intentionally out of scope for the example. onBeforeSubAgentuses a permissive-by-default sketch: if you want to allow lazy chat creation on first connect (no explicitcreateChatstep), drop thehasSubAgentcheck — the framework will callsubAgent()as part of dispatch.
design/rfc-sub-agent-routing.md— the routing primitive this example is built on.onBeforeSubAgent,parentPath,useAgent({ sub }),hasSubAgent, etc.design/rfc-think-multi-session.md— the follow-upChatsbase class +useChats()hook, which will turn most of this example into ~10 lines of sugar.design/rfc-ai-chat-maintenance.md— stance on howAIChatAgentis maintained alongsideThink.examples/ai-chat— single-conversation AIChatAgent demo with MCP, tools, approval, browser tools.