Install in Claude Code
Copygit clone --depth 1 https://github.com/anthropics/claude-quickstarts /tmp/chat-sdk && cp -r /tmp/chat-sdk/managed-agents/chat-sdk ~/.claude/skills/chat-sdkThen start a new Claude Code session; the skill loads automatically.
Definition
skill.md
# Setup tips & tricks: Chat SDK web adapter × Claude Managed Agents
Things that aren't obvious from the docs and tend to cost debugging time.
---
## Mental model
### The conversation ID is the session ID
Every browser conversation is a DM on a durable thread (`web:{userId}:{conversationId}`), and in this quickstart the `conversationId` half is a Managed Agents session ID, verbatim. The page creates the session first (`POST /api/sessions` → `sessions.create()`), hands the returned ID to `useChat` as its `threadId`, and every message in that conversation lands in that session. There is no mapping table: the bridge decodes the session ID back out of the thread ID (`adapters.web.decodeThreadId`) and uses it directly.
That makes the Managed Agents API the entire conversation store:
- The sidebar is `sessions.list({ agent_id })` -- server-side filter, archived sessions excluded by default.
- Opening an old chat is `sessions.events.list()`: `user.message` and `agent.message` events become bubbles again (`src/sessions.ts`). The session also holds all research context server-side, so follow-ups into a replayed chat work -- the analyst remembers what it already researched.
- The first message of a fresh conversation doubles as its title: it travels as `useChat` message metadata and the bridge writes it with `sessions.update()`, so the sidebar and the Console agree on what the chat is about.
The server keeps nothing. `createMemoryState()` in `src/bot.ts` holds only Chat SDK internals (message dedup, locks), and `persistMessageHistory: false` keeps the adapter from caching message bodies there too -- the browser holds the live transcript, the session holds the durable one.
### Never trust a conversation ID
The flip side of "the browser sends a session ID" is that the browser sends a session ID. It becomes an API path parameter, and the server's Anthropic credentials can see every session in the workspace -- including other agents' sessions. `ownedSession()` in `src/managed-agents.ts` is the gate every session-touching route goes through: the ID must look like an ID, resolve, belong to this quickstart's agent, and not be archived. `/api/history` 404s without it; `runTurn` refuses the turn. If you fork this, keep that function in the path of anything that takes a conversation ID from a client.
### Two held streams, no webhooks
`useChat` POSTs to `/api/chat` and the web adapter answers with a response stream it keeps open until the handler returns. Inside the handler, the bridge holds an Anthropic SSE stream open for the same turn and forwards each `agent.message` with `thread.post()`, which the adapter writes onto the response as its own text part. So the acknowledgment renders in the browser within seconds and the brief lands on the same response minutes later. There is no Anthropic webhook, no platform webhook, and no `ANTHROPIC_WEBHOOK_SIGNING_KEY` in this quickstart.
This is why `src/bot.ts` awaits the turn instead of firing and forgetting. The Slack and WhatsApp adapters need the opposite (ack the platform webhook in seconds, post through their API later); on web, returning early closes the only channel the reply can travel on.
Porting to a webhook surface also changes where sessions come from. There is no browser to create one, so the handler creates a session for each new platform thread and keeps the mapping (thread metadata, or a small table) -- the web shortcut of using the session ID as the conversation ID doesn't travel.
### Previews stream the message being written
The bridge opens the event stream with `event_deltas: ["agent.message"]` (`src/managed-agents.ts`). That one query param is the whole token-streaming feature, and it is always on here. With it, three things arrive for every agent message instead of one: `event_start` (the id a buffered `agent.message` will carry), a run of `event_delta` text fragments while the model writes, then the buffered `agent.message` itself. On `event_start` the bridge opens a streamed `thread.post(asyncIterable)` so the bubble appears immediately; each fragment is pushed straight through; the buffered event closes it.
Two rules make this safe to copy:
- **The buffered event is the truth.** Previews are best-effort, never persisted, and always a verbatim prefix of the final text, so reconciliation is `final.slice(sent.length)` then end. A preview whose buffered event never comes (its model request errored) is closed by `span.model_request_end`.
- **No previews must mean no change.** In an org without session streaming, or on an `@anthropic-ai/sdk` without `event_deltas`, the same loop sees only buffered events and posts each one whole. That is the entire pre-streaming behavior, preserved as the degraded path rather than as a second code path.
### The activity feed is a second lane
The web adapter v1 carries message text only (no tool or data parts), so progress does not travel through `thread.post`. Instead the bridge reports every interesting event -- `agent.tool_use` with a short input hint, `agent.tool_result`, `agent.thinking` (start-only: Managed Agents says that the model is reasoning, not what), `span.model_request_start`, retries -- through the `TurnHooks.activity` callback, and `src/app.ts` fans those out per thread on `GET /api/activity?conversation=...`. The page tails it with an `EventSource` while a turn runs. The feed itself stores nothing -- a tab that attaches mid-turn sees the rest of the turn, and the chat lane stays pure Chat SDK.
What survives the turn is the **tool-call trace**. The bridge collects every `agent.tool_use` (name plus input hint, failures marked from `agent.tool_result`) and posts the list as a fenced ` ```tools ` message when the turn ends cleanly; `/api/history` re-derives the same trace from the event log, so a reload or replay keeps it. The page renders it as a collapsed, expandable list -- plain text only, because tool inputs can quote text from pages the agent read. Same never-stored pattern