Claude Agent SDK Custom Tools: Give Your Agent Its Own Functions (2026)
A runnable 2026 guide to custom tools in the Claude Agent SDK: define functions with tool() / @tool, bundle them into an in-process MCP server, register with allowedTools, and gate risky calls per argument with canUseTool. TypeScript and Python.
Updated on July 10, 2026

On this page
Quick Answer (July 2026): Custom tools in the Claude Agent SDK are your own functions that Claude can call during a run. You define each one with tool() in TypeScript or the @tool decorator in Python, bundle them into an in-process MCP server with createSdkMcpServer / create_sdk_mcp_server, and pass that server to query() through the mcpServers option. Each tool is then exposed to Claude as mcp__{server}__{tool}; list that name in allowedTools so it runs without a prompt. For anything risky, gate the call per invocation with a canUseTool callback. No separate server process is required, the tools run inside your own app.
A Claude agent with no tools can only talk. The moment you want it to read your database, hit an internal API, or run one specific piece of your business logic, you need custom tools. This is the part of the Claude Agent SDK people skip, and then their agent hallucinates an answer instead of calling the function that has the real one.
This tutorial is a focused deep dive on tools alone. It does not rebuild an agent from scratch (the TypeScript quickstart already does that). Here you wire real functions into the agent, control which ones it may run, and gate the dangerous ones. Every snippet is copy-paste runnable against the 2026 SDK.
All code is verified against the current
@anthropic-ai/claude-agent-sdk (TypeScript) and claude-agent-sdk (Python) docs as of July 2026.
What a "tool" actually is in the Agent SDK
A tool is four things bundled together:
- Name - a unique id Claude uses to call it.
- Description - plain text Claude reads to decide when to call it. This is prompt engineering, not a code comment. Write it for the model.
- Input schema - the arguments Claude must supply. TypeScript uses a Zod schema and types your handler args automatically. Python uses a small dict like
{"latitude": float}, or a full JSON Schema dict when you need enums or ranges. - Handler - the async function that runs when Claude calls the tool. It returns a
contentarray.
The SDK runs your tools through an in-process MCP server. That phrase scares people off, but it means the opposite of complicated: there is no second process, no socket, no subprocess to manage. Your functions run in the same Node or Python process as the rest of your app, and the SDK speaks MCP to them internally.
Prerequisites
You need Node 20+ (or Python 3.10+), an Anthropic API key exported as ANTHROPIC_API_KEY, and the SDK installed:
# TypeScript
npm install @anthropic-ai/claude-agent-sdk zod
# Python
pip install claude-agent-sdk
Define one tool
Start with a single read-only tool that looks up a temperature. Notice the handler returns a content array of blocks, each with a type. Claude reads that array as the tool result.
import { tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
const getTemperature = tool(
"get_temperature",
"Get the current temperature at a location",
{
latitude: z.number().describe("Latitude coordinate"),
longitude: z.number().describe("Longitude coordinate"),
},
async (args) => {
// args is typed from the schema: { latitude: number; longitude: number }
const res = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${args.latitude}&longitude=${args.longitude}¤t=temperature_2m&temperature_unit=fahrenheit`
);
const data: any = await res.json();
return {
content: [
{ type: "text", text: `Temperature: ${data.current.temperature_2m}F` },
],
};
}
);
The Python shape is identical, using the @tool decorator:
from typing import Any
import httpx
from claude_agent_sdk import tool, create_sdk_mcp_server
@tool(
"get_temperature",
"Get the current temperature at a location",
{"latitude": float, "longitude": float},
)
async def get_temperature(args: dict[str, Any]) -> dict[str, Any]:
async with httpx.AsyncClient() as client:
res = await client.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": args["latitude"],
"longitude": args["longitude"],
"current": "temperature_2m",
"temperature_unit": "fahrenheit",
},
)
data = res.json()
return {"content": [{"type": "text", "text": f"Temperature: {data['current']['temperature_2m']}F"}]}
One gotcha worth internalizing early: in TypeScript you make an argument optional with .default() on the Zod field. In Python the dict schema treats every key as required, so to make a parameter optional you leave it out of the schema, mention it in the description, and read it with args.get() in the handler.
Bundle tools into an in-process server
A tool is inert until it lives on a server. Wrap it with createSdkMcpServer (TypeScript) or create_sdk_mcp_server (Python). The name you give the server matters, it becomes part of every tool's fully qualified name in the next step.
const weatherServer = createSdkMcpServer({
name: "weather",
version: "1.0.0",
tools: [getTemperature],
});
weather_server = create_sdk_mcp_server(
name="weather",
version="1.0.0",
tools=[get_temperature],
)
Register the server and let Claude call the tool
Pass the server to query() via mcpServers. The key you use here (weather) becomes the {server} segment of the tool name. The pattern is strict and worth memorizing:
> mcp__{server_name}__{tool_name}
So get_temperature on the weather server is exposed to Claude as mcp__weather__get_temperature. List that exact string in allowedTools and the tool runs with no permission prompt.
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({
prompt: "What's the temperature in San Francisco?",
options: {
mcpServers: { weather: weatherServer },
allowedTools: ["mcp__weather__get_temperature"],
},
})) {
if (message.type === "result" && message.subtype === "success") {
console.log(message.result);
}
}
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, ResultMessage
async def main():
options = ClaudeAgentOptions(
mcp_servers={"weather": weather_server},
allowed_tools=["mcp__weather__get_temperature"],
)
async for message in query(prompt="What's the temperature in San Francisco?", options=options):
if isinstance(message, ResultMessage) and message.subtype == "success":
print(message.result)
asyncio.run(main())
Run it. Claude reads your tool description, decides it needs the temperature, calls get_temperature with a latitude and longitude it infers for San Francisco, and folds the result into its answer. That is the entire loop.
If you forget the allowedTools entry, the tool still exists but every call falls through to the permission flow (covered in Step 6) instead of running automatically. A server with two tools can be covered with a wildcard: mcp__weather__*.
Add a second tool, and let Claude batch them
Servers hold as many tools as you list. Add a readOnlyHint annotation to any tool with no side effects and the SDK is allowed to call it in parallel with other read-only tools, which is a real latency win when Claude fans out several lookups at once.
Annotations are the fifth argument to tool() in TypeScript and the annotations keyword in Python:
tool(
"get_temperature",
"Get the current temperature at a location",
{ latitude: z.number(), longitude: z.number() },
async (args) => ({ content: [{ type: "text", text: "..." }] }),
{ annotations: { readOnlyHint: true } } // lets Claude batch this with other read-only calls
);
One honest caveat straight from the 2026 docs: annotations are metadata, not enforcement. A tool marked readOnlyHint: true can still write to disk if that is what its handler does. Keep the hint truthful to the handler, or you will get parallel writes you did not plan for.
Return errors Claude can actually recover from
A thrown exception does not crash the run. The in-process server catches it and hands Claude the raw message. That works, but the raw message is often useless to the model. Compose the error yourself and set isError: true (TypeScript) or "is_error": True (Python) so Claude reads your explanation and can adjust.
async (args) => {
try {
const res = await fetch(args.endpoint);
if (!res.ok) {
return {
content: [{ type: "text", text: `API error: ${res.status} ${res.statusText}` }],
isError: true,
};
}
return { content: [{ type: "text", text: JSON.stringify(await res.json()) }] };
} catch (e) {
return {
content: [{ type: "text", text: `Failed to fetch: ${e instanceof Error ? e.message : String(e)}` }],
isError: true,
};
}
}
The difference in practice: a bare TypeError: fetch failed tells Claude nothing, so it retries the same broken call. API error: 429 Too Many Requests tells it to back off or try another approach. Error messages are prompts too.
Gate the dangerous tools per call
Here is the question the quickstarts leave open: allowedTools is all-or-nothing per tool. How do you approve a delete_record tool for some arguments but not others? The answer is the canUseTool callback, and it is the most useful thing in this guide.
canUseTool fires whenever Claude wants a tool that nothing earlier in the permission flow has already approved. It receives the tool name and the exact input, and it returns either an allow or a deny decision. Critically, on allow you can rewrite the input before it executes.
const options = {
mcpServers: { db: dbServer },
// note: do NOT pre-approve delete_record in allowedTools,
// or it skips the callback entirely
canUseTool: async (toolName, input) => {
if (toolName === "mcp__db__delete_record") {
if (input.table === "users") {
return { behavior: "deny", message: "Deleting from users is not allowed. Soft-delete instead." };
}
// approve, but force a dry-run flag on before it runs
return { behavior: "allow", updatedInput: { ...input, dryRun: true } };
}
return { behavior: "allow", updatedInput: input };
},
};
The Python callback returns typed result objects instead of plain dicts:
from claude_agent_sdk.types import PermissionResultAllow, PermissionResultDeny
async def can_use_tool(tool_name, input_data, context):
if tool_name == "mcp__db__delete_record":
if input_data.get("table") == "users":
return PermissionResultDeny(message="Deleting from users is not allowed.")
return PermissionResultAllow(updated_input={**input_data, "dryRun": True})
return PermissionResultAllow(updated_input=input_data)
options = ClaudeAgentOptions(can_use_tool=can_use_tool)
Two things that will bite you if nobody tells you:
- Auto-approved tools never reach
canUseTool. If a tool is listed bare inallowedTools, an allow rule already resolved it and your callback is silently skipped. Leave a tool out ofallowedToolsif you want to gate it here. - In Python,
can_use_toolrequires streaming mode. A finitequery(prompt=...)closes the input stream before the callback can fire. The documented workaround is a no-opPreToolUsehook that returns{"continue_": True}, which keeps the stream open. TypeScript has no such requirement.
For a check that must run on every call regardless of rules, use a PreToolUse hook instead, which runs before the whole permission flow. But for "approve this tool, but sanitize the arguments first," canUseTool is exactly the right tool.
Common mistakes
- Wrong tool name in
allowedTools. It ismcp__server__tool, not justtool. Double underscores, server name from themcpServerskey. - Descriptions written for humans. Claude picks tools from the description. Vague descriptions cause missed or wrong calls.
- Pinning a model id in examples. Use a model alias, not a dated snapshot id, so your agent does not break when the snapshot ages out.
- Expecting
structuredContentin Python in-process servers. The Python@tooldecorator forwards onlycontentandis_error. If you need machine-readable structured output, run a standalone MCP server instead.
Where to go next
You now have the full tool surface: define, bundle, register, annotate for parallelism, return recoverable errors, and gate per call. The natural next step is deciding how much orchestration you want to hand-roll versus let the SDK own, which is exactly the trade-off in the SDK versus writing the agent loop yourself. The full API surface lives in the Anthropic Agent SDK TypeScript repo if you want to read the types directly.
Written by
Ren OkabeRen builds agent infrastructure and writes copy-paste tutorials for engineers shipping LLM tool-use systems.
Frequently asked questions
How do I add a custom tool to a Claude agent with the Agent SDK?
Define the tool with tool() in TypeScript or the @tool decorator in Python (name, description, input schema, async handler), wrap it in an in-process MCP server with createSdkMcpServer / create_sdk_mcp_server, pass that server to query() via the mcpServers option, and list the tool as mcp__{server}__{tool} in allowedTools so it runs without a prompt.
What is the tool name format in the Claude Agent SDK?
Tools are exposed to Claude as mcp__{server_name}__{tool_name} with double underscores. The server name is the key you used in the mcpServers object. For example, a tool named get_temperature on a server registered as weather becomes mcp__weather__get_temperature. A wildcard like mcp__weather__* allows every tool on that server.
How do I approve some tool calls but deny others based on their arguments?
Use the canUseTool callback (can_use_tool in Python). It receives the tool name and the exact input, and returns an allow or deny decision. On allow you can also rewrite the input before it runs. Do not pre-approve the tool in allowedTools, because auto-approved tools skip the callback entirely.
Do I need a separate server process to run Agent SDK tools?
No. createSdkMcpServer / create_sdk_mcp_server create an in-process MCP server that runs inside your own Node or Python process. There is no subprocess, socket, or separate deployment to manage.
Why does can_use_tool never fire in my Python agent?
In Python, can_use_tool requires streaming mode. A finite query(prompt=...) closes the input stream before the callback can be invoked. The documented workaround is a no-op PreToolUse hook that returns {"continue_": True} to keep the stream open. Also confirm the tool is not pre-approved in allowed_tools, since that skips the callback.
Related tutorials
Build an AI Agent with the Claude Agent SDK in TypeScript (2026)
A runnable 2026 quickstart: install the Claude Agent SDK, wire a custom tool with tool() and createSdkMcpServer(), and let the agent loop call it for you in about 40 lines of TypeScript.
Claude Agent SDK vs Writing the Agent Loop Yourself (2026): When Each Wins
Should you use the Claude Agent SDK or hand-roll the agent loop with the Anthropic Client SDK? An honest 2026 comparison with runnable TypeScript for both and a decision table.
Run Claude Tool Calls in Parallel (TypeScript, 2026)
Speed up your Claude agent loop by running independent tool calls concurrently in TypeScript. Copy-paste code for parallel tool_use, is_error handling, and disable_parallel_tool_use.