- 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
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.
| Harness | Deployment | Built-in tools | |
|---|---|---|---|
| Claude Agent SDK | Full Claude Code loop | Yours | Read, Write, Edit, Bash, Glob, Grep, web |
| API tool runner | Loop over your tools | Yours | None — you define every tool |
| Managed Agents | Anthropic's | Anthropic's sandbox | Hosted toolset |
| Manual API loop | Yours | Yours | None |
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
@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.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:
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 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
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.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
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.
| # | Step | What it can do |
|---|---|---|
| 01 | Hooks | Deny outright, or pass on. Runs before all rules and modes. |
| 02 | Deny rules | Block the call — including under bypassPermissions. |
| 03 | Ask rules | Force the call down to canUseTool, even if an allow rule matches. |
| 04 | Permission mode | bypassPermissions approves; acceptEdits approves file ops; plan routes writes to the callback. |
| 05 | Allow rules | Approve the call and skip the callback. |
| 06 | canUseTool | Decide at runtime. Skipped entirely in dontAsk mode (denied instead). |
“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.
| Rule | Effect |
|---|---|
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
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.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:
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);
}
});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
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.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 receives | It does not receive |
|---|---|
| Its own system prompt (AgentDefinition.prompt) | The parent's conversation history |
| The Agent tool's prompt string | The parent's tool results |
| Tool definitions (inherited, or the tools subset) | The parent's system prompt |
| Project CLAUDE.md, via settingSources | Preloaded skill content, unless listed in skills |
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 permissionMode — except 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
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
| Change | Version | What it means for you |
|---|---|---|
| Subagents run in the background by default | v2.1.198 | An Agent call omitting run_in_background is now async; earlier versions ran it synchronously |
| Subagents inherit extended thinking | v2.1.198 | Previously thinking was disabled inside subagents regardless of the parent setting |
| Nesting depth default | v2.1.217 → v2.1.219 | Moved five → one → three. Set the env var if it matters |
| Tool renamed Task → Agent | v2.1.63 | tool_use emits "Agent", but system:init and permission_denials still say "Task" — match both |
| Subagent output scanning | v2.1.210 | Instruction-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
- 01Agent SDK overview — Anthropic, 2026
- 02Configure permissions — Anthropic, 2026
- 03Subagents in the SDK — Anthropic, 2026
- 04TypeScript SDK reference — Anthropic, 2026