# How to build a tool-calling loop with the Claude Agent SDK

> Call two REST operations from one agent three ways: the Claude Agent SDK query loop, the Anthropic SDK tool runner, and a loop you write over the Messages API.

Source: https://voxgig.com/howto/tool-calling-loop-with-the-claude-agent-sdk

- Audience: agent-builder
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/helpers.md) | 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](https://code.claude.com/docs/en/agent-sdk/typescript) | 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](https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview) | 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.

```js title="tools.mjs"
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](https://code.claude.com/docs/en/agent-sdk/overview) 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](https://code.claude.com/docs/en/agent-sdk/custom-tools) 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.

```js title="agent-sdk.mjs"
// 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](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/helpers.md)
says `max_iterations` has no limit by default, so this page sets one.[^2]

```js title="tool-runner.mjs"
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](https://platform.claude.com/docs/en/agents-and-tools/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.

```js title="loop.mjs"
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.

```bash
node demo.mjs
```

```text output
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`](https://pypi.org/project/claude-agent-sdk/) with `query`, the `tool`
decorator, and `create_sdk_mcp_server`, [`anthropic`](https://pypi.org/project/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.

```bash
node --test loop.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
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](https://code.claude.com/docs/en/agent-sdk/typescript) 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

- [Wire an API client into the OpenAI Agents SDK](/howto/wire-an-api-client-into-openai-agents-sdk)

- [Build a stateful API agent with LangGraph](/howto/stateful-api-agent-with-langgraph)

- [Add a dry-run mode to an agent's write tools](/howto/add-dry-run-to-agent-write-tools)

- [Cache tool results across the steps of one agent run](/howto/cache-tool-results-across-agent-steps)

- [Choose between calling an SDK directly and using MCP](/howto/direct-sdk-calls-versus-mcp-in-an-agent)

## 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.

[^1]: The [TypeScript reference](https://code.claude.com/docs/en/agent-sdk/typescript) 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 `claude` on 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.

[^2]: The [helpers document](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/helpers.md)
introduces the runner as `anthropic.beta.messages.toolRunner` in every example and then files
its reference entry under `client.messages.toolRunner(params)`. The pitfall script settles the
question: the first is a function and the second is `undefined`. A heading is the one part of
a reference that nobody runs.

[^3]: The Agent SDK [overview](https://code.claude.com/docs/en/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.