A showcase of all Project Think features, built with @cloudflare/think and
the sub-agent routing primitive from agents.
- Multi-session via sub-agent routing — each user gets an
AssistantDirectoryparent DO that owns the sidebar. Each chat is its ownMyAssistantfacet (full Think DO — own extensions, memory, messages). Addressed transparently viauseAgent({ sub: [{ agent: "MyAssistant", name: chatId }] }) - Shared workspace across chats —
AssistantDirectoryowns oneWorkspacebacked by its SQLite; everyMyAssistantchild gets aSharedWorkspaceproxy that forwards file I/O to the parent. Ahello.txtwritten in chat A is visible verbatim in chat B. The proxy swaps in via theWorkspaceFsLiketype exported by@cloudflare/shell— no casts; builtin workspace tools AND codemode'sstate.*sandbox API both route through it - Shared MCP across chats — server registry, OAuth credentials, live
connections, and tool descriptors all live on
AssistantDirectory. Auth to a server once (e.g. GitHub MCP) and every chat sees its tools. Each child carries aSharedMCPClientproxy that builds per-turn MCP tool sets via one DO RPC hop to the parent.useChats()surfacesmcpState/addMcpServer/removeMcpServerso the MCP panel is the same across chats and open tabs - Live cross-chat file updates — the directory's
Workspaceis wired withonChange→broadcast, so every open tab's file browser updates live whenever any chat writes, edits, or deletes a file.useChats()surfaces it as aworkspaceRevisioncounter foruseEffectdeps - Think base class —
getModel(),configureSession(),getTools(),maxStepsfor a batteries-included agent - Built-in workspace — file tools (read, write, edit, find, grep, delete) auto-wired on every turn
- Sandboxed code execution —
createExecuteToollets the LLM write and run JavaScript in a Dynamic Worker via@cloudflare/codemode - Self-authored extensions —
extensionLoader+createExtensionToolslet the agent create new tools at runtime - Persistent memory — context blocks (
soul,memory) the model can read and write across sessions - Non-destructive compaction — older messages summarized when context overflows, originals preserved
- Searchable knowledge base — FTS5-backed
AgentSearchProviderwithsearch_contextandset_contexttools - Dynamic configuration — typed
AgentConfigwith model tier and persona, persisted in SQLite - Server-side tools —
getWeather,calculateexecute on the server - Client-side tools —
getUserTimezoneruns in the browser viaonToolCall - Tool approval —
calculaterequires user approval for large numbers - MCP integration — connect external tool servers; tools appear in every chat automatically (shared at the directory level)
- Lifecycle hooks —
beforeTurn,beforeToolCall,afterToolCall,onStepFinish,onChatResponse - Durable chat recovery —
chatRecoverywraps turns in fibers for crash recovery - Parent-owned scheduled work — daily summary scheduled from the directory (facets can't own schedules), fans out to the most recently active chat
- Regeneration with branch navigation — v1/v2/v3 response versions via
getBranches - Stream resumption — page refresh replays the active stream (built into Think)
- useAgentChat — Think speaks the same CF_AGENT protocol as AIChatAgent
- GitHub OAuth — users sign in with GitHub; the Worker owns all DO naming, so each user gets their own directory + isolated chats
Go to GitHub OAuth Apps, create a new OAuth App, and set:
- Homepage URL:
http://localhost:5173 - Authorization callback URL:
http://localhost:5173/auth/callback
cp .env.example .envThen fill in:
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secretnpm install
npm startOpen the app, click Sign in with GitHub, approve the OAuth flow, and you will land in the Think assistant scoped to your GitHub login.
AssistantDirectory ("alice") ◄── one DO per authenticated GitHub user
├─ MyAssistant[chat-abc] [facet] ◄── each chat is its own Think DO
├─ MyAssistant[chat-def] [facet]
└─ MyAssistant[chat-ghi] [facet]
AssistantDirectory owns the chat list, the sidebar state, the shared
workspace, the shared MCP registry (servers, OAuth creds, live
connections), and any cross-chat concerns (e.g. the daily-summary
schedule — facets can't schedule() so the parent does it and fans
out). MyAssistant is a Think DO per conversation, with its own
SQLite storage, extensions, and message history — plus a
SharedWorkspace proxy and a SharedMCPClient proxy that route file
operations and MCP tool invocations back to the directory.
The browser never chooses a DO name. It connects to /chat (the
directory) and /chat/sub/my-assistant/<chatId> (a specific chat), and
the Worker resolves the AssistantDirectory instance from the
authenticated GitHub cookie:
if (url.pathname === "/chat" || url.pathname.startsWith("/chat/")) {
const user = await getGitHubUserFromRequest(request);
if (!user) return createUnauthorizedResponse(request);
const directory = await getAgentByName(env.AssistantDirectory, user.login);
return directory.fetch(request);
}The directory's built-in sub-agent router picks up the
/sub/my-assistant/<chatId> tail — no per-chat plumbing lives in the
Worker. Access control lives on the parent via onBeforeSubAgent as a
strict registry gate:
override async onBeforeSubAgent(_req, { className, name }) {
if (!this.hasSubAgent(className, name)) {
return new Response("Not found", { status: 404 });
}
}On the client, useChats() (a local hook in src/use-chats.ts) wraps
the sidebar connection and RPCs. Each chat pane uses
useAgent({ agent: "AssistantDirectory", basePath: "chat", sub: [{ agent: "MyAssistant", name: chatId }] }).
See examples/multi-ai-chat for the minimal AIChatAgent version of the
same pattern.
Each MyAssistant overrides this.workspace with a SharedWorkspace
proxy that forwards every call to AssistantDirectory.workspace over
a DO RPC hop:
class MyAssistant extends Think<Env> {
override workspace: WorkspaceFsLike = new SharedWorkspace(this);
getTools() {
return {
execute: createExecuteTool({
tools: createWorkspaceTools(this.workspace),
// state.* in the sandbox also hits the shared workspace,
// because SharedWorkspace satisfies WorkspaceFsLike.
state: createWorkspaceStateBackend(this.workspace),
loader: this.env.LOADER
})
// ...
};
}
}
class SharedWorkspace implements WorkspaceFsLike {
readFile(p) {
return (await this.parent()).readFile(p);
}
writeFile(p, c) {
return (await this.parent()).writeFile(p, c);
}
// ...readFileBytes / writeFileBytes / appendFile / exists / stat /
// lstat / mkdir / readDir / rm / cp / mv / symlink / readlink / glob
}The proxy satisfies @cloudflare/shell's WorkspaceFsLike interface,
which is a strict superset of @cloudflare/think's WorkspaceLike.
That one type annotation unlocks two things at once:
- All of Think's workspace-aware machinery (
createWorkspaceTools, lifecycle hooks, the builtinlistWorkspaceFiles/readWorkspaceFileRPCs) works unchanged against the proxy. - Codemode's
state.*sandbox API works too, viacreateWorkspaceStateBackend(this.workspace). Multi-file operations likestate.planEditsandstate.applyEditsrun against the shared workspace, so a plan composed in one chat can mutate files another chat just created.
The parent DO and the child facet live on the same machine, so each RPC hop is in-process and cheap (no network, no serialization across external links).
Trade-offs worth knowing:
- Every chat can see every chat's files. That's the design — a
multi-chat assistant should remember what it wrote in previous
chats. If you fork this for a less-trusted surface (e.g. public
guests), gate access in
AssistantDirectoryinstead of exposing the workspace methods directly. - Extensions, messages, Think config, and branch history stay
per-chat. The workspace and the MCP registry are shared; everything
else lives in each child DO's own storage. Extensions in particular
persist to
ctx.storage(not the workspace), so a tool authored in chat A isn't auto-available in chat B. That's a sensible default for this demo — extensions are "this chat's custom tools" — but if you want a fork where extensions cross chats too, move their persistence into the parent directory DO alongside the workspace and MCP registry. - Extensions with
workspace: "read-write"permissions inherit the same reach. The shell-level permission model is about what the LLM can do inside a single chat; it doesn't distinguish between "this chat's files" and "this user's files" because the underlyingWorkspacedoesn't either. For the assistant example this is what we actually want. For other apps — e.g. a hostile-code sandbox — consider giving each chat its own non-shared workspace by removing the override inMyAssistant. - Serialization is per-file, not per-turn. Two chats writing to the same path queue behind each other in the parent DO's single-threaded isolate, which is the usual semantics you'd want.
- Change events fan out to every client, but not to sibling chats.
AssistantDirectory.workspaceis constructed withonChange: (ev) => this.broadcast(...), so every file mutation reaches every client connected to the directory — that's every browser tab the user has open, across every chat.useChats()translates those broadcasts into aworkspaceRevisioncounter that chat panes pass into their file-browser effects, so a write in chat A lights up chat B's files list live. The parent does not RPC events into sibling child facets — no server-side tool in this example reacts to another chat's writes. Add a parent → child RPC if that use case shows up.
MCP follows the same pattern as the workspace: the registry, OAuth
credentials, live connections, and tool caches all live on
AssistantDirectory. Each child carries a SharedMCPClient proxy
that RPCs the parent on each turn:
class MyAssistant extends Think<Env> {
sharedMcp = new SharedMCPClient(this);
async beforeTurn(ctx) {
// Splice the directory's shared MCP tools into this turn.
return { tools: await this.sharedMcp.getAITools() };
}
}
class SharedMCPClient {
async getAITools(timeoutMs = 5_000): Promise<ToolSet> {
const parent = await this.parent();
// Wait up to `timeoutMs` for any in-progress connections; returns
// only tools from servers that are ready.
const descriptors = await parent.listMcpToolDescriptors(timeoutMs);
return buildToolSet(descriptors, (serverId, name, args) =>
parent.callMcpTool(serverId, name, args)
);
}
}OAuth callback URL is /chat/mcp-callback — one URL for every
server across every chat. The Worker's existing /chat* gate
forwards it to the directory; Agent._onRequest dispatches to
handleMcpOAuthCallback, which uses mcp.isCallbackRequest to
match on stored callback URLs. Token lives in the directory's DO
storage via DurableObjectOAuthClientProvider.
Browser-side, useChats() exposes mcpState, addMcpServer,
removeMcpServer, sourced from the directory's
CF_AGENT_MCP_SERVERS broadcasts. The MCP panel in each Chat
reads these from props, so every tab sees the same server list in
real time.
Trade-offs worth knowing:
- Every chat can call every MCP tool you've connected. Same model
as the workspace — this is the point of a multi-chat assistant. If
you need per-chat tool gating, filter in
SharedMCPClient.getAIToolsusing the existinggetAITools(filter?)signature onMCPClientManageras a template. - Each tool invocation is one extra DO RPC hop. Same machine, in-process, cheap. If an MCP tool call is network-bound (most are), the added hop is noise.
- The parent's isolate is the serialization point. Two chats calling tools at the same time interleave in the parent's JS event loop (single-threaded DO isolate). MCP tools usually await network, so they don't block each other in practice, but the parent is technically the user's MCP fan-in point.
- Connection count per user = server count. The directory keeps one live connection per registered server. SSE-style MCP transports are lightweight but still real. Worth knowing before forking this for users who register dozens of servers.
- OAuth callbacks on this URL require an authenticated GitHub
session. Callbacks come back to the same origin in the user's
browser, so the GitHub session cookie is present; the Worker's
existing
/chat*gate validates it before forwarding to the directory. Unauthenticated probes to/chat/mcp-callback401.
Create or update your GitHub OAuth App so it also has your production callback URL:
https://your-domain.example/auth/callback
Set the secrets:
wrangler secret put GITHUB_CLIENT_ID
wrangler secret put GITHUB_CLIENT_SECRETDeploy:
npm run deployServer (src/server.ts):
export class AssistantDirectory extends Agent<Env, DirectoryState> {
// Strict registry gate — clients can only reach chats this
// directory spawned via `createChat`.
override async onBeforeSubAgent(_req, { className, name }) {
if (!this.hasSubAgent(className, name)) {
return new Response("Not found", { status: 404 });
}
}
@callable()
async createChat() {
const id = nanoid(10);
await this.subAgent(MyAssistant, id); // spawn the facet
/* ... persist meta, refresh sidebar ... */
}
}
export class MyAssistant extends Think<Env> {
chatRecovery = true;
extensionLoader = this.env.LOADER;
getModel() {
/* model tier from config */
}
configureSession(session) {
/* persona, memory, compaction, knowledge */
}
getTools() {
/* execute, extensions, getWeather, calculate, ... */
}
// Each turn updates the parent's sidebar preview via the
// typed `parentAgent(AssistantDirectory)` stub.
async onChatResponse(result) {
const directory = await this.parentAgent(AssistantDirectory);
await directory.recordChatTurn(this.name, extractPreview(result));
}
}Client (src/client.tsx) — useChats() (a local prototype in
src/use-chats.ts) drives the sidebar; each chat pane uses
useAgentChat from @cloudflare/ai-chat/react over a sub-routed
useAgent connection.