2.11 · Building Custom Agents with the SDK

2.11 · 30 min

Building Custom Agents with the SDK

Your 2.9 diff reviewer works, but look at its seams: the output contract is a paragraph of prompt begging for JSON, validation is a jq call hoping for the best, and permissions live in shell flags three layers from the logic. Now you want the same trick for changelogs, on a schedule, feeding a release pipeline that needs typed data, not text that is usually JSON. This is the moment the shell stops being the right host and the engine becomes a library.

The engine as a library

The Agent SDK is Claude Code's core, importable: the same agent loop, the same built-in tools (Read, Bash, Glob, Grep, and the rest), the same context management, exposed as a TypeScript package (Python exists too; this course uses TS).

npm install @anthropic-ai/claude-agent-sdk

The package bundles a native Claude Code binary for your platform as an optional dependency, so there is nothing else to install, and it authenticates with ANTHROPIC_API_KEY. The whole API surface you need is one function: query() takes a prompt and options, runs the full agent loop, and returns an async iterable of messages. You watch the loop happen (every tool call and result streams past you as a typed message) and the final result message carries the payload. As of July 2026 there is also a session-based v2 API in preview (unstable_v2_createSession); it is explicitly marked unstable, so this lesson builds on the stable query() surface.

Custom agent vs headless vs plain API, argued

Three ways to get model work done in a program, and the boundaries are concrete:

  • Plain API call (the anthropic SDK, a single messages.create): the model thinks; nothing acts. Right when the task is inference over input you already have: classify this ticket, extract fields from this document, rewrite this paragraph. No loop, no tools, no repo access, cheapest and fastest. If you find yourself pasting file contents into a plain API prompt in a loop you wrote yourself, congratulations, you are hand-building the agent loop; move up a tier.
  • Headless Claude Code (2.9): the full agent, your machine's rig, shell-shaped. Right for CI glue and personal automation where a prompted contract plus jq -e is acceptable rigor and the surrounding logic is small enough to live in bash. The moment the wrapper script grows retry logic, output parsing, and conditionals, it is an application written in the wrong language.
  • Custom SDK agent: the full agent loop with programmatic control: schema-validated output the SDK enforces, permissions declared in code, custom tools from your own functions, and results as typed values in the language your application already speaks. Right when the agent is a component of software: a worker in a pipeline, a capability inside your product, anything whose output another program consumes.

The changelog writer below is squarely the third case: it runs unattended and its output feeds machinery, so "usually valid JSON" is not a contract, and a bash wrapper would just be a worse program.

In practice: the changelog agent

One file, complete and runnable. This exact code typechecks against @anthropic-ai/claude-agent-sdk 0.3.x and zod 4.x under strict TypeScript; you will run it in the lab.

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

// The output contract. This schema IS the spec the agent loop optimizes
// against: the SDK validates the agent's final output against it and
// re-prompts on mismatch, so what you get back is this shape or an error.
const ChangelogEntry = z.object({
  kind: z.enum(["feature", "fix", "refactor", "docs", "chore"]),
  summary: z.string(),
  commits: z.array(z.string()).min(1),
});

const Changelog = z.object({
  range: z.string(),
  entries: z.array(ChangelogEntry),
  breaking: z.array(z.string()),
  markdown: z.string(),
});

export type Changelog = z.infer<typeof Changelog>;

export async function generateChangelog(repoPath: string): Promise<Changelog> {
  const run = query({
    prompt: [
      "Write a changelog for this repository covering every commit since the",
      "most recent git tag (use git describe --tags --abbrev=0; if no tag",
      "exists, cover the last 20 commits). Read git log for the range, read",
      "changed files only where a commit message is too vague to classify.",
      "Group entries by kind, note anything breaking, and produce a concise",
      "markdown changelog. Report the exact range you covered.",
    ].join(" "),
    options: {
      cwd: repoPath,
      // Nothing from ~/.claude or the repo's .claude/ leaks in: no hooks,
      // no CLAUDE.md, no MCP servers. The agent is exactly what this file
      // declares, on every machine that runs it.
      settingSources: [],
      // Read-only permission surface: git inspection plus file reads.
      // dontAsk denies anything not pre-approved instead of prompting,
      // because there is no human attached to answer.
      permissionMode: "dontAsk",
      allowedTools: [
        "Bash(git log:*)",
        "Bash(git describe:*)",
        "Bash(git tag:*)",
        "Bash(git diff:*)",
        "Bash(git show:*)",
        "Read",
        "Grep",
        "Glob",
      ],
      maxTurns: 30,
      outputFormat: {
        type: "json_schema",
        schema: z.toJSONSchema(Changelog),
      },
    },
  });

  for await (const message of run) {
    if (message.type !== "result") continue; // tool calls stream past here
    if (message.subtype === "success" && message.structured_output) {
      // The SDK validated this against the schema; parsing narrows the
      // type from unknown to Changelog and re-checks at the boundary.
      return Changelog.parse(message.structured_output);
    }
    throw new Error(
      `changelog agent failed: ${message.subtype} after ${message.num_turns} turns`,
    );
  }
  throw new Error("changelog agent ended without a result message");
}

// CLI entry: npx tsx changelog-agent.ts [repo-path]
const repoArg = process.argv[2] ?? ".";
generateChangelog(repoArg).then(
  (changelog) => {
    console.log(changelog.markdown);
    console.error(
      `${changelog.entries.length} entries for ${changelog.range}`,
    );
  },
  (error: unknown) => {
    console.error(error instanceof Error ? error.message : error);
    process.exitCode = 1;
  },
);

Four options carry the design; everything else is the task prompt.

