Module 14 · 50 min

The Agent SDK

You can run the Claude Code loop from your own JavaScript, watch every message it emits, and say exactly which system prompt it runs under.

Surface
@anthropic-ai/claude-agent-sdk
Claude Code
v2.1.251 (bundled)
claude-agent-sdk
v0.3.251
Docs checked
2026-08-29
A single machined rail running the width of the frame, with four mounting saddles bolted along it, one holding a component.

You already know the claude command, a program Anthropic wrote that runs a loop: send your message to the model (Claude itself), read back what it wants to do, run the tool it asked for, send the result, and go round again until it has an answer. This track puts that same loop inside a program you write. Three layers are involved, and people routinely build against the wrong one.

flowchart TD
subgraph api["Claude API"]
  direction LR
  a1["your code<br/>writes the loop: send messages,<br/>read tool calls, run tools, send results"] --> a2["model"]
end
subgraph sdk["Agent SDK"]
  direction LR
  b1["your code<br/>calls query(), consumes messages"] --> b2["Anthropic's loop<br/>your control surface"] --> b3["claude<br/>bundled binary, one process per session"]
end
subgraph cli["Claude Code CLI"]
  direction LR
  c1["you, in a terminal"] --> c2["Anthropic's loop<br/>plus Anthropic's terminal UI"]
end
api ~~~ sdk ~~~ cli
Three layers. The loop is the thing that moves between them: yours at the top, Anthropic's below, and a terminal on top of Anthropic's at the bottom.

Start with the bottom layer. The Claude API, short for application programming interface, is the raw way a program sends messages to the model over the network and reads the replies. It gives you a model that can ask for a tool to be run, a request called a tool call. It runs nothing. If you build there, the loop that reads a tool request, runs it, and feeds the result back is your code, and so are permissions, hooks, and sessions.

The Agent SDK is that loop, packaged. SDK is short for software development kit: a package you build your own program on top of. The overview page describes it as the same harness Claude Code runs on, exposed as a library. The harness is the loop and the machinery around it that runs tools and checks permissions; a library is a package your program imports. Your program calls one function, query(), and receives a stream of messages while the loop does its work.

The npm package is @anthropic-ai/claude-agent-sdk. A Python package exists (claude-agent-sdk on PyPI, Python’s package registry), mirrors this API, and carries its own version numbers. This course uses plain JavaScript throughout; module 13 is the reading you need before this page.

Here is the part that surprises people: the SDK bundles a native Claude Code binary inside the npm package, a ready-to-run copy of the Claude Code program, built for your operating system. Each query() call starts it as a separate process, a second program running alongside yours. The agent-loop page states it plainly: most installs need no separate claude install, because the binary ships with the SDK.

Your program and that process talk over stdio, short for standard input and output, the same text-in, text-out channel a terminal uses with any program. So when your app runs one session, one run of the loop from a prompt to its final answer, there is one extra process on the machine. When it runs fifty, there are fifty. Yes, really.

The smallest thing that runs

You need a folder, two installs, and one file. Module 15 starts a separate workbench and does not reuse this lab.

mkdir agent-sdk-lab && cd agent-sdk-lab
npm init -y
npm install @anthropic-ai/claude-agent-sdk
export ANTHROPIC_API_KEY=sk-ant-...

npm init -y prints the new package.json, as it did in module 13. npm install ends on a line that starts with added and a package count. export prints nothing, it sets an environment variable, a named value your shell hands to every program it starts, so the key never sits in a file. export is the bash form; on PowerShell the same line is $env:ANTHROPIC_API_KEY = "sk-ant-...", which is the shell difference module 13 promised you would meet.

The SDK proves who you are with an API key, a long secret string you create in the Anthropic Console, not with your claude.ai login. The overview page carries a warning: third-party products may not offer claude.ai login or its rate limits, the caps on how much you can send per minute, without prior approval from Anthropic. Use a console API key for the lab. Each run below costs a few cents.

The smallest program prints one answer. The braces inside query({ ... }) hand the function an object: a group of named values, here just one, named prompt.

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

for await (const message of query({
  prompt: 'What files are in this directory?',
})) {
  if (message.type === 'result' && message.subtype === 'success') {
    console.log(message.result);
  }
}

Reading message.type further down is that same shape from the other side: the value stored under the name type. query() returns an async iterator, a stream of values that arrive one at a time, which your for await loop reads one message at a time as the agent works. Save it as smallest.mjs, run node smallest.mjs, and you get one block of answer text naming the files in the folder.

But keeping only the result message throws away every other message type. The loop ran a whole session to produce that string: it chose a tool, ran it, read the output, and decided it was done. All of that passed through your for await as messages you ignored. That is the mistake most people make first with this API, and the next script fixes it.

Watch the loop instead

Keeping only the final answer hides the work. The next script prints every message type instead of filtering to the last one. Here is the shape those messages take:

