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-sdkThe 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
anthropicSDK, a singlemessages.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 -eis 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.
- 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 thechangelog-agent.tsfrom this lesson and a stricttsconfig.json("strict": true,"module": "NodeNext","moduleResolution": "NodeNext","noEmit": true). - Typecheck first:
npx tsc --noEmitmust exit clean before anything runs. The contract starts at compile time. - 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 againstgit logyourself: correct range, sane classifications? - Prove the permission wall exists. Add a step to the prompt asking the agent to also
git committhe changelog to the repo, and re-run. The commit must not happen (git logunchanged); the run either completes without committing or reports the denial. Remove the sabotage. - Prove the schema is enforced. Change
kindtoz.enum(["feature"])only, re-run, and observe what happens when reality will not fit the contract: either every entry is forced intofeature(note the summaries getting strained) or the run ends witherror_max_structured_output_retries. Both outcomes teach the same thing: the schema is load-bearing. Restore it. - Pipe the output somewhere real:
npx tsx changelog-agent.ts ../agentic-practice > CHANGELOG.mdin the practice repo, and commit it yourself.
Verify
npx tsc --noEmitexits 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 loguntouched: 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.mdexists 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 pointpathToClaudeCodeExecutableat an installedclaudebinary.- Authentication error at run time: the SDK reads
ANTHROPIC_API_KEYfrom 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_turnson a large repo: thirty turns is a policy, not a law. RaisemaxTurnsmoderately or narrow the prompt's range (fewer commits per run). structured_outputis undefined on success: check thatoutputFormatis set and you are reading theresultmessage, not an assistant message mid-stream.
Knowledge check
Knowledge check
Sources
- Agent SDK overview (install, bundled binary, auth, capabilities): https://code.claude.com/docs/en/agent-sdk/overview (fetched July 2026)
- Agent SDK reference, TypeScript (query(), Options, message and result types; cross-checked against the installed package's type declarations, v0.3.201): https://code.claude.com/docs/en/agent-sdk/typescript (fetched July 2026)
- Get structured output from agents (outputFormat, z.toJSONSchema, retry and error subtypes): https://code.claude.com/docs/en/agent-sdk/structured-outputs (fetched July 2026)
- Configure permissions (evaluation order, dontAsk mode, allow rules): https://code.claude.com/docs/en/agent-sdk/permissions (fetched July 2026)
- Give Claude custom tools (tool(), createSdkMcpServer, in-process servers): https://code.claude.com/docs/en/agent-sdk/custom-tools (fetched July 2026)