Read our operating thesis — Machines work better in tandem
JOURNALENGINEERING · JULY 2026

Building production agents with the Claude Agent SDK

The SDK is Claude Code as a library. This is the part the quickstart skips: the six-step permission evaluation order, the three ways your approval callback gets silently bypassed, and what a subagent actually inherits.

WRITTEN BYTandem Machines
Coordinated Intelligence
PUBLISHED
READING TIME18 minutes
SHARE
LinkedInX
KEY TAKEAWAYS
  • 01The Agent SDK is Claude Code as a library — built-in tools, agent loop and context management included. You still host it.
  • 02Permissions are evaluated in six ordered steps. Knowing the order is the difference between a control and a decoration.
  • 03allowedTools is an auto-approve list, not a capability limit. Only disallowedTools removes capability.
  • 04Your canUseTool callback is skipped for anything approved earlier — including every bare entry in allowedTools.
  • 05A subagent receives only the Agent tool's prompt string from its parent. Nothing else crosses the boundary.

What the Claude Agent SDK is

The Claude Agent SDK is Claude Code packaged as a library for Python and TypeScript. It ships the agent loop, context management, built-in file and shell tools, permissions, hooks, subagents and sessions. You call one function with a prompt and options; the SDK drives everything else. You host and deploy it yourself.

Three things get confused with it, and the confusion is expensive because the answer to “how do I restrict this?” is different in each. The distinction that matters is who supplies the harness and who supplies the deployment.

HarnessDeploymentBuilt-in tools
Claude Agent SDKFull Claude Code loopYoursRead, Write, Edit, Bash, Glob, Grep, web
API tool runnerLoop over your toolsYoursNone — you define every tool
Managed AgentsAnthropic'sAnthropic's sandboxHosted toolset
Manual API loopYoursYoursNone
THE AGENT SDK AND THE TOOL RUNNER ARE BOTH HARNESS-ONLY — ONLY MANAGED AGENTS ADDS HOSTING

If you are reaching for the SDK because you want a coding or filesystem agent and would rather not write the loop, that is exactly what it is for. If you want a hosted agent with no infrastructure of your own, you want Managed Agents instead — a different product with a different API.

A first agent, then the same agent hardened

Install @anthropic-ai/claude-agent-sdk (TypeScript) or claude-agent-sdk (Python), then iterate the async generator returned by query(). The minimal version is four lines. The production version differs only in options — and those options are the whole subject of this guide.
Minimal — do not ship this
import { query } from "@anthropic-ai/claude-agent-sdk";

for await (const message of query({
  prompt: "Find and fix the failing test in src/",
})) {
  if ("result" in message) console.log(message.result);
}

That works, and it will happily run any command it decides it needs. The same agent with the controls that make it deployable:

Production shape
import { query } from "@anthropic-ai/claude-agent-sdk";

for await (const message of query({
  prompt: "Find and fix the failing test in src/",
  options: {
    model: "claude-opus-5",

    // Capability: what the agent can do at all.
    disallowedTools: ["Bash(rm *)", "Bash(git push *)"],

    // Approval: what runs without asking. NOT a capability limit.
    allowedTools: ["Read", "Glob", "Grep"],

    // Anything not pre-approved is denied rather than prompted.
    permissionMode: "dontAsk",

    // Bounds. A loop without these is an open-ended bill.
    maxTurns: 40,
    maxBudgetUsd: 5,

    // Confine the filesystem surface.
    cwd: "/srv/checkout",
  },
})) {
  if ("result" in message) console.log(message.result);
}
THE TWO LISTS DO DIFFERENT JOBS — SEE THE PERMISSION MODEL BELOW

The pairing of allowedTools with permissionMode: "dontAsk" is the documented shape for a locked-down headless agent: listed tools are approved, and anything else is refused outright rather than falling through to a prompt that nothing is there to answer.

Custom tools

Define a tool with tool(name, description, schema, handler) and register it through createSdkMcpServer(). The schema is a Zod object; the handler returns MCP-shaped content. Custom tools run in your process, so this is where you put anything that needs your credentials or your business logic.
A custom tool
import { tool, createSdkMcpServer, query } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";

const lookupOrder = tool(
  "lookup_order",
  "Fetch an order by ID. Call this before answering any question about order status.",
  { orderId: z.string() },
  async ({ orderId }) => {
    const order = await db.orders.findById(orderId);
    return { content: [{ type: "text", text: JSON.stringify(order) }] };
  },
  { annotations: { readOnlyHint: true } },
);

const server = createSdkMcpServer({
  name: "orders",
  version: "1.0.0",
  tools: [lookupOrder],
});