message.typeWhen it arrivesWhat is inside
system (subtype init)Once, firstSession id, model, the tool list, the working directory (the folder the agent is standing in)
assistantEach model turnThe reply, in pieces called content blocks: text, or tool_use with the tool name and input
userAfter each tool runsThe tool results, fed back to the model as if a user sent them
resultOnce, lastOutcome subtype, turn count, cost in dollars, the final answer
agent-sdk-lab/
├─ src/
src/inspect.mjs

Prints every message the loop emits, rather than only the final answer.

// Module 14, first script. Run one small task and print every message the loop
// emits, rather than only the final answer. The point is to see the loop itself:
// system init, assistant turns, tool calls, tool results, and the result
// envelope that closes the session.
//
//   ANTHROPIC_API_KEY=sk-... node src/inspect.mjs
//
// Cost: one short session, a few cents.
//
// ANTHROPIC_API_KEY is an environment variable: a named value your shell
// passes to the program, instead of writing the secret key into a file.

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

const label = (s) =>
`\n=== ${s} ${"=".repeat(Math.max(0, 60 - s.length))}`;

// `for await` is a loop built for values that arrive one at a time over
// time, such as messages streaming in from a running session. A plain
// `for` loop needs every value up front; `for await` waits for each one
// as it shows up.
for await (const message of query({
prompt:
  "List the files in this directory and say in one sentence what this project is.",
options: {
  maxTurns: 5,
},
})) {
// `switch` picks one branch to run based on the value of `message.type`,
// instead of a chain of `if / else if` checks for the same variable.
switch (message.type) {
  case "system":
    // First message of every session: model, tools, cwd, session id.
    console.log(label(`system (${message.subtype})`));
    if (message.subtype === "init") {
      console.log(`  session ${message.session_id}`);
      console.log(`  model   ${message.model}`);
      console.log(`  tools   ${message.tools.join(", ")}`);
    }
    break;

  case "assistant":
    // One per assistant turn. Content blocks are text or tool_use.
    console.log(label("assistant"));
    for (const block of message.message.content) {
      if (block.type === "text")
        console.log(`  text: ${block.text.slice(0, 200)}`);
      else if (block.type === "tool_use")
        console.log(
          `  tool_use: ${block.name} ${JSON.stringify(block.input).slice(0, 160)}`,
        );
      else console.log(`  ${block.type}`);
    }
    break;

  case "user":
    // Tool results come back to the model as user messages. Your code
    // never ran the tool; the bundled Claude Code process did.
    console.log(label("user (tool results)"));
    break;

  case "result":
    // The envelope that ends the session: outcome, turns, cost.
    console.log(label(`result (${message.subtype})`));
    console.log(`  turns   ${message.num_turns}`);
    console.log(`  cost    $${message.total_cost_usd?.toFixed(4)}`);
    if (message.subtype === "success")
      console.log(`  answer  ${message.result.slice(0, 300)}`);
    break;

  default:
    // The SDK adds message types over time. Print what you do not know
    // rather than dropping it on the floor.
    console.log(label(`(unhandled) ${message.type}`));
}
}
The whole lab. Read inspect.mjs first; it is the module in code form.

Run npm run inspect. The npm run part looks up inspect in the scripts section of package.json and runs the command written there, node src/inspect.mjs. Read the sequence as it prints. Each message starts on a line of = signs with the message type in it. First the init message tells you what the session has: the model, the tool list, the working directory. Then assistant turns and tool results alternate until a result message closes the session with a turn count and a dollar figure. Later modules stream, approve, and resume sessions from this loop.

Build

The same ladder as module 13: run it, read one file, change one line, then build.

  1. Run it. Make the lab yourself rather than cloning it: the folder, the install, inspect.mjs from the tree above. Run it against any small project you have. You should see at least one tool_use block naming a real tool and a result line with a cost under a cent or two. If you only see text and a result, your prompt did not need a tool; ask something about the files on disk and run it again.
  2. Read one file. Read inspect.mjs top to bottom and, for each block the run printed, say which of the four case branches printed it.
  3. Change one line. Change slice(0, 200) in the assistant branch to slice(0, 40) and run again. Predict which printed lines get shorter before you look. Only the text: lines should change.
  4. Build. Write a third script, count-tools.mjs, that prints only the number of tool_use blocks the session used and the cost. Every line it needs is in inspect.mjs.

The reference copy lives in the course repo under examples/14-agent-sdk-lab/.

Which system prompt is it running?

This is the question that separates a working prototype from a working product. A system prompt is the standing instruction the model reads before your first message: who it is, how to behave, what it may touch. systemPrompt is the option that sets it, and the modifying-system-prompts page says what happens when you leave it out.

Omit systemPrompt and the SDK gives you a minimal default that covers tool calling and nothing else. It does not include the safety instructions in the Claude Code preset, the ready-made system prompt the claude command runs under. It does not include the environment context block either, the paragraph that tells the model your working directory, platform, and git state. The same page states the inverse in the same breath: claude -p, the command’s headless mode (one prompt in, one answer out, no screen to interact with), uses the full Claude Code system prompt by default.

