Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Build LLM-powered apps with the Anthropic Claude API or SDK across Python, TypeScript, Java, Go, Ruby, C#, and PHP.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
shared/managed-agents-client-patterns.md
1# Managed Agents — Common Client Patterns23Patterns you'll write on the client side when driving a Managed Agent session, grounded in working SDK examples.45Code samples are TypeScript — Python and cURL follow the same shape; see `python/managed-agents/README.md` and `curl/managed-agents.md` for equivalents.67---89## 1. Lossless stream reconnect1011**Problem:** SSE has no replay. If the connection drops mid-session, a naive reconnect re-opens the stream from "now" and you silently miss every event emitted in between.1213**Solution:** on reconnect, fetch the full event history via `events.list()` *before* consuming the live stream, and dedupe on event ID as the live stream catches up.1415```ts16const seenEventIds = new Set<string>()17const stream = await client.beta.sessions.events.stream(session.id)1819// Stream is now open and buffering server-side. Read history first.20for await (const event of client.beta.sessions.events.list(session.id)) {21seenEventIds.add(event.id)22handle(event)23}2425// Tail the live stream. Dedupe only gates handle() — terminal checks must run26// even for already-seen events, or a terminal event that was in the history27// response gets skipped by `continue` and the loop never exits.28for await (const event of stream) {29if (!seenEventIds.has(event.id)) {30seenEventIds.add(event.id)31handle(event)32}33if (event.type === 'session.status_terminated') break34if (event.type === 'session.status_idle' && event.stop_reason.type !== 'requires_action') break35}36```3738---3940## 2. `processed_at` — queued vs processed4142Every event on the stream carries `processed_at` (ISO 8601). For client-sent events (`user.message`, `user.interrupt`, `user.tool_confirmation`, `user.custom_tool_result`) it's `null` when the event has been queued but not yet picked up by the agent, and populated once the agent processes it. The same event appears on the stream twice — once with `processed_at: null`, once with a timestamp.4344```ts45for await (const event of stream) {46if (event.type === 'user.message') {47if (event.processed_at == null) onQueued(event.id)48else onProcessed(event.id, event.processed_at)49}50}51```5253Use this to drive pending → acknowledged UI state for anything you send. How you map a locally-rendered optimistic message to the server-assigned `event.id` is application-specific (typically via the return value of `events.send()` or FIFO ordering).5455---5657## 3. Interrupt a running session5859Send `user.interrupt` as a normal event. The session keeps running until it reaches a safe boundary, then goes idle.6061```ts62await client.beta.sessions.events.send(session.id, {63events: [{ type: 'user.interrupt' }],64})6566// Drain until the session is truly done — see Pattern 5 for the full gate.67for await (const event of stream) {68if (event.type === 'session.status_terminated') break69if (70event.type === 'session.status_idle' &&71event.stop_reason.type !== 'requires_action'72) break73}74```7576Reference: `interrupt.ts` — sends the interrupt the moment it sees `span.model_request_start`, drains to idle, then verifies via `sessions.retrieve()`.7778---7980## 4. `tool_confirmation` round-trip8182When the agent has `permission_policy: { type: 'always_ask' }`, any call to that tool fires an `agent.tool_use` event with `evaluated_permission === 'ask'` and the session goes idle waiting for a decision. Respond with `user.tool_confirmation`.8384```ts85for await (const event of stream) {86if (event.type === 'agent.tool_use' && event.evaluated_permission === 'ask') {87await client.beta.sessions.events.send(session.id, {88events: [{89type: 'user.tool_confirmation',90tool_use_id: event.id, // not a toolu_ id — use event.id91result: 'allow', // or 'deny'92// deny_message: '...', // optional, only with result: 'deny'93}],94})95}96}97```9899Key points:100- `tool_use_id` is `event.id` (typically `sevt_...`), **not** a `toolu_...` ID.101- `result` is `'allow' | 'deny'`. Use `deny_message` to tell the model *why* you denied — it gets surfaced back to the agent.102- Multiple pending tools: respond once per `agent.tool_use` event with `evaluated_permission === 'ask'`.103104Reference: `tool-permissions.ts`.105106---107108## 5. Correct idle-break gate109110Do not break on `session.status_idle` alone. The session goes idle transiently — e.g. between parallel tool executions, while waiting for a `user.tool_confirmation`, or while awaiting a `user.custom_tool_result`. Break when idle with a terminal `stop_reason`, or on `session.status_terminated`.111112```ts113for await (const event of stream) {114handle(event)115if (event.type === 'session.status_terminated') break116if (event.type === 'session.status_idle') {117if (event.stop_reason.type === 'requires_action') continue // waiting on you — handle it118break // end_turn or retries_exhausted — both terminal119}120}121```122123`stop_reason.type` values on `session.status_idle`:124- `requires_action` — agent is waiting on a client-side event (tool confirmation, custom tool result). Handle it, don't break.125- `retries_exhausted` — terminal failure. Break, then check `sessions.retrieve()` for the error state.126- `end_turn` — normal completion.127128---129130## 6. Post-idle status-write race131132The SSE stream emits `session.status_idle` slightly before the session's queryable status reflects it. Clients that break on idle and immediately call `sessions.delete()` or `sessions.archive()` will intermittently 400 with "cannot delete/archive while running."133134Poll before cleanup:135136```ts137let s138for (let i = 0; i < 10; i++) {139s = await client.beta.sessions.retrieve(session.id)140if (s.status !== 'running') break141await new Promise(r => setTimeout(r, 200))142}143if (s?.status !== 'running') {144await client.beta.sessions.archive(session.id)145} // else: still running after 2s — don't archive, let it settle or escalate146```147148---149150## 7. Stream-first, then send151152Always open the stream **before** sending the kickoff event. Otherwise the agent may process the event and emit the first events before your consumer is attached, and you'll miss them.153154```ts155const stream = await client.beta.sessions.events.stream(session.id)156await client.beta.sessions.events.send(session.id, {157events: [{ type: 'user.message', content: [{ type: 'text', text: 'Hello' }] }],158})159for await (const event of stream) { /* ... */ }160```161162The `Promise.all([stream, send])` shape works too, but stream-first is simpler and has the same effect — the stream starts buffering the moment it's opened.163164---165166## 8. File-mount gotchas167168**The mounted resource has a different `file_id` than the file you uploaded.** Session creation makes a session-scoped copy.169170```ts171const uploaded = await client.beta.files.upload({ file })172// uploaded.id → the original file173const session = await client.beta.sessions.create({174/* ... */175resources: [{ type: 'file', file_id: uploaded.id, mount_path: '/workspace/data.csv' }],176})177// session.resources[0].file_id !== uploaded.id ← different IDs178```179180Delete the original via `files.delete(uploaded.id)`; the session-scoped copy is garbage-collected with the session. `mount_path` must be absolute — see `shared/managed-agents-environments.md`.181182---183184## 9. Secrets for non-MCP APIs and CLIs — keep them host-side via custom tools185186**Problem:** you want the agent to call a third-party API or run a CLI that needs a secret (API key, token, service-account credential), but there is currently no way to set environment variables inside the session container, and vaults currently hold MCP credentials only — they are not exposed to the container's shell. So `curl`, installed CLIs, or SDK clients running via the `bash` tool have no first-class place to read a secret from.187188**Solution:** move the authenticated call to your side. Declare a custom tool on the agent; when the agent emits `agent.custom_tool_use`, your orchestrator (the process reading the SSE stream) executes the call with its own credentials and responds with `user.custom_tool_result`. The container never sees the key.189190```ts191// Agent template: declare the tool, no credentials192tools: [{ type: 'custom', name: 'linear_graphql', input_schema: { /* query, vars */ } }]193194// Orchestrator: handle the call with host-side creds195for await (const event of stream) {196if (event.type === 'agent.custom_tool_use' && event.name === 'linear_graphql') {197const result = await linear.request(event.input.query, event.input.vars) // host's key198await client.beta.sessions.events.send(session.id, {199events: [{ type: 'user.custom_tool_result', tool_use_id: event.id, result }],200})201}202}203```204205Same shape works for `gh` CLI, local eval scripts, or anything else that needs host-side auth or binaries.206207**Security note:** this does not expose a public endpoint. `agent.custom_tool_use` arrives on the SSE stream your orchestrator already holds open with your Anthropic API key, and `user.custom_tool_result` goes back via `events.send()` under the same key. Your orchestrator is a client, not a server — nothing unauthenticated is listening.208209**Do not embed API keys in the system prompt or user messages as a workaround.** Prompts and messages are stored in the session's event history, returned by `events.list()`, and included in compaction summaries — a secret placed there is durably persisted and readable via the API for the life of the session.210