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
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
contentlist, the same text blocks the messages lesson pulled apart.
What the decorator made
print(type(lookup_order).__name__)
print(lookup_order.name)
print(lookup_order.description)
print(lookup_order.input_schema)SdkMcpTool
lookup_order
Look up an order's shipping status by its id, such as A17.
{'order_id': <class 'str'>}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:
print(await lookup_order.handler({"order_id": "A17"}))
print(await lookup_order.handler({"order_id": "B22"})){'content': [{'type': 'text', 'text': 'Order A17 shipped on 3 March.'}]}
{'content': [{'type': 'text', 'text': 'No order B22 was found.'}]}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:
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){'order_id': typing.Annotated[str, 'The order id, such as A17']}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:
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 types | JSON Schema dict | |
|---|---|---|
| Looks like | {"order_id": str} | {"type": "object", "properties": {...}} |
| Required | Every key | Only what required lists |
| Field descriptions | With Annotated[str, "..."] | With "description" on the property |
| Enums, ranges, nesting | No | Yes |
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.
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.Related
- Previous: The built-in tools
- Next: An in-process MCP server
- Reference: the custom tools page and @tool in the Python reference
- 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.annotationsand compare the field names with the ones you passed.
Little by little, you're building something great.