Now run the four configs in prompt-lab.mjs against the same task and watch the differences:

npm run prompt-lab -- none
npm run prompt-lab -- preset
npm run prompt-lab -- append
npm run prompt-lab -- custom

Each run prints --- systemPrompt config: and the name, then a [tool] line for every tool the agent reached for, the answer text, and a [done] line with the turn count and cost. Compare the [tool] lines across the four runs, that is where the presets show their teeth.

none gets an agent with tools but without Claude Code’s operating instructions or environment context. preset uses the full Claude Code system prompt. append is the preset plus your rule stacked after it, the SDK’s equivalent of --append-system-prompt, the flag (an option you add after a command) from module 7. custom replaces the preset entirely, so every behaviour the preset supplied is now yours to write.

CLAUDE.md is not part of the system prompt here. The SDK adds it to the conversation as a message, and whether it loads at all is controlled by a setting called settingSources, which module 20 covers. Choosing the preset does not opt you into anyone’s CLAUDE.md.

Sharing a prompt cache across machines

Anthropic keeps a prompt cache: when the opening stretch of a prompt is identical to one it has seen recently, that stretch is reused instead of processed again. The preset normally embeds per-session facts in the system prompt: working directory, platform, shell (the program inside the terminal that reads your commands), whether the folder is a git repository. Two machines therefore produce two different system prompts and miss each other’s cache.

Setting excludeDynamicSections: true inside a preset systemPrompt moves those facts into the first user message, so identical configs share one cached opening. The modifying-system-prompts page pins this to SDK v0.2.98 and later, notes the claude command’s own flag —exclude-dynamic-system-prompt-sections, and names the cost: context delivered in a user message carries somewhat less weight than the same words in the system prompt. Prediction check before module 24: two identical configs on different machines, one with this flag and one without. Which pair shares a cache entry?

claude -p is not the neutral option

If you prototyped with claude -p and it felt like the SDK should behave the same way, read this section before you ship anything. -p, the flag module 7 uses to run one prompt and exit, is what the headless page calls headless mode. Before you script against it, read what it loads. Per that page: without further flags, a -p session picks up every surface the learn track taught, hooks, MCP (Model Context Protocol) servers, skills, commands, subagents, plugins, auto memory, and CLAUDE.md, from the working directory and from ~/.claude, your personal Claude Code folder. It does this with no trust dialog, the question Claude Code normally asks before it reads a new folder’s configuration, and no per-server approval prompt. Point it at a folder you did not write and everything executable in that folder’s .claude/ is now part of your session.

The --bare flag skips all of it. The CLI reference, CLI being short for command line interface, the claude command itself, documents one partial exception: a directory added with --add-dir still contributes its .claude/skills/, though not its commands or agents.

Bare mode also never reads your OAuth login (the browser sign-in to claude.ai) or the system keychain, where your computer stores saved passwords. You supply ANTHROPIC_API_KEY or an apiKeyHelper, a setting that names a script whose output is the key. The headless page recommends --bare for scripted and SDK use, and says it will become the default for -p in a future release. When that flip lands, scripts that relied on the old loading behaviour will quietly stop loading their project config, so write the flag explicitly either way: --bare when you want isolation, and your settingSources set out loud when you want loading.

The mistake most people make first

You prototype with claude -p “review this repo” and it loads your project context and your CLAUDE.md. You move it to the SDK, omit systemPrompt because the CLI never needed one, and ship. Nothing errors, the agent still answers, still uses tools, still sounds like Claude. What is missing is invisible: no environment context, no CLAUDE.md, none of the preset’s safety instructions. You find out when it edits a file your CLI sessions would have left alone, or answers as if it has no idea what project it is standing in, because it does not. Your inspect.mjs transcript shows the difference: an agent running the minimal default never mentions your working directory or git state unprompted, because nobody told it. When SDK behaviour and CLI behaviour differ on the same task, check which system prompt each one is actually running before you debug anything else.

Does this travel?

Every agent vendor has a raw model API, and most now ship some packaged loop on top of it. The specifics do not travel. query(), the message shapes, and the claude_code preset are this SDK’s own, and the bundled-binary design, one extra process per session, is unusual enough that you should not assume it elsewhere. For any agent framework, your first question is “what system prompt is this running when I pass nothing?”, and do not assume the default is the right one.

Check yourself

  1. Your program is running three SDK sessions at once. How many Claude Code processes are on the machine, and what did each one cost you to find out?
  2. A teammate reports the SDK agent “ignores CLAUDE.md” while their claude -p runs honour it. Name the two different defaults involved, and the page you would cite.
  3. Which of these four configs can load a skill from a folder you never trusted: the SDK with systemPrompt omitted, the SDK with the claude_code preset, claude -p without flags, claude -p --bare?