How to build a production Claude Agent SDK app in 6 chapters
engineers + tech leads building agent products on the Claude Agent SDK in 2026
Build channel-scoped agents for multi-user teams
Channel-scoped agents share a single SDK session across all users in a Slack channel, enforced at the channel_id boundary. This chapter adds the channel adapter, memory isolation rules, and an admin audit log to the incident-triage agent from ch 6, using Claude Tag as the production reference pattern for multiplayer delegation and per-channel budget controls.
The Three Execution Contexts
The SDK supports three distinct ways to scope an agent's memory and identity. Choose the wrong one and you either leak context across users or lose continuity across turns.
Per-task run. A single query() call with no resume option. Each request is stateless: Claude sees only the current prompt, with no accumulated history. Right for one-shot jobs where isolation matters more than continuity — a CI check, a single data transform.
Per-user session. One session per user, resumed with a user-specific session_id. Each user's conversation history is private, accumulated only from their turns. The single-user harness from ch 2 uses this model. Covered in Turn a prompt script into a controllable agent harness.
Channel-scoped shared context. A single session shared across every requester in a channel. All team members contribute to and read from the same accumulated history, so Claude can "pick up the conversation from where the last person left off." This is the model Claude Tag uses in production, and the model this chapter implements.
Claude Tag: The Production Reference
Anthropic's Claude Tag, launched on 2026-06-23 for Claude Enterprise and Team customers, is the canonical production pattern for channel-scoped agents. It runs on Opus 4.8 in Slack: a user tags @Claude, the agent plans and executes stages with available tools, and responds in the thread.
Claude Tag expresses the full multiplayer architecture:
- Channel-level permissions. Admins define which tools and information each channel can access. A
#security-incidentschannel might haveBash;#marketing-copydoes not. Permission sets are fully independent per channel, not per user. - Isolated channel identities. The agent instance for the sales channel has zero access to engineering-channel history — enforced at the channel permission boundary, not the user level.
- Two-tier budget controls. Admins set token-spend limits at both the organization level and per channel. In SDK terms: a global cap plus a per-channel
maxBudgetUsdyou enforce by accumulatingResultMessage.total_cost_usdacross runs. (Sonnet 5 migration note: the Sonnet 5 tokenizer produces ~30% more tokens for equivalent prompts, and adaptive thinking creates additional text-only decision turns that count toward `total_cost_usd`. Rebaseline your per-channel budget thresholds when migrating from Sonnet 4.x.) - Full activity log with requester attribution. Admins view everything Claude did and who requested each task — the audit schema you will implement below.
- Ambient mode. When enabled, Claude proactively flags relevant updates and schedules tasks for itself over hours or days. This requires a persistent harness with an event-driven wake mechanism; it is not covered in this chapter.
Channel Adapter Identity Mapping
The SDK has no native concepts of workspace, channel, thread, or user. Your adapter layer maps platform IDs to SDK primitives:
Workspace ID
└─ Channel ID ← one SDK session_id (primary permission boundary)
└─ Thread ID ← Slack thread timestamp; used for response routing
└─ User ID ← requester attribution in prompt + audit log
└─ Task ID ← one @Claude delegation (application UUID)
└─ Run ID ← one query() call
└─ Artifact IDs ← <channel_id>/<task_id>/<name>
The critical mapping is `channel_id` → `session_id`. Capture the SDK session_id from the init SystemMessage's message.session_id on the first run, persist it in your application database keyed by channel_id, and pass it to resume: on every subsequent call for that channel.
Never use continue: true in a multi-channel deployment. It resumes the most recent session in the working directory — which may belong to a different channel.
for await (const message of query({ prompt, options })) {
if (message.type === "system" && message.subtype === "init") {
ctx.sessionId = message.session_id; // persist to DB keyed by channel_id
}
}
Memory Boundaries and Permission Inheritance
Channel isolation is not enforced by the SDK — it is your application's responsibility. Four rules keep channels from bleeding into each other:
- One session per channel. Never share or merge
session_idvalues across channels. A misroutedsession_idgives the recipient full read access to another channel's history. - Explicit `allowedTools` per channel, with `permissionMode: "dontAsk"`. In
dontAskmode, any tool not on theallowedToolslist is hard-denied — no fallback tocanUseTool. Tool schema design for individual tools is covered in ch 3. - No `bypassPermissions` in channel agents. Subagents inherit the parent's permission mode and cannot override it.
bypassPermissionsgrants every subagent unconditional access to all tools. - Artifact namespace prefixing. Store all outputs under
<channel_id>/<task_id>/<artifact_name>to prevent cross-channel artifact reads via tool calls.
Permission evaluation order from the Claude Agent SDK permissions docs: Hooks → Deny rules → Ask rules → Permission mode → Allow rules → canUseTool. Populate both allowedTools (positive grant) and disallowedTools (hard blocklist). Do not rely on canUseTool as your primary gate — it is a last resort, not an enforcement mechanism.
Admin Audit Log Event Schema
Build the log from SDK hooks. Every PreToolUse and PostToolUse event carries session_id, hook_event_name, tool_name, and tool_input. PostToolUse also carries the tool result. Correlate pre/post pairs via tool_use_id.
interface AuditEvent {
eventId: string; // UUID per event
taskId: string; // one @Claude delegation
runId: string; // one query() call
sessionId: string; // SDK session_id — maps to channelId
channelId: string;
requesterId: string; // set by adapter layer, never derived from model output
eventType: "tool_read" | "tool_write" | "tool_call" | "decision" | "spend";
toolName: string | null;
toolInput: unknown | null; // sanitise secrets before persisting
toolOutput: unknown | null;
costUsd: number | null;
timestamp: string; // ISO 8601
}
Wire the hooks via the Claude Agent SDK hooks docs:
const options = {
hooks: {
PreToolUse: [{ hooks: [makeAuditHook(ctx)] }],
PostToolUse: [{ hooks: [makeAuditHook(ctx)] }],
},
};
Text-only model turns — Claude thinking out loud or issuing a recommendation without calling a tool — do not fire tool hooks. Capture these by processing AssistantMessage objects in your message stream loop and emitting eventType: "decision" records.
<Callout type="warning">
Requester attribution must come from your adapter layer, not from model output. Inject requesterId at task creation time and stamp every AuditEvent with it. Never derive the requester from what Claude says — the model can be wrong, and an attacker can prompt it to lie.
</Callout>
Hands-On Exercise
Extend the incident-triage agent from ch 6 to support two Slack channels: #infra-oncall and #product-alerts. Each channel should have its own isolated session and a distinct allowedTools list (#infra-oncall allows Bash; #product-alerts does not).
Steps:
- Create a
ChannelContextStore(in-memory Map for now) keyed bychannel_id. StoresessionId,allowedTools, andtotalSpendUsdper entry. - On each simulated
@Claudemention, callhandleChannelRequest(channelId, userId, threadId, prompt, ctx). Capture and persistsession_idfrom the initSystemMessage. - Wire
PreToolUseandPostToolUsehooks that writeAuditEventrecords to a local JSON file, includingrequesterIdinjected from the caller. - Run the CANARY leakage test: inject
"CANARY-INFRA"into#infra-oncall, then ask#product-alerts"what was discussed earlier?" — the string must not appear.
Success criteria:
- The CANARY string never surfaces in #product-alerts responses.
- #product-alerts returns a hard denial if a request triggers a Bash tool call.
- The audit log JSON contains at least one PreToolUse event with channelId, requesterId, and toolName populated.
- Cumulative spend is tracked per channel and would trigger a budget block if it exceeded maxBudgetUsdPerRun.
You have completed the course. Return to Ship the smallest useful Claude Agent SDK app to build a new agent from scratch applying everything you have shipped across these seven chapters.