The Claude Agent SDK hands you the agent loop behind Claude Code as a library: file tools, a shell, subagents, hooks and sessions, driven from your own Python or TypeScript process. Getting one to run takes ten lines. The part that decides whether you can leave it unattended is the policy around it: which tools exist, what is pre-approved and what gets refused.
In this tutorial I build a small repo-audit agent in Python from the pieces the official docs describe: one custom tool, a PreToolUse hook that blocks risky Bash commands, a permission callback and one focused subagent. I read the docs and the PyPI and npm pages on October 14, 2026 and pinned the versions. I did not execute the finished agent for this post, so there is a four-prompt smoke test to run before you trust it, and an honest section on what this policy cannot guarantee.
1. The Problem: An Agent With a Shell and No Policy
A repo-audit agent has to read a lot and run a few commands. The quickest demo gives it Bash and switches approvals off, and it works, until it audits a repository you did not write. A README, a code comment or a file name can carry instructions, and a model that follows one will run it with your privileges. Anthropic’s deployment guide states the threat model plainly: agents can take unintended actions through prompt injection or plain model error. The design-level view is in my prompt injection deep dive; this post is the implementation side.
The Most Common Mistake
Treating allowed_tools as a whitelist. It is not: it only pre-approves the tools you list, and every other tool stays available. The docs spell out the worst case: allowed_tools=[“Read”] combined with permission_mode=“bypassPermissions” still approves every tool, including Bash, Write and Edit. To remove a tool you use tools, or a bare name in disallowed_tools.
What we build instead is a layered policy in a single file of under 200 lines: availability (what Claude can see), approval (what runs without asking), a hook (what is refused no matter what) and a callback (what happens to everything else). Versions I pinned: claude-agent-sdk 0.2.167 on PyPI (Python 3.10 or newer, uploaded October 14, 2026) and @anthropic-ai/claude-agent-sdk 0.3.300 on npm. The release cadence is fast and the docs gate several features by version, so pin the version and read the changelog before you upgrade.
2. Minimum Concepts Before Touching Code
Six ideas explain every line below. Python and TypeScript share them; I picked Python because the docs ship complete examples for hooks, callbacks and subagents there (a few hook events exist only in TypeScript, noted later).
query() vs ClaudeSDKClient
one shot or one session
query() starts a new session per call and yields messages. ClaudeSDKClient keeps one session across calls, so a follow-up has the full context. This tutorial uses the client.
Availability vs approval
two different layers
tools (and bare names in disallowed_tools) change what Claude can see. allowed_tools only pre-approves: unlisted tools still exist and fall through to the permission mode.
Custom tool = in-process MCP server
your function, Claude’s tool
@tool defines name, description, input schema and an async handler; create_sdk_mcp_server wraps it. It runs inside your process and is named mcp__<server>__<tool>.
PreToolUse hook
runs first, can say no
A callback that runs before every other permission step. Returning {} leaves the call to the rest of the flow; a deny beats defer, ask and allow, even in bypassPermissions.
can_use_tool callback
the last resort
Called only when the permission flow ends in a prompt. Calls approved by allowed_tools, an allow rule or the mode never reach it, so it cannot be your only check.
Subagent
own context, own tools
A separate agent instance started through the Agent tool. Its context starts fresh: it gets its own prompt plus the prompt string you pass, and only its final message comes back.
The mental model to keep
The hook is the law: it runs first and a deny always holds. allowed_tools is the green lane for things you trust. The callback is what happens to everything else. A sandbox is the wall underneath, the one that still holds when the three above are wrong.
3. Step 1: Install and Write the Custom Tool
Both SDKs bundle a native Claude Code binary in most installs, so a pip install is usually enough. Auth is an ANTHROPIC_API_KEY in the environment of the process that runs the agent; the SDK does not load .env files for you. Unless previously approved, Anthropic does not allow third-party products to offer claude.ai login for agents built on the SDK, so use API-key auth.
Shell: install and authenticate
python3 -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1 pip install "claude-agent-sdk==0.2.167" export ANTHROPIC_API_KEY=your-api-key # PowerShell: $env:ANTHROPIC_API_KEY = "your-api-key"
The tool counts TODO, FIXME and HACK markers per file. It is deliberately boring, because the interesting part is what it validates: the path must resolve inside the repo, symlinks are skipped, and failures return is_error so Claude can react instead of the loop choking. The readOnlyHint annotation lets Claude batch it with other read-only calls.
Python: audit_agent.py, part 1 (imports and the custom tool)
# audit_agent.py, part 1: imports and the custom tool
import asyncio
import re
import shlex
import sys
from pathlib import Path
from typing import Annotated, Any
from claude_agent_sdk import (
AgentDefinition, AssistantMessage, ClaudeAgentOptions, ClaudeSDKClient,
HookMatcher, ResultMessage, TextBlock, ToolAnnotations,
create_sdk_mcp_server, tool,
)
from claude_agent_sdk.types import PermissionResultAllow, PermissionResultDeny
REPO = Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
MARKERS = ("TODO", "FIXME", "HACK")
SKIP = {".git", "node_modules", ".venv", "__pycache__"}
@tool(
"todo_report",
"Count TODO/FIXME/HACK markers per file under a repo-relative directory",
{"directory": Annotated[str, "Repo-relative directory to scan, for example src"]},
annotations=ToolAnnotations(readOnlyHint=True),
)
async def todo_report(args: dict[str, Any]) -> dict[str, Any]:
target = (REPO / args["directory"]).resolve()
if not target.is_relative_to(REPO) or not target.is_dir():
msg = f"Not a directory inside the repo: {args['directory']}"
return {"content": [{"type": "text", "text": msg}], "is_error": True}
rows = []
for path in target.rglob("*"):
rel = path.relative_to(REPO)
if path.is_symlink() or not path.is_file() or SKIP & set(rel.parts):
continue
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
continue
hits = sum(text.count(marker) for marker in MARKERS)
if hits:
rows.append((hits, rel.as_posix()))
rows.sort(reverse=True)
lines = [f"{n:3d} {p}" for n, p in rows[:20]] or ["No markers found."]
return {"content": [{"type": "text", "text": "\n".join(lines)}]}
audit_server = create_sdk_mcp_server(name="audit", version="1.0.0", tools=[todo_report])Tip: the handler runs in your process
An in-process tool is plain Python with your privileges. No Claude Code permission rule validates its arguments for you, so do your own path checks. Annotations are metadata, not enforcement: readOnlyHint=True on a handler that writes files would still write them.
4. Step 2: A PreToolUse Hook That Blocks Risky Bash
Hooks are callbacks the SDK runs at lifecycle points. PreToolUse fires before a tool executes and receives tool_name and tool_input; for Bash, tool_input.command is the full command text. A hookSpecificOutput with permissionDecision “deny” blocks the call, and permissionDecisionReason tells the model why so it does not retry the same thing. Returning {} leaves the decision to the rest of the permission flow.
Python: part 2 (guards and registration)
# part 2: PreToolUse guards (they run before every other permission step)
RISKY_BASH = [
(re.compile(r"\brm\b.*\s(-\w*[rR]\w*|--recursive)\b"), "recursive delete"),
(re.compile(r"\b(curl|wget)\b.*\|\s*(ba|z)?sh\b"), "download piped into a shell"),
(re.compile(r"\b(sudo|chmod|chown|mkfs|dd)\b"), "privilege or disk change"),
(re.compile(r"\.env\b|\.ssh\b|\.aws\b|id_rsa|\.npmrc"), "credential path"),
]
SECRET_PATH = re.compile(r"(^|[\\/])(\.env(\..+)?|id_rsa|\.npmrc)$|[\\/]\.(ssh|aws)[\\/]")
GIT_AT_START = re.compile(r"(^|[;&|]\s*)git\b")
SHELL_META = re.compile(r"[;&|<>$\n\x60]") # \x60 is a backtick
SAFE_GIT = {"log", "diff", "show", "status", "ls-files", "shortlog"}
def deny(reason: str) -> dict[str, Any]:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": f"Blocked by audit policy: {reason}",
}
}
def harden_git(command: str) -> str | None:
"""Rewrite a simple read-only git command so repo-controlled config cannot run code."""
if SHELL_META.search(command):
return None
try:
parts = shlex.split(command)
except ValueError:
return None
if len(parts) < 2 or parts[0] != "git" or parts[1] not in SAFE_GIT:
return None
if any(p.startswith("--output") for p in parts):
return None # git diff --output=<file> writes a file
extra = ["--no-ext-diff", "--no-textconv"] if parts[1] in {"diff", "show", "log"} else []
return shlex.join(["git", "-c", "core.fsmonitor=false", parts[1], *extra, *parts[2:]])
async def guard_bash(input_data, tool_use_id, context):
command = input_data["tool_input"].get("command", "")
for pattern, label in RISKY_BASH:
if pattern.search(command):
return deny(f"{label}. This agent is read-only.")
hardened = harden_git(command)
if hardened:
return {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "read-only git, config hardened",
"updatedInput": {**input_data["tool_input"], "command": hardened},
}
}
if GIT_AT_START.search(command):
return deny("only simple read-only git commands are available (log, diff, show, status, ls-files, shortlog).")
return {} # no opinion: the rest of the permission flow decides
async def guard_reads(input_data, tool_use_id, context):
if SECRET_PATH.search(input_data["tool_input"].get("file_path", "")):
return deny("secret files are out of scope for this audit.")
return {}
HOOKS = {
"PreToolUse": [
HookMatcher(matcher="Bash", hooks=[guard_bash]),
HookMatcher(matcher="Read", hooks=[guard_reads]),
]
}Gotchas the docs call out
Matchers only match tool names, never file paths or arguments: to filter by path, inspect tool_input yourself, as guard_reads does with file_path. Event names are case-sensitive (PreToolUse, not preToolUse). If a PreToolUse callback times out, Claude Code does not run the tool call and Claude is told the hook did not respond. And Python callbacks cover ten events; SessionStart and SessionEnd are TypeScript-only.
Notice the hook does two jobs. It refuses what it recognizes, and it rewrites what it allows. Built-in read-only commands such as cat, ls and read-only git forms run without a prompt in every mode, so a callback never sees them; the hook does. Git is the sharp case: it reads config the repository controls, which is the bug class behind GitSpawn. So simple read-only git commands are rewritten with core.fsmonitor=false (plus --no-ext-diff and --no-textconv for diff-like subcommands, the hardened call that post recommends), and any other git invocation is refused. When a hook denies, write a reason that tells the model what to do instead; several hooks on one event run in parallel and the most restrictive result applies.
5. Step 3: The Permission Policy, Availability, Approval, Callback
Now the layers that decide what happens when the hook has no opinion. The tools list controls availability: built-ins you leave out are not in Claude’s context at all, so there is no Edit or Write to approve or deny. allowed_tools pre-approves the read-only tools, the Agent tool and our custom tool by its full name, mcp__audit__todo_report. Bash is deliberately absent from allowed_tools, because tools on that list skip can_use_tool.
Python: part 3 (the permission callback)
# part 3: the permission callback, the last resort
async def audit_policy(tool_name, input_data, context):
"""Runs only when nothing earlier decided and the call would have asked a human."""
summary = f"{tool_name}: {str(input_data)[:120]}"
if sys.stdin.isatty():
answer = await asyncio.to_thread(input, f"Allow {summary} ? [y/N] ")
if answer.strip().lower() == "y":
return PermissionResultAllow(updated_input=input_data)
print(f"[policy] denied {summary}", file=sys.stderr)
return PermissionResultDeny(
message=(
f"{tool_name} was not approved. This is a read-only audit: "
"use Read, Grep, Glob, todo_report or read-only git commands."
)
)Warning: the callback is not a gate for approved tools
Any tool approved earlier, by allowed_tools, an allow rule, acceptEdits or bypassPermissions, skips can_use_tool, so checks you put there are silently bypassed for it. For logic that must run on every call, use a PreToolUse hook. Here the callback is only the net below the hook: a human at a terminal can approve, and anything unattended is refused with a message the model can act on.
Two more details. Pass permission_mode=“default” explicitly: when you omit it, the starting mode can be auto mode, which drops broad allow rules. And if you want deny-by-default with no callback at all, permission_mode=“dontAsk” turns every would-be prompt into a denial and never calls can_use_tool.
6. Step 4: A Focused Subagent, the Options and the Run Loop
A subagent is a separate agent instance the main agent starts through the Agent tool. The parent’s history and tool results are not passed down, and only the subagent’s final message comes back, so a reviewer can read dozens of files without bloating the main conversation. Restrict it with tools and give it a cheap model: model accepts an alias such as haiku, sonnet, opus or inherit, or a full model ID. The cost arithmetic for small workers is in the Haiku 5.5 subagents guide.
Python: part 4 (subagent, options and run loop)
# part 4: subagent, options and the run loop
REVIEWER = AgentDefinition(
description="Reviews ONE directory for test gaps and risky config. Use it once per directory.",
prompt=(
"You are a focused code reviewer. Inspect only the directory you are given. "
"Report at most five findings, each with a file path, a line reference and a one-line fix. "
"You are read-only: never propose running commands."
),
tools=["Read", "Grep", "Glob"],
model="haiku",
maxTurns=15, # camelCase on purpose: AgentDefinition uses wire-format field names
)
OPTIONS = ClaudeAgentOptions(
system_prompt=(
"You are a read-only repo-audit agent. Start with todo_report on the main source "
"directory, delegate one directory at a time to the test-gap-reviewer subagent, "
"then answer with a ranked list: file, risk, one-line fix."
),
cwd=str(REPO),
setting_sources=[], # do not load the audited repo's .claude settings or hooks
tools=["Read", "Grep", "Glob", "Bash", "Agent"], # availability: no Edit, Write or web
allowed_tools=["Read", "Grep", "Glob", "Agent", "mcp__audit__todo_report"], # approval
mcp_servers={"audit": audit_server},
permission_mode="default", # say it out loud: the built-in default can be auto mode
can_use_tool=audit_policy,
hooks=HOOKS,
agents={"test-gap-reviewer": REVIEWER},
max_turns=30,
max_budget_usd=2.0,
)
def show(message) -> None:
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(block.text)
elif isinstance(message, ResultMessage):
print(f"[{message.subtype}] turns={message.num_turns} cost={message.total_cost_usd}")
async def main() -> None:
async with ClaudeSDKClient(options=OPTIONS) as client:
await client.query("Audit this repository and give me the ranked findings.")
async for message in client.receive_response():
show(message)
# same client = same session: the follow-up keeps all the context
await client.query("Keep only the top three, each with the exact file and the fix.")
async for message in client.receive_response():
show(message)
asyncio.run(main())Three details are worth a second look. AgentDefinition fields keep camelCase (maxTurns, disallowedTools), and a snake_case keyword raises TypeError at construction. setting_sources=[] stops the audited repo’s .claude settings and shell hooks from loading, which default options would do. And your PreToolUse hooks also fire inside the subagent, with agent_id and agent_type set, so log them. To cap nesting and fan-out, the docs give the CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH and CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS variables (set through env) and max_budget_usd.
Smoke test before you trust it
Run these four prompts against a scratch repo. The expected behavior comes from the docs, not from output I captured. (1) “Clean up with rm -rf build” should be denied by the hook, with the reason visible in the stream. (2) “Print the contents of .env” should be denied by guard_reads or guard_bash. (3) “Show the last five commits with git log” should run the rewritten command with core.fsmonitor=false. (4) “Install requests with pip” should reach audit_policy: a y/N prompt in a terminal, a denial in CI. If any of the four surprises you, fix the policy before adding features.
7. What This Policy Does and Does Not Guarantee
Everything above is policy: code that runs inside the agent’s own trust boundary and inspects text. The docs are direct about it. Command parsing for permissions is “a permission gate, not a sandbox”, and a Bash deny rule “isn’t a security boundary around the program”. Treat the three boxes below as the honest ledger.
✅ What it enforces
- Visibility: Claude sees Read, Grep, Glob, Bash, Agent and one custom tool. There is no Edit or Write in its context.
- Hard no: matching Bash commands and secret-file reads are denied before any other step, even in bypassPermissions.
- Default deny: whatever would have asked a human is refused, or approved by one at a terminal, with a message the model can act on.
- Budgets: max_turns and max_budget_usd end runaway loops with an error subtype you can alert on.
⚠️ What it cannot guarantee
- Denylist leaks: /bin/rm, bash -c, variables and encoded payloads walk past regexes. The docs’ own table says the same of rules: rm * is stopped, /bin/rm and bash -c are not.
- Allowlist leaks: git diff has --output, which writes a file, and git runs config the repo controls. core.fsmonitor=false closes one door, not the class.
- Reads: Read, Grep and Glob can still reach anything the process can read. Only Read’s file_path is checked here, so keep secrets outside the tree.
- Your own tool: the in-process tool runs with your privileges and outside any Bash sandbox.
- Tool output: file names and comments flow back into the model as input. A tool result is untrusted data.
🧱 What goes underneath
- Container or VM: run the whole process in one, mount the repo read-only and drop capabilities. The secure deployment guide has a hardened docker run you can copy.
- SDK sandbox for Bash: ClaudeAgentOptions has a sandbox field. Set failIfUnavailable, because the Python default is to run unsandboxed with a warning.
- Network: no egress by default; reach the API through a proxy that injects credentials, so the agent never sees them.
- Isolation runtimes: the two related posts below cover sandboxes for agent code and OpenShell’s policy-enforced runtime.
Python: optional SDK sandbox settings for Bash
# optional wall for Bash: pass it as ClaudeAgentOptions(sandbox=SANDBOX)
SANDBOX = {
"enabled": True,
"failIfUnavailable": True, # Python default: run unsandboxed with a warning
"allowUnsandboxedCommands": False, # the model may not ask to leave the sandbox
"network": {"allowedDomains": []}, # no outbound hosts for sandboxed commands
}Mind the platform: the sandbox depends on OS support (bubblewrap and socat on Linux, per the docs), and its network proxy filters by hostname without inspecting TLS, so techniques such as domain fronting can potentially bypass it. It narrows the blast radius; it does not make the agent trustworthy.
8. Common Mistakes (Most Are Documented Gotchas)
Almost every failure I would expect from this setup is one of these. The left column is what holds up; the right column is what bites.
✅ Do this
- • Pass permission_mode explicitly; the built-in default can be auto mode
- • Put non-negotiable rules in a PreToolUse hook: it runs first and a deny holds in every mode
- • Use tools (or bare disallowed_tools) to remove capabilities; use allowed_tools only to pre-approve
- • Return a reason Claude can act on from every deny
- • Write AgentDefinition fields in camelCase: maxTurns, disallowedTools, mcpServers
- • Set max_turns and max_budget_usd, and branch on ResultMessage.subtype
❌ Not this
- • allowed_tools=[“Read”] as a restriction, especially next to bypassPermissions
- • Security checks only inside can_use_tool: approved calls never reach it
- • permissionDecision at the top level of hook output instead of inside hookSpecificOutput with hookEventName
- • Filtering file paths with HookMatcher.matcher: it only sees tool names
- • A regex denylist presented as a security boundary
- • Auditing an untrusted repo with default setting_sources: its .claude settings and hooks load too
Golden rule
A hook is a policy; a sandbox is a boundary. Write the policy so the model fails safely and understandably, then assume it is wrong once and make sure the wall underneath turns that mistake into an error, not an incident.
Conclusion
You now have the whole loop: a custom tool as an in-process MCP server, a PreToolUse hook that both refuses and rewrites, a callback as the default-deny net and a read-only subagent on a cheap model, all inside one session. The shape transfers to TypeScript almost line by line (tool() with Zod, createSdkMcpServer, hooks and canUseTool), and the TypeScript SDK has extra hook events such as SessionStart and PostToolBatch that Python callbacks lack.
Next, in order of value: turn the four smoke prompts into assertions that run in CI, move the process into a container with the repo mounted read-only, and package the audit instructions as a reusable skill, which the Agent Skills tutorial walks through. Re-read the changelog before every SDK upgrade: the docs themselves note behavior that changed between releases.
Tutorial Cheat Sheet
Layers
- • tools = availability
- • allowed_tools = approval
- • PreToolUse hook = hard rules
- • can_use_tool = last resort
Names to remember
- • @tool + create_sdk_mcp_server
- • HookMatcher(matcher, hooks)
- • PermissionResultAllow / Deny
- • AgentDefinition(maxTurns=…)
Always
- • permission_mode set explicitly
- • setting_sources=[] on untrusted repos
- • pin claude-agent-sdk==0.2.167
- • a sandbox underneath
Sources
- Agent SDK quickstart (install, ANTHROPIC_API_KEY, first agent)
- Agent SDK Python reference (ClaudeAgentOptions, hooks, AgentDefinition, sandbox)
- Agent SDK hooks (PreToolUse, matchers, outputs, timeouts)
- Agent SDK permissions (evaluation order, allowed_tools, modes)
- Agent SDK approvals and user input (can_use_tool callback)
- Agent SDK custom tools (@tool, in-process MCP server)
- Agent SDK subagents (AgentDefinition, inheritance, caps)
- Securely deploying AI agents (threat model, isolation, credentials)
- Claude Code permissions (Bash rule limits)
- PyPI: claude-agent-sdk (0.2.167, Python 3.10+)
- npm: @anthropic-ai/claude-agent-sdk (0.3.300)
- Git manual: git diff (--output option)



