Most “AI agent” demos fall apart the moment you ask them to do real work — they hallucinate a tool, lose the thread after two steps, or have no safe way to touch your filesystem. The Claude Agent SDK is the exception, because it’s the exact harness that runs Claude Code: a battle-tested loop for tool use, context management, and permissioning. This guide shows you how to drive that engine from your own code.

Overview

We’ll build a small but real agent — a “release assistant” that can read a changelog, fetch live data, and delegate research — covering the three pillars you’ll use in every serious agent:

  • Custom tools — give the model typed, validated functions it can call
  • MCP servers — plug in external capabilities over the Model Context Protocol
  • Subagents — delegate focused work to isolated, specialized agents

Everything here runs with the official SDK in both TypeScript and Python. Pick your language; the concepts are identical.

Setup

The SDK wraps the Claude Code runtime, so you install one package and set one environment variable.

# TypeScript / Node 18+
npm install @anthropic-ai/claude-agent-sdk

# Python 3.10+
pip install claude-agent-sdk
export ANTHROPIC_API_KEY="sk-ant-..."

The smallest possible agent is a single query() call. It returns an async stream of messages — the model thinking, calling tools, and producing text — not one blocking response.

import { query } from "@anthropic-ai/claude-agent-sdk";

for await (const msg of query({
  prompt: "Summarize what changed in this repo's last commit.",
  options: { model: "claude-opus-4-8" },
})) {
  if (msg.type === "result") console.log(msg.result);
}

That alone already has file reading, bash, and search — the default Claude Code toolset. The interesting part is making it yours.

Pillar 1 — Custom tools

A tool is a typed function the model can choose to call. You describe it; Claude decides when and with what arguments. The SDK validates the input against your schema before your code ever runs, so you never parse a half-formed JSON blob by hand.

import { query, tool, createSdkMcpServer } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";

const getDeployStatus = tool(
  "get_deploy_status",
  "Get the current deploy status for a given environment",
  { env: z.enum(["qa", "production"]) },
  async ({ env }) => {
    const res = await fetch(`https://status.internal/api/${env}`);
    const data = await res.json();
    return { content: [{ type: "text", text: `${env}: ${data.state}` }] };
  }
);

// Bundle your tools into an in-process MCP server
const opsServer = createSdkMcpServer({
  name: "ops",
  version: "1.0.0",
  tools: [getDeployStatus],
});

The Python shape mirrors it with a decorator:

from claude_agent_sdk import tool, create_sdk_mcp_server

@tool("get_deploy_status", "Get deploy status for an environment",
      {"env": str})
async def get_deploy_status(args):
    env = args["env"]
    # ... fetch status ...
    return {"content": [{"type": "text", "text": f"{env}: live"}]}

ops_server = create_sdk_mcp_server(
    name="ops", version="1.0.0", tools=[get_deploy_status]
)

Three rules separate a tool the model uses well from one it ignores:

RuleWhy it matters
Name the action, not the implementationget_deploy_status beats fetchStatusV2 — the model matches on intent
Make the description answer “when would I call this?”The description is the model’s only routing signal
Keep the schema tightEnums and required fields stop the model from inventing arguments

Pillar 2 — MCP servers

Custom tools defined in-process are great, but the real leverage is the Model Context Protocol — an open standard that lets your agent talk to external tool servers: databases, GitHub, Slack, a headless browser, hundreds of community servers. You wire them in options.mcpServers, then allow the tools you want exposed.

for await (const msg of query({
  prompt: "Is production healthy? If a deploy is stuck, check recent GitHub PRs.",
  options: {
    model: "claude-opus-4-8",
    mcpServers: {
      // in-process server from Pillar 1
      ops: opsServer,
      // external server over stdio
      github: {
        command: "npx",
        args: ["-y", "@modelcontextprotocol/server-github"],
        env: { GITHUB_TOKEN: process.env.GITHUB_TOKEN! },
      },
    },
    allowedTools: [
      "mcp__ops__get_deploy_status",
      "mcp__github__list_pull_requests",
    ],
  },
})) {
  if (msg.type === "result") console.log(msg.result);
}

Two things worth burning into memory:

  • Tool names are namespaced as mcp__<server>__<tool>. That prefix is how allowedTools and permission rules target them.
  • allowedTools is your safety boundary. Anything not listed can’t run. For anything that writes or deletes, prefer an explicit allowlist over a broad permissionMode.

This is the same mechanism Claude Code uses for its MCP integrations — you’re not on a lesser API.

Pillar 3 — Subagents

A single agent holding every tool and the entire task in one context window gets slow and sloppy. Subagents fix this: you define focused, named agents with their own prompt and a restricted toolset, and the main agent delegates to them. Each runs in its own context, so a 40-file research sweep never pollutes the orchestrator’s working memory.

for await (const msg of query({
  prompt: "Draft release notes for v2.4. Research what changed since v2.3 first.",
  options: {
    model: "claude-opus-4-8",
    mcpServers: { ops: opsServer, github: githubServer },
    agents: {
      researcher: {
        description: "Read-only investigator. Use to gather facts across the repo and PRs.",
        prompt: "You find and summarize facts. Never write files. Report concise findings.",
        tools: ["Read", "Grep", "mcp__github__list_pull_requests"],
        model: "haiku",   // cheap model for fan-out search
      },
      writer: {
        description: "Drafts polished release notes from gathered findings.",
        prompt: "You write clear, skimmable release notes in Markdown.",
        tools: ["Read", "Write"],
        model: "opus",
      },
    },
  },
})) {
  if (msg.type === "result") console.log(msg.result);
}

The orchestrator reads each subagent’s description to decide who to call — so write descriptions like job postings, not labels. Notice the model split: route cheap, parallel search to haiku and reserve opus for the reasoning-heavy writing. That single choice often cuts agent cost more than any prompt tweak.

Isolation is the point. Give each subagent only the tools it needs. A researcher with no Write tool cannot accidentally mutate your repo, no matter what the prompt says — the boundary is enforced, not requested.

Tying it together

The release assistant now works as a pipeline with one entry point:

  1. You ask for v2.4 release notes.
  2. The orchestrator delegates fact-finding to researcher (Haiku) — it greps the repo and lists merged PRs via the GitHub MCP server.
  3. Findings flow back; the orchestrator hands them to writer (Opus) to draft Markdown.
  4. Along the way it can call get_deploy_status (your custom tool) to confirm what’s actually live.

Three layers — tools, MCP, subagents — compose into something that genuinely does the job, with cost and blast radius controlled at every step.

Key Takeaways

  • The SDK is Claude Code’s engine. You get its tool loop, context management, and permission model — not a stripped-down API.
  • Tools are about routing. Good names + “when to call me” descriptions + tight schemas decide whether the model uses a tool correctly.
  • MCP is the integration layer. Namespaced mcp__server__tool names plus a strict allowedTools list give you reach and safety together.
  • Subagents buy isolation and cost control. Restrict each one’s tools, and route fan-out work to cheaper models like Haiku.
  • Start small. A single query() call is a working agent; add pillars only when the task demands them.

Resources