# How to Build an AI Agent with the Claude Agent SDK

- Tool: Claude Agent SDK
- Difficulty: Intermediate
- Time required: 45-60 min
- Compatibility: Python 3.10+ (or Node.js 20+ for TypeScript), claude-agent-sdk, Claude API key, any OS
- Last updated: November 2026

## TL;DR

The Claude Agent SDK lets you build a Claude-powered agent as a Python or TypeScript process you control. Install claude-agent-sdk, define custom tools with @tool and create_sdk_mcp_server, wire them into ClaudeAgentOptions, then gate the risky one behind a canUseTool approval callback. This guide builds a support agent that looks up orders and only issues a refund once a human says yes. Budget 45-60 minutes and a few cents of API usage per test run.

## What you'll build

You'll build a support agent for an e-commerce store. It can look up an order by ID from a small in-memory order store, and when a customer is owed money, it can issue a refund. The twist, and the whole point of this guide, is that look_up_order runs automatically but issue_refund always stops and waits for a human to type y or n before it executes. That's the shape almost every production support agent needs: read freely, write with a human in the loop.

Under the hood, the Claude Agent SDK runs Claude Code's own agent loop as a library. You define tools with the @tool decorator, wrap them in an in-process MCP server with create_sdk_mcp_server, and pass that server into ClaudeAgentOptions. Every tool call then goes through a permission pipeline: hooks, deny rules, ask rules, permission mode, allow rules, and finally your canUseTool callback if nothing else resolved it. You only need to write code for the step that matters, the callback, and let the SDK handle the rest.

Pick the Claude Agent SDK over LangChain, the OpenAI Agents SDK, or CrewAI when you specifically want Claude's own harness, fine-grained tool permissions, and a model-agnostic escape hatch into any MCP server, and you're comfortable running the agent as a process you own rather than a hosted canvas. If you'd rather not write code at all, see the n8n guide instead.

## Before you start

- Python 3.10+ and a terminal
- An Anthropic API key (ANTHROPIC_API_KEY) with available credits
- pip install claude-agent-sdk httpx
- Basic comfort with async/await in Python

## Step-by-step guide

### 1. Install the SDK and set your API key

Install the Python package and httpx, which the tool handlers use for HTTP calls in a real deployment (this guide's order store is in-memory, but you'll swap it for a real API later). Export your Anthropic API key as an environment variable rather than hardcoding it anywhere; the SDK reads it automatically and never logs it. If you're on TypeScript, install @anthropic-ai/claude-agent-sdk with npm instead and the same concepts apply one-to-one.

```
pip install claude-agent-sdk httpx
export ANTHROPIC_API_KEY="sk-ant-..."
```

**Expected result:** pip install finishes with no errors and `python -c "import claude_agent_sdk"` runs silently.

### 2. Define the read-only tool: look up an order

Define look_up_order with the @tool decorator: a name, a description Claude reads to decide when to call it, a dict input schema, and an async handler. Mark it readOnlyHint=True in its annotations since it only reads data, which lets Claude batch it with other read-only calls and signals to your own permission logic later that this one is safe to auto-approve. Return a dict with a content array, the shape every tool handler must return.

```
ORDERS = {"ORD-1042": {"status": "delivered", "total": 89.00, "item": "Ceramic planter"}}

@tool(
    "look_up_order",
    "Look up an order by ID. Returns its status, total, and item.",
    {"order_id": str},
    annotations=ToolAnnotations(readOnlyHint=True),
)
async def look_up_order(args: dict) -> dict:
    order = ORDERS.get(args["order_id"])
    if not order:
        return {"content": [{"type": "text", "text": "Not found"}], "is_error": True}
    return {"content": [{"type": "text", "text": f"{order}"}]}
```

**Expected result:** A Python function decorated as a tool, importable and callable on its own for a quick unit test.

### 3. Define the sensitive tool: issue a refund

issue_refund follows the same shape but takes order_id, amount, and reason, and is annotated destructiveHint=True. The handler itself doesn't check for approval; that happens one layer up, in the permission callback you'll write in step 5. Keeping the handler dumb (it just performs the refund when called) and the approval logic separate is what makes this pattern reusable across every destructive tool you add later.

