All courses 360 min1 chaptersBuilderanthropic

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

Chapters in this course
Build channel-scoped agents for multi-user teams55m
Chapter 7 · 55 min

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.

Knowledge check1 of 1
A team wants Claude to remember context from a colleague's earlier request in the same Slack channel. Which execution model fits?

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-incidents channel might have Bash; #marketing-copy does 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 maxBudgetUsd you enforce by accumulating ResultMessage.total_cost_usd across 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
  }
}
Knowledge check1 of 1
Your multi-channel deployment runs all channels from the same working directory. A developer suggests using `continue: true` for simplicity. What is the risk?

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:

  1. One session per channel. Never share or merge session_id values across channels. A misrouted session_id gives the recipient full read access to another channel's history.
  2. Explicit `allowedTools` per channel, with `permissionMode: "dontAsk"`. In dontAsk mode, any tool not on the allowedTools list is hard-denied — no fallback to canUseTool. Tool schema design for individual tools is covered in ch 3.
  3. No `bypassPermissions` in channel agents. Subagents inherit the parent's permission mode and cannot override it. bypassPermissions grants every subagent unconditional access to all tools.
  4. 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:

  1. Create a ChannelContextStore (in-memory Map for now) keyed by channel_id. Store sessionId, allowedTools, and totalSpendUsd per entry.
  2. On each simulated @Claude mention, call handleChannelRequest(channelId, userId, threadId, prompt, ctx). Capture and persist session_id from the init SystemMessage.
  3. Wire PreToolUse and PostToolUse hooks that write AuditEvent records to a local JSON file, including requesterId injected from the caller.
  4. 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.

Chapter 7 check
1 / 5
Multiple users in a Slack channel share a single Claude agent session. Which SDK option correctly implements this channel-scoped model?