AI Agent Development with Anthropic Claude Agent SDK

By · · AI Engineering

AI agent development has matured over the past year, and one of the more interesting additions is Anthropic's Claude Agent SDK (claude_agent_sdk), a Python library that provides a structured, opinionated way to build agents powered by Claude. Unlike raw API calls, the Agent SDK handles agent loops, tool orchestration, and multi-turn state management, letting you focus on what your agent actually does.

I will walk through the core concepts of the Claude Agent SDK and build an agent that can research topics, write summaries, and save results to files.

Why an agent SDK?

If you have built agents by manually wiring up Claude API calls, you know the pain: managing conversation history, handling tool call results, implementing retry logic, and structuring the loop that lets the model decide when it is done. The Claude Agent SDK abstracts all of this into a clean, declarative interface.

The key primitives are:

Setting up

Install the SDK and make sure you have your Anthropic API key set:

pip install claude-agent-sdk
export ANTHROPIC_API_KEY="your-key-here"

Defining tools

Tools are regular Python functions decorated with @tool. The SDK inspects the function signature and docstring to generate the tool schema that Claude sees.

from claude_agent_sdk import tool
import json
from pathlib import Path


@tool
def search_knowledge_base(query: str, max_results: int = 5) -> str:
    """Search the internal knowledge base for relevant documents.

    Args:
        query: The search query string.
        max_results: Maximum number of results to return.
    """
    # In production, this would hit a vector database or search index.
    # Simplified for demonstration purposes.
    documents = [
        {"title": "Agent Design Patterns", "content": "Agents benefit from clear tool boundaries..."},
        {"title": "Prompt Engineering for Agents", "content": "System prompts should define the agent's role..."},
        {"title": "Error Handling in Agents", "content": "Agents should gracefully handle tool failures..."},
    ]
    results = [d for d in documents if query.lower() in d["title"].lower() or query.lower() in d["content"].lower()]
    return json.dumps(results[:max_results])


@tool
def save_to_file(filename: str, content: str) -> str:
    """Save content to a file on disk.

    Args:
        filename: The name of the file to write.
        content: The text content to save.
    """
    output_dir = Path("./output")
    output_dir.mkdir(exist_ok=True)
    file_path = output_dir / filename
    file_path.write_text(content, encoding="utf-8")
    return f"File saved successfully to {file_path}"

Notice that the docstrings matter. Claude reads them to understand when and how to use each tool. Clear, concise descriptions with typed arguments lead to much better tool selection by the model.

Creating the agent

With tools defined, you create an Agent instance that ties everything together:

from claude_agent_sdk import Agent

researcher = Agent(
    model="claude-sonnet-4-20250514",
    system_prompt="""You are a research assistant. Your job is to:
    1. Search the knowledge base for relevant information.
    2. Synthesize findings into a clear, well-structured summary.
    3. Save the final summary to a file when the user asks.

    Always cite which documents you found relevant. Be thorough but concise.""",
    tools=[search_knowledge_base, save_to_file],
    max_turns=10,
)

The max_turns parameter is a safety valve. It caps the number of agent loop iterations, preventing runaway tool calls. In practice, most tasks complete in 3-5 turns.

Running the agent loop

The simplest way to run an agent is with agent.run(), which blocks until the agent produces a final text response:

response = researcher.run("Search for information about agent design patterns and save a summary.")
print(response.content)

Under the hood, the agent loop works like this:

  1. Send the user message and system prompt to Claude.
  2. If Claude responds with a tool call, execute the tool and feed the result back.
  3. Repeat until Claude responds with text only (no more tool calls).
  4. Return the final response.

You can also stream the agent's progress for real-time feedback:

for event in researcher.stream("Research error handling in agents and summarize."):
    if event.type == "tool_call":
        print(f"Calling tool: {event.tool_name}({event.arguments})")
    elif event.type == "tool_result":
        print(f"Tool returned: {event.result[:100]}...")
    elif event.type == "text":
        print(event.content, end="", flush=True)

Structured outputs

Structured outputs let you skip free-text parsing entirely. You define a Pydantic model and the agent returns data that conforms to it:

from pydantic import BaseModel
from claude_agent_sdk import Agent


class ResearchReport(BaseModel):
    title: str
    summary: str
    key_findings: list[str]
    sources: list[str]
    confidence_score: float


report_agent = Agent(
    model="claude-sonnet-4-20250514",
    system_prompt="You are a research agent. Produce structured research reports.",
    tools=[search_knowledge_base],
    output_schema=ResearchReport,
)

report = report_agent.run("Research best practices for AI agent development.")
print(f"Title: {report.parsed.title}")
print(f"Confidence: {report.parsed.confidence_score}")
for finding in report.parsed.key_findings:
    print(f"  - {finding}")

The output_schema parameter instructs the SDK to use constrained decoding, guaranteeing that the final response is valid JSON matching your Pydantic model. This eliminates an entire class of parsing bugs.

Multi-turn conversations

Agents often need to maintain context across multiple user interactions. The SDK handles this through conversation sessions:

from claude_agent_sdk import Agent, Session

agent = Agent(
    model="claude-sonnet-4-20250514",
    system_prompt="You are a helpful research assistant.",
    tools=[search_knowledge_base, save_to_file],
)

session = Session(agent)

# First turn
response1 = session.send("What do we know about agent design patterns?")
print(response1.content)

# Second turn - the agent remembers the previous context
response2 = session.send("Now save those findings to a file called patterns.md")
print(response2.content)

# Inspect the full conversation history
for message in session.history:
    print(f"[{message.role}] {message.content[:80]}...")

The Session object maintains the full message history, including tool calls and results, so each subsequent turn has full context of what happened before.

Error handling and retries

Production agents need to handle failures gracefully. The SDK provides hooks for tool errors:

from claude_agent_sdk import Agent, ToolError


@tool
def risky_operation(url: str) -> str:
    """Fetch data from a URL."""
    try:
        # Simulate a network call
        if "invalid" in url:
            raise ConnectionError("Could not reach server")
        return "Data fetched successfully"
    except ConnectionError as e:
        raise ToolError(f"Failed to fetch URL: {e}. Try a different URL or approach.")

When a tool raises ToolError, the SDK sends the error message back to Claude as a tool result with an error flag. Claude then decides how to proceed, often retrying with different parameters or informing the user of the issue.

Best practices

After building several agents with this SDK, here are the patterns I have found most effective:

Wrapping up

The Claude Agent SDK removes a lot of boilerplate from agent development. Declarative tool definitions, automatic agent loops, structured outputs, and session management make it possible to go from idea to working agent quickly. If you have been building agents with raw API calls, the SDK is worth trying. It strikes a good balance between flexibility and convenience.

In a future post, I will explore how to combine multiple agents into a coordinated multi-agent system using the SDK's delegation features.