Claude Agent SDKclaude-agent-sdk 0.2.157 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
46 small wins to finish your pathNext lesson →

The project and its tools

The project is a shop's help desk that answers order questions with two tools of its own, hands a shipping question to a subagent, and holds back any refund nobody approved. This lesson is the tools; the rules that gate them come next.

Last updated: 20 Oct, 2026 · Claude Agent SDK

Order A17 shipped on 3 March. Order B22 does not exist, which is the answer a customer hits most often. A refund needs a person's approval first, and A17 is the only order that has one. Everything the desk does comes out of those three facts.

Four files hold it, and every one of them is in this lesson or the next.

  • shop_tools.py, written below: the two tools and the server that offers them.
  • shop_desk.py, saved below: the stand-in that answers the SDK in Claude Code's place, with its scripted model in the same file.
  • watch.py, saved below: its describe turns one message into one readable line.
  • desk.py, written in the next lesson: the permission callback, the audit hook, the subagent and the options.

Looking an order up

A tool is an ordinary async function with a decorator on it. The decorator takes the name Claude will call, the description Claude reads when it decides whether this is the right tool, and the shape of the arguments.

python
from claude_agent_sdk import create_sdk_mcp_server, tool

ORDERS = {"A17": "Order A17 shipped on 3 March."}


# the description is what Claude reads to decide this is the right tool
@tool("lookup_order", "Look up an order's shipping status by its id, such as A17.",
      {"order_id": str})
async def lookup_order(args):
    order = args["order_id"]

    # an unknown id is answered in words, not raised, so Claude can pass it on
    status = ORDERS.get(order, f"No order {order} was found.")
    return {"content": [{"type": "text", "text": status}]}

An order that is not in ORDERS comes back as a sentence rather than an exception. Claude can read a sentence and pass it on to the customer; an exception would end the tool call and leave it with an error to work around.

Refunding an order

The refund tool is the one worth guarding. It appends to a list here, which stands in for whatever a real shop would do with money, and it has no idea whether anyone approved the refund. That decision belongs outside the tool.

python
REFUNDED = []


@tool("refund_order", "Refund an order by its id. The money goes back to the customer.",
      {"order_id": str})
async def refund_order(args):
    REFUNDED.append(args["order_id"])
    text = f"Order {args['order_id']} has been refunded."
    return {"content": [{"type": "text", "text": text}]}

One line puts both tools behind a server that runs inside your own process, with no second program to start.

python
shop = create_sdk_mcp_server("shop", tools=[lookup_order, refund_order])

The server is named shop, so Claude sees the tools as mcp__shop__lookup_order and mcp__shop__refund_order. That full name is what every rule, hook and log line in the next two lessons matches on.

Saving the stand-in the project runs against

Every run in this part hands query() a transport of its own, so nothing starts a real process and nothing needs a key. Save the seven tabs below, in order, into shop_desk.py beside your lesson files. It is the desk grown through the course, with every piece the project uses in one file.

import asyncio
import itertools
import json
import re

from claude_agent_sdk import ClaudeAgentOptions, Transport

ORDER = re.compile(r"\b[A-Z]\d+\b")
SESSIONS = itertools.count(1)


