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 →

Custom tools with @tool

@tool is a decorator that turns an async Python function into a tool Claude can call, by giving it a name, a description and an input schema.

Last updated: 20 Oct, 2026 · Claude Agent SDK

The built-in tools read files and run commands. None of them knows where order A17 is, because that answer lives in the shop's own data. The shop's order lookup becomes the first tool you write.

Defining the order lookup

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}]}

@tool takes three arguments, and the handler is the function underneath:

  • The name Claude uses to call it, lookup_order.
  • The description Claude reads to decide this is the right tool. Write it the way you would brief a new colleague, with an example id in it.
  • The input schema, a dict of names to types: {"order_id": str}. The SDK turns it into JSON Schema for you.
  • The handler, which the custom tools page requires to be async. It takes one dict of arguments and returns a dict with a content list, the same text blocks the messages lesson pulled apart.

What the decorator made

Example
print(type(lookup_order).__name__)
print(lookup_order.name)
print(lookup_order.description)
print(lookup_order.input_schema)

The decorator returns an SdkMcpTool, a record of the four parts. The original function is kept as its handler, so you can call it yourself:

Example
print(await lookup_order.handler({"order_id": "A17"}))
print(await lookup_order.handler({"order_id": "B22"}))

B22 is not in the dictionary, so the tool says so in words a model can pass on to a customer. Calling the handler on its own is also how you test a tool without any agent around it.

Describing a field, and hinting how the tool behaves

Two optional extras are worth knowing. Wrapping a type in Annotated gives the field a description Claude sees, and annotations carries the MCP hints:

Example
from typing import Annotated

from claude_agent_sdk import ToolAnnotations, tool


@tool("pack_size", "How many units of an order ship in one box.",
      {"order_id": Annotated[str, "The order id, such as A17"]},
      # read-only tools can be called in parallel with other read-only tools
      annotations=ToolAnnotations(readOnlyHint=True))
async def pack_size(args):
    return {"content": [{"type": "text", "text": "4 to a box"}]}


print(pack_size.input_schema)

The description travels with the field. Of the four hints, readOnlyHint is the one that changes behaviour: the custom tools page says it lets Claude call the tool in parallel with other read-only tools. The other three, destructiveHint, idempotentHint and openWorldHint, are informational. None of them is enforced, so keep a hint true to what the handler does.

Making an argument optional

A schema written as a dict of types makes every key required. For an optional argument, pass a full JSON Schema dict instead and leave the key out of required:

python
SCHEMA = {"type": "object",
          "properties": {"order_id": {"type": "string"},
                         "since": {"type": "string", "description": "A date to count from"}},
          "required": ["order_id"]}


@tool("order_history", "An order's history, since a date if you have one.", SCHEMA)
async def order_history(args):
    # since is not in required, so read it with a default rather than args["since"]
    return {"content": [{"type": "text", "text": args.get("since", "the whole history")}]}

The same dict form is the way to enums, ranges and nested objects. Reading the optional key with args.get() matters: the SDK validates the arguments against the schema before your handler runs, so a key that is not required may be missing.

A dict of types against a JSON Schema dict

Dict of typesJSON Schema dict
Looks like{"order_id": str}{"type": "object", "properties": {...}}
RequiredEvery keyOnly what required lists
Field descriptionsWith Annotated[str, "..."]With "description" on the property
Enums, ranges, nestingNoYes

Two jobs a custom tool does well

  • Reaching your own data. An order lookup, a stock level, a customer record: Claude cannot guess any of it, and a tool is a function call away from your database.
  • Doing something that counts. A refund, a ticket, an email. The permissions part of this course is about exactly these, because a tool that moves money needs a check in front of it.
Watch out. The handler has to be async def. A plain def returns a dict rather than an awaitable, and the server has nothing to await, so the call fails inside the SDK rather than in your own code.
Try it yourself
  • Call the handler with {"order_id": "C40"} and read the text it returns.
  • Add a second tool, order_count, with an empty schema {}, returning how many orders the shop has.
  • Print pack_size.annotations and compare the field names with the ones you passed.

Little by little, you're building something great.