```
@tool(
    "issue_refund",
    "Issue a refund for an order. Always requires human approval before it runs.",
    {"order_id": str, "amount": float, "reason": str},
    annotations=ToolAnnotations(destructiveHint=True),
)
async def issue_refund(args: dict) -> dict:
    # In production this calls your payments API (e.g. Stripe refunds.create)
    text = f"Refunded ${args['amount']:.2f} on {args['order_id']} ({args['reason']})"
    return {"content": [{"type": "text", "text": text}]}
```

**Expected result:** A second tool function, same shape as the first, ready to be wrapped into the same server.

### 4. Wrap both tools in an in-process MCP server

create_sdk_mcp_server bundles your tool functions into a server that runs inside your own process, not as a separate subprocess. Claude sees each tool's fully qualified name as mcp__{server_name}__{tool_name}, which is exactly the string you'll use in allowed_tools next. This is the only glue code required to turn a Python function into something Claude can call.

```
support_server = create_sdk_mcp_server(
    name="support",
    version="1.0.0",
    tools=[look_up_order, issue_refund],
)
```

**Expected result:** support_server is a dict-like config object ready to pass into ClaudeAgentOptions.mcp_servers.

### 5. Write the approval callback and lock down permissions

can_use_tool fires whenever Claude wants to run a tool that nothing earlier in the permission pipeline already resolved. Because look_up_order is listed in allowed_tools, it skips this callback entirely and runs immediately. issue_refund is deliberately left out of allowed_tools, so every call to it falls through to this function. Here it's a terminal y/n prompt; in production swap input() for a Slack message or a PermissionRequest hook that pages whoever owns refund approvals.

```
async def approve_refund(tool_name, input_data, context):
    if tool_name != "mcp__support__issue_refund":
        return PermissionResultAllow(updated_input=input_data)
    print(f"[APPROVAL NEEDED] ${input_data['amount']:.2f} on {input_data['order_id']}")
    decision = input("Approve? (y/n): ").strip().lower()
    if decision == "y":
        return PermissionResultAllow(updated_input=input_data)
    return PermissionResultDeny(message="Refund rejected. Offer store credit instead.")

options = ClaudeAgentOptions(
    mcp_servers={"support": support_server},
    allowed_tools=["mcp__support__look_up_order"],
    can_use_tool=approve_refund,
    permission_mode="default",
)
```

**Expected result:** An options object where look_up_order is pre-approved and issue_refund always reaches approve_refund.

### 6. Run the agent with ClaudeSDKClient

Use ClaudeSDKClient instead of the one-shot query() function because this is a conversation, not a single exchange: the agent needs to remember the order it just looked up when it decides whether and how much to refund. Open it as an async context manager, send one query, and stream the response with receive_response(), which yields messages until the final ResultMessage.

```
async with ClaudeSDKClient(options=options) as client:
    await client.query(
        "Customer says order ORD-1042 arrived cracked and wants $45 back. Handle it."
    )
    async for message in client.receive_response():
        if isinstance(message, AssistantMessage):
            for block in message.content:
                if isinstance(block, ToolUseBlock):
                    print(f"[tool call] {block.name}({block.input})")
        elif isinstance(message, ResultMessage) and message.subtype == "success":
            print(message.result)
```

**Expected result:** The script prints a look_up_order tool call, then an [APPROVAL NEEDED] prompt, and after you type y, a final summary from the agent.

### 7. Harden it: swap the mock store and add an audit trail

Two changes separate this from a toy: replace ORDERS with real calls to your orders and payments APIs inside the handlers (wrap them in try/except and return is_error: True on failure so Claude can react instead of crashing), and log every ToolUseBlock plus the approval decision to your observability stack (Langfuse, LangSmith, or even a flat file) before the callback returns. That audit trail matters more than the approval UI itself when someone asks why a refund went out six months from now.

```
async def issue_refund(args: dict) -> dict:
    try:
        resp = await payments_client.create_refund(**args)
    except Exception as e:
        return {"content": [{"type": "text", "text": str(e)}], "is_error": True}
    return {"content": [{"type": "text", "text": f"Refund {resp.id} issued"}]}
```

**Expected result:** A handler that fails loudly to Claude (not to your process) and a log line for every tool call, approved or denied.

## Complete code example

File: `support_agent.py`

```python
"""Support agent: looks up orders, issues refunds only after a human approves.
Run: python support_agent.py (pip install claude-agent-sdk httpx; set ANTHROPIC_API_KEY)
"""
import asyncio
from typing import Any

from claude_agent_sdk import (
    tool,
    ToolAnnotations,
    create_sdk_mcp_server,
    ClaudeAgentOptions,
    ClaudeSDKClient,
    AssistantMessage,
    ToolUseBlock,
    ResultMessage,
)
from claude_agent_sdk.types import (
    PermissionResultAllow,
    PermissionResultDeny,
    ToolPermissionContext,
)

# Mock order store - swap for a real orders API/DB in production
ORDERS = {
    "ORD-1042": {"status": "delivered", "total": 89.00, "item": "Ceramic planter (large)"},
    "ORD-1099": {"status": "in_transit", "total": 34.50, "item": "Watering can"},
}


@tool(
    "look_up_order",
    "Look up an order by ID. Returns its status, total, and item.",
    {"order_id": str},
    annotations=ToolAnnotations(readOnlyHint=True),
)
async def look_up_order(args: dict[str, Any]) -> dict[str, Any]:
    order = ORDERS.get(args["order_id"])
    if not order:
        return {
            "content": [{"type": "text", "text": f"No order found for {args['order_id']}"}],
            "is_error": True,
        }
    return {
        "content": [{
            "type": "text",
            "text": f"{args['order_id']}: {order['status']}, ${order['total']:.2f}, {order['item']}",
        }]
    }


@tool(
    "issue_refund",
    "Issue a refund for an order. This always requires human approval before it runs.",
    {"order_id": str, "amount": float, "reason": str},
    annotations=ToolAnnotations(destructiveHint=True),
)
async def issue_refund(args: dict[str, Any]) -> dict[str, Any]:
    # In production this calls your payments API (e.g. Stripe refunds.create)
    return {
        "content": [{
            "type": "text",
            "text": f"Refunded ${args['amount']:.2f} on {args['order_id']} ({args['reason']})",
        }]
    }


support_server = create_sdk_mcp_server(
    name="support", version="1.0.0", tools=[look_up_order, issue_refund]
)

async def approve_refund(tool_name: str, input_data: dict, context: ToolPermissionContext):
    if tool_name != "mcp__support__issue_refund":
        return PermissionResultAllow(updated_input=input_data)

    print(f"\n[APPROVAL NEEDED] Refund ${input_data['amount']:.2f} "
          f"on {input_data['order_id']}: {input_data['reason']}")
    decision = input("Approve? (y/n): ").strip().lower()

    if decision == "y":
        return PermissionResultAllow(updated_input=input_data)
    return PermissionResultDeny(
        message="Refund rejected by support lead. Offer store credit instead."
    )


SYSTEM_PROMPT = (
    "You are a support agent for an e-commerce store. Always call look_up_order "
    "before making any claim about an order. Never tell a customer you refunded "
    "something without actually calling issue_refund."
)


async def main():
    options = ClaudeAgentOptions(
        system_prompt=SYSTEM_PROMPT,
        mcp_servers={"support": support_server},
        allowed_tools=["mcp__support__look_up_order"],  # issue_refund is NOT pre-approved
        can_use_tool=approve_refund,
        permission_mode="default",
    )

    async with ClaudeSDKClient(options=options) as client:
        await client.query(
            "Customer says order ORD-1042 arrived cracked and wants $45 back. Handle it."
        )
        async for message in client.receive_response():
            if isinstance(message, AssistantMessage):
                for block in message.content:
                    if isinstance(block, ToolUseBlock):
                        print(f"[tool call] {block.name}({block.input})")
            elif isinstance(message, ResultMessage) and message.subtype == "success":
                print(f"\nAgent: {message.result}")


if __name__ == "__main__":
    asyncio.run(main())
```