outputFormat is the 2.9 upgrade. The Zod schema converts to JSON Schema with z.toJSONSchema(), the SDK validates the agent's final output against it and re-prompts on mismatch, and the validated value arrives on message.structured_output. The prompted-contract problem from 2.9 is gone: you get the shape or you get an explicit error subtype (error_max_structured_output_retries), never almost-JSON. The final Changelog.parse() then turns unknown into a typed value at the boundary, so everything downstream is ordinary typed code.

allowedTools plus permissionMode: "dontAsk" is least privilege in code. The rules use the same syntax as your 2.1 settings (Bash(git log:*) scopes Bash to commands matching the pattern), and dontAsk means anything unresolved by the rules is denied rather than queued for a human who is not there. This agent can inspect git and read files. It cannot write, push, or install anything, and that guarantee lives in the artifact itself, reviewable in a PR like any other code.

settingSources: [] is bare mode's sibling. Omit the option and the SDK loads filesystem settings like the CLI would; the empty array isolates the agent from every machine-specific setting, hook, and CLAUDE.md so it behaves identically everywhere, which is what 2.9 taught you to demand from anything unattended.

maxTurns is the budget. The loop gets thirty turns to converge or the run ends as error_max_turns. Every unattended agent needs a leash; pick the number from observed runs, not hope.

One more capability you will want eventually: custom tools. tool() defines a function with a Zod input schema, createSdkMcpServer() bundles tools into an in-process MCP server, and mcpServers in the options hands them to the agent, no separate process, no transport. That is how an agent calls your database or your internal API instead of shelling out. File it for Track B, where your workers will use it.

Where it breaks

Schema failure is a real outcome, not a corner case. If the agent cannot produce output matching the schema within the retry limit, the result subtype is error_max_structured_output_retries and there is no partial data. Overconstrained schemas invite this: a z.enum that cannot represent an odd merge commit, a .min(1) on a repo with no classifiable changes. Design schemas with escape hatches (a chore kind, an empty-array-allowed breaking) and handle the error subtype as a first-class branch, because on a long enough schedule it will fire.

The agent inherits nothing, including things you liked. settingSources: [] cuts CLAUDE.md, your hooks, and your MCP servers. People port a workflow to the SDK and quietly lose the guardrail a hook was providing. Whatever the agent needs must be passed explicitly: rules in the prompt or systemPrompt, servers in mcpServers, gates as code around the loop.

Unattended cost compounds. The result message carries total_cost_usd; a scheduled agent that drifts from 12 turns to 28 turns per run doubles its bill silently while still succeeding. Log cost and num_turns per run and alert on drift. You are now the operator of a fleet of one, and Part 3's harness discipline is how a fleet stays cheap.

Lablab-2-11Ship the changelog agent

Goal: Run the changelog agent against the practice repo, verify its typed contract end to end, then prove the permission scoping and failure modes are real.

Prereqs: the agentic-practice repo with several commits, Node 18+, ANTHROPIC_API_KEY exported.

  1. In a new changelog-agent/ directory: npm init -y && npm install @anthropic-ai/claude-agent-sdk zod && npm install -D typescript tsx @types/node. Create the changelog-agent.ts from this lesson and a strict tsconfig.json ("strict": true, "module": "NodeNext", "moduleResolution": "NodeNext", "noEmit": true).
  2. Typecheck first: npx tsc --noEmit must exit clean before anything runs. The contract starts at compile time.
  3. Run it against the practice repo: npx tsx changelog-agent.ts ../agentic-practice. Markdown arrives on stdout, the entry count on stderr. Compare the entries against git log yourself: correct range, sane classifications?
  4. Prove the permission wall exists. Add a step to the prompt asking the agent to also git commit the changelog to the repo, and re-run. The commit must not happen (git log unchanged); the run either completes without committing or reports the denial. Remove the sabotage.
  5. Prove the schema is enforced. Change kind to z.enum(["feature"]) only, re-run, and observe what happens when reality will not fit the contract: either every entry is forced into feature (note the summaries getting strained) or the run ends with error_max_structured_output_retries. Both outcomes teach the same thing: the schema is load-bearing. Restore it.
  6. Pipe the output somewhere real: npx tsx changelog-agent.ts ../agentic-practice > CHANGELOG.md in the practice repo, and commit it yourself.

Verify

  • npx tsc --noEmit exits 0 on the unmodified agent.
  • A normal run prints markdown whose entries match real commits in the range it reports, and exits 0.
  • The step-4 run left git log untouched: the write was denied by the permission surface, not by luck.
  • You observed the step-5 outcome and can say which one occurred and why.
  • CHANGELOG.md exists in the practice repo with your own commit.
>Troubleshooting
  • Native CLI binary for <platform> not found: your package manager skipped optional dependencies. Reinstall without --no-optional, or point pathToClaudeCodeExecutable at an installed claude binary.
  • Authentication error at run time: the SDK reads ANTHROPIC_API_KEY from the environment; a CLI subscription login does not carry over. Export the key in the shell running the agent.
  • The run ends with error_max_turns on a large repo: thirty turns is a policy, not a law. Raise maxTurns moderately or narrow the prompt's range (fewer commits per run).
  • structured_output is undefined on success: check that outputFormat is set and you are reading the result message, not an assistant message mid-stream.

Knowledge check

Knowledge check

Q1A nightly job must produce release notes as JSON that a deploy script consumes. Which tier is right, per the lesson's argument?
Q2Your SDK agent runs perfectly on your laptop and misbehaves on the server: it ignores rules you always see applied locally. Its options omit settingSources. What is the likely cause?
Q3In the changelog agent, what does the combination of permissionMode: 'dontAsk' and the scoped allowedTools list actually guarantee?
Q4A scheduled SDK agent starts returning error_max_structured_output_retries on some runs after weeks of working. What is the most productive first diagnosis?

Sources