▶ Watch this course as a narrated video playlist (7 lessons, about 25 min)
Build intelligent, tool-using AI agents from scratch. Learn to create custom tools, orchestrate multi-agent systems, manage state, and deploy production-ready agentic applications.
Start Learning ↓Significant Anthropic news since this course was written. Newest first; entries older than 8 weeks are pruned.
claude-sonnet-5-5 keeps Sonnet 5's $2/$10 per Mtok ($0.20 cache reads, $2.50 cache writes) with 1M context and 128K max output. It inherits the Opus 5.5 contract: adaptive thinking is on by default and disabling it returns a 400 (if you ran Sonnet with thinking off, move to the new between_tools setting, the lowest available), forced tool_choice is rejected in favor of auto plus strict tools, and the minimum cacheable prompt drops from 1,024 to 512 tokens. Effort has five levels (low to max) and defaults to high on the API but medium in Claude Code and the apps, so set it explicitly if you compare results across surfaces.stop_details.category can be cyber, bio, frontier_llm, reasoning_extraction or general_harms, and server-side fallback retries only cyber and frontier_llm declines (higher-risk cyber requests visibly fall back to Sonnet 5). It is the first Sonnet with frontier cyber safeguards, so expect more refusals on benign security tasks. Your agent loop needs an explicit branch for each non-retried category rather than a generic retry.sec-default mod loads first so user mods can't override deny rules, and the /diff pane is now itself a mod. For SDK builders this is the same middleware pattern worth copying in your own harness: one interception layer for policy, redaction and audit, not checks scattered across tools.claude-opus-5-5 runs at roughly Fable 5.1's level for $4/$20 per Mtok with $0.20 cache reads, and Anthropic puts typical workload cost ~40% below Opus 5. Before you swap the model string, know what it rejects with a 400: thinking: disabled or a budget_tokens budget (adaptive thinking is always on and output_config.effort is the only control), forced tool_choice (any/tool — use auto plus strict tool use or structured outputs), non-default temperature/top_p/top_k, and the old computer_20251124 tool on the Claude API and Google Cloud (declare computer_toolset_20260801). The quiet one: default effort drops from high to medium, so an unchanged request silently reasons less — set effort explicitly and re-run your evals.thinking blocks, so any content[0].text or position-based stream handler must select blocks by type; max_tokens covers thinking plus text (start at 64K for xhigh/max). The narration the model writes between tool calls now arrives as progress-update thinking blocks that are empty at the default display — set display: "updates" (beta) or "summarized" if your UI shows progress. Thinking blocks must be passed back unmodified and are bound to the model and to an append-only conversation, so a router that falls back from Opus 5.5 to anything but Fable 5.1 or Mythos 5.1 runs those turns without them. The Agent SDK and Managed Agents already keep history append-only; Anthropic says Managed Agents need only the model-name change, and /claude-api migrate in Claude Code automates the rest.stop_details.category can now be "bio" or "reasoning_extraction" as well as "cyber"; most cybersecurity tasks are transparently re-routed to Opus 4.8. Server-side fallback won't retry reasoning_extraction refusals, so your loop must handle them. Smaller notes: the prompt-caching minimum drops to 512 tokens, Priority Tier is not supported on Opus 5.5, and zero data retention remains available.CLAUDE_CODE_MAX_MCP_DESCRIPTION_LENGTH to raise or lower the 2,048-character cap on MCP tool descriptions and server instructions, judges writes through a symlink by where they land, and stops auto mode from retrying an action a safety check declined to review. Anthropic also raised five-hour usage limits on Pro, Max, Team and seat-based Enterprise plans and gave subscribers a saveable rate-limit reset — welcome after the September 14 weekly-limit cut.maxEffortLevel cap from September 9 are the two levers that make a tighter limit survivable. The same release train continued shipping reliability, MCP, telemetry, and UX work across desktop, VS Code, web, Slack, and code review.maxEffortLevel and session plumbing (September 9): A new maxEffortLevel setting caps how much reasoning a session may spend, giving you a ceiling on cost and latency that survives whatever the model would otherwise choose — the natural complement to the per-message output_config.effort beta from last week's release. The same drop refreshes system-prompt rendering and improves resume, prompt-cache behavior, artifact publishing, and sandbox workflows. If you run unattended agents, the effort ceiling plus --restricted from August is now a reasonable default pair.claude-fable-5-1) is GA on the Claude API, Bedrock, Vertex, and Foundry at Fable 5's $10/$50 per Mtok, with 1M context, 128K max output, and always-on adaptive thinking; Mythos 5.1 is limited to the Cyber and Life Sciences Verification Programs and now powers Claude Security. It is the new default Fable model in Claude Code (High effort there, Medium in Cowork and claude.ai). Fable 5.1 tops Artificial Analysis's Intelligence Index at 66 and more than doubled Terminal-Bench-Science versus Fable 5 (24.7% → 52.6%) — it fixes root causes rather than taking shortcuts, at the cost of noticeably higher latency.tool_choice: "any" and "tool" return a 400 on Fable 5.1 and Mythos 5.1 — use auto plus strict tool use or structured outputs to guarantee schema-conformant inputs. (2) Thinking blocks are prefix-bound: editing anything before a thinking block (system prompt, tools, earlier turns) invalidates later blocks, and earlier models can't read Fable 5.1's thinking. (3) New betas: per-message effort via a role: "system" message with output_config.effort (header mid-conversation-output-config-2026-07-01) changes effort mid-conversation without breaking the cache, and thinking.display: "updates" (header thinking-display-updates-2026-08-18) returns the short progress notes Claude writes between tool calls as text. Text from both models now carries Anthropic's watermark, and code-execution images/videos carry C2PA credentials via the Files API.managedMcpServers managed setting so organizations can provision HTTP/SSE MCP servers to every user, unattended headless permission controls, GitLab MR recognition, JSON plugin validation, faster startup, and workflow views. Fixes include concurrent sessions no longer reverting each other's ~/.claude.json changes, SDK-provided MCP servers missing from the first turn, and a Fable 5.1 prompt-caching bug that re-sent post-tool-result context uncached on every turn. Auto-compact now triggers just before the 1M limit on Opus and Fable sessions. VS Code gains an add/remove MCP servers form./model picker now lists it as standard. Undo any reforecast that assumed a 50% increase. Also this week: a reported six-year, $35B compute deal with Nvidia-backed Lambda, and Salesforce's "Claudeforce" open beta is expected this month.--restricted mode (August 28): The new flag (or CLAUDE_CODE_RESTRICTED=1) removes the built-in tools that run commands or code, plus WebFetch. This is the first-party answer to embedding coding agents in environments where arbitrary execution is unacceptable — previously you had to deny-list tools yourself and hope you caught them all. The release also improves cross-session messaging (falling back to a private per-user /tmp directory) and adds usage credits for Enterprise.The Claude Agent SDK is a Python library that lets you build agentic applications powered by Claude. Unlike simple API calls where you send a prompt and get a response, the Agent SDK enables Claude to use tools, make decisions, and take actions in an autonomous loop.
At its core, the SDK implements an agent loop: Claude receives a prompt, decides which tools to call, executes those tools, observes the results, and continues until the task is complete. You don't need to manually orchestrate this — the SDK handles it for you.
When you call query(), the SDK starts an asynchronous iteration. Each iteration yields a
message that represents one step in the agent's reasoning or action cycle. The three
primary message types are:
SystemMessage — Internal SDK messages such as initialization events. The init
subtype includes the session_id you can use to resume conversations later.
AssistantMessage — Claude's thoughts, tool calls, and intermediate reasoning. You can inspect these to see what Claude is doing at each step.
ResultMessage — The final result when the agent loop completes. Check message.subtype
for "success" or "error" and read message.result for the output.
pip install claude-agent-sdk httpx
The SDK reads your API key from the ANTHROPIC_API_KEY environment variable.
Set it in your terminal before running any agent code.
export ANTHROPIC_API_KEY="sk-ant-your-key-here"
This is the simplest possible agent. It uses the Bash and Glob tools
to list files in the current directory. Notice how you iterate over messages asynchronously.
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions
async def main():
async for message in query(
prompt="What files are in this directory?",
options=ClaudeAgentOptions(allowed_tools=["Bash", "Glob"]),
):
if hasattr(message, "result"):
print(message.result)
asyncio.run(main())
To understand what the agent is doing under the hood, inspect each message type as it comes through the async iterator.
import asyncio
from claude_agent_sdk import (
query, ClaudeAgentOptions,
SystemMessage, AssistantMessage, ResultMessage
)
async def main():
async for message in query(
prompt="List all Python files in this directory",
options=ClaudeAgentOptions(allowed_tools=["Bash", "Glob"]),
):
if isinstance(message, SystemMessage):
print(f"[SYSTEM] subtype={message.subtype}")
if message.subtype == "init":
print(f" Session ID: {message.data['session_id']}")
elif isinstance(message, AssistantMessage):
print(f"[ASSISTANT] {message.content[:100]}...")
elif isinstance(message, ResultMessage):
print(f"[RESULT] status={message.subtype}")
print(message.result)
asyncio.run(main())
asyncio throughout. All agent interactions are async generators,
so you must use async for to iterate over messages.
Build an agent that reads a Python file and prints a concise summary of its contents — including classes, functions, and top-level logic.
summarizer.pyquery, ClaudeAgentOptions, and ResultMessagequery() with a prompt like: "Read the file example.py and provide a summary of its classes, functions, and purpose"["Read", "Glob"] so Claude can find and read filesResultMessage where subtype == "success"async for to iterate over messages from query()allowed_tools option controls which tools the agent can use@tool decoratorcreate_sdk_mcp_serveris_errormcp__{server}__{tool}A custom tool in the Agent SDK has four parts: a name, a description (used by Claude to decide when to call it), an input schema (defines the parameters), and a handler function (the async function that runs when the tool is called).
Tools are grouped into MCP servers. Even for a single tool, you create an MCP server
to host it. This server runs in-process — no separate server process needed. When
Claude wants to call your tool, it references it as mcp__{server_name}__{tool_name}.
The create_sdk_mcp_server() function creates a lightweight MCP server that runs inside
your Python process. You register tools with the @tool decorator, group them into a server,
and pass the server to your query() call via the mcp_servers option.
When a tool encounters an error, return a response with is_error=True. This tells Claude
that the tool failed, so it can try a different approach or report the error to the user — rather
than crashing the agent loop entirely.
This example defines a tool that fetches the current temperature from the Open-Meteo API. It demonstrates the full lifecycle: decorator, schema, async handler, and MCP server creation.
from claude_agent_sdk import tool, create_sdk_mcp_server
@tool(
"get_temperature",
"Get the current temperature at a location",
{"latitude": float, "longitude": float},
)
async def get_temperature(args):
import httpx
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": args["latitude"],
"longitude": args["longitude"],
"current": "temperature_2m",
"temperature_unit": "fahrenheit",
},
)
data = response.json()
return {
"content": [
{
"type": "text",
"text": f"Temperature: {data['current']['temperature_2m']}°F",
}
]
}
# Create the MCP server
weather_server = create_sdk_mcp_server(
name="weather",
version="1.0.0",
tools=[get_temperature],
)
Pass the MCP server to query() and allow the tool by its full name:
mcp__weather__get_temperature. The naming convention is always
mcp__{server_name}__{tool_name}.
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
async def main():
options = ClaudeAgentOptions(
mcp_servers={"weather": weather_server},
allowed_tools=["mcp__weather__get_temperature"],
)
async for message in query(
prompt="What's the temperature in San Francisco?",
options=options,
):
if isinstance(message, ResultMessage) and message.subtype == "success":
print(message.result)
asyncio.run(main())
Return an error response so Claude knows the tool failed and can adapt.
@tool(
"fetch_stock_price",
"Look up a stock price by ticker symbol",
{"ticker": str},
)
async def fetch_stock_price(args):
try:
# ... fetch stock price logic ...
price = 182.63
return {
"content": [{"type": "text", "text": f"{args['ticker']}: ${price}"}]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error: {str(e)}"}],
"is_error": True,
}
allowed_tools. For example,
"mcp__weather__*" allows all tools on the weather server.
Build a custom tool that queries a SQLite database and returns results.
users table (columns: id, name, email)query_users that accepts a search_term parameterLIKE clause on the name column"database" and test with a query like "Find all users named Alice"@tool decorator defines name, description, and input schema in one placecreate_sdk_mcp_server() to group tools into in-process MCP serversis_error: True to let Claude recover gracefully from tool failuresmcp__{server}__{tool}AgentDefinitionparent_tool_use_idA single agent with many tools and a broad prompt can get confused about priorities. The solution is subagents — smaller agents with focused prompts and limited tool sets. A coordinator agent decides which subagent to delegate to based on the task.
You define subagents using AgentDefinition, which specifies the agent's description
(used by the coordinator to decide when to delegate), its system prompt, and the tools it can access.
There are three main patterns for multi-agent coordination:
Specialization — Each subagent is an expert in a specific domain. A security reviewer focuses only on vulnerabilities; a code quality reviewer only evaluates maintainability.
Parallelization — The coordinator delegates to multiple subagents simultaneously to gather diverse perspectives, then synthesizes the results.
Escalation — Simple tasks are handled directly; complex or ambiguous tasks get escalated to a more capable or specialized subagent.
Each subagent call is tracked via a parent_tool_use_id, which lets you trace
which coordinator spawned which subagent. This is useful for logging and debugging
complex multi-agent interactions.
This example creates two specialist subagents — a security reviewer and a quality reviewer — and lets the coordinator delegate to each one as appropriate.
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, AgentDefinition
async def main():
async for message in query(
prompt="Review this codebase for security issues and code quality",
options=ClaudeAgentOptions(
allowed_tools=["Read", "Glob", "Grep", "Agent"],
agents={
"security-reviewer": AgentDefinition(
description="Security expert that finds vulnerabilities.",
prompt=(
"Analyze code for security issues: "
"injection, auth flaws, data exposure. "
"Report each finding with severity and recommendation."
),
tools=["Read", "Glob", "Grep"],
),
"quality-reviewer": AgentDefinition(
description="Code quality expert for maintainability reviews.",
prompt=(
"Review code for readability, DRY, SOLID principles. "
"Identify code smells, complex functions, and "
"suggest improvements."
),
tools=["Read", "Glob", "Grep"],
),
},
),
):
if hasattr(message, "result"):
print(message.result)
asyncio.run(main())
The coordinator agent receives the prompt and has access to the Agent tool, which lets
it spawn subagents. It sees the agent definitions you provided and decides which subagent(s) to
call. Each subagent gets its own conversation context with its specialized prompt and limited tool set.
The Agent tool must be included in the coordinator's allowed_tools for
delegation to work. Subagents don't need the Agent tool unless you want them to
further delegate (nested agents).
Build a multi-agent system for generating project documentation with a coordinator and two specialist agents.
"api-documenter" subagent that extracts function signatures, parameters, and return types"readme-writer" subagent that creates a project overview with setup instructions["Read", "Glob", "Grep"] toolsAssistantMessage events and the final ResultMessageAgentDefinition containing description, prompt, and toolsAgent tool to delegate tasks to specialists
Every time you call query(), the SDK emits a SystemMessage with subtype
"init" containing a session_id. This ID uniquely identifies the
conversation context — all the messages, tool calls, and results accumulated during that query.
To continue a conversation where you left off, pass the session ID to the resume option
in a subsequent query() call. The agent will have access to everything from the
previous session, as if you never stopped.
Session resume is essential for multi-turn interactions. For example, you might ask an agent to read and analyze a codebase in one query, then ask follow-up questions in a second query — without the agent needing to re-read all the files.
The CLAUDE.md file is a special convention for providing persistent project-level
context to agents. Place it in your project root, and agents will automatically read it for
background information about coding conventions, architecture decisions, and project-specific
instructions. It acts as long-term memory that persists across all sessions.
For production deployments, Managed Agents support memory stores — persistent key-value stores that survive across sessions. This lets agents remember user preferences, learned patterns, and accumulated knowledge over time, without relying solely on session continuity.
This example shows how to capture a session ID from the first query and resume it in a second query. The second query has full context from the first.
import asyncio
from claude_agent_sdk import (
query, ClaudeAgentOptions,
SystemMessage, ResultMessage
)
async def main():
session_id = None
# First query: read and analyze
print("--- Query 1: Analyzing auth module ---")
async for message in query(
prompt="Read and analyze the auth module in this project",
options=ClaudeAgentOptions(allowed_tools=["Read", "Glob"]),
):
if isinstance(message, SystemMessage) and message.subtype == "init":
session_id = message.data["session_id"]
print(f"Session ID: {session_id}")
if isinstance(message, ResultMessage):
print(message.result)
# Second query: follow up with full context
print("\n--- Query 2: Follow-up with context ---")
async for message in query(
prompt="Now find all security issues in what you just read",
options=ClaudeAgentOptions(resume=session_id),
):
if isinstance(message, ResultMessage):
print(message.result)
asyncio.run(main())
Place this file in your project root. Agents will pick it up automatically.
# CLAUDE.md - Project context for agents
## Project: MyApp API Server
- Framework: FastAPI
- Database: PostgreSQL with SQLAlchemy ORM
- Auth: JWT tokens with bcrypt password hashing
- Python version: 3.12
## Coding Conventions
- Use type hints on all function signatures
- Prefer async/await for I/O operations
- Tests go in tests/ directory, mirroring src/ structure
- Use pydantic models for request/response validation
## Important Notes
- Never modify migration files directly
- The auth module in src/auth/ is security-critical
- Environment variables are in .env (not committed)
In a managed deployment, memory stores allow persistent data across sessions. The agent can save and retrieve key-value pairs that survive between conversations.
# Conceptual example - Managed Agents API
# Memory stores persist data across sessions
# Agent saves a learned preference
await memory.set("user_preference_language", "python")
await memory.set("last_reviewed_file", "src/auth/middleware.py")
# In a later session, the agent retrieves it
lang = await memory.get("user_preference_language")
# Returns: "python"
Build an agent that performs iterative code analysis across multiple turns, remembering what it has already read.
SystemMessage init eventSystemMessage events with subtype "init"resume=session_id to continue where you left offThe Model Context Protocol (MCP) is an open standard for connecting AI agents to external tool providers. MCP servers expose tools over a standardized interface, and the Agent SDK can connect to them seamlessly.
There are two types of MCP servers you can use with the SDK:
In-process servers — Created with create_sdk_mcp_server() as
we saw in Module 2. These run inside your Python process with zero latency overhead.
External servers — Separate processes that communicate via stdio transport.
You specify a command to launch them (e.g., npx for Node.js servers), and the SDK
manages the subprocess lifecycle automatically.
External MCP servers use stdio (standard input/output) for communication. You provide
the command and args to launch the server process. The SDK starts
the process, sends tool calls via stdin, and reads results from stdout. This is the most
common transport for MCP servers.
A powerful pattern is combining built-in tools (like Read, Glob, Bash), custom in-process tools (your @tool definitions), and external MCP tools in a single agent. This gives Claude access to a wide range of capabilities while keeping each tool provider focused and maintainable.
Connect the Playwright MCP server for browser automation. This lets Claude navigate web pages, fill forms, click buttons, and extract content.
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
async def main():
options = ClaudeAgentOptions(
mcp_servers={
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"],
}
},
)
async for message in query(
prompt="Go to https://example.com and tell me the page title",
options=options,
):
if isinstance(message, ResultMessage):
print(message.result)
asyncio.run(main())
The filesystem MCP server provides sandboxed file operations with configurable permissions. Useful when you want more control than the built-in tools provide.
options = ClaudeAgentOptions(
mcp_servers={
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/path/to/allowed/directory",
],
}
},
)
You can register multiple MCP servers and mix them with built-in and custom tools. This gives your agent maximum capability.
import asyncio
from claude_agent_sdk import (
query, ClaudeAgentOptions, ResultMessage,
tool, create_sdk_mcp_server,
)
# Custom in-process tool
@tool("analyze_sentiment", "Analyze text sentiment", {"text": str})
async def analyze_sentiment(args):
# Simplified example
text = args["text"].lower()
if any(w in text for w in ["great", "love", "excellent"]):
sentiment = "positive"
elif any(w in text for w in ["bad", "hate", "terrible"]):
sentiment = "negative"
else:
sentiment = "neutral"
return {"content": [{"type": "text", "text": f"Sentiment: {sentiment}"}]}
custom_server = create_sdk_mcp_server(
name="analysis", version="1.0.0", tools=[analyze_sentiment]
)
async def main():
options = ClaudeAgentOptions(
# Built-in tools
allowed_tools=[
"Read", "Glob", "Grep",
"mcp__analysis__*", # Custom tools
"mcp__playwright__*", # External MCP tools
],
mcp_servers={
# In-process custom server
"analysis": custom_server,
# External MCP server (Playwright)
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"],
},
},
)
async for message in query(
prompt="Visit example.com and analyze the sentiment of the page text",
options=options,
):
if isinstance(message, ResultMessage):
print(message.result)
asyncio.run(main())
Build an agent that uses both an external MCP server and a custom tool together.
summarize_data that formats data into a reportcommand and argsallowed_tools wildcard patternsA production-grade agent application combines multiple concepts: custom tools for domain-specific functionality, subagents for delegating specialized tasks, hooks for observability and auditing, sessions for multi-turn interactions, and MCP servers for external integrations.
Hooks let you intercept agent events for logging, auditing, rate limiting, or
custom logic. The most common hook is PostToolUse, which fires after every tool
call. You can match specific tools using regex patterns with HookMatcher.
Hooks receive the tool input data, the tool use ID, and a context object. They are async functions that return a dictionary (which can be empty for pure logging hooks).
The permission_mode option controls how the agent handles potentially destructive
operations:
"default" — The agent asks for confirmation before writing files or running commands. Good for interactive use.
"acceptEdits" — The agent can write files without asking but still confirms before running shell commands. Good for automated code generation tasks.
"full" — The agent runs everything without confirmation. Only use this in sandboxed or trusted environments.
When deploying agents to production, consider: rate limiting (use hooks to throttle tool calls), error recovery (sessions let you resume after failures), cost control (limit max turns and token usage), and security (restrict tools to the minimum necessary set).
This complete application demonstrates every concept from the course. It uses subagents for different analysis types, custom tools for metrics, hooks for audit logging, and session management for iterative improvement.
import asyncio
from datetime import datetime
from claude_agent_sdk import (
query,
ClaudeAgentOptions,
AgentDefinition,
tool,
create_sdk_mcp_server,
HookMatcher,
SystemMessage,
ResultMessage,
)
# ── Custom Metrics Tool ──────────────────────────────────────
@tool(
"calculate_complexity",
"Calculate cyclomatic complexity of a Python file",
{"file_path": str},
)
async def calculate_complexity(args):
"""Analyze a Python file and return a complexity score."""
try:
with open(args["file_path"], "r") as f:
code = f.read()
# Count complexity indicators
branches = sum(
code.count(kw)
for kw in ["if ", "elif ", "for ", "while ", "except ", "with "]
)
functions = code.count("def ")
classes = code.count("class ")
score = branches + functions
level = (
"low" if score < 10
else "moderate" if score < 25
else "high"
)
return {
"content": [{
"type": "text",
"text": (
f"File: {args['file_path']}\n"
f"Complexity score: {score} ({level})\n"
f"Branches: {branches}, Functions: {functions}, "
f"Classes: {classes}"
),
}]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error: {str(e)}"}],
"is_error": True,
}
@tool(
"count_lines",
"Count lines of code, comments, and blanks in a file",
{"file_path": str},
)
async def count_lines(args):
"""Count different types of lines in a source file."""
try:
with open(args["file_path"], "r") as f:
lines = f.readlines()
total = len(lines)
blank = sum(1 for ln in lines if ln.strip() == "")
comments = sum(1 for ln in lines if ln.strip().startswith("#"))
code = total - blank - comments
return {
"content": [{
"type": "text",
"text": (
f"File: {args['file_path']}\n"
f"Total: {total}, Code: {code}, "
f"Comments: {comments}, Blank: {blank}"
),
}]
}
except Exception as e:
return {
"content": [{"type": "text", "text": f"Error: {str(e)}"}],
"is_error": True,
}
# Create the metrics MCP server
metrics_server = create_sdk_mcp_server(
name="metrics",
version="1.0.0",
tools=[calculate_complexity, count_lines],
)
# ── Audit Hook ────────────────────────────────────────────────
async def audit_hook(input_data, tool_use_id, context):
"""Log every tool call for auditing purposes."""
timestamp = datetime.now().isoformat()
tool_name = input_data.get("tool_name", "unknown")
with open("./agent_audit.log", "a") as f:
f.write(
f"{timestamp} | tool={tool_name} | "
f"id={tool_use_id}\n"
)
return {}
# ── Main Application ─────────────────────────────────────────
async def main():
session_id = None
print("=" * 60)
print(" Codebase Health Checker")
print("=" * 60)
# Phase 1: Initial comprehensive analysis
print("\n[Phase 1] Running comprehensive health check...\n")
async for message in query(
prompt=(
"Perform a comprehensive health check on this codebase. "
"Use the security auditor to find vulnerabilities, "
"the test coverage analyzer to review tests, and "
"the metrics tools to measure complexity. "
"Provide a detailed health report."
),
options=ClaudeAgentOptions(
allowed_tools=[
"Read", "Glob", "Grep", "Bash", "Agent",
"mcp__metrics__*",
],
mcp_servers={"metrics": metrics_server},
agents={
"security-auditor": AgentDefinition(
description=(
"Finds security vulnerabilities in code."
),
prompt=(
"Scan for common security issues: "
"SQL injection, XSS, hardcoded secrets, "
"insecure dependencies, auth flaws. "
"Rate each finding as critical/high/medium/low."
),
tools=["Read", "Glob", "Grep"],
),
"test-coverage-analyzer": AgentDefinition(
description=(
"Analyzes test coverage and suggests "
"improvements."
),
prompt=(
"Review test files and identify gaps. "
"Check for missing edge cases, untested "
"functions, and suggest new test cases."
),
tools=["Read", "Glob", "Grep", "Bash"],
),
},
hooks={
"PostToolUse": [
HookMatcher(
matcher=".*",
hooks=[audit_hook],
)
]
},
permission_mode="acceptEdits",
),
):
if isinstance(message, SystemMessage):
if message.subtype == "init":
session_id = message.data["session_id"]
if isinstance(message, ResultMessage):
print(message.result)
# Phase 2: Follow-up with session context
if session_id:
print("\n" + "=" * 60)
print("[Phase 2] Generating improvement plan...\n")
async for message in query(
prompt=(
"Based on the health check you just performed, "
"create a prioritized improvement plan. "
"List the top 5 actions ranked by impact."
),
options=ClaudeAgentOptions(resume=session_id),
):
if isinstance(message, ResultMessage):
print(message.result)
print("\n" + "=" * 60)
print(" Health check complete. See agent_audit.log for details.")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
Let's trace through the key design decisions in this capstone project:
Custom Tools — calculate_complexity and count_lines
provide domain-specific metrics that Claude can't compute on its own. They're registered on
the metrics MCP server using the pattern from Module 2.
Subagents — The security-auditor and test-coverage-analyzer
are specialists with focused prompts. The coordinator delegates to them automatically based
on the task decomposition (Module 3 patterns).
Hooks — The audit_hook logs every tool call with a timestamp,
creating an audit trail. The HookMatcher with ".*" matches all tools.
In production, you could add rate limiting or cost tracking here.
Sessions — Phase 1 captures the session ID, and Phase 2 resumes with full context. The improvement plan in Phase 2 references findings from Phase 1 without re-reading any files (Module 4 pattern).
Permission Mode — "acceptEdits" lets the agent write files
(like the audit log) without manual confirmation, appropriate for automated CI/CD use.
max_turns to ClaudeAgentOptions to prevent
runaway agent loops, and use hooks to track token consumption for cost management.
Take the Codebase Health Checker and extend it with new capabilities.
"dependency-checker" that reviews requirements.txt or pyproject.toml for outdated or vulnerable packagescheck_type_coverage that counts how many functions have type hints vs. those that don'tagent_audit.log to understand the agent's decision-making processdefault, acceptEdits, or full