You want an agent that answers one question by calling two operations on your REST API, and
the first thing you hit is a choice between two packages with similar names. One installs a
binary of about 270 MB and arrives with file and shell tools you did not ask for. The other
has a method you cannot find on client.messages. Both come from the same vendor.
What you get
You will end up with one two-operation agent built three ways, each answering the same question from the same scripted stand-in, with a transcript of what each one sent. This is for you if you are choosing your first Claude harness.
Short answer
Supply the two operations as in-process tools to the Claude Agent SDK’s query() through
createSdkMcpServer, and pass an empty tools list so Claude Code’s built-in tools stay out
of the request. The answer arrives in the result message. The Anthropic SDK’s
client.beta.messages.toolRunner runs the same loop over the same two tools with no built-ins
and no binary. A hand-written loop over messages.create runs until stop_reason is no longer
tool_use.
You will need
Node 22 or later, the Anthropic SDK, and Zod. An API key for the Claude API if you run any of
the three against the real model; the runs on this page use a scripted stand-in and no key.
The Claude Agent SDK, installed beside the sample with
npm install --no-save @anthropic-ai/claude-agent-sdk, for the query() version only.
Verified 2026-10-21 against Node 22.22.0, @anthropic-ai/sdk 0.135.0, zod 4.6.7, and
@anthropic-ai/claude-agent-sdk 0.3.303. The meters API, its client, and the stand-in run in
one process on 127.0.0.1. The stand-in reads the conversation and answers from a script, so
the loops run offline and print the same thing on every run.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Anthropic SDK tool runner | Your own tools, your own process, and no wish to write the loop | A beta surface under client.beta.messages that can change, and a loop whose hooks are the only places you can intervene | The loop must do something its hooks do not cover |
| Claude Agent SDK | A coding or filesystem agent that should behave like Claude Code, with your tools added | A native Claude Code binary of about 270 MB per platform, a child process per query, and built-in tools to opt out of | The agent only ever calls your API and runs on a small host |
| Hand-written Messages API loop | A team that wants every line on the path and no beta dependency | Every decision is yours to write and test: the stop condition, the error result, the turn cap, parallel calls | You would rather own the tools than the loop |
The three differ in how much harness you own. The Agent SDK hands you Claude Code’s loop, permissions, and built-in tools, and charges a binary and a child process for them. The tool runner hands you the loop alone, on a beta namespace. The hand-written loop hands you nothing and asks for thirty lines, each of which the other two already contain. All three leave hosting to you.
Define the two operations once
Each operation is a name, a description the model reads, a Zod schema for its input, and a function that calls the client. The three harnesses on this page take those four fields in three shapes, so the tools are defined in one file and adapted where they are used. The API key lives in the client and never in a tool input, so the model never sees it.
export function defineTools(client) {
return [
{
name: 'list_meters',
description: 'List every meter with its id, site, and unit.',
inputSchema: z.object({}),
run: () => client.listMeters(),
},
{
name: 'get_meter',
description: 'Fetch one meter by id, including its current reading.',
inputSchema: z.object({ id: z.string().describe('Meter id, such as mtr-101') }),
run: ({ id }) => client.getMeter(id),
},
]
}
Run the loop with the Agent SDK
The Agent SDK is Claude Code as a
library: query() starts the Claude Code binary and drives its loop. Your tools reach it as
an in-process MCP server from createSdkMcpServer. The
custom tools guide gives each one
the name mcp__<server>__<tool>, which is also the name to list in allowedTools so the call
runs without a permission prompt. The tools option decides which of Claude Code’s own tools
travel with the request, and an empty list means none.
// tool() takes the Zod shape, not the object, and the handler returns MCP content blocks.
const meters = createSdkMcpServer({
name: 'meters',
version: '1.0.0',
tools: defineTools(client).map((t) =>
tool(t.name, t.description, t.inputSchema.shape, async (args) => ({ content: [{ type: 'text', text: asText(await t.run(args)) }] })),
),
})
// The SDK spawns Claude Code, which reads the API location and key from its environment. The
// env option replaces the subprocess environment, so process.env is spread in to keep PATH.
const env = { ...process.env, ANTHROPIC_BASE_URL: scripted.url, ANTHROPIC_API_KEY: 'sk-ant-test', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' }
delete env.CLAUDECODE
let answer = ''
for await (const message of query({
prompt: 'What does the first meter read?',
options: {
model: MODEL,
tools: [],
mcpServers: { meters },
allowedTools: ['mcp__meters__list_meters', 'mcp__meters__get_meter'],
permissionMode: 'dontAsk',
maxTurns: 5,
systemPrompt: 'Answer from the meters tools.',
env,
},
})) {
The env option replaces the child process’s environment rather than adding to it, which is
why process.env is spread in. The variable Claude Code sets in its own shells is removed
because this page was written inside one. Run by hand with the SDK installed beside the
sample, the script printed tool_use mcp__meters__list_meters {}, then
tool_use mcp__meters__get_meter {"id":"mtr-101"}, then the same sentence the other two
harnesses produce below, after three requests to the stand-in. With { type: 'preset', preset: 'claude_code' } in place of the empty list, the last request carried 33 tools: the
two from this page and 31 of Claude Code’s own.1 permissionMode: 'dontAsk' refuses any
tool the allow list does not name, so a built-in the model asks for under the preset is denied
rather than run.
Run the same tools through the tool runner
The tool runner lives in the regular Anthropic SDK, under the beta namespace. betaZodTool
turns each definition into a runnable tool, and toolRunner calls the API, runs whatever the
reply asked for, appends the results, and asks again. Its
helpers document
says max_iterations has no limit by default, so this page sets one.2
export async function runWithToolRunner({ anthropic, client, question, maxIterations = 5, log = () => {} }) {
const tools = defineTools(client).map((t) =>
betaZodTool({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
run: async (args) => asText(await t.run(args)),
}),
)
const runner = anthropic.beta.messages.toolRunner({
model: MODEL,
max_tokens: 4096,
messages: [{ role: 'user', content: question }],
tools,
max_iterations: maxIterations,
})
// Each iteration yields one reply. Before the next one, the runner has run every tool the
// reply asked for and appended the results as a user message.
for await (const reply of runner) {
for (const block of reply.content) {
if (block.type === 'tool_use') log(`tool_use ${block.name} ${JSON.stringify(block.input)}`)
}
}
const final = await runner.done()
return {
answer: final.content.filter((b) => b.type === 'text').map((b) => b.text).join(''),
stopReason: final.stop_reason,
}
Iterating the runner yields one reply per request. A tool that throws is caught by the runner
and sent back as a tool_result with is_error set, which is what the tests below check.
Write the loop out
The tool use overview
states the contract in two sentences. Claude responds with stop_reason: "tool_use" and one or
more tool_use blocks, and your code executes the operation and sends back a tool_result.
Everything else in the loop is a decision the runner made for you.
export async function runHandWrittenLoop({ anthropic, client, question, maxTurns = 5, log = () => {} }) {
const tools = defineTools(client)
const definitions = tools.map((t) => ({ name: t.name, description: t.description, input_schema: toInputSchema(t.inputSchema) }))
const messages = [{ role: 'user', content: question }]
for (let turn = 1; turn <= maxTurns; turn++) {
const reply = await anthropic.messages.create({ model: MODEL, max_tokens: 4096, tools: definitions, messages })
messages.push({ role: 'assistant', content: reply.content })
if (reply.stop_reason !== 'tool_use') {
return {
answer: reply.content.filter((b) => b.type === 'text').map((b) => b.text).join(''),
stopReason: reply.stop_reason,
turns: turn,
}
}
// Every tool_use block in the reply gets a tool_result in one user message. A tool that
// throws becomes an error result the model can read, not an exception that ends the run.
// The input is checked against the tool's schema first, as the tool runner checks it.
const results = []
for (const block of reply.content) {
if (block.type !== 'tool_use') continue
log(`tool_use ${block.name} ${JSON.stringify(block.input)}`)
const tool = tools.find((t) => t.name === block.name)
try {
if (!tool) throw new Error(`no tool named ${block.name}`)
const input = tool.inputSchema.parse(block.input)
results.push({ type: 'tool_result', tool_use_id: block.id, content: asText(await tool.run(input)) })
} catch (err) {
results.push({ type: 'tool_result', tool_use_id: block.id, content: err.message, is_error: true })
}
}
messages.push({ role: 'user', content: results })
}
throw new Error(`no answer after ${maxTurns} turns`)
}
The demo runs the two harnesses this page can run offline against the same meters API and the same stand-in, with the same question.
node demo.mjs
tool runner: client.beta.messages.toolRunner
tool_use list_meters {}
tool_use get_meter {"id":"mtr-101"}
answer Meter mtr-101 in Dublin reads 42.5 kWh.
requests 3 to the Messages API, 2 to the meters API, stop_reason end_turn
tools list_meters, get_meter
hand-written loop: client.messages.create until stop_reason is not tool_use
tool_use list_meters {}
tool_use get_meter {"id":"mtr-101"}
answer Meter mtr-101 in Dublin reads 42.5 kWh.
requests 3 to the Messages API, 2 to the meters API, stop_reason end_turn
tools list_meters, get_meter
The transcripts are identical to the line: two tool calls, three requests to the Messages API,
two to the meters API, and one sentence. The difference between the two is not in what reaches
the model. It is in who wrote the schema check, the catch, the cap, and the stop_reason
check.
In Python the same three shapes are
claude-agent-sdk with query, the tool
decorator, and create_sdk_mcp_server, anthropic
with client.beta.messages.tool_runner and the beta_tool decorator, and a loop over
client.messages.create.
Check it worked
Twelve tests cover both harnesses on the happy path, on input that breaks the schema, and on a tool that throws. The rest cover both caps against a model that never stops, a call to a tool the loop lacks, the ids the client refuses, the client’s error, and the shared definition.
node --test loop.test.mjs
1..12
# tests 12
# suites 0
# pass 12
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 576.231636
The two tests to read are the ones on the thrown tool. The stand-in guesses mtr-404 first,
the client throws 404 no meter with id mtr-404, and the second request to the Messages API
carries exactly one tool_result with is_error set, in both harnesses.
When it goes wrong
client.messages.toolRunner is not a function. The runner is on the beta namespace, and the
reference heading in its own documentation says otherwise. The pitfall script prints the two
typeof results, then shows what happens to each harness when the model asks for a tool on
every turn.
node pitfall.mjs
where the tool runner lives
typeof client.messages.toolRunner undefined
typeof client.beta.messages.toolRunner function
a scripted model that asks for list_meters on every turn
tool runner, max_iterations 3 3 requests, then stopped with stop_reason tool_use and no text
hand-written loop, maxTurns 3 3 requests, then threw: no answer after 3 turns
without either cap the loop runs until the account does
The agent edits files when you only wanted two API calls. The Agent SDK’s default tool set is
Claude Code’s, and the request that reaches the model carries all of it. Pass tools: [], or a
list naming only the built-ins you want, and check the count of tools on the first request.
Native CLI binary for <platform>-<arch> not found. The package manager skipped the optional
dependency that holds the Claude Code binary. The
TypeScript reference says to set
pathToClaudeCodeExecutable to a separately installed claude binary in that case.
When not to do this
Do not install the Agent SDK to get a loop over your own API. The cost is a binary of about 270 MB per platform, a child process for every query, and a default tool set that reads, writes, and runs commands on the host. The tool runner gives you the loop in the package you already have, and the hand-written loop gives you the loop in thirty lines you can read.3
Do not pick the tool runner when the loop has to do something its hooks do not cover. It is a beta surface, and its reference heading and its examples already disagree about where it lives.
Do not run any of the three without a cap. The pitfall script shows a model that asks for the
same tool forever; the runner’s max_iterations, the loop’s maxTurns, and the Agent SDK’s
maxTurns are the three places that cap goes.
Do not put the API key where the model can see it. On this page it lives in MetersClient
and the tools take an id; a key in a tool input is a key in the conversation.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, @anthropic-ai/sdk 0.135.0, zod 4.6.7, and
@anthropic-ai/claude-agent-sdk 0.3.303. Every output block is what the command preceding it
printed. The tool runner and the hand-written loop ran against the scripted stand-in with no
model and no key. agent-sdk.mjs ran by hand against the same stand-in through the Claude
Code binary the SDK installs, once with an empty tools list and once with the claude_code
preset, where a scripted Bash call was refused. The checks on this page do not run it,
because that binary is kept out of the sample’s lockfile. No request reached the Claude API.
Footnotes
-
The TypeScript reference says the SDK bundles a native Claude Code binary for your platform as an optional dependency, and that the SDK version tracks the bundled Claude Code version. Its example is that SDK 0.3.191 bundles Claude Code 2.1.191. On the machine that wrote this page, the Linux package at 0.3.303 occupied 273 MB and the
claudeon the path reported 2.1.303. The patch numbers agree, as the reference says they should. The SDK’s type declarations list 54 built-in tool names for that binary to choose from. ↩︎ Back to text -
The helpers document introduces the runner as
anthropic.beta.messages.toolRunnerin every example and then files its reference entry underclient.messages.toolRunner(params). The pitfall script settles the question: the first is a function and the second isundefined. A heading is the one part of a reference that nobody runs. ↩︎ Back to text -
The Agent SDK overview draws the line itself, in a table of what to use when. The Agent SDK is a library that runs the Claude Code binary, with Claude Code’s capabilities, such as built-in tools, permissions, sessions, and hooks. The client SDK is direct access to the Claude API, where you write the tool loop yourself or let the client SDK’s beta tool runner drive it. The distinction is drawn on the Agent SDK’s page, so a reader who starts from the client SDK meets it only after choosing. ↩︎ Back to text