The Claude Agent SDK gives your application the tools, agent loop, and context management behind Claude Code. Install @anthropic-ai/claude-agent-sdk for TypeScript or claude-agent-sdk for Python. Its hooks run your code at lifecycle events: check a tool before execution, rewrite its input, or record its outcome. Anthropic's SDK overview and hook guide describe these surfaces.
Below: one reusable runner, seven paired examples, the current event table, and the permission, timeout, and accounting details that determine how your agent behaves.
Install the Claude Agent SDK
The SDK runs the Claude Code binary. With the Anthropic Client SDK, you write the loop yourself or use its beta tool runner. For other frameworks, see our agent SDK comparison.
API baseline: TypeScript 0.3.285 and Python 0.2.162, checked September 27, 2026.
- 1Install one package
Create and enter a new disposable lab directory. The quickstart requires Node.js 18 or later for TypeScript, or Python 3.10 or later for Python.
Shellnpm init -y npm pkg set type=module npm install @anthropic-ai/claude-agent-sdk npm install --save-dev tsx typescript @types/nodeRun this in an active Python virtual environment; the Python installation reference covers setup.
Shellpip install claude-agent-sdkMost installations bundle the CLI. Python source distributions and TypeScript installs omitting optional dependencies have binary-installation exceptions.
- 2Set authentication
For direct Anthropic access:
Shellexport ANTHROPIC_API_KEY="your-anthropic-api-key"The SDK reads the process environment; it does not load
.envautomatically. Requesty users should use the gateway setup below instead. - 3Create disposable file fixtures
Run this macOS/Linux setup in the new lab directory:
Shellmkdir workspace outside-demo printf 'Hook demonstration workspace.\n' > workspace/README.md printf 'placeholder\n' > workspace/edit-demo.txtRun every example from this directory. Reset fixtures and start a fresh session when switching recipes.
1. Block dangerous Bash patterns
A matcher selects Bash calls; the callback checks tool_input.command and returns a PreToolUse denial. The DELETE_DEMO marker lets you exercise this policy with harmless echo.
On Windows without Git Bash, change Bash to PowerShell in the runner, matcher, callback tool-name check, and prompt. The marker exercise works with either tool; the dangerous-command patterns below target Bash syntax.
Save the hook module for your language:
import type { HookCallback, Options } from "@anthropic-ai/claude-agent-sdk";
const patterns = [
/DELETE_DEMO/, /\brm\s+-rf\b/, /(?:^|\s)sudo(?:\s|$)/,
/\bcurl\b[\s\S]*\|\s*(?:sh|bash)\b/,
];
export const blockCommand: HookCallback = async (input) => {
if (input.hook_event_name !== "PreToolUse" || input.tool_name !== "Bash") return {};
const args = input.tool_input as Record<string, unknown>;
const command = typeof args.command === "string" ? args.command : "";
if (!patterns.some((pattern) => pattern.test(command))) return {};
console.log({ tool_use_id: input.tool_use_id, decision: "deny" });
return { hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Command matched the application's block list.",
} };
};
export const hooks: NonNullable<Options["hooks"]> = {
PreToolUse: [{ matcher: "Bash", timeout: 5, hooks: [blockCommand] }],
};import re
from claude_agent_sdk import HookMatcher
PATTERNS = [
r"DELETE_DEMO", r"\brm\s+-rf\b", r"(?:^|\s)sudo(?:\s|$)",
r"\bcurl\b[\s\S]*\|\s*(?:sh|bash)\b",
]
async def block_command(input_data, tool_use_id, context):
if input_data["hook_event_name"] != "PreToolUse" or input_data["tool_name"] != "Bash":
return {}
command = input_data["tool_input"].get("command", "")
if not isinstance(command, str) or not any(re.search(p, command) for p in PATTERNS):
return {}
print({"tool_use_id": input_data["tool_use_id"], "decision": "deny"})
return {"hookSpecificOutput": {
"hookEventName": "PreToolUse", "permissionDecision": "deny",
"permissionDecisionReason": "Command matched the application's block list.",
}}
hooks = {"PreToolUse": [HookMatcher(matcher="Bash", timeout=5, hooks=[block_command])]}Shared runner: use this for every recipe
Save this runner once. Later recipes replace the hook module and change only tools and prompt, except the usage recipe's result logger. The runners disable filesystem settings sources and ambient MCP configuration for these demonstrations.
import { query } from "@anthropic-ai/claude-agent-sdk";
import { hooks } from "./hooks.js";
const tools: string[] = ["Bash"];
const prompt = "Use Bash to run exactly: echo DELETE_DEMO. If denied, explain without retrying.";
for await (const message of query({
prompt,
options: {
cwd: process.cwd(),
tools,
allowedTools: tools,
permissionMode: "dontAsk",
settingSources: [],
strictMcpConfig: true,
maxTurns: 6,
hooks,
},
})) {
if (message.type !== "result") continue;
console.log({ subtype: message.subtype, is_error: message.is_error,
terminal_reason: message.terminal_reason });
if (message.subtype === "success") console.log(message.result);
}Run npx tsx agent.ts.
import asyncio
from pathlib import Path
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, ResultMessage
from hooks import hooks
TOOLS = ["Bash"]
PROMPT = "Use Bash to run exactly: echo DELETE_DEMO. If denied, explain without retrying."
async def main():
options = ClaudeAgentOptions(
cwd=str(Path.cwd()), tools=TOOLS, allowed_tools=TOOLS,
permission_mode="dontAsk", setting_sources=[], strict_mcp_config=True,
max_turns=6, hooks=hooks,
)
async with ClaudeSDKClient(options=options) as client:
await client.query(PROMPT)
async for message in client.receive_response():
if isinstance(message, ResultMessage):
print({"subtype": message.subtype, "is_error": message.is_error,
"terminal_reason": message.terminal_reason})
if message.subtype == "success":
print(message.result)
if __name__ == "__main__":
asyncio.run(main())Run python agent.py.
Look for the callback's deny record and tool-use ID. No record means you should check whether a valid Bash call occurred.
tools sets availability; allowedTools / allowed_tools pre-approves. Pre-approval still passes through PreToolUse. dontAsk denies calls requiring a permission prompt. For human approval, use default, remove the reviewed tool from pre-approval, and provide canUseTool / can_use_tool; auto-approved calls bypass that callback.
These patterns miss alternative spellings, scripts, and other tools, and reject some harmless quoted text. For sensitive operations, expose narrowly scoped tools and apply OS isolation.
Hook events and the agent lifecycle
The runtime invokes your callback; the model does not choose whether to follow it. PostToolUse records success; PostToolUseFailure records executed failure. Permission denials are separate, and invalid arguments or unknown tools can fail before hooks. EndConversation skips PreToolUse and PostToolUse.

The SDK event list, TypeScript union, and Python union establish this September 27, 2026 matrix. Can block? identifies event-specific callback decisions, excluding universal termination requests and shell exit codes.
| Event | TS | Python | Can block? |
|---|---|---|---|
PreToolUse | Yes | Yes | Tool execution |
PostToolUse | Yes | Yes | No, tool already ran |
PostToolUseFailure | Yes | Yes | No, failure already occurred |
PostToolBatch | Yes | No | Next model call |
Notification | Yes | Yes | No |
UserPromptSubmit | Yes | Yes | Prompt |
UserPromptExpansion | Yes | No | Command/MCP expansion |
SessionStart | Yes | No | No |
SessionEnd | Yes | No | No |
Stop | Yes | Yes | Completion, resumes work |
StopFailure | Yes | No | No |
SubagentStart | Yes | Yes | No |
SubagentStop | Yes | Yes | Completion, resumes work |
PreCompact | Yes | Yes | Compaction |
PostCompact | Yes | No | No |
PreModelSwitch | Yes | No | Requested switch |
PostModelSwitch | Yes | No | No |
PermissionRequest | Yes | Yes | Tool permission |
PermissionDenied | Yes | No | No, auto denial occurred |
Setup | Yes | No | No |
TeammateIdle | Yes | No | No callback veto |
TaskCreated | Yes | No | Creation |
TaskCompleted | Yes | No | No callback veto |
Elicitation | Yes | No | Decline/cancel MCP input |
ElicitationResult | Yes | No | Decline/cancel MCP response |
ConfigChange | Yes | No | Apply change, except policy |
WorktreeCreate | Yes | No | Path-return contract |
WorktreeRemove | Yes | No | No callback veto |
InstructionsLoaded | Yes | No | No |
CwdChanged | Yes | No | No |
FileChanged | Yes | No | No |
DirectoryAdded | Yes | No | No |
MessageDisplay | Yes | No | No, display only |
Python cannot register session callbacks. TypeScript supports them, but a Windows streaming-input report on 0.3.273 describes missing invocations. Put setup before the query and cleanup in finally or a context manager. Stop excludes interrupts and API errors.
Notification in SDK sessions covers pending approval through canUseTool and elicitation notifications. MessageDisplay receives completed assistant text in delta in SDK queries; display replacement leaves transcripts and model context unchanged.
Matchers, inputs, and return values
Match the event target, then inspect its input
Matcher grammar is shared with the CLI, including for Python:
| Matcher | Meaning for tool events |
|---|---|
Bash | Exact name |
Write|Edit or Write,Edit | Exact-name list |
^Edit$ | Whole-name JavaScript regex |
^mcp__ or mcp__memory__.* | MCP-name regex |
Omitted, empty, or * | All targets |
Strings containing only letters, digits, underscores, hyphens, spaces, commas, and pipes use exact matching/list syntax. Other characters select unanchored JavaScript regex; FileChanged and StopFailure have narrower rules. Bare mcp__memory is an exact name, not a prefix.
Tool matchers inspect names, not paths or commands. Stop and UserPromptSubmit ignore matchers; subagent events match agent type, Notification matches notification type, and PreCompact matches manual or auto.
Callback signatures
See the TypeScript hook types and Python contract for all fields.
// HookCallback signature:
// (input: HookInput, toolUseID: string | undefined,
// options: { signal: AbortSignal }) => Promise<HookJSONOutput>from claude_agent_sdk import HookInput, HookContext, HookJSONOutput
async def callback(input_data: HookInput, tool_use_id: str | None,
context: HookContext) -> HookJSONOutput:
return {}Shared inputs include hook_event_name, session_id, transcript_path, and cwd. PreToolUse, PostToolUse, and PostToolUseFailure add tool_name, tool_input, and tool_use_id. Success adds tool_response; failure adds error. PermissionRequest provides the tool name and input but no tool_use_id. Prompt submission provides prompt; Stop provides stop_hook_active.
Narrow the event before accessing fields. TypeScript tool input is unknown. Tool-use IDs correlate records; optional agent_id identifies subagent tool activity. Python exposes fewer optional fields, and its context signal is currently None.
Permission decisions and context
PreToolUse decisions belong under hookSpecificOutput, with hookEventName: "PreToolUse":
| Return | Effect |
|---|---|
| Empty object | No decision/change; normal permissions apply |
permissionDecision: "deny" | Prevent this tool call |
permissionDecision: "allow" | Approve subject to deny/ask rules and special restrictions |
permissionDecision: "ask" | Send to permission handling; supply approval UX |
permissionDecision: "defer" | Persisted single-call handoff |
updatedInput | Replace the entire input; preserve unchanged fields |
additionalContext | Add model context |
Decision priority is deny, defer, ask, allow. defer ignores updatedInput; multi-call batches ignore defer and follow normal permissions. A rewrite without a permission decision preserves permission evaluation.
systemMessage is user-facing. Use additionalContext for the model. Prompt hooks cannot replace the prompt. PostToolUse.updatedToolOutput replaces output before the model sees it; built-in output must match the tool schema. Context/message strings have a 10,000-character cap, after which the runtime supplies a file path and preview.
Stop with decision: "block" requests continued work. continue: false, or Python continue_: False, requests termination on events honoring it; neither undoes side effects.

Matching callbacks run in parallel. Combine ordered validation and transformation in one callback.
2. Audit tool calls and outcomes
Runner: tools ['Read']; prompt Use Read to read workspace/README.md, then summarize it. For an executed-failure exercise, use ['Bash'] and Use Bash to run exactly: exit 1. Report the failure without retrying.
Log metadata on all three tool events. Serialize appends and keep secrets out of the records.
import { appendFile } from "node:fs/promises";
import type { HookCallback, Options } from "@anthropic-ai/claude-agent-sdk";
let queue: Promise<void> = Promise.resolve();
const audit: HookCallback = async (input) => {
if (input.hook_event_name !== "PreToolUse" &&
input.hook_event_name !== "PostToolUse" &&
input.hook_event_name !== "PostToolUseFailure") return {};
const line = JSON.stringify({
event: input.hook_event_name, session_id: input.session_id,
agent_id: input.agent_id ?? null,
tool_name: input.tool_name, tool_use_id: input.tool_use_id,
}) + "\n";
const write = queue.then(() => appendFile("./audit.jsonl", line));
queue = write.catch(() => {});
try { await write; } catch (error) { console.error(error); }
return {};
};
export const hooks: NonNullable<Options["hooks"]> = {
PreToolUse: [{ timeout: 5, hooks: [audit] }],
PostToolUse: [{ timeout: 5, hooks: [audit] }],
PostToolUseFailure: [{ timeout: 5, hooks: [audit] }],
};import asyncio
import json
from claude_agent_sdk import HookMatcher
lock = asyncio.Lock()
def append(line):
with open("./audit.jsonl", "a", encoding="utf-8") as file:
file.write(line)
async def audit(input_data, tool_use_id, context):
if input_data["hook_event_name"] not in (
"PreToolUse", "PostToolUse", "PostToolUseFailure"
):
return {}
record = {key: input_data.get(key) for key in (
"hook_event_name", "session_id", "agent_id", "tool_name", "tool_use_id"
)}
try:
async with lock:
await asyncio.to_thread(append, json.dumps(record) + "\n")
except OSError as error:
print(error)
return {}
hooks = {event: [HookMatcher(timeout=5, hooks=[audit])] for event in (
"PreToolUse", "PostToolUse", "PostToolUseFailure"
)}Correlate by tool-use ID, not file order. Log guard denials separately and inspect result permission-denial data. This logger continues on storage failures; production audits need a durable sink outside agent-writable files. Python cancellation does not stop the worker-thread append; use a dedicated writer and drain it on shutdown.
3. Redact a known secret from tool input
Runner: tools ['Read', 'Write', 'Edit']; prompt Write workspace/redact-demo.txt containing DEMO_SECRET_123. Then Read workspace/edit-demo.txt and Edit placeholder to DEMO_SECRET_123.
Replace Write.content and Edit.new_string, preserving Edit.old_string as the exact-match target.
import type { HookCallback, Options } from "@anthropic-ai/claude-agent-sdk";
const redact: HookCallback = async (input) => {
if (input.hook_event_name !== "PreToolUse") return {};
const args = input.tool_input as Record<string, unknown>;
const field = input.tool_name === "Write" ? "content" :
input.tool_name === "Edit" ? "new_string" : null;
if (!field || typeof args[field] !== "string") return {};
const after = (args[field] as string).split("DEMO_SECRET_123").join("[REDACTED]");
if (after === args[field]) return {};
return { hookSpecificOutput: {
hookEventName: "PreToolUse", updatedInput: { ...args, [field]: after },
additionalContext: "A known secret was removed from this write.",
} };
};
export const hooks: NonNullable<Options["hooks"]> = {
PreToolUse: [{ matcher: "Write|Edit", timeout: 5, hooks: [redact] }],
};from claude_agent_sdk import HookMatcher
async def redact(input_data, tool_use_id, context):
if input_data["hook_event_name"] != "PreToolUse":
return {}
field = {"Write": "content", "Edit": "new_string"}.get(input_data["tool_name"])
args = input_data["tool_input"]
if field is None or not isinstance(args.get(field), str):
return {}
after = args[field].replace("DEMO_SECRET_123", "[REDACTED]")
if after == args[field]:
return {}
return {"hookSpecificOutput": {
"hookEventName": "PreToolUse", "updatedInput": {**args, field: after},
"additionalContext": "A known secret was removed from this write.",
}}
hooks = {"PreToolUse": [HookMatcher(matcher="Write|Edit", timeout=5, hooks=[redact])]}Inspect both files. Read before Edit in the same conversation, keeping the replacement target unambiguous, for portable behavior across models. The fake token demonstrates one rewrite: it does not remove prior model exposure, transcripts, Bash writes, or network exfiltration. No permission decision is returned.
4. Restrict writes to an allowed directory
Runner: tools ['Write']; prompt Write workspace/allowed-demo.txt containing demo. In a fresh run, use Write outside-demo/blocked-demo.txt containing demo. If denied, do not retry. Both targets remain in the disposable lab.
File tools supply absolute native paths. This compact policy resolves the existing parent, uses component containment, and rejects final symlinks, including dangling links. Create workspace before importing the module.
import path from "node:path";
import { lstat, realpath } from "node:fs/promises";
import type { HookCallback, Options } from "@anthropic-ai/claude-agent-sdk";
const root = await realpath("./workspace");
function inside(target: string) {
const rel = path.relative(root, target);
return !path.isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${path.sep}`);
}
async function permitted(file: string) {
try {
if (!path.isAbsolute(file) || file.split(/[\\/]/).includes("..") || /[\\/]$/.test(file)) return false;
if (!inside(await realpath(path.dirname(file)))) return false;
let entry;
try { entry = await lstat(file); }
catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; }
return entry.isFile() && !entry.isSymbolicLink() && inside(await realpath(file));
} catch { return false; }
}
const guard: HookCallback = async (input) => {
if (input.hook_event_name !== "PreToolUse") return {};
const args = input.tool_input as Record<string, unknown>;
if (typeof args.file_path === "string" && await permitted(args.file_path)) return {};
return { hookSpecificOutput: {
hookEventName: "PreToolUse", permissionDecision: "deny",
permissionDecisionReason: "Write must stay inside the allowed workspace.",
} };
};
export const hooks: NonNullable<Options["hooks"]> = {
PreToolUse: [{ matcher: "Write|Edit", timeout: 5, hooks: [guard] }],
};import asyncio
import re
import stat
from pathlib import Path
from claude_agent_sdk import HookMatcher
ROOT = Path("./workspace").resolve(strict=True)
def permitted(file):
try:
target = Path(file)
if not target.is_absolute() or ".." in re.split(r"[\\/]", file) or re.search(r"[\\/]$", file):
return False
if not target.parent.resolve(strict=True).is_relative_to(ROOT):
return False
try:
mode = target.lstat().st_mode
except FileNotFoundError:
return True
return stat.S_ISREG(mode) and target.resolve(strict=True).is_relative_to(ROOT)
except (OSError, ValueError, RuntimeError):
return False
async def guard(input_data, tool_use_id, context):
if input_data["hook_event_name"] != "PreToolUse":
return {}
file = input_data["tool_input"].get("file_path")
if isinstance(file, str) and await asyncio.to_thread(permitted, file):
return {}
return {"hookSpecificOutput": {
"hookEventName": "PreToolUse", "permissionDecision": "deny",
"permissionDecisionReason": "Write must stay inside the allowed workspace.",
}}
hooks = {"PreToolUse": [HookMatcher(matcher="Write|Edit", timeout=5, hooks=[guard])]}The containment logic uses Node path operations, lstat, and Python strict resolution. A sibling such as workspace-other is outside the root. Parents must exist; inspection errors deny the call.
These checks are not atomic authorization: paths can change afterward, hard links and mounts need separate controls, and Bash/NotebookEdit/MCP writes require their own policies. Combine the check with OS isolation.
5. Add context when a prompt is submitted
Runner: tools []; prompt What environment and test command apply to this workspace? Inspect the printed answer for staging and the configured command.
additionalContext attaches trusted application facts alongside the prompt.
import type { HookCallback, Options } from "@anthropic-ai/claude-agent-sdk";
const context: HookCallback = async (input) => {
if (input.hook_event_name !== "UserPromptSubmit") return {};
return { hookSpecificOutput: {
hookEventName: "UserPromptSubmit",
additionalContext: "Environment: staging. Test command: python3 -m unittest discover -s tests.",
} };
};
export const hooks: NonNullable<Options["hooks"]> = {
UserPromptSubmit: [{ timeout: 5, hooks: [context] }],
};from claude_agent_sdk import HookMatcher
async def add_context(input_data, tool_use_id, context):
if input_data["hook_event_name"] != "UserPromptSubmit":
return {}
return {"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": "Environment: staging. Test command: python3 -m unittest discover -s tests.",
}}
hooks = {"UserPromptSubmit": [HookMatcher(timeout=5, hooks=[add_context])]}Enforce staging through credentials and permissions. Static conventions fit CLAUDE.md; hooks suit changing application state. Keep untrusted external content separate from trusted instructions.
6. Check completion without a Stop loop
Runner: tools ['Read', 'Write']; prompt Read workspace/README.md and summarize it in your answer. Start without workspace/SUMMARY.md; the hook requests that missing artifact.
Stop blocking continues the agent. Check stop_hook_active to avoid repeating the same request indefinitely.
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { HookCallback, Options } from "@anthropic-ai/claude-agent-sdk";
const summary = path.resolve("workspace/SUMMARY.md");
export async function summaryReady() {
try { return (await readFile(summary, "utf8")).trim().length > 0; }
catch { return false; }
}
const requireSummary: HookCallback = async (input) => {
if (input.hook_event_name !== "Stop" || input.stop_hook_active) return {};
if (await summaryReady()) return {};
return { decision: "block", reason: `Write a nonempty summary to ${summary} before finishing.` };
};
export const hooks: NonNullable<Options["hooks"]> = {
Stop: [{ timeout: 5, hooks: [requireSummary] }],
};Import summaryReady alongside hooks. After the runner's loop, add:
if (!(await summaryReady())) throw new Error("Summary missing or empty");import asyncio
from pathlib import Path
from claude_agent_sdk import HookMatcher
SUMMARY = Path("workspace/SUMMARY.md").resolve()
async def summary_ready():
try:
text = await asyncio.to_thread(SUMMARY.read_text, encoding="utf-8")
return bool(text.strip())
except (OSError, UnicodeError):
return False
async def require_summary(input_data, tool_use_id, context):
if input_data["hook_event_name"] != "Stop" or input_data["stop_hook_active"]:
return {}
if await summary_ready():
return {}
return {"decision": "block", "reason": f"Write a nonempty summary to {SUMMARY} before finishing."}
hooks = {"Stop": [HookMatcher(timeout=5, hooks=[require_summary])]}Import summary_ready alongside hooks. Inside main(), after the client context, add:
if not await summary_ready():
raise RuntimeError("Summary missing or empty")The follow-up path permits stopping, so the host checks again before accepting the artifact. Also check result status: interrupts, API errors, and turn limits bypass normal completion. Nonempty is one requirement; validate content quality separately. Hook code runs in your host process, outside the Bash tool's sandbox.
7. Track usage from result messages
Runner: tools ['Read', 'Glob', 'Grep']; prompt Read workspace/README.md and summarize it without changing files. Replace the hook module below, import its logger alongside hooks, and call the logger inside the runner's result branch.
The cost-tracking API exposes these fields on results, not Stop inputs:
import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";
export const hooks = {};
export function logUsage(message: SDKResultMessage) {
console.log({
session_id: message.session_id,
estimated_cost_usd: message.total_cost_usd,
model_usage: message.modelUsage,
});
}Use logUsage(message) in the result branch. Optional estimated budget: add maxBudgetUsd: 1 to the runner's options.
from claude_agent_sdk import ResultMessage
hooks = {}
def log_usage(message: ResultMessage):
print({
"session_id": message.session_id,
"estimated_cost_usd": message.total_cost_usd,
"model_usage": message.model_usage,
})Use log_usage(message) in the ResultMessage branch. Optional estimated budget: add max_budget_usd=1 to its options.
| Field | Scope |
|---|---|
usage | Main loop; per turn in streaming input |
modelUsage / model_usage | Main loop, subagents, compaction, other query-pipeline calls; some helpers excluded |
total_cost_usd | Cumulative client estimate, not billed charges |
Read the latest cumulative snapshot, not the sum of snapshots. Resumed/forked sessions restore earlier totals; the budget still measures the current call's spend. Deduplicate API message IDs for per-step input/cache tokens, and use final result counts for output: assistant-message output tokens are placeholders. Python accounting fields can be None; errors still carry costs, while crashes can leave incomplete totals.
The $1 option uses client pricing estimates; the crossing response has already consumed tokens. Gateway model IDs and changed prices can cause estimate drift. Use Requesty accounting for routed charges.
Async hooks and timeouts
An async callback is awaited by default. Background work requires starting a task and returning async: true, or Python async_: True; it cannot subsequently enforce a decision. Retain tasks and flush them during shutdown.
| Timeout setting | Unit |
|---|---|
Matcher timeout | Seconds |
Async output asyncTimeout | Milliseconds |
Current defaults: 600 seconds for most callbacks, 30 for prompt/model-switch events, 10 for MessageDisplay, and a 1.5-second shutdown budget for SessionEnd. Set application timeouts explicitly.
SDK callback timeouts block PreToolUse tools and UserPromptSubmit prompts. PostToolUse keeps the result; Stop supplies no decision. Settings command/HTTP/MCP PreToolUse timeouts instead proceed through normal permissions.
Catch dependency failures and return an explicit policy outcome. Deny when a required security check fails; log and continue for optional telemetry. Pass TypeScript's signal to cancellable work; implement Python cancellation separately.
Test callbacks before live sessions
Keep recipe 1's hook module. These fixtures call the policy without executing a command:
import assert from "node:assert/strict";
import { blockCommand } from "./hooks.js";
const output = await blockCommand({
hook_event_name: "PreToolUse", session_id: "test", cwd: process.cwd(),
transcript_path: "/tmp/fixture.jsonl", tool_name: "Bash",
tool_input: { command: "echo DELETE_DEMO" }, tool_use_id: "test-tool",
}, "test-tool", { signal: new AbortController().signal });
assert("hookSpecificOutput" in output &&
output.hookSpecificOutput?.hookEventName === "PreToolUse");
assert.equal(output.hookSpecificOutput.permissionDecision, "deny");Run the fixture, then type-check the three files with explicit NodeNext settings:
npx tsx hooks.test.ts
npx tsc --noEmit --strict --target ES2022 --module NodeNext \
--moduleResolution NodeNext --types node hooks.ts agent.ts hooks.test.tsimport asyncio
from hooks import block_command
async def test():
output = await block_command({
"hook_event_name": "PreToolUse", "tool_name": "Bash",
"tool_input": {"command": "echo DELETE_DEMO"}, "tool_use_id": "test-tool",
"session_id": "test", "cwd": ".", "transcript_path": "/tmp/fixture.jsonl",
}, "test-tool", {"signal": None})
assert output["hookSpecificOutput"]["permissionDecision"] == "deny"
asyncio.run(test())Run python test_hooks.py and python -m py_compile hooks.py agent.py test_hooks.py.
Use new Write filenames or reset files before another session. Assert actual files and callback decisions, not the agent's prose. Record runtime and SDK versions with your results.
Agent SDK hook test checklist
0 of 7 done
Why your Claude Agent SDK hook is not firing
| Symptom | Cause | Fix |
|---|---|---|
| No invocation | Wrong event spelling | Use PreToolUse, not pre-tool-use |
| Some tools missing | Matcher filters them | Remove matcher; inspect metadata |
| MCP tools missing | Incomplete name | Match fully qualified mcp__ name |
| No tool event | No valid tool call | Check tool availability and validation |
| Python session hook absent | Unsupported callback event | Use host lifecycle/settings hook |
| Rewrite ignored | Wrong nesting | Include hookSpecificOutput.hookEventName |
| Input fields disappear | Rewrite replaces whole object | Copy unchanged fields |
| Approval callback skipped | Tool pre-approved | Put invariants in PreToolUse |
| Conflicting decisions | Parallel or settings hooks | Inspect every active handler |
| Follow-up callback fails | Older callback plumbing | Update SDK and bundled/custom CLI |
See official troubleshooting. September fixes in Python 0.2.160 and TypeScript 0.3.284 address follow-up callbacks after background subagents. Check the SDK's CLI, not just the shell executable.
includeHookEvents: true / include_hook_events=True adds lifecycle messages. Some event families omit parts of that stream; log callback entry and decisions independently.
SDK callbacks versus Claude Code settings hooks
If you use the Claude Code CLI rather than building your own agent, settings hooks are the right tool. Our guide to 10 practical Claude Code hooks has copy-paste recipes.
| Question | SDK callbacks | Settings shell hooks |
|---|---|---|
| Defined in | Agent options | Settings JSON |
| Handler | Application function | Executable/command |
| Input | Callback argument | JSON on stdin |
| Output | Returned object | Exit status/stdout JSON |
| Matcher fields | matcher, hooks, timeout | Handler type, command |
Keep shell-handler fields out of SDK matcher objects. For scripts and exit-code examples, see the official Claude Code hook guide.
Current query() loads user, project, and local settings by default. Disable them with settingSources: [] or setting_sources=[]; use ["project"] to load project shell hooks. Empty sources do not erase environment variables or managed policies. Both Python query() and ClaudeSDKClient support callbacks.
Route Agent SDK model requests through Requesty
Point the SDK at Requesty with ANTHROPIC_BASE_URL=https://router.requesty.ai and your key in ANTHROPIC_AUTH_TOKEN. You get model choice across 600+ models, configured fallbacks, a monthly spend cap per agent key, and per-agent cost analytics through our Agent SDK integration.
Assume REQUESTY_API_KEY holds your Requesty key:
export ANTHROPIC_BASE_URL="https://router.requesty.ai"
export ANTHROPIC_AUTH_TOKEN="$REQUESTY_API_KEY"
export ANTHROPIC_MODEL="anthropic/claude-opus-5-5"
export ANTHROPIC_CUSTOM_HEADERS="X-Requesty-Agent: repo-reviewer
X-Requesty-Environment: staging"Use the root URL and the key without Bearer. The model ID appears in our model catalog. Anthropic's ANTHROPIC_CUSTOM_HEADERS uses newline-separated Name: Value entries; Requesty captures those analytics tags and strips them before provider forwarding. Keep secrets and personal data out of tags.
When switching from a cloud route, clear its provider-selection variables. ANTHROPIC_AUTH_TOKEN has higher precedence than ANTHROPIC_API_KEY. For per-query env, TypeScript replaces inherited environment, so spread process.env; Python merges.

Use a key per top-level agent application for separate monthly caps. Caps block new requests; they do not cancel in-flight work. Launch headers tag the enclosing application; hook agent_id provides individual subagent tool attribution.
Create a fallback policy with your chosen models/providers and use its policy/name as the model. Test the chain's tool calling, streaming, context, and required features. Gateway fallback does not imply an SDK PostModelSwitch event.
Keep hook policies and final validation in your application. Add routed model accounting, agent tags, and per-key caps with the Agent SDK integration docs.
Start with one callback policy, test its returned decision, then verify the disposable-workspace outcome. Add accounting and shared spend controls once the tool behavior is clear.
Sources
References checked September 27, 2026.
Frequently asked questions
- What is the Claude Agent SDK?
- The Claude Agent SDK is a TypeScript and Python library that runs the Claude Code binary, giving your application its agent loop, tools, and context management. The Anthropic Client SDK instead exposes model API calls and a beta tool runner.
- How do Claude Agent SDK hooks work?
- Register callbacks under lifecycle events such as PreToolUse. A matcher selects targets, the host invokes your callback, and its returned object controls the event. Callbacks are awaited by default.
- Does Python query() support Claude Agent SDK hooks?
- Yes. Both query() and ClaudeSDKClient support hooks in the current Python API. Older README text saying hooks require ClaudeSDKClient does not describe the current API.
- Are Agent SDK hooks the same as Claude Code hooks?
- They share lifecycle events and much of the output contract, but SDK hooks are application callbacks rather than settings.json shell handlers. Settings-based hooks also run in SDK sessions when their settings sources are enabled.
- Why is my Claude Agent SDK hook not firing?
- Check the case-sensitive event name, matcher target, language support, and installed SDK plus bundled CLI version. A prompt that produces no valid tool call will not trigger a tool hook, and validation failures can happen before hooks run.
- Can hooks block commands or change tool inputs?
- PreToolUse can deny a tool call or replace its arguments using updatedInput. PostToolUse runs after execution and cannot undo its side effects. Pattern checks do not replace sandboxing.
- Can I track cost with a Stop hook?
- Read total_cost_usd and per-model usage from SDK result messages. Stop inputs are not the cost accounting surface. SDK dollar totals are client-side estimates rather than authoritative billing data.
- SEP '26
Claude Code Hooks: 10 Examples for Logging, Safety and Costs
Set up Claude Code hooks with 10 copy-paste examples for logs, guardrails, formatting and notifications, plus session cost tracking that uses the right data.
- JUN '26
Best AI Agent SDKs Compared (2026): LangGraph, CrewAI, OpenAI, Anthropic, and Google ADK
Six agent SDKs compete for production deployments in 2026. LangGraph leads on state control, CrewAI on rapid prototyping, and the vendor SDKs from Anthropic, OpenAI, and Google ship native tool execution. This guide compares architecture, benchmarks, token efficiency, and gateway compatibility so you can pick the right SDK for your stack.
- MAY '26
Agent Harness: Why Your LLM Gateway Is the Backbone of Production Agents
The model is the brain. The harness is the body. In 2026 the agent harness has become the critical infrastructure layer for production AI. This post breaks down the stack and shows how an LLM gateway like Requesty fits in with real code examples.
