Agent SDK Builder skill for Claude Code
Ship a custom agent on Claude Code's own loop. Builds agents on the Claude Agent SDK that are safe to run inside a product. Claude starts with a read-only loop that logs every message type, adds domain tools as in-process MCP servers, gates risky calls with canUseTool or hooks, chooses the system prompt preset and settingSources on purpose so CLAUDE.md and skills load only when intended, and bounds every run by turns, time and logged cost.
- name
agent-sdk-builder - size232 lines · ≈2,550 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds custom agents on the Claude Agent SDK in TypeScript or Python, covering the query loop and message types, allowed tools and permission modes, canUseTool callbacks, custom in-process MCP tools, system prompt presets, settingSources for CLAUDE.md and skills, sessions and resume, subagents, hooks, cost limits, and deployment. Use when the user wants to build an agent, bot, or automation on the Claude Agent SDK, embed Claude Code's agent loop in an app or service, or migrate from claude -p scripts or the older Claude Code SDK. Try asking
- Build a support agent with the Agent SDK that can look up and refund orders
- Port our claude -p bash script to a Python Agent SDK service with sessions
- Why doesn't my SDK agent see our CLAUDE.md or project skills?
Use it when
- Embedding an agent with file, shell or custom tools in your own app
- A claude -p script has outgrown shell and needs sessions or permission logic
- Adding domain tools and per-call permission checks to an agent
- Migrating from the older Claude Code SDK package
Skip it when
- A single CI step or cron job is enough — use headless-ci-agent instead
- You only need model calls with your own tool loop — use llm-app-builder instead
- You are building a reusable tool server for many clients — use mcp-server-builder instead
How to check the result
The happy path returns a success result, forbidden edits and commands are denied with your message, a looping task stops at maxTurns, and cost per run is logged.
The common mistake it prevents
Assuming the agent picks up CLAUDE.md, skills and project permissions automatically, when nothing from the filesystem loads until settingSources includes it.
An SDK agent is ready when its tool surface is the minimum the job needs, every
dangerous action passes through an explicit permission decision, runs are bounded by
turns and budget, results are read from the typed result message rather than scraped
text, and the behavior is pinned (model, prompt, settings) so it is reproducible.
Pick the right layer
| Need | Use |
|---|---|
| One-off or CI automation driven by a shell script | claude -p (see headless-ci-agent) |
| An agent inside your app/service with file, shell, and web tools, sessions, subagents | Claude Agent SDK |
| Plain model calls, your own tool loop, no filesystem agent | Messages API (see llm-app-builder) |
Install and auth
npm install @anthropic-ai/claude-agent-sdk # TypeScript (Node 18+)
pip install claude-agent-sdk # Python 3.10+
export ANTHROPIC_API_KEY=... # or CLAUDE_CODE_USE_BEDROCK=1 / CLAUDE_CODE_USE_VERTEX=1 with cloud creds
The SDK runs the Claude Code agent runtime as a subprocess; check the package README for whether your version bundles it or needs the CLI installed.
Workflow
- [ ] 1. Write the job spec: inputs, outputs, allowed side effects, budget
- [ ] 2. Minimal loop with read-only tools; print every message type
- [ ] 3. Add tools: built-ins via allowedTools, domain tools as SDK MCP tools
- [ ] 4. Add permission control (permissionMode + canUseTool or hooks)
- [ ] 5. Set system prompt and settingSources deliberately
- [ ] 6. Add sessions, subagents, structured output as needed
- [ ] 7. Bound it (maxTurns, timeouts, cost tracking) and test failure paths
2. Minimal loop (TypeScript)
import { query } from "@anthropic-ai/claude-agent-sdk";
const run = query({
prompt: "Find why `pnpm test --filter api` fails and summarize the root cause. Do not edit files.",
options: {
cwd: "/srv/checkouts/shop",
allowedTools: ["Read", "Grep", "Glob", "Bash"],
permissionMode: "default",
maxTurns: 15,
systemPrompt: { type: "preset", preset: "claude_code", append: "You run unattended. Never ask questions." },
settingSources: ["project"], // load the repo's CLAUDE.md and .claude/settings.json
},
});
for await (const msg of run) {
if (msg.type === "system" && msg.subtype === "init") console.error("session", msg.session_id);
if (msg.type === "assistant") {
for (const block of msg.message.content) if (block.type === "text") console.error(block.text);
}
if (msg.type === "result") {
if (msg.subtype === "success") console.log(msg.result);
else console.error("failed:", msg.subtype); // e.g. error_max_turns, error_during_execution
console.error(`turns=${msg.num_turns} cost=$${msg.total_cost_usd}`);
}
}
Python equivalent:
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, AssistantMessage, ResultMessage, TextBlock
async def main():
options = ClaudeAgentOptions(
cwd="/srv/checkouts/shop",
allowed_tools=["Read", "Grep", "Glob", "Bash"],
max_turns=15,
system_prompt={"type": "preset", "preset": "claude_code", "append": "You run unattended."},
setting_sources=["project"],
)
async for msg in query(prompt="Find why the api tests fail; do not edit files.", options=options):
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, TextBlock):
print(block.text)
elif isinstance(msg, ResultMessage):
print(msg.subtype, msg.num_turns, msg.total_cost_usd)
asyncio.run(main())
For multi-turn conversations in Python, use ClaudeSDKClient (async with ClaudeSDKClient(options) as client: await client.query(...); async for m in client.receive_response(): ...).
3. Custom tools (in-process MCP)
import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
const lookupOrder = tool(
"lookup_order",
"Look up an order by ID (format ord_...). Returns status, total_cents, items. Use before answering any order question.",
{ orderId: z.string().regex(/^ord_/) },
async ({ orderId }) => {
const order = await orders.find(orderId);
if (!order) return { isError: true, content: [{ type: "text", text: `No order ${orderId}. Ask for the ID from the confirmation email.` }] };
return { content: [{ type: "text", text: JSON.stringify(order) }] };
},
);
const shop = createSdkMcpServer({ name: "shop", version: "1.0.0", tools: [lookupOrder] });
async function* input() {
yield { type: "user" as const, message: { role: "user" as const, content: "Where is order ord_8812?" } };
}
for await (const msg of query({
prompt: input(), // SDK MCP tools need streaming input
options: { mcpServers: { shop }, allowedTools: ["mcp__shop__lookup_order"], maxTurns: 6 },
})) { /* handle messages */ }
Python: decorate with @tool("lookup_order", "description", {"order_id": str}), bundle
with create_sdk_mcp_server(name="shop", version="1.0.0", tools=[lookup_order]), pass
mcp_servers={"shop": server} and allow mcp__shop__lookup_order.
Tool names are always mcp__<server>__<tool>. External MCP servers (stdio or HTTP) go in
the same mcpServers option as config objects. Tool design rules from
mcp-server-builder apply: task-level tools, bounded schemas, small outputs,
isError with a fix-it message.
4. Permissions
| Mechanism | Use |
|---|---|
allowedTools / disallowedTools | The tool surface; anything not allowed needs a permission decision |
permissionMode | default, acceptEdits, plan (no edits), bypassPermissions (sandboxed containers only) |
canUseTool callback | Per-call decisions in code: inspect input, allow, deny with message, or rewrite input |
hooks (PreToolUse etc.) | Same event model as Claude Code hooks, as functions |
canUseTool: async (toolName, input) => {
if (toolName === "Bash" && /\b(rm|curl|wget|ssh)\b/.test(String(input.command))) {
return { behavior: "deny", message: "Network and delete commands are not allowed in this agent." };
}
if ((toolName === "Edit" || toolName === "Write") && !String(input.file_path).startsWith("/srv/checkouts/shop/src/")) {
return { behavior: "deny", message: "Edits are limited to src/." };
}
return { behavior: "allow", updatedInput: input };
},
Default to deny-by-omission: list only needed tools, then gate the risky ones. Never ship
bypassPermissions outside a disposable sandbox, and confirm with the user before
enabling it anywhere.
5. System prompt and settings
- Without
systemPrompt, the SDK uses a minimal prompt, not Claude Code’s. Use{ type: "preset", preset: "claude_code", append: "..." }to get Claude Code’s coding behavior plus your rules, or a plain string for a fully custom agent. - Filesystem settings are not loaded unless requested.
settingSources: ["project"]loads.claude/settings.jsonand CLAUDE.md; add"user"/"local"only when the agent should inherit a person’s setup (rarely, for services). - Skills: load them via
settingSources(project.claude/skills/or user) and include"Skill"inallowedTools.
6. Sessions, subagents, structured output
- Capture
session_idfrom thesystem/initmessage. Continue later withoptions.resume = sessionId; branch withforkSession: true. Store session IDs with your own job records. - Subagents:
agents: { reviewer: { description: "...", prompt: "...", tools: ["Read", "Grep"], model: "sonnet" } }. Same design rules as.claude/agentsfiles (seesubagent-design). - Structured results: instruct an exact JSON shape, extract from
result, and validate with Zod/Pydantic; retry once with the validation error appended, then fail.
7. Bound and observe
- Always set
maxTurns; wrap the run in your own wall-clock timeout and abort with anAbortController(TSabortControlleroption) on expiry. - Log per run: session_id, num_turns, total_cost_usd, duration, result subtype, tools used. Alert on cost or turn outliers.
- Pin the model ID and SDK version; prompt or model drift changes behavior silently.
Deployment
- Run each agent session in an isolated working directory or container; never point
cwdat a shared checkout two sessions can edit. - Long-lived services: one SDK process per active session, a concurrency cap, and cleanup of temp dirs.
- Treat any user-supplied or fetched content as untrusted input that may contain instructions; tool gating, not prompt wording, is the real defense.
Gotchas
- Expecting CLAUDE.md, skills, or project permissions to apply automatically. They do
not until
settingSourcesincludes the right source. - Allowing custom tools by bare name; it must be
mcp__<server>__<tool>. - Passing a string prompt with in-process SDK MCP tools in TypeScript; use an async iterable prompt.
- Reading the last assistant text instead of the
resultmessage, missing failures likeerror_max_turns. - Migrating from the older Claude Code SDK package: the package name, defaults (system prompt, settings loading), and some option names changed. Re-check each option.
Verification
- Happy path: the agent completes the spec’d task;
result.subtype === "success". - Permission tests: prompts that try a forbidden edit and a forbidden command are denied with your message, and the agent recovers or stops cleanly.
- Bounds: a task designed to loop hits
maxTurnsand is reported as a failure. - Resume: a second run with
resumecontinues with prior context. - Cost and turns per run are logged and within the budget from the spec.
Output
**Agent:** order-support (TypeScript, @anthropic-ai/claude-agent-sdk, version pinned)
**Tools:** Read, Grep; mcp__shop__lookup_order, mcp__shop__refund_order (canUseTool requires amount <= 5000 cents)
**Prompt:** claude_code preset + support rules; settingSources: ["project"]
**Bounds:** maxTurns 8, 60 s timeout, cost logged per session
**Tests:** happy path, 2 denied actions, max-turns failure, resume - all pass About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.