How-to › Use AI to do the integration

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.

Audience
Agent builder
Level
beginner
Topic
Build an agent that calls APIs
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
Anthropic SDK tool runnerYour own tools, your own process, and no wish to write the loopA beta surface under client.beta.messages that can change, and a loop whose hooks are the only places you can interveneThe loop must do something its hooks do not cover
Claude Agent SDKA coding or filesystem agent that should behave like Claude Code, with your tools addedA native Claude Code binary of about 270 MB per platform, a child process per query, and built-in tools to opt out ofThe agent only ever calls your API and runs on a small host
Hand-written Messages API loopA team that wants every line on the path and no beta dependencyEvery decision is yours to write and test: the stop condition, the error result, the turn cap, parallel callsYou 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.

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

  1. 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 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. ↩︎ Back to text

  2. The helpers document 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. ↩︎ Back to text

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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.