
Claude Agent Sdk Expert
- 9 installs
- 27 repo stars
- Updated March 19, 2026
- raroque/claude-agent-sdk-skill
Helps with ai & agent building tasks.
About
claude-agent-sdk-expert is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- claude-agent-sdk-expert
- AI & Agent Building
- AI-coding skill
Claude Agent Sdk Expert by the numbers
- 9 all-time installs (skills.sh)
- Ranked #12,152 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/raroque/claude-agent-sdk-skill --skill claude-agent-sdk-expertAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 9 |
|---|---|
| repo stars | ★ 27 |
| Last updated | March 19, 2026 |
| Repository | raroque/claude-agent-sdk-skill ↗ |
What it does
Helps with ai & agent building tasks.
Files
Claude Agent SDK Expert
Why This Skill Exists
AI-assisted agent development introduces characteristic failure modes that traditional code review misses. Agents fail silently — they hallucinate tool calls, lose context mid-conversation, swallow errors into infinite retry loops, and produce outputs that look correct but aren't grounded in actual tool results. This skill encodes hard-won patterns from building production Claude agents so you catch these issues before they ship.
Core Principle
An agent is only as good as its tools and instructions. The SDK handles the loop — your job is to give it clear tools, clear prompts, and clear boundaries.
Process
Work through each step below. Step 0 is always loaded. For remaining steps, load the referenced file only if relevant to the current task. Skip steps that don't apply.
Step 0: Known Gotchas (Always Load)
Before any deep review or build, scan for the top 10 most common agent mistakes. These are fast to check and catch the majority of production incidents.
Read file: references/gotchas.mdQuick Scan (Review Mode)
For code reviews, run the static analysis script first to surface mechanical anti-patterns before doing a manual review. This catches issues like missing maxIterations, tool_choice: "any", silent catch blocks, and missing additionalProperties: false.
bash scripts/scan-agent-patterns.sh <target-directory>Review the output, then proceed with the manual steps below for deeper analysis.
Step 1: Agentic Loop Architecture
Evaluate the core agent loop structure, stop condition handling, and iteration guards.
Read file: references/agentic-loop.mdStep 2: Tool Design & Scoping
Review tool definitions for clarity, scope, and schema quality. Ensure descriptions serve as the primary selection mechanism.
Read file: references/tool-design.mdStep 3: Prompt Engineering for Agents
Assess system prompts, instruction clarity, and few-shot example usage.
Read file: references/prompt-engineering.mdStep 4: Structured Output & Schema Design
Check extraction patterns, schema strictness, and field design.
Read file: references/structured-output.mdStep 5: Context Management
Evaluate context window usage, session management, and information retention strategies.
Read file: references/context-management.mdStep 6: MCP Integration
Review MCP server configuration, tool namespacing, and integration patterns.
Read file: references/mcp-integration.mdStep 7: Error Handling & Reliability
Assess error propagation, validation loops, retry strategies, and escalation triggers.
Read file: references/error-handling.mdStep 8: Hooks & Lifecycle
Review PreToolCall, PostToolCall, and StopHook implementations for correctness and safety.
Read file: references/hooks-lifecycle.mdStep 9: Multi-Agent Patterns
Evaluate coordinator-subagent architecture, context passing, and handoff patterns.
Read file: references/multi-agent.mdCore Instructions
1. Report genuine issues only. Do not fabricate problems. If the code is solid, say so. 2. Prioritize by impact. Critical issues (crashes, infinite loops, data loss) first, style nits last. 3. Skip irrelevant sections. If the agent doesn't use MCP, skip Step 6. If it's single-agent, skip Step 9. 4. Dual mode — review and build.
- Review mode: Audit existing agent code. Output severity-ranked findings with concrete fixes.
- Build mode: Help write new agent code. Follow the process steps as a checklist to ensure nothing is missed.
5. Show, don't tell. Every finding or recommendation must include a concrete code example — before/after for reviews, working snippets for builds. 6. Ground in SDK reality. Reference actual SDK APIs (query(), tool_use, stop_reason, hooks, etc.). Do not invent APIs that don't exist. 7. Maintain the review log. After completing a review or build session, append a one-line JSON entry to data/review-log.jsonl:
{"date":"YYYY-MM-DD","mode":"review|build","project":"project-name","sdk":"ts|py","findings":["finding1","finding2"],"severity_counts":{"critical":0,"high":0,"medium":0,"low":0}}At session start, if data/review-log.jsonl exists, read it and note recurring patterns across past sessions to inform the current review.
Output Format
Review Mode
Rank findings by severity:
## [CRITICAL] Issue title
**What**: Description of the problem
**Why it matters**: Impact (crashes, data loss, infinite loops, etc.)
**Where**: File and line reference
**Fix**:
// Before (BAD)
<problematic code>
// After (GOOD)
<fixed code>## [HIGH] Issue title
...
## [MEDIUM] Issue title
...
## [LOW] Issue title
...Build Mode
Structure output as:
1. Architecture Decision — Which pattern to use and why 2. Implementation — Working code following all best practices 3. Checklist — Verification points from the relevant process steps
References
| # | Topic | File |
|---|---|---|
| 0 | Gotchas (Always Load) | references/gotchas.md |
| 1 | Agentic Loop Architecture | references/agentic-loop.md |
| 2 | Tool Design & Scoping | references/tool-design.md |
| 3 | Prompt Engineering | references/prompt-engineering.md |
| 4 | Structured Output | references/structured-output.md |
| 5 | Context Management | references/context-management.md |
| 6 | MCP Integration | references/mcp-integration.md |
| 7 | Error Handling | references/error-handling.md |
| 8 | Hooks & Lifecycle | references/hooks-lifecycle.md |
| 9 | Multi-Agent Patterns | references/multi-agent.md |
display_name: "Claude Agent SDK Expert"
short_description: "Review and build production-grade Claude agents following SDK best practices"
brand_color: "#D97706"
default_prompt: |
You are the Claude Agent SDK Expert. Review the current codebase for agent anti-patterns
and help build production-grade agents following Claude Agent SDK best practices.
Focus on: agentic loop architecture, tool design, prompt engineering, structured output,
context management, MCP integration, error handling, hooks lifecycle, and multi-agent patterns.
Report issues ranked by severity (Critical > High > Medium > Low) with concrete fixes.
Agentic Loop Architecture
Core Pattern
The Claude Agent SDK manages the agentic loop for you. The query() function sends a message, receives a response, checks if the model wants to use tools, executes them, and loops until the model stops. Your job is to configure it correctly — not to reimplement it.
// GOOD: Let the SDK handle the loop
const agent = new Agent({
name: "research-agent",
model: "claude-sonnet-4-6-20250514",
instructions: "You are a research assistant...",
tools: [searchTool, readTool, summarizeTool],
});
const result = await agent.query("Find recent papers on transformer efficiency");# GOOD: Python equivalent
agent = Agent(
name="research-agent",
model="claude-sonnet-4-6-20250514",
instructions="You are a research assistant...",
tools=[search_tool, read_tool, summarize_tool],
)
result = await agent.query("Find recent papers on transformer efficiency")Stop Reason Handling
The SDK loop continues until the model returns a stop_reason of end_turn. Understanding stop reasons is critical:
stop_reason | Meaning | SDK Behavior |
|---|---|---|
end_turn | Model is done | Loop exits, returns result |
tool_use | Model wants to call a tool | SDK executes tool, feeds result back |
max_tokens | Response was truncated | Danger zone — see below |
max_tokens Truncation
When the model hits max_tokens, its response is cut off mid-generation. This is almost always a bug, not intentional behavior.
// BAD: Ignoring max_tokens — agent silently produces truncated output
const result = await agent.query(prompt);
// If stop_reason was max_tokens, result.content is incomplete
// GOOD: Set appropriate max_tokens and handle truncation
const agent = new Agent({
name: "writer",
model: "claude-sonnet-4-6-20250514",
maxTokens: 8192, // Generous limit for the task
instructions: "...",
tools: [...],
});Max Iteration Guards
Agentic loops can run indefinitely if the model keeps calling tools without converging. Always set a max iteration limit.
// BAD: No iteration limit — infinite loop if model never stops
const agent = new Agent({
name: "agent",
instructions: "...",
tools: [searchTool],
});
// GOOD: Explicit iteration limit
const agent = new Agent({
name: "agent",
instructions: "...",
tools: [searchTool],
maxIterations: 20, // Bail out after 20 tool calls
});Single vs. Multi-Agent Decision
Use a single agent when:
- The task has one clear domain (e.g., "answer questions about this codebase")
- All tools are relevant to the same workflow
- Context doesn't need to be isolated between subtasks
Use multi-agent when:
- Subtasks have different trust boundaries (e.g., one agent reads files, another writes them)
- You need to limit which tools are available for which subtasks
- The coordinator needs to synthesize results from independent workstreams
Anti-pattern: Reaching for multi-agent when a single agent with good tools would suffice. Multi-agent adds complexity, latency, and context-passing overhead.
Streaming vs. Non-Streaming
// Non-streaming: Wait for complete result
const result = await agent.query("Analyze this data");
console.log(result.content);
// Streaming: Process tokens as they arrive
const stream = agent.stream("Analyze this data");
for await (const event of stream) {
if (event.type === "text") {
process.stdout.write(event.text);
}
}Use streaming when:
- You need to show progress to users in real-time
- The response might be long and you want to display incrementally
Use non-streaming when:
- You're processing the result programmatically
- You're in a pipeline where partial results aren't useful
Anti-Patterns to Detect
1. Parsing natural language for control flow: Using string matching, regex, or keyword checks on Claude's text output to decide what the agent does next. This is fundamentally fragile — Claude's phrasing varies every time.
// BAD: Parsing text to decide control flow
const response = await claude.messages.create({ ... });
const text = response.content[0].text;
if (text.toLowerCase().includes("check order")) {
checkOrder();
} else if (text.toLowerCase().includes("escalate")) {
escalate();
}
// Claude might say "let's look into the order" or "get a human involved"
// — your string matching misses it, the agent stalls
// GOOD: Use tool_use and stop_reason — structured, deterministic
const response = await claude.messages.create({ ..., tools });
if (response.stop_reason === "tool_use") {
// Claude chose a specific tool with structured JSON input
const toolCall = response.content.find(b => b.type === "tool_use");
const result = executeTool(toolCall.name, toolCall.input);
} else if (response.stop_reason === "end_turn") {
return response.content[0].text; // Claude is done
}Control flow should always be driven by stop_reason and tool_use content blocks, never by parsing free text. If you need structured decisions from Claude, define tools that represent those decisions.
2. Reimplementing the loop: Writing your own while loop around raw API calls instead of using Agent.query(). The SDK handles tool execution, error recovery, and iteration correctly — don't reinvent it.
2. No iteration guard: Missing maxIterations means a confused model can loop forever, burning tokens and time.
3. Ignoring stop_reason: Not checking or handling max_tokens truncation leads to silently incomplete outputs.
4. Premature multi-agent: Splitting into coordinator + subagents when one agent with 3-4 tools would be simpler and faster.
5. Blocking on streaming unnecessarily: Using streaming when you just need the final result adds complexity for no benefit.
Context Management
Context Window Limits
Every model has a finite context window. Agent conversations consume context fast because each tool call and result adds to the message history. A single agentic loop can easily burn through 50-100k tokens.
| Model | Context Window | Practical Limit* |
|---|---|---|
| Claude Sonnet 4.6 | 200k tokens | ~150k usable |
| Claude Opus 4.6 | 200k tokens | ~150k usable |
| Claude Haiku 4.5 | 200k tokens | ~150k usable |
*Practical limit accounts for system prompt, tool definitions, and output tokens.
Persistent Case Facts
When an agent works through a multi-step task, critical facts from early steps can get "pushed out" of the model's effective attention by later tool results. This is the lost-in-the-middle effect.
// BAD: Relying on the model to remember facts from 20 tool calls ago
const agent = new Agent({
instructions: "You are a research assistant. Investigate the issue and write a report.",
tools: [searchTool, readTool, writeTool],
});
// After 15 tool calls, the model forgets what it found in call #2
// GOOD: Use a scratch pad tool to persist key findings
const scratchPadTool = {
name: "save_finding",
description: "Save an important finding to your scratch pad. Use this to record key facts you'll need later. The scratch pad persists across all tool calls.",
inputSchema: {
type: "object",
properties: {
key: { type: "string", description: "Short label for this finding" },
value: { type: "string", description: "The finding or fact to save" },
},
required: ["key", "value"],
additionalProperties: false,
},
};
const readScratchPadTool = {
name: "read_findings",
description: "Read all saved findings from your scratch pad. Use this before writing your final report to ensure you include all key facts.",
inputSchema: {
type: "object",
properties: {},
additionalProperties: false,
},
};Session Management
For long-running agent tasks, manage context deliberately:
// BAD: One massive conversation that hits context limits
const result = await agent.query(
"Analyze all 500 files in the repository and report issues"
);
// Context overflow halfway through
// GOOD: Chunk work and summarize between chunks
const files = await getFileList();
const chunkSize = 20;
const findings = [];
for (let i = 0; i < files.length; i += chunkSize) {
const chunk = files.slice(i, i + chunkSize);
const result = await agent.query(
`Analyze these files and report issues:\n${chunk.join("\n")}\n\n` +
`Previous findings summary: ${findings.length} issues found so far.`
);
findings.push(...parseFindings(result));
// Each chunk starts with a fresh(er) context
}Context Passing to Subagents
When delegating to subagents, pass only what they need:
// BAD: Passing full conversation history to subagent
const subagent = new Agent({
name: "summarizer",
instructions: "Summarize the provided content.",
tools: [],
});
const result = await subagent.query(fullConversationHistory); // Wasteful, noisy
// GOOD: Pass a focused, minimal context
const subagent = new Agent({
name: "summarizer",
instructions: "Summarize the key findings from the provided text. Focus on actionable items.",
tools: [],
});
const result = await subagent.query(
`Summarize these findings:\n\n${findings.map(f => `- ${f.title}: ${f.detail}`).join("\n")}`
);Anti-Patterns to Detect
1. Full context to subagents: Dumping the entire parent conversation into a subagent's context. Subagents should receive only the information they need for their specific task.
2. No summarization strategy: Letting tool results accumulate without any mechanism to compress or summarize intermediate findings. Eventually, early context is effectively invisible.
3. Unbounded tool output: Tools that return full file contents, complete database results, or verbose API responses without truncation or summarization. One large tool result can push everything else out of effective attention.
// BAD: Tool returns full file
async function readFile(path: string) {
return fs.readFileSync(path, "utf-8"); // Could be 10k lines
}
// GOOD: Tool returns relevant portion
async function readFile(input: { path: string; startLine?: number; endLine?: number }) {
const content = fs.readFileSync(input.path, "utf-8");
const lines = content.split("\n");
const start = input.startLine ?? 1;
const end = input.endLine ?? Math.min(start + 100, lines.length);
return {
content: lines.slice(start - 1, end).join("\n"),
totalLines: lines.length,
showing: `lines ${start}-${end}`,
};
}4. No context window awareness: Not considering how many tokens the agent has consumed and whether it's approaching limits. For long tasks, implement checkpointing.
5. Repeated information: Including the same context in every tool call result (e.g., appending system info to every response). This wastes tokens on redundant information the model already has.
Error Handling & Reliability
Structured Error Propagation
Errors in agent systems must flow back to the model in a structured, actionable format. The model needs to understand what went wrong and what it can do about it.
// BAD: Opaque error — model has no idea what to do next
async function apiTool(input: { endpoint: string }) {
const res = await fetch(input.endpoint);
if (!res.ok) throw new Error("Request failed");
return res.json();
}
// GOOD: Structured error with recovery guidance
async function apiTool(input: { endpoint: string }) {
try {
const res = await fetch(input.endpoint);
if (!res.ok) {
return {
success: false,
error: {
type: "http_error",
status: res.status,
statusText: res.statusText,
retryable: res.status >= 500,
suggestion: res.status === 404
? "Check the endpoint path. It may have changed."
: res.status === 401
? "Authentication failed. Check credentials."
: res.status >= 500
? "Server error. Try again in a moment."
: "Check the request parameters.",
},
};
}
return { success: true, data: await res.json() };
} catch (err) {
return {
success: false,
error: {
type: "network_error",
message: err.message,
retryable: true,
suggestion: "Network request failed. Check connectivity and try again.",
},
};
}
}Validation Loops
When the model produces output that doesn't pass validation, feed the error back so it can self-correct:
// GOOD: Validation loop with structured feedback
const extractionTool = {
name: "extract_invoice",
description: "Extract invoice fields from text",
inputSchema: invoiceSchema,
};
// In your tool handler:
async function handleExtraction(input: any) {
const validation = validateInvoice(input);
if (!validation.valid) {
return {
success: false,
error: "validation_failed",
issues: validation.errors.map(e => ({
field: e.path,
message: e.message,
expected: e.expected,
received: e.received,
})),
instruction: "Fix the listed issues and call this tool again with corrected data.",
};
}
return { success: true, invoice: input };
}Escalation Triggers
Not all errors should be retried. Define clear escalation paths:
const instructions = `
Error handling rules:
- Retryable errors (network timeout, 5xx, rate limit): Retry up to 2 times with backoff
- Validation errors: Fix the input and try again (max 3 attempts)
- Auth errors (401, 403): STOP and report to the user — do not retry
- Not found (404): Try alternative approaches (different search query, different path)
- Unknown errors: STOP and report the full error to the user
If you hit 3 consecutive errors on the same operation, STOP and report the issue instead of continuing to retry.
`;Retry Strategies
// BAD: Immediate retry with no limit — can loop forever
async function retryTool(fn: Function) {
while (true) {
try {
return await fn();
} catch (e) {
// Just keep trying forever
}
}
}
// GOOD: Bounded retry with exponential backoff
async function withRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
baseDelayMs: number = 1000
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxRetries) throw err;
if (!isRetryable(err)) throw err;
const delay = baseDelayMs * Math.pow(2, attempt);
await new Promise(r => setTimeout(r, delay));
}
}
throw new Error("Unreachable");
}
function isRetryable(err: any): boolean {
if (err.status >= 500) return true;
if (err.status === 429) return true;
if (err.code === "ECONNRESET" || err.code === "ETIMEDOUT") return true;
return false;
}Anti-Patterns to Detect
1. Opaque errors: Returning "Error occurred" or throwing generic exceptions. The model needs specific error types, messages, and recovery suggestions.
2. Silent failures: Catching errors and returning empty/default results instead of signaling failure. The model proceeds as if the operation succeeded.
// BAD: Silent failure — model thinks it read a file successfully
async function readFile(path: string) {
try {
return fs.readFileSync(path, "utf-8");
} catch {
return ""; // Model thinks the file is empty
}
}
// GOOD: Explicit failure
async function readFile(path: string) {
try {
return { success: true, content: fs.readFileSync(path, "utf-8") };
} catch (err) {
return {
success: false,
error: `File not found: ${path}. Check the path and try again.`,
};
}
}3. Crashing loops: An error causes the model to retry the exact same action, hitting the same error, burning through iterations.
// What happens:
// 1. Model calls tool with bad input
// 2. Tool returns error
// 3. Model retries with the SAME bad input
// 4. Tool returns same error
// 5. Repeat until maxIterations
// Fix: Return actionable error messages that tell the model
// what specifically was wrong and how to fix it4. No timeout on external calls: Tool functions that call external APIs without timeouts. A hanging API call blocks the entire agent.
// BAD: No timeout
const result = await fetch(url);
// GOOD: Timeout
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const result = await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timeout);
}5. Swallowing stack traces: Catching errors and only forwarding the message, losing the stack trace that would help debug the issue.
6. Retry without backoff: Retrying immediately after failure, especially for rate-limited APIs. This makes the situation worse.
Top 10 Agent Gotchas
Quick-reference checklist of the most common Claude Agent SDK mistakes. Each one has caused production incidents. Check these before diving into a full review.
---
1. Missing maxIterations
Symptom: Agent runs forever, burns tokens, never returns. Why: Without a cap, the agentic loop continues until the model emits end_turn — which may never happen if the task is ambiguous. Fix: Always set maxIterations (TS) or max_iterations (Python). Start with 10-20 for most tasks.
2. Swallowing Tool Errors Into Empty Returns
Symptom: Agent proceeds as if the tool succeeded, produces hallucinated downstream results. Why: A catch block returns "", [], null, or undefined instead of surfacing the error. The model has no signal that something failed. Fix: Return a descriptive error string from the tool: return "Error: failed to fetch user — connection timeout". Let the agent decide how to recover.
3. Reimplementing the Agentic Loop
Symptom: Hand-rolled while loop calling messages.create() repeatedly, manually appending tool results. Why: Duplicates logic the SDK already handles (tool dispatch, stop conditions, context assembly). Bugs hide in the seams. Fix: Use agent.query() or agent.stream(). The SDK manages the loop, tool execution, and stop conditions.
4. tool_choice: "any" When You Meant a Specific Tool
Symptom: Agent calls random tools instead of the one you intended. Why: "any" means "must call some tool" — not a specific one. The model picks whichever tool seems relevant. Fix: Use tool_choice: { type: "tool", name: "specific_tool_name" } to force a specific tool call.
5. No additionalProperties: false on Schemas
Symptom: Model invents extra fields not in your schema. Validation passes but downstream code breaks on unexpected keys. Why: Without additionalProperties: false, the JSON Schema allows any extra properties. Models will hallucinate plausible-sounding fields. Fix: Add additionalProperties: false to every object type in your tool input schemas and structured output schemas.
6. Dumping Full Context to Subagents
Symptom: Subagent hits context window limit, runs slowly, or gets confused by irrelevant information. Why: Passing the parent's entire conversation history to a subagent wastes tokens and dilutes the subagent's focus. Fix: Pass only the specific task description and minimal required context. Subagents should receive a focused prompt, not a conversation dump.
7. StopHook That Can Never Be Satisfied
Symptom: Agent loops until maxIterations, then stops without completing the task. Why: The StopHook condition requires something the agent can't achieve (e.g., "all tests pass" when tests have an unrelated failure). Fix: StopHooks should check for agent completion signals, not external success criteria. Use them to validate the agent tried, not that the world changed.
8. Tool Descriptions Under 20 Characters
Symptom: Agent picks wrong tools or ignores useful tools. Why: The model selects tools primarily by description. Short descriptions like "Gets data" provide no selection signal. Fix: Write 1-3 sentence descriptions covering: what the tool does, when to use it, what it returns. Think of it as a docstring for the model.
9. No Timeout on External Calls in Tools
Symptom: Agent hangs indefinitely waiting for an API or database call inside a tool. Why: Tools execute synchronously in the agentic loop. A hung tool blocks the entire agent. Fix: Set explicit timeouts on all HTTP requests, database queries, and subprocess calls inside tools. Return a timeout error so the agent can retry or adapt.
10. Parsing JSON From Text Output Instead of tool_use Extraction
Symptom: Brittle regex/JSON.parse on model text that breaks when the model changes formatting. Why: Model text output is free-form. Even with instructions to output JSON, the model may wrap it in markdown, add commentary, or change structure. Fix: Use tool_use content blocks for structured data extraction. Define a tool whose input schema matches your desired output, and the model will return validated structured data.
Hooks & Lifecycle
Overview
Hooks are lifecycle callbacks that run at specific points during the agent's execution. They let you inject validation, logging, caching, and guardrails without modifying the agent's core logic.
The three hook types:
- PreToolCall: Runs before a tool is executed
- PostToolCall: Runs after a tool completes
- StopHook: Runs when the agent is about to stop (before returning the final result)
When to Recommend Hooks (Not Prompts)
Most people try to control agent behavior through the system prompt — "always validate before refunding," "always format dates consistently." But prompts are suggestions, not guarantees. Hooks are code — they execute deterministically every time.
Use hooks when the requirement involves:
| Domain | Why prompts aren't enough | Hook type |
|---|---|---|
| Money / billing | A prompt saying "don't refund over $500" will eventually be bypassed by a creative user message or edge case. A PreToolCall hook that checks amount <= 500 will not. | PreToolCall |
| Security / access control | Path traversal, privilege escalation, unauthorized operations — these need hard boundaries, not soft guidelines. | PreToolCall |
| Data integrity / normalization | Inconsistent data formats (dates, currencies, IDs) from tools cause hallucination and downstream errors. Cleaning data before Claude sees it is more reliable than asking Claude to handle inconsistency. | PostToolCall |
| Compliance / PII | Regulations don't accept "the model usually follows the instruction." PII scrubbing, audit logging, and sensitive data handling require deterministic enforcement. | PostToolCall, StopHook |
| Exit conditions | "Keep going until the ticket is actually resolved" can't be reliably enforced by prompt alone — Claude will say "done" when it thinks it's done. A StopHook can verify against external state. | StopHook |
The rule of thumb: If a failure in this behavior would cause a security incident, financial loss, compliance violation, or data corruption — use a hook. If it would just produce a suboptimal but harmless response — a prompt instruction is fine.
Defense in Depth: Prompts + Hooks + Tool Validation
For high-stakes operations, use all three layers together:
1. Prompt — Sets the policy. Tells Claude the rules so it makes correct decisions most of the time: "Never process refunds over $500." 2. PreToolCall hook — Enforces the gate. Blocks the call before the tool even runs: checks amount, validates order, confirms eligibility. 3. Tool implementation — Enforces during execution. The tool's own code validates inputs as a final backstop.
No single layer is sufficient alone. Prompts can be circumvented by edge cases or prompt injection. Hooks can be misconfigured. Tool validation catches anything that slips through. Three layers, zero gaps.
// Layer 1: Prompt
const instructions = "Never process refunds over $500. Escalate to a manager instead.";
// Layer 2: PreToolCall hook
const refundGate: PreToolCallHook = {
name: "refund-limit",
async run({ toolName, toolInput }) {
if (toolName === "process_refund" && toolInput.amount > 500) {
return { decision: "block", message: "Refund exceeds $500 limit. Suggest escalation." };
}
return { decision: "allow" };
},
};
// Layer 3: Tool implementation
async function processRefund(input: { orderId: string; amount: number }) {
if (input.amount > 500) {
return { error: "Refund amount exceeds $500 limit. Escalate to manager." };
}
const order = await db.getOrder(input.orderId);
if (!order) return { error: `Order ${input.orderId} not found.` };
if (daysSince(order.date) > 90) return { error: "Order older than 90 days. Escalate." };
return await paymentSystem.refund(input.orderId, input.amount);
}PreToolCall Hooks
Use PreToolCall for validation and approval gates — things that must be checked before an action is taken.
// Validation: Ensure file paths are within allowed directories
const fileAccessGuard: PreToolCallHook = {
name: "file-access-guard",
async run({ toolName, toolInput }) {
if (toolName === "write_file" || toolName === "delete_file") {
const path = toolInput.path as string;
if (!path.startsWith("/allowed/directory/")) {
return {
decision: "block",
message: `Blocked: Cannot write to ${path}. Only /allowed/directory/ is permitted.`,
};
}
}
return { decision: "allow" };
},
};
// Approval gate: Require confirmation for destructive operations
const destructiveOpGate: PreToolCallHook = {
name: "destructive-op-gate",
async run({ toolName, toolInput }) {
const destructiveTools = ["delete_file", "drop_table", "send_email"];
if (destructiveTools.includes(toolName)) {
const approved = await requestUserApproval(
`Agent wants to call ${toolName} with: ${JSON.stringify(toolInput)}`
);
if (!approved) {
return {
decision: "block",
message: "User denied this operation.",
};
}
}
return { decision: "allow" };
},
};PostToolCall Hooks
Use PostToolCall for logging, caching, and result transformation — things that process tool results without changing the agent's behavior.
// Logging: Track all tool calls for observability
const toolLogger: PostToolCallHook = {
name: "tool-logger",
async run({ toolName, toolInput, toolOutput, durationMs }) {
await logger.info({
event: "tool_call",
tool: toolName,
input: toolInput,
outputSize: JSON.stringify(toolOutput).length,
durationMs,
timestamp: new Date().toISOString(),
});
// PostToolCall hooks don't modify the output — they observe it
},
};
// Data normalization: Clean inconsistent tool output before Claude sees it
const dataNormalizer: PostToolCallHook = {
name: "data-normalizer",
async run({ toolName, toolOutput }) {
if (toolName === "query_crm") {
// Normalize dates, currencies, phone numbers etc.
// Claude works with clean, consistent data → fewer hallucinations
return normalizeRecords(toolOutput);
}
},
};
// Caching: Cache expensive tool results
const toolCache: PostToolCallHook = {
name: "tool-cache",
async run({ toolName, toolInput, toolOutput }) {
if (toolName === "search_database") {
const cacheKey = `${toolName}:${JSON.stringify(toolInput)}`;
await cache.set(cacheKey, toolOutput, { ttl: 300 });
}
},
};StopHook
Use StopHook for quality checks and guardrails — things that validate the agent's final output before it's returned.
// Quality check: Ensure the agent's response meets requirements
const qualityCheck: StopHook = {
name: "quality-check",
async run({ response, conversationHistory }) {
// Check if the response actually addresses the user's question
const userQuery = conversationHistory[0]?.content;
if (response.length < 50 && userQuery?.length > 100) {
return {
decision: "continue",
message: "Your response seems too brief for the question asked. Please provide more detail.",
};
}
// Check for hallucination indicators
if (response.includes("I don't have access to") && response.includes("but here's")) {
return {
decision: "continue",
message: "You indicated you don't have access to information but then provided an answer. Either use a tool to get the information or clearly state you cannot answer.",
};
}
return { decision: "stop" };
},
};
// Guardrail: Prevent sensitive information in output
const piiGuard: StopHook = {
name: "pii-guard",
async run({ response }) {
const piiPatterns = [
/\b\d{3}-\d{2}-\d{4}\b/, // SSN
/\b\d{16}\b/, // Credit card
];
for (const pattern of piiPatterns) {
if (pattern.test(response)) {
return {
decision: "continue",
message: "Your response contains what appears to be PII (SSN or credit card number). Remove sensitive data and respond again.",
};
}
}
return { decision: "stop" };
},
};Configuring Hooks
const agent = new Agent({
name: "secure-agent",
instructions: "...",
tools: [readTool, writeTool, searchTool],
hooks: {
preToolCall: [fileAccessGuard, destructiveOpGate],
postToolCall: [toolLogger, toolCache],
stop: [qualityCheck, piiGuard],
},
});Anti-Patterns to Detect
1. Business logic in hooks: Hooks should handle cross-cutting concerns (security, logging, validation), not core business logic. If a hook is making API calls or transforming data for the agent's task, it should be a tool instead.
// BAD: Business logic as a hook
const enrichmentHook: PostToolCallHook = {
async run({ toolOutput }) {
// This is business logic, not a cross-cutting concern
const enriched = await enrichWithCustomerData(toolOutput);
return enriched; // PostToolCall hooks shouldn't transform output
},
};
// GOOD: Make it a tool the agent can call explicitly
const enrichTool = {
name: "enrich_with_customer_data",
description: "Enrich search results with customer profile data",
inputSchema: { ... },
};2. Blocking without timeout: PreToolCall hooks that make external calls (approval APIs, validation services) without timeouts. A hanging hook blocks the entire agent.
// BAD: No timeout on external approval
const approvalHook: PreToolCallHook = {
async run({ toolName }) {
const approved = await externalApprovalAPI.check(toolName); // Could hang
return approved ? { decision: "allow" } : { decision: "block" };
},
};
// GOOD: Timeout with sensible default
const approvalHook: PreToolCallHook = {
async run({ toolName }) {
try {
const approved = await Promise.race([
externalApprovalAPI.check(toolName),
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000)),
]);
return approved ? { decision: "allow" } : { decision: "block" };
} catch {
return { decision: "block", message: "Approval service unavailable. Blocking by default." };
}
},
};3. StopHook infinite loops: A StopHook that always returns "continue" because the agent can't satisfy its condition. This creates an infinite loop.
// BAD: StopHook with unreachable condition
const strictHook: StopHook = {
async run({ response }) {
// If the agent can never produce a response with exactly 3 citations,
// this loops forever
const citations = countCitations(response);
if (citations !== 3) {
return { decision: "continue", message: "Must have exactly 3 citations" };
}
return { decision: "stop" };
},
};
// GOOD: StopHook with attempt tracking and fallback
let stopAttempts = 0;
const strictHook: StopHook = {
async run({ response }) {
stopAttempts++;
if (stopAttempts > 3) {
return { decision: "stop" }; // Accept after 3 attempts
}
const citations = countCitations(response);
if (citations < 1) {
return { decision: "continue", message: "Please include at least one citation." };
}
return { decision: "stop" };
},
};4. Hooks modifying agent state: Hooks should observe and gate, not modify the agent's tools, instructions, or conversation history mid-execution.
5. Too many hooks: Loading many hooks adds latency to every tool call. Keep hooks focused and minimal — 2-3 per hook type is a good ceiling.
6. No error handling in hooks: A hook that throws an exception can crash the entire agent. Always wrap hook logic in try/catch and fail safely.
MCP Integration
What Is MCP
The Model Context Protocol (MCP) is an open standard for connecting AI models to external tools and data sources. Instead of building custom tool integrations, you configure MCP servers that expose tools, resources, and prompts through a standardized protocol.
In the Claude Agent SDK, MCP servers are configured at the agent level and their tools become available alongside your custom tools.
Server Configuration
Project-Level Configuration (.mcp.json)
Project-level MCP config lives in .mcp.json at the project root and is shared across the team:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/dir"]
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
}
}
}
}User-Level Configuration
User-level MCP config goes in ~/.claude/settings.json and applies to all projects:
{
"mcpServers": {
"slack": {
"command": "npx",
"args": ["-y", "@anthropic/mcp-server-slack"],
"env": {
"SLACK_TOKEN": "${SLACK_TOKEN}"
}
}
}
}SDK-Level Configuration
In the Agent SDK, MCP servers are configured programmatically:
import { Agent, McpServer } from "claude-agent-sdk";
const agent = new Agent({
name: "dev-assistant",
instructions: "...",
tools: [customTool],
mcpServers: [
new McpServer({
name: "github",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
env: { GITHUB_TOKEN: process.env.GITHUB_TOKEN },
}),
],
});Custom vs. Community Servers
Community servers (from the MCP ecosystem): Pre-built integrations for GitHub, Slack, filesystem, databases, etc. Use these when available — they handle auth, pagination, and error cases.
Custom servers: Build your own when you need domain-specific tools that don't exist in the ecosystem. Follow the MCP specification for tool definitions.
// When to build custom vs. use community
// Community: GitHub, Slack, PostgreSQL, filesystem, Notion, Linear
// Custom: Your internal API, proprietary data format, company-specific workflowAnti-Patterns to Detect
1. Too many MCP servers: Loading 10+ MCP servers floods the model with tool definitions. Each server may expose multiple tools, and the model sees ALL of them. This degrades tool selection accuracy.
// BAD: Kitchen sink approach
{
"mcpServers": {
"github": { ... },
"slack": { ... },
"linear": { ... },
"notion": { ... },
"postgres": { ... },
"redis": { ... },
"s3": { ... },
"elasticsearch": { ... },
"datadog": { ... },
"pagerduty": { ... }
}
}
// GOOD: Only servers needed for this project's workflow
{
"mcpServers": {
"github": { ... },
"postgres": { ... }
}
}2. Tool name conflicts: Two MCP servers exposing tools with the same name. The model can't distinguish them and may call the wrong one.
// BAD: Both servers have a "search" tool
Server A: search (searches codebase)
Server B: search (searches documentation)
// GOOD: Namespaced or distinct names
Server A: search_code
Server B: search_docs3. Missing environment variables: Configuring MCP servers with env vars that aren't set. The server starts but fails on first use.
// BAD: No fallback or validation
{
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
}
}
// If GITHUB_TOKEN isn't set, every GitHub tool call fails at runtime
// GOOD: Validate at startup// Validate MCP server requirements before starting the agent
if (!process.env.GITHUB_TOKEN) {
throw new Error("GITHUB_TOKEN is required for the GitHub MCP server");
}4. Secrets in project config: Hardcoding tokens or secrets in .mcp.json which gets committed to version control.
// BAD: Secret in project config
{
"mcpServers": {
"github": {
"env": {
"GITHUB_TOKEN": "ghp_xxxxxxxxxxxx"
}
}
}
}
// GOOD: Reference environment variables
{
"mcpServers": {
"github": {
"env": {
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
}
}
}
}5. No server health checks: Not handling the case where an MCP server fails to start or crashes mid-session. The agent should degrade gracefully, not hang or crash.
6. Treating MCP tools as trusted: MCP server tools execute external code. Treat their outputs as untrusted input — validate and sanitize before using in sensitive operations.
Multi-Agent Patterns
Coordinator-Subagent Architecture
The most effective multi-agent pattern is a coordinator that delegates to specialized subagents. The coordinator understands the overall task and routes subtasks to agents with focused tool sets and instructions.
import { Agent } from "claude-agent-sdk";
// Subagent: Focused on code analysis
const codeAnalyzer = new Agent({
name: "code-analyzer",
model: "claude-sonnet-4-6-20250514",
instructions: `You analyze code for bugs and anti-patterns.
Focus only on correctness and reliability issues — ignore style.
Return findings as a structured list with file, line, severity, and description.`,
tools: [readFileTool, searchCodeTool, grepTool],
});
// Subagent: Focused on documentation
const docWriter = new Agent({
name: "doc-writer",
model: "claude-sonnet-4-6-20250514",
instructions: `You write technical documentation based on code analysis.
Write concise, accurate docs. Do not speculate about code behavior — only document what you can verify by reading the code.`,
tools: [readFileTool, writeFileTool],
});
// Coordinator: Orchestrates the workflow
const coordinator = new Agent({
name: "code-review-coordinator",
model: "claude-sonnet-4-6-20250514",
instructions: `You coordinate code reviews.
Process:
1. Use the code-analyzer subagent to find issues in the changed files
2. Use the doc-writer subagent to update documentation if needed
3. Synthesize findings into a review summary
Only delegate to subagents — do not read or modify files directly.`,
tools: [
codeAnalyzer.asTool("analyze_code", "Analyze code files for bugs and anti-patterns"),
docWriter.asTool("write_docs", "Write or update documentation based on findings"),
],
});Context Passing
Subagents start with a blank context. They have no memory of the coordinator's conversation, no access to what the user said, and no knowledge of what other subagents have done. Every piece of information a subagent needs must be explicitly passed in the spawn/query call.
If the coordinator learned the customer's order is #12345, the refund amount is $47.99, and the reason is "item damaged" — the subagent knows none of this unless you pass it. Without explicit context, subagents will either ask the user to repeat information (bad UX), hallucinate the missing details (dangerous), or fail silently with wrong data.
Pass only what each subagent needs. Over-sharing context wastes tokens and can confuse the subagent.
// BAD: Dumping everything to the subagent
const analyzerTool = {
name: "analyze",
async run(input: { task: string }) {
return await codeAnalyzer.query(
`Full conversation so far: ${entireConversation}\n\n` +
`All files in repo: ${allFiles}\n\n` +
`Task: ${input.task}`
);
},
};
// GOOD: Focused context for the specific subtask
const analyzerTool = {
name: "analyze",
async run(input: { files: string[]; focusAreas: string[] }) {
return await codeAnalyzer.query(
`Analyze these files for issues:\n` +
`Files: ${input.files.join(", ")}\n` +
`Focus areas: ${input.focusAreas.join(", ")}`
);
},
};Subagent Isolation
Subagents should be self-contained. They get their own tools, instructions, and context. They should NOT:
- Know about other subagents
- Share state with other subagents
- Assume context from the coordinator's conversation
// BAD: Subagent references another subagent
const subagentA = new Agent({
instructions: "After you're done, pass results to the doc-writer agent",
// Subagent A shouldn't know about subagent B
});
// GOOD: Subagent is self-contained
const subagentA = new Agent({
instructions: "Analyze the provided code and return structured findings. Your output will be used by other systems — be precise and complete.",
// No knowledge of other agents — the coordinator handles handoffs
});Structured Handoffs
The coordinator should pass structured data between subagents, not raw text:
// BAD: Passing raw text between subagents
const analysisResult = await codeAnalyzer.query("Analyze auth.ts");
await docWriter.query(`Here's what the analyzer found: ${analysisResult.content}`);
// docWriter has to parse free-text to understand findings
// GOOD: Structured handoff
const analysisResult = await codeAnalyzer.query("Analyze auth.ts");
// Coordinator parses the structured findings
const findings = parseFindings(analysisResult.content);
await docWriter.query(
`Update documentation for the following confirmed issues:\n` +
JSON.stringify(findings.filter(f => f.severity === "critical"), null, 2)
);When to Use Multi-Agent
Use multi-agent when:
- Different subtasks need different tool sets (separation of concerns)
- Subtasks have different trust levels (e.g., read-only analyst vs. read-write editor)
- You need to parallelize independent work streams
- Context isolation is important (subagent A's tool results shouldn't pollute subagent B's context)
Don't use multi-agent when:
- A single agent with 4-5 tools can handle the entire workflow
- The "subtasks" are sequential and share all context
- You're adding multi-agent purely for architectural elegance
Anti-Patterns to Detect
1. Circular delegation: Agent A delegates to Agent B, which delegates back to Agent A. This creates infinite loops.
// BAD: Potential circular delegation
const agentA = new Agent({
tools: [agentB.asTool("call_b", "...")],
});
const agentB = new Agent({
tools: [agentA.asTool("call_a", "...")], // Circular!
});2. Subagents knowing each other: Subagents that reference other subagents by name or assume they exist. All coordination should go through the coordinator.
3. Premature multi-agent: Splitting a simple task into coordinator + subagents when one agent would suffice. This adds latency (each subagent call is a full API round trip), complexity, and token cost.
// Signs you've over-architected:
// - Coordinator just passes through to a single subagent
// - Subagents have overlapping tool sets
// - You're passing the full context to every subagent anyway
// - The task completes in 2-3 tool calls total4. No result synthesis: Coordinator delegates to subagents but doesn't synthesize their results. It just concatenates outputs, losing the coordination value.
5. Shared mutable state: Subagents writing to the same file, database, or resource without coordination. This leads to race conditions and overwritten results.
// BAD: Both subagents write to the same file
const subagentA = new Agent({ tools: [writeFileTool] }); // Writes to report.md
const subagentB = new Agent({ tools: [writeFileTool] }); // Also writes to report.md
// Whoever writes last wins — other's work is lost
// GOOD: Subagents return results, coordinator writes
const subagentA = new Agent({ tools: [readFileTool, analysisTool] });
const subagentB = new Agent({ tools: [readFileTool, analysisTool] });
// Coordinator collects both results and writes the final report6. Unbalanced subagent workloads: One subagent doing 90% of the work while others sit idle. This suggests the decomposition is wrong.
Prompt Engineering for Agents
Explicit Over Implicit
Agent prompts must be explicit. Unlike chat, agents take autonomous actions — ambiguity leads to wrong tool calls, not wrong words.
// BAD: Implicit expectations
const agent = new Agent({
name: "code-reviewer",
instructions: "Review code and give feedback.",
tools: [readFileTool, searchTool, commentTool],
});
// GOOD: Explicit instructions with boundaries
const agent = new Agent({
name: "code-reviewer",
instructions: `You are a code reviewer for a TypeScript monorepo.
Your job:
1. Read the files that were changed (use read_file tool)
2. Check for: type safety issues, missing error handling, performance problems
3. Post comments on specific lines (use post_comment tool)
Rules:
- Only review files in src/ — ignore test files, config files, and generated code
- Do NOT suggest style changes (formatting is handled by Prettier)
- If you find a critical issue (security vulnerability, data loss risk), prefix the comment with [CRITICAL]
- If all files look good, post a single approval comment instead of nitpicking`,
tools: [readFileTool, searchTool, commentTool],
});System Prompt Structure
A well-structured agent system prompt has four sections:
1. IDENTITY: Who you are and your expertise
2. TASK: What you're doing right now (specific to this invocation)
3. TOOLS: When and how to use each tool (supplement tool descriptions)
4. RULES: Boundaries, constraints, and edge case handlingconst instructions = `
# Identity
You are a database migration assistant for a PostgreSQL database.
# Task
Analyze the requested schema change and generate a safe migration.
# Tools
- read_schema: Use this FIRST to understand the current table structure
- generate_migration: Use this to create the migration SQL. Always include a rollback.
- validate_migration: Use this AFTER generating to check for destructive operations
# Rules
- NEVER generate DROP TABLE or DROP COLUMN without explicit user confirmation
- Always check for foreign key dependencies before modifying a column
- If a migration would lock a table with >1M rows, flag it as HIGH RISK
- Include estimated execution time in your response
`;Few-Shot Examples
For complex tool usage patterns, include examples directly in the instructions:
const instructions = `
You extract structured data from documents.
Example interaction:
User: "Extract the invoice details from this PDF"
You: First, read the document:
[calls read_document tool with the file path]
Then, extract structured data:
[calls extract_data tool with the schema]
Finally, validate the extraction:
[calls validate tool to check required fields]
If extraction confidence is below 80%, flag uncertain fields rather than guessing.
`;Anti-Patterns to Detect
1. Over-prompting: Instructions so long and detailed that the model loses track of what matters. Prompts over ~500 words start to see diminishing returns.
// BAD: 2000 words of instructions covering every edge case
const instructions = `
You are an AI assistant. You should be helpful. When the user asks...
[200 lines of instructions]
Also remember to be concise. And thorough. And careful. And creative.
`;
// GOOD: Focused, prioritized instructions
const instructions = `
You analyze error logs and identify root causes.
Process:
1. Read the error log (read_log tool)
2. Search for related errors in the past 24h (search_logs tool)
3. Identify the root cause and suggest a fix
Priority: accuracy over speed. If unsure, say so.
`;2. Instruction hierarchy violations: Putting critical rules at the end where they get less attention. The model attends most strongly to the beginning and end of the context window, with a dip in the middle.
// BAD: Critical safety rule buried in the middle
const instructions = `
You are a helpful assistant.
[50 lines of general instructions]
NEVER execute DELETE queries without confirmation.
[50 more lines of instructions]
`;
// GOOD: Critical rules up front
const instructions = `
CRITICAL RULES:
- NEVER execute DELETE queries without user confirmation
- NEVER modify production data directly
You are a database assistant. [rest of instructions]
`;3. Conflicting instructions: Telling the agent to be both "thorough" and "concise" without clarifying when each applies.
4. Missing tool guidance: Defining tools but not explaining when to use each one in the instructions. Tool descriptions help, but in-prompt guidance catches edge cases.
5. Generic identity: "You are a helpful AI assistant" tells the model nothing useful. Specific identities lead to better tool selection and output quality.
// BAD
instructions: "You are a helpful AI assistant that can use tools."
// GOOD
instructions: "You are a senior security engineer reviewing infrastructure-as-code for misconfigurations. You specialize in AWS IAM policies and S3 bucket permissions."6. No stopping criteria: Not telling the agent when it's done. Without clear completion conditions, agents tend to over-iterate.
// BAD: When does this agent stop?
instructions: "Research the topic and provide information."
// GOOD: Clear completion criteria
instructions: `Research the topic. You are done when:
- You have found at least 3 authoritative sources
- You can answer the original question with specific facts
- OR you have exhausted available search results (stop after 5 searches with no new relevant results)`Structured Output & Schema Design
Use tool_use for Extraction
The most reliable way to get structured output from Claude is to define a tool whose schema matches your desired output format and use tool_choice to force the model to call it.
// BAD: Asking for JSON in the prompt — fragile, model may wrap in markdown
const result = await agent.query(
"Extract the customer info and return it as JSON: {name, email, phone}"
);
const data = JSON.parse(result.content); // May fail
// GOOD: Define an extraction tool with a strict schema
const extractCustomerTool = {
name: "extract_customer",
description: "Extract structured customer information from the provided text.",
inputSchema: {
type: "object",
properties: {
name: {
type: "string",
description: "Full name of the customer",
},
email: {
type: "string",
description: "Email address",
},
phone: {
type: "string",
description: "Phone number in E.164 format (e.g., +14155551234)",
},
},
required: ["name", "email"],
additionalProperties: false,
},
};
// Force the model to use this specific tool
const result = await client.messages.create({
model: "claude-sonnet-4-6-20250514",
messages: [{ role: "user", content: documentText }],
tools: [extractCustomerTool],
tool_choice: { type: "tool", name: "extract_customer" },
});
const extracted = result.content.find(b => b.type === "tool_use")?.input;Required vs. Optional vs. Nullable
Be intentional about which fields are required:
// BAD: Everything optional — model skips fields it's unsure about
const schema = {
type: "object",
properties: {
name: { type: "string" },
email: { type: "string" },
phone: { type: "string" },
address: { type: "string" },
notes: { type: "string" },
},
// No required array — everything is optional
};
// GOOD: Required fields are required, truly optional fields use nullable
const schema = {
type: "object",
properties: {
name: {
type: "string",
description: "Customer full name. Always present in invoices.",
},
email: {
type: "string",
description: "Customer email. Always present in invoices.",
},
phone: {
type: ["string", "null"],
description: "Phone number if present, null if not found in document.",
},
shippingAddress: {
type: ["string", "null"],
description: "Shipping address if different from billing, null otherwise.",
},
},
required: ["name", "email", "phone", "shippingAddress"],
additionalProperties: false,
};The pattern: make all fields required, but use ["type", "null"] for fields that may legitimately be absent. This forces the model to explicitly return null rather than silently omitting fields.
additionalProperties: false
Always set additionalProperties: false. Without it, the model may add extra fields you don't expect, and your downstream code won't be typed for them.
// BAD: Model might add random extra fields
const schema = {
type: "object",
properties: {
title: { type: "string" },
summary: { type: "string" },
},
};
// GOOD: Strict — only these fields allowed
const schema = {
type: "object",
properties: {
title: { type: "string" },
summary: { type: "string" },
},
required: ["title", "summary"],
additionalProperties: false,
};Enum Usage
Use enums whenever a field has a known set of valid values:
// BAD: Free-text category — model invents inconsistent values
category: {
type: "string",
description: "The category of the issue",
}
// GOOD: Constrained to valid values
category: {
type: "string",
description: "The category of the issue",
enum: ["bug", "feature", "performance", "security", "documentation"],
}Anti-Patterns to Detect
1. All-optional schemas: Every field is optional, so the model returns sparse, unpredictable objects.
2. Deep nesting: Schemas more than 2-3 levels deep confuse the model and lead to structural errors.
// BAD: 4 levels deep
const schema = {
type: "object",
properties: {
customer: {
type: "object",
properties: {
address: {
type: "object",
properties: {
geo: {
type: "object",
properties: {
lat: { type: "number" },
lng: { type: "number" },
},
},
},
},
},
},
},
};
// GOOD: Flattened where possible
const schema = {
type: "object",
properties: {
customerName: { type: "string" },
addressLine1: { type: "string" },
city: { type: "string" },
latitude: { type: ["number", "null"] },
longitude: { type: ["number", "null"] },
},
required: ["customerName", "addressLine1", "city", "latitude", "longitude"],
additionalProperties: false,
};3. Parsing raw JSON from text output: Using JSON.parse() on the model's text response instead of using tool_use extraction. This breaks when the model wraps JSON in markdown code blocks or adds explanation text.
4. Missing descriptions on schema fields: The model uses field descriptions to understand what to put in each field. Without them, it guesses based on the field name alone.
5. Inconsistent enum values: Using different casing or naming conventions across enums in the same schema (e.g., mixing "Bug", "FEATURE", "perf-issue").
Tool Design & Scoping
The Cardinal Rule
Tool descriptions are the primary selection mechanism. The model reads descriptions to decide which tool to call. A vague or misleading description means wrong tool selection — no amount of clever schema design can fix that.
// BAD: Vague description — model can't distinguish from other search tools
const searchTool = {
name: "search",
description: "Search for things",
inputSchema: { ... }
};
// GOOD: Specific, actionable description with clear scope
const searchTool = {
name: "search_codebase",
description: "Search the current repository for code matching a regex pattern. Returns file paths and matching lines. Use this when you need to find where a function, class, or pattern is defined or used. Do NOT use this for searching external documentation — use search_docs instead.",
inputSchema: { ... }
};The 4-5 Tool Rule
Agents work best with 4-5 focused tools. More than that and the model struggles to select correctly. If you need more capabilities, consider:
- Combining related operations into one tool with a mode parameter
- Splitting into subagents, each with their own focused tool set
- Removing tools that are rarely used
// BAD: Too many fine-grained tools
const tools = [
readFileTool,
writeFileTool,
appendFileTool,
deleteFileTool,
renameFileTool,
copyFileTool,
moveFileTool,
listDirectoryTool,
createDirectoryTool,
// Model wastes tokens deliberating between these
];
// GOOD: Consolidated file operations
const tools = [
fileOperationTool, // mode: read | write | delete | list
searchTool, // Find files and content
executeTool, // Run shell commands
summarizeTool, // Compress content for context management
];tool_choice Options
Control how the model selects tools:
// Let the model decide (default)
tool_choice: "auto"
// Force a specific tool call (useful for structured extraction)
tool_choice: { type: "tool", name: "extract_data" }
// Force the model to use SOME tool (any tool)
tool_choice: "any"
// Prevent tool use entirely
tool_choice: "none"Anti-pattern: Using tool_choice: "any" when you meant to use a specific tool. This forces tool use but doesn't guarantee the right tool.
Input Schema Best Practices
// BAD: No descriptions, loose types, optional everything
const schema = {
type: "object",
properties: {
q: { type: "string" },
n: { type: "number" },
opts: { type: "object" },
},
};
// GOOD: Descriptive names, constrained types, required fields, closed schema
const schema = {
type: "object",
properties: {
query: {
type: "string",
description: "The search query. Supports regex patterns.",
},
maxResults: {
type: "integer",
description: "Maximum number of results to return. Range: 1-100.",
minimum: 1,
maximum: 100,
},
fileType: {
type: "string",
description: "Filter results to this file extension.",
enum: ["ts", "js", "py", "go", "rs"],
},
},
required: ["query"],
additionalProperties: false,
};Structured Error Responses
Tools should return structured errors, not throw exceptions or return raw strings.
// BAD: Throwing or returning unstructured errors
async function searchTool(input: { query: string }) {
const results = await db.search(input.query);
if (!results.length) {
throw new Error("No results found"); // Model sees unhelpful error
}
return results;
}
// GOOD: Structured error response the model can reason about
async function searchTool(input: { query: string }) {
try {
const results = await db.search(input.query);
if (!results.length) {
return {
success: false,
error: "no_results",
message: `No results found for "${input.query}". Try broadening the search terms or checking for typos.`,
suggestions: ["Remove filters", "Use fewer keywords", "Check spelling"],
};
}
return { success: true, results };
} catch (err) {
return {
success: false,
error: "search_failed",
message: `Search failed: ${err.message}`,
};
}
}Anti-Patterns to Detect
1. Side-effect-only tools: Tools that perform an action but return nothing (or just "OK"). The model needs feedback to know what happened and decide next steps.
// BAD: No feedback
async function deployTool() {
await deploy();
return "OK";
}
// GOOD: Actionable feedback
async function deployTool() {
const result = await deploy();
return {
success: true,
url: result.url,
version: result.version,
duration: result.durationMs,
};
}2. Tools returning too much data: Dumping entire database tables or full file contents into the context window. Summarize or paginate.
// BAD: Returns entire table
async function listUsersTool() {
return await db.query("SELECT * FROM users"); // Could be 100k rows
}
// GOOD: Paginated with summary
async function listUsersTool(input: { page?: number; pageSize?: number }) {
const page = input.page ?? 1;
const pageSize = Math.min(input.pageSize ?? 20, 100);
const offset = (page - 1) * pageSize;
const [rows, total] = await Promise.all([
db.query(`SELECT id, name, email FROM users LIMIT $1 OFFSET $2`, [pageSize, offset]),
db.query(`SELECT COUNT(*) FROM users`),
]);
return { users: rows, page, pageSize, totalUsers: total, totalPages: Math.ceil(total / pageSize) };
}3. Ambiguous tool boundaries: Two tools that overlap in capability, so the model frequently picks the wrong one. Consolidate or sharpen descriptions.
4. Missing required fields: Making everything optional when the tool can't function without certain inputs. This leads to the model omitting critical parameters.
5. No enum constraints: Using raw strings where a fixed set of values is expected. The model may hallucinate invalid values.
#!/usr/bin/env bash
# scan-agent-patterns.sh — Static analysis for common Claude Agent SDK anti-patterns
# Usage: bash scan-agent-patterns.sh <directory>
# Outputs: [PATTERN_NAME] file:line — description
# Always exits 0 (informational, not a gate)
set -euo pipefail
TARGET="${1:-.}"
FOUND=0
if [ ! -d "$TARGET" ]; then
echo "Error: '$TARGET' is not a directory"
exit 0
fi
# Portable grep — prefer rg if available, fall back to grep -rn
if command -v rg &>/dev/null; then
RG="rg --no-heading --line-number"
else
RG="grep -rn"
fi
echo "=== Claude Agent SDK Pattern Scan ==="
echo "Target: $TARGET"
echo ""
# 1. new Agent( without maxIterations nearby
# Look for Agent instantiation, then check if maxIterations appears within 10 lines
while IFS=: read -r file line _rest; do
[ -z "$file" ] && continue
end=$((line + 10))
if ! sed -n "${line},${end}p" "$file" 2>/dev/null | grep -q "maxIterations\|max_iterations"; then
echo "[MISSING_MAX_ITERATIONS] ${file}:${line} — new Agent() without maxIterations within 10 lines"
FOUND=$((FOUND + 1))
fi
done < <($RG "new Agent\(" "$TARGET" --include='*.ts' --include='*.js' --include='*.py' --include='*.tsx' --include='*.jsx' 2>/dev/null || true)
# 2. tool_choice: "any" usage
while IFS=: read -r file line _rest; do
[ -z "$file" ] && continue
echo "[TOOL_CHOICE_ANY] ${file}:${line} — tool_choice set to \"any\" — did you mean a specific tool?"
FOUND=$((FOUND + 1))
done < <($RG 'tool_choice.*["\x27]any["\x27]' "$TARGET" --include='*.ts' --include='*.js' --include='*.py' --include='*.tsx' --include='*.jsx' 2>/dev/null || true)
# 3. JSON.parse on result.content or response.content
while IFS=: read -r file line _rest; do
[ -z "$file" ] && continue
echo "[JSON_PARSE_TEXT] ${file}:${line} — JSON.parse on content — use tool_use blocks for structured extraction"
FOUND=$((FOUND + 1))
done < <($RG 'JSON\.parse\(.*\.(content|text)' "$TARGET" --include='*.ts' --include='*.js' --include='*.tsx' --include='*.jsx' 2>/dev/null || true)
# 4. Catch blocks returning empty/null (silent failure)
while IFS=: read -r file line _rest; do
[ -z "$file" ] && continue
echo "[SILENT_CATCH] ${file}:${line} — catch block returns empty value — surface the error to the agent"
FOUND=$((FOUND + 1))
done < <($RG 'catch.*\{' "$TARGET" --include='*.ts' --include='*.js' --include='*.tsx' --include='*.jsx' -A 3 2>/dev/null | grep -E 'return\s*(""|\x27\x27|`{2}|\[\]|null|undefined)\s*;?' | sed 's/-[0-9]*-/:/;s/-[0-9]*:/:/;' || true)
# 5. Object with properties but missing additionalProperties
while IFS=: read -r file line _rest; do
[ -z "$file" ] && continue
end=$((line + 15))
if ! sed -n "${line},${end}p" "$file" 2>/dev/null | grep -q "additionalProperties"; then
echo "[MISSING_ADDITIONAL_PROPS] ${file}:${line} — schema object with properties but no additionalProperties: false"
FOUND=$((FOUND + 1))
fi
done < <($RG 'type.*["\x27]object["\x27]' "$TARGET" --include='*.ts' --include='*.js' --include='*.py' --include='*.tsx' --include='*.jsx' --include='*.json' 2>/dev/null | grep -v node_modules | grep -v "\.d\.ts" || true)
# 6. Short tool descriptions (under 20 chars)
while IFS=: read -r file line rest; do
[ -z "$file" ] && continue
# Extract the description string value
desc=$(echo "$rest" | sed -n 's/.*description.*["'\'']\([^"'\'']*\)["'\''].*/\1/p')
if [ -n "$desc" ] && [ ${#desc} -lt 20 ]; then
echo "[SHORT_DESCRIPTION] ${file}:${line} — tool description under 20 chars: \"${desc}\""
FOUND=$((FOUND + 1))
fi
done < <($RG 'description\s*[:=]' "$TARGET" --include='*.ts' --include='*.js' --include='*.py' --include='*.tsx' --include='*.jsx' 2>/dev/null | grep -v node_modules | grep -v "\.d\.ts" | grep -v "package.json" || true)
# 7. while(true) or for(;;) near API calls
while IFS=: read -r file line _rest; do
[ -z "$file" ] && continue
end=$((line + 10))
if sed -n "${line},${end}p" "$file" 2>/dev/null | grep -qE '(messages\.create|\.query\(|\.stream\(|fetch\(|axios)'; then
echo "[MANUAL_LOOP] ${file}:${line} — infinite loop near API call — use SDK agentic loop instead"
FOUND=$((FOUND + 1))
fi
done < <($RG '(while\s*\(\s*true\s*\)|for\s*\(\s*;\s*;\s*\))' "$TARGET" --include='*.ts' --include='*.js' --include='*.tsx' --include='*.jsx' 2>/dev/null || true)
# 8. Full conversation concatenation into query/stream
while IFS=: read -r file line _rest; do
[ -z "$file" ] && continue
echo "[CONTEXT_DUMP] ${file}:${line} — possible full conversation dump to query/stream — pass focused context instead"
FOUND=$((FOUND + 1))
done < <($RG '(messages|conversation|history)\s*\.\s*(join|map|reduce).*\.(query|stream)\(' "$TARGET" --include='*.ts' --include='*.js' --include='*.tsx' --include='*.jsx' 2>/dev/null || true)
echo ""
echo "=== Scan Complete ==="
echo "Patterns found: $FOUND"
if [ "$FOUND" -eq 0 ]; then
echo "No common anti-patterns detected. (This doesn't mean the code is perfect — run a full review for deeper analysis.)"
fi
exit 0