Two things in that snippet are load-bearing and easy to skip. The description is prescriptive about when to call the tool, not merely descriptive of what it does — that phrasing measurably changes how often the model reaches for it. And readOnlyHint declares that the tool has no side effects, which is information your permission layer and your reviewers both need.

Where to put the credential

Custom tools execute in your process, which means the credential stays in your process. That is the reason to promote an action from Bash to a dedicated tool: a shell command is an opaque string your harness cannot inspect, whereas a typed tool call is something you can gate, log, rate-limit and audit. Start with Bash for breadth; promote anything you need to control.

The permission model

Every tool call is evaluated in six ordered steps: hooks, deny rules, ask rules, permission mode, allow rules, then the canUseTool callback. Earlier steps win. A deny rule blocks a call even in bypassPermissions, and a hook runs before everything else — which makes hooks the only place a check applies unconditionally.

This ordering is the single most important thing to internalise, because almost every “my permission check didn’t fire” report is a call resolved at an earlier step than the author expected.

#StepWhat it can do
01HooksDeny outright, or pass on. Runs before all rules and modes.
02Deny rulesBlock the call — including under bypassPermissions.
03Ask rulesForce the call down to canUseTool, even if an allow rule matches.
04Permission modebypassPermissions approves; acceptEdits approves file ops; plan routes writes to the callback.
05Allow rulesApprove the call and skip the callback.
06canUseToolDecide at runtime. Skipped entirely in dontAsk mode (denied instead).
EARLIER STEPS WIN. A HOOK DENY APPLIES IN EVERY MODE.

“A hook deny applies in every mode. Everything else can be out-ranked by something above it.”

Deny rules: bare name versus scoped pattern

These behave differently, and the difference is the difference between removing a capability and constraining one.

RuleEffect
disallowedTools: ["Bash"]The tool definition is removed from the request. The model never sees Bash and cannot attempt it.
disallowedTools: ["Bash(rm *)"]Bash stays available. Calls matching rm * are denied in every mode. Other Bash calls fall through to the mode.
disallowedTools: ["*"]Every tool definition is removed. Tool-name globs work in deny rules; mcp__* matches every MCP tool.

Removing the definition is the stronger control and the cheaper one — a tool the model cannot see costs no tokens and generates no attempts to talk you into it. Reach for scoped rules only when you genuinely need most of a tool’s surface.

Path rules have four anchor forms, and two traps