class ShopModel:
    """Stands in for Claude: reads the conversation, picks the next step."""

    def __init__(self, script=None):
        self.script = list(script or [])

    def reply(self, said, results, tools=(), agents=()):
        # a scripted reply lets a lesson force a step the stand-in would not pick
        if self.script:
            return self.script.pop(0)
        if results:
            return {"text": " ".join(results)}

        # naming an agent in the message is this stand-in's way of delegating
        for name in agents:
            if name in said[-1]:
                return {"calls": [("Agent", {"subagent_type": name, "prompt": said[-1]})]}
        return self.pick(said, tools)

    def pick(self, said, tools):
        # the newest message first, then the last id named earlier in the chat
        orders = ORDER.findall(said[-1]) or ORDER.findall(" ".join(said))[-1:]
        wanted = "refund_order" if "refund" in said[-1].lower() else "lookup_order"
        tool = next((name for name in tools if name.endswith(wanted)), None)
        if orders and tool:
            return {"calls": [(tool, {"order_id": order}) for order in orders]}
        return {"text": f"I cannot help with order {orders[0]} yet." if orders
                else "Which order is this about?"}
  • ShopModel stands in for Claude. It reads the conversation, delegates when a message names an agent, and otherwise picks a tool from the order id it finds.
  • ShopDesk stands in for the Claude Code binary. It subclasses the SDK's Transport, so the SDK writes JSON lines into write and reads message dictionaries out of read_messages.
  • permit is the order of the rules. Hooks run first, then a bypass or an allow rule, then your callback, which is the order Claude Code itself uses.
  • It calls no model. That is why every cost below is 0.0 and the token counts are word counts.

The printer that turns a message into a line

The transcripts in this part are printed by one function, so the runs stay short. Save both tabs as watch.py.

from claude_agent_sdk import (AssistantMessage, ResultMessage, SystemMessage,
                              TextBlock, ToolResultBlock, ToolUseBlock, UserMessage, query)

from shop_desk import ShopDesk


def describe(message):
    # the two messages with no content blocks are read from their own fields
    if isinstance(message, SystemMessage):
        return f"system     {message.subtype}"
    if isinstance(message, ResultMessage):
        return f"result     {message.subtype}: {message.result or message.errors}"

    # a type this course does not use prints its name rather than failing
    if not isinstance(message, (AssistantMessage, UserMessage)):
        return type(message).__name__
    return describe_block(message.content[0])

Asking for a refund with no rule in place

Here is the desk with the tools alone: no callback, no hook, no cap. bypassPermissions lets every call through, which is the behaviour to look at before deciding what the rules have to stop.

Example
from claude_agent_sdk import ClaudeAgentOptions, query
from shop_desk import ShopDesk
from shop_tools import REFUNDED, shop
from watch import describe

# no callback and no rule yet: bypassPermissions lets every tool call through
options = ClaudeAgentOptions(mcp_servers={"shop": shop}, permission_mode="bypassPermissions")

async for message in query(prompt="Please refund my order C40", options=options,
                           transport=ShopDesk(options)):
    print(describe(message))
print("refunded:", REFUNDED)

What the ungated run did

  • The model asked for a refund and got one. Nothing stood between the request and the function, so the tool ran on the first try.
  • C40 is not a real order. The refund tool never checks; it refunds whatever id it is handed, which is why the check has to live outside it.
  • Nobody approved it. REFUNDED ends the run holding C40, and there is no record anywhere of the call having been made.
  • The result says success. From the loop's point of view the turn went perfectly, which is what makes this failure quiet.

What belongs in a tool and what does not

Inside the toolOutside it
ExampleReading an order, writing a refund, formatting the answerWhether this caller may refund this order at all
Who can change itYou, by editing the functionYou, by changing options without touching the tool
What Claude seesThe description, and whatever text comes backA refusal with a reason, which it can read and pass on
Why it is thereThe work itselfThe work should not depend on the model behaving

Where a pair of tools like this fits

  • Any desk where one action is free to take and another costs money or cannot be undone.
  • A read tool whose failure is ordinary, such as a missing record, answered in words rather than raised.
  • An in-process server when the functions already live in your application and need no separate program.
Watch out. A tool that decides for itself whether it may run ends up with two sources of truth, the function and the options, and they drift. Keep the decision in one place, outside the tool, where the next lesson puts it.
Try it yourself
  • Add an order D31 to ORDERS and ask where it is.
  • Change lookup_order's description and see whether the desk still picks it.
  • Ask for a refund of A17 instead and watch REFUNDED take it as readily.

Slow is fine. Stopping is the only problem.