## Common mistakes

- **Adding issue_refund to allowed_tools 'just to make testing faster' and forgetting to remove it** — undefined Fix: Keep destructive tools out of allowed_tools permanently. Test the approval flow itself instead of bypassing it; that flow is the feature.
- **Using the one-shot query() function across multiple messages in the same conversation** — undefined Fix: query() starts a fresh session each call by default. Use ClaudeSDKClient when the agent needs to remember what it just looked up.
- **Setting permission_mode='bypassPermissions' to 'simplify' local testing** — undefined Fix: bypassPermissions approves almost everything, including your destructive tools, before canUseTool is even consulted. Use 'default' and pre-approve only read-only tools.
- **Letting a handler raise an uncaught exception instead of returning is_error: True** — undefined Fix: An uncaught exception still reaches Claude as a raw string with no context. Catch it and compose a message that tells Claude what to try next.
- **Writing the approval prompt as a bare terminal input() and shipping it to production** — undefined Fix: A terminal prompt blocks the whole process and nobody sees it. Route approval through Slack, a ticket, or a PermissionRequest hook that notifies the right person.

## Best practices

- Default to permission_mode='default' and pre-approve only read-only tools through allowed_tools; approve destructive ones per call.
- Set readOnlyHint and destructiveHint accurately on every tool. Your own permission logic and Claude's auto mode both rely on them being true.
- Keep tool handlers dumb and put approval logic in canUseTool. Mixing the two makes every new destructive tool require re-implementing the gate.
- State the tool-use rule explicitly in the system prompt: 'never claim you refunded something without calling issue_refund.' Models will narrate actions they didn't take if you let them.
- Log every ToolUseBlock and the approval decision before the callback returns, independent of whether the call was approved.
- Scope mcp_servers and allowed_tools as narrowly as the task needs. A support agent has no reason to see a Bash tool.

## Use cases

### undefined



### undefined



### undefined



## Frequently asked questions

### What's the difference between the Claude Agent SDK and just calling the Claude API directly?

The Claude API gives you raw messages and tool_use blocks; you write the loop that calls tools, feeds results back, and repeats. The Claude Agent SDK is Claude Code's agent loop packaged as a library: it handles that loop, plus permissions, hooks, sessions, and built-in tools, so you write tool handlers and policy instead of loop plumbing.

### Do I need Claude Code (the CLI) installed to use the Claude Agent SDK?

No. The SDK bundles a native Claude Code binary internally, but you install it as a package (claude-agent-sdk or @anthropic-ai/claude-agent-sdk) and never touch the CLI directly. You can say your product is 'Powered by Claude,' but not 'Claude Code.'

### Can this agent call external MCP servers in addition to my custom tools?

Yes. mcp_servers accepts both the in-process servers you build with create_sdk_mcp_server and connections to external MCP servers (filesystem, GitHub, Slack, a hosted server over Streamable HTTP). Mix both in the same options object.

### How much does a conversation like this cost in tokens?

On Claude Sonnet 5.5 ($2/M input, $10/M output), a short support exchange with one tool call and a few thousand tokens of system prompt and history runs about $0.01-0.03 per turn uncached, less with prompt caching once your system prompt and tool definitions stabilize.

### Does permission_mode='bypassPermissions' ever still ask for approval?

Rarely. It skips canUseTool for almost everything except recognized critical-path file removals and, outside a cloud session, tools you've explicitly marked always_ask. A destructive tool like issue_refund runs with no prompt unless you've marked it always_ask yourself, so don't rely on this mode for approval gating.

### Can someone build this for me instead of doing it myself?

Yes. RapidDev builds production agents on the Claude Agent SDK, OpenAI Agents SDK, and n8n, including the approval and audit-trail pieces most tutorials skip. A free 30-minute consultation is the fastest way to find out what your version would take.

---

Source: https://www.rapidevelopers.com/build-ai-agent/how-to-build-an-ai-agent-with-claude-agent-sdk
© RapidDev — https://www.rapidevelopers.com/build-ai-agent/how-to-build-an-ai-agent-with-claude-agent-sdk