Scoped rules for Read and Edit take a path pattern, and the leading-slash convention decides what the pattern is relative to. A double slash means an absolute filesystem path: Edit(//secrets/**) blocks writes anywhere under /secretson disk. A single slash anchors at the rule’s source — for rules passed through disallowedToolsthat means the session’s working directory, so Edit(/secrets/**) does not protect /secrets on disk. If you meant the filesystem, use two slashes.

The second trap is quieter. Edit(path) rules govern every built-in tool that writes files, including Write and NotebookEdit. A Write(path) rule is never matched by the file permission checks at all — so a rule you wrote to block writes, written as Write(...), does nothing.

Three ways your approval callback gets bypassed

A canUseTool callback only runs when no earlier step resolved the call. Three configurations skip it silently: a bare entry in allowedTools, bypassPermissions mode, and dontAsk mode. The first two approve without asking; the third denies without asking.
WHY THE CALLBACK DIDN'T FIRE
01Bare allow entry"Read" auto-approves every Read call; "Bash(ls *)" only matching ones
02bypassPermissionsApproves everything reaching the mode step, whatever allowedTools says
03dontAskNever prompts — unapproved calls are denied, callback never runs
THE TYPESCRIPT SDK WARNS ON THE FIRST TWO — LISTEN FOR IT

Since v2.1.198 the TypeScript SDK detects the first two at query construction and emits a Node process warning with the code CLAUDE_SDK_CAN_USE_TOOL_SHADOWED. It is worth wiring up, because the failure is otherwise invisible — your gate simply never runs:

Catch the shadowed-callback warning
process.on("warning", (w) => {
  if ((w as NodeJS.ErrnoException).code === "CLAUDE_SDK_CAN_USE_TOOL_SHADOWED") {
    logger.error("canUseTool is unreachable — permission gate is not running", w.message);
  }
});
ALLOW RULES COMING FROM SETTINGS FILES ARE NOT VISIBLE TO THIS CHECK

The one that catches everyone

allowedTools does not constrain bypassPermissions. Setting allowedTools: ["Read"] alongside permissionMode: "bypassPermissions" still approves Bash, Write and Edit — because unlisted tools are not matched by any allow rule, so they fall through to the mode, and the mode approves them. The combination reads like a restriction and functions as its opposite.

If you need bypassPermissions but want specific things blocked, the answer is disallowedTools, which is evaluated two steps earlier. And if a check must run on every call regardless of mode, the answer is a PreToolUse hook.

The rule worth memorising
allowedToolsanswers “what runs without asking?”. disallowedToolsanswers “what can this agent do at all?”. Only the second is a security boundary.

Subagents

Define subagents in the agents option as a map of name to AgentDefinition. Each runs in a fresh context with its own system prompt and its own tool subset; only its final message returns to the parent. Claude decides when to invoke one based on its description.
Two subagents with different authority
for await (const message of query({
  prompt: "Review the authentication module for security issues",
  options: {
    // 'Agent' must be here or delegation silently never happens.
    allowedTools: ["Read", "Grep", "Glob", "Agent"],
    agents: {
      "code-reviewer": {
        description:
          "Security and quality review. Use for reviewing code, never for changing it.",
        prompt: "You review code for security and correctness. You never edit files.",
        tools: ["Read", "Grep", "Glob"],   // read-only by construction
        model: "sonnet",                    // step down: this task is mechanical
      },
      "test-runner": {
        description: "Runs test suites and analyses failures.",
        prompt: "You run tests and report what failed and why.",
        tools: ["Bash", "Read", "Grep"],
        permissionMode: "dontAsk",
      },
    },
  },
})) {
  if ("result" in message) console.log(message.result);
}

The trap: Agent must be in allowedTools

Claude invokes subagents through the Agent tool. If Agent is not in allowedTools, invocations fall through to canUseTool — or, in dontAskmode, are denied. The symptom is not an error. It is Claude quietly doing the work itself on the main thread, and you concluding that subagents “don’t seem to trigger”.

What a subagent actually inherits

This is the second thing teams get wrong, and it produces subagents that appear to have amnesia.

It receivesIt does not receive
Its own system prompt (AgentDefinition.prompt)The parent's conversation history
The Agent tool's prompt stringThe parent's tool results
Tool definitions (inherited, or the tools subset)The parent's system prompt
Project CLAUDE.md, via settingSourcesPreloaded skill content, unless listed in skills
THE AGENT TOOL'S PROMPT STRING IS THE ONLY CHANNEL FROM PARENT TO CHILD

So every file path, error message, branch name and decision the subagent needs has to be written into the prompt the parent passes it. A subagent that keeps re-discovering context is almost always a subagent that was briefed in one sentence.

Permission inheritance has a hard edge

Subagents inherit the parent session’s permission mode. An AgentDefinition can override it with its own permissionModeexcept when the parent is running bypassPermissions, acceptEdits or auto. Those three apply to every subagent and cannot be overridden per subagent.

That asymmetry matters more than it looks. Subagents have different system prompts and often looser behaviour than the main agent, so a parent running bypassPermissions hands full autonomous system access to every child it spawns — and the per-subagent control you would reach for to fix that is exactly the one that stops working in that mode.

Nesting, and the depth limit

Subagents can spawn their own subagents, three layers below the main conversation by default. Set CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH to change it, or to 1 to turn nesting off. The default has moved twice across recent versions — five layers, then one, then three — so if depth matters to your cost model, set it explicitly rather than inheriting whatever the installed version thinks.

Output scanning at the boundary

A subagent’s final message is untrusted input to the parent, and the harness treats it that way. Since v2.1.210 Claude Code scans that message for instruction-shaped patterns before the parent reads it: tags only the harness should emit (such as a system-reminder block) are neutralised in place, lines beginning Human: or Assistant: get an escape inserted so they cannot fake a turn boundary, and matches are announced with a [harness: ...] marker line. Nothing is removed or reworded.

Worth knowing for two reasons. It is a real mitigation against a subagent that read attacker-controlled content trying to redirect its parent. And if you are diffing subagent output and find an unexpected backslash, that is what put it there.

Production concerns

Four options separate a demo from a deployment: bounds on the loop (maxTurns, maxBudgetUsd), explicit control of what configuration is loaded from disk (settingSources), error handling for subagent failures, and a deliberate model choice per agent rather than one model everywhere.

Bound the loop

An agent loop with no ceiling is an open-ended bill and an open-ended wait. maxTurns caps agentic turns; maxBudgetUsd caps spend; taskBudget tells the model its own budget so it paces itself rather than being cut off mid-thought. Per-subagent maxTurns is available on AgentDefinition too, which is usually where a runaway actually happens.

Decide what loads from disk

The SDK reads skills, commands, memory and permission rules from .claude/ and ~/.claude/, the same as Claude Code. That is convenient locally and surprising in production, where the machine’s home directory is not the developer’s. Set settingSources explicitly — and note that project settings.json permission rules only apply when "project" is included, so setting the option without it silently drops the allow and deny rules you checked into the repository.

Handle subagent failure explicitly

An API error that ends a subagent early is never delivered as its result. If a rate limit or server error cuts off a foreground subagent that had already produced text, the Agent tool returns that partial output with a note that it did not finish. A subagent that produced nothing, or only tool calls, fails with Agent terminated early due to an API error. Both cases look like a completed delegation to code that only checks whether a result came back.

Choose the model per agent

AgentDefinition.model takes an alias — 'opus', 'sonnet', 'haiku', 'inherit' — or a full model ID. The useful pattern is a capable model on the main thread where planning happens, stepping down for subagents whose work is mechanical. A read-only reviewer scanning for a known pattern rarely needs the top tier; the planner deciding what to review does. Note that switching models invalidates the prompt cache, which is a further argument for keeping the main thread on one model and pushing variety into subagents.

Version gotchas worth knowing

The SDK moves quickly and several defaults have changed recently. If behaviour does not match the documentation you are reading, check the installed version before debugging your own code.
ChangeVersionWhat it means for you
Subagents run in the background by defaultv2.1.198An Agent call omitting run_in_background is now async; earlier versions ran it synchronously
Subagents inherit extended thinkingv2.1.198Previously thinking was disabled inside subagents regardless of the parent setting
Nesting depth defaultv2.1.217 → v2.1.219Moved five → one → three. Set the env var if it matters
Tool renamed Task → Agentv2.1.63tool_use emits "Agent", but system:init and permission_denials still say "Task" — match both
Subagent output scanningv2.1.210Instruction-shaped patterns in subagent output are neutralised before the parent reads them

One platform-specific trap that is not a version issue: on Windows, a subagent with a very long prompt can fail against the 8191-character command-line limit. Keep programmatic prompts tight, or move long instructions into filesystem-based agent definitions.

None of this is exotic. It is the ordinary work of turning a capable harness into something you would let near a customer: decide what the agent may do, enforce it where enforcement actually happens, brief delegates properly, bound the loop, and know which of your controls are boundaries and which are conveniences. The SDK gives you all of them. The quickstart gives you one.

Frequently asked questions

What is the Claude Agent SDK?

The Claude Agent SDK is Claude Code packaged as a library for Python and TypeScript. It ships the agent loop, context management, built-in file and shell tools, permissions, hooks, subagents and sessions, so you call query(prompt, options) rather than writing a tool-execution loop. You host and deploy it yourself.

How is the Agent SDK different from the Anthropic API tool runner?

The tool runner is part of the regular Anthropic API SDK and loops over tools you define — no built-in tools, no filesystem access. The Agent SDK is the full Claude Code harness with Read, Write, Edit, Bash, Glob, Grep and web tools already present. Both are harness-only: you supply the deployment. Managed Agents is the separate product where Anthropic hosts both.

How do permissions work in the Claude Agent SDK?

Each tool call is evaluated in six steps, in order: hooks, deny rules, ask rules, permission mode, allow rules, then the canUseTool callback. Earlier steps win. A deny rule blocks a call even in bypassPermissions mode, and a hook can deny before anything else runs.

Why is my canUseTool callback never called?

Because something approved the call earlier in the evaluation order. A bare entry in allowedTools such as "Read" auto-approves every call to that tool, and bypassPermissions approves everything that reaches the mode step — in both cases the callback is skipped. Since v2.1.198 the TypeScript SDK emits a process warning with the code CLAUDE_SDK_CAN_USE_TOOL_SHADOWED when it detects this. For a check that must run on every call, use a PreToolUse hook instead.

Does allowedTools restrict what the agent can do?

No. allowedTools is an auto-approve list, not a capability limit. A tool you leave out still exists and still reaches the permission mode, where bypassPermissions will approve it. To actually remove capability, use disallowedTools — a bare name there strips the tool definition from the request so the model never sees it.

What does a subagent inherit from its parent?

Its own system prompt, the Agent tool's prompt string, tool definitions, and project CLAUDE.md. It does not receive the parent's conversation history, tool results, or system prompt. The prompt string is the only channel from parent to subagent, so file paths, error messages and decisions have to be written into it explicitly.

Which model should I use with the Agent SDK?

Set model on the options object, and per-subagent via AgentDefinition.model, which accepts an alias such as 'opus', 'sonnet', 'haiku' or 'inherit', or a full model ID. Use a capable model on the main thread and step subagents down where the task is mechanical — a read-only reviewer rarely needs the top tier.

Sources

  1. 01Agent SDK overview Anthropic, 2026
  2. 02Configure permissions Anthropic, 2026
  3. 03Subagents in the SDK Anthropic, 2026
  4. 04TypeScript SDK reference Anthropic, 2026
Tandem Machines
Notes on coordinated intelligence — one essay a month
Subscribe
PREVIOUS

Idempotency for AI agent actions