How to build a tool-use loop with the Claude API in Node.js
A step-by-step Node.js tutorial for Claude tool use: define a strict tool schema, run the request and result loop, handle every stop reason, report tool errors, and avoid the forced-tool 400 error on the Claude 5.5 models.
Updated 9 min read

In this article
A chat model on its own can only answer from what it already knows. The moment you want it to look up an order, read a row from your database or create a ticket, you need tool use: you describe functions to the model, the model asks you to call one, your code runs it, and you send the result back. The pattern is simple, but small mistakes in the message format are the most common reason a first integration fails with a 400 error.
This tutorial builds a complete, working tool-use loop in plain Node.js with the official @anthropic-ai/sdk package (version 0.131.0 on npm at the time of writing). The example is a small customer-support helper that can check an order's status. You will see how to define a tool with a strict JSON Schema, how to run the loop safely, how to report tool errors back to the model, and which rules changed with the current Claude 5.5 models.
Everything here was checked against Anthropic's documentation on 7 October 2026. Model names and prices change, so confirm them on the models overview page before you ship.
What you need
- Node.js 20 or later. The SDK's README lists Node.js 20 LTS or later (non-EOL versions) as supported; the examples below use ES modules and top-level
await. - An API key from the Claude Console, exported as
ANTHROPIC_API_KEY. The SDK reads this variable by default, so the key never needs to appear in your code. - A model ID. The current lineup on the models overview page is Claude Fable 5.1 (
claude-fable-5-1), Claude Opus 5.5 (claude-opus-5-5), Claude Sonnet 5.5 (claude-sonnet-5-5) and Claude Haiku 4.5 (claude-haiku-4-5). This tutorial uses Sonnet 5.5, which Anthropic describes as the best combination of speed and intelligence, priced at $2 per million input tokens and $10 per million output tokens.
mkdir order-helper && cd order-helper
npm init -y
npm pkg set type=module
npm install @anthropic-ai/sdk
export ANTHROPIC_API_KEY="your-key-here" # use a secrets manager in productionHow tool use works in the Messages API
Claude does not run your code. The flow is a conversation in which tool calls and tool results are just content blocks:
- You send a request with a
toolsarray. Each tool has aname, adescriptionand aninput_schemawritten in JSON Schema. - If Claude decides to call a tool, the response has
stop_reason: "tool_use"and one or moretool_useblocks, each with anid, anameand aninputobject. - Your code runs the matching function and replies with a
usermessage containingtool_resultblocks. Each result carries thetool_use_idit answers, thecontent, and optionallyis_error: true. - You repeat until the stop reason is something other than
tool_use, typicallyend_turn.
Two formatting rules cause most 400 errors. First, the tool result message must come immediately after the assistant message that requested the tools. Second, inside that user message, the tool_result blocks must come first; any text you add must come after all of them.

Step 1: define a tool the model can use correctly
Anthropic's guidance is blunt: detailed descriptions are by far the most important factor in tool performance. Say what the tool does, when to use it and when not to, what each parameter means, and what the tool does not return. Aim for three or four sentences at least.
Add strict: true as well. Strict tool use constrains sampling so that the tool's input always matches your schema, which removes a whole class of bugs such as a number arriving as a string or a required field going missing. Strict schemas should set additionalProperties: false.
// tools.mjs
export const tools = [
{
name: "get_order_status",
description:
"Looks up a single customer order by its order ID and returns the current " +
"status, the courier name and the estimated delivery date. Use it whenever " +
"the customer asks where an order is or when it will arrive. Order IDs look " +
"like A-1001. It does not return payment, price or address details.",
strict: true,
input_schema: {
type: "object",
properties: {
order_id: {
type: "string",
description: "The order ID exactly as the customer gave it, e.g. A-1001",
},
},
required: ["order_id"],
additionalProperties: false,
},
},
];
// Stand-in for your database or order API.
const orders = {
"A-1001": { status: "shipped", courier: "Example Express", eta: "2026-10-09" },
"A-1002": { status: "processing", courier: null, eta: null },
};
export async function runTool(name, input) {
if (name === "get_order_status") {
const order = orders[input.order_id];
if (!order) {
throw new Error(
`No order found with ID ${input.order_id}. Ask the customer to check the ID.`
);
}
return JSON.stringify({ order_id: input.order_id, ...order });
}
throw new Error(`Unknown tool: ${name}`);
}Notice that the error message tells the model what to do next. Anthropic recommends instructive errors over a bare "failed", because the model uses the text to recover.
Step 2: write the loop
The loop below sends the conversation, appends the assistant's full response to the history, runs every requested tool, and sends all results back in one user message. Appending response.content unchanged matters: it keeps any thinking blocks and the tool_use blocks that your results refer to.
// agent.mjs
import Anthropic from "@anthropic-ai/sdk";
import { tools, runTool } from "./tools.mjs";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY
const MODEL = "claude-sonnet-5-5";
const SYSTEM =
"You are a support assistant for an online shop. Use the tools to answer " +
"order questions. Never guess an order status.";
export async function ask(question, { maxTurns = 5 } = {}) {
const messages = [{ role: "user", content: question }];
for (let turn = 0; turn < maxTurns; turn++) {
const response = await client.messages.create({
model: MODEL,
max_tokens: 2048,
system: SYSTEM,
tools,
messages,
});
// Keep the assistant turn exactly as returned.
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason !== "tool_use") {
const text = response.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n");
return { stopReason: response.stop_reason, text, usage: response.usage };
}
// Run every tool call in this turn; results go back together.
const results = [];
for (const block of response.content) {
if (block.type !== "tool_use") continue;
try {
const output = await runTool(block.name, block.input);
results.push({ type: "tool_result", tool_use_id: block.id, content: output });
} catch (err) {
results.push({
type: "tool_result",
tool_use_id: block.id,
content: err instanceof Error ? err.message : String(err),
is_error: true,
});
}
}
messages.push({ role: "user", content: results });
}
throw new Error(`No final answer after ${maxTurns} turns`);
}
const answer = await ask("Where is my order A-1001, and what about A-9999?");
console.log(answer.stopReason, "\n", answer.text);Run it with node agent.mjs. With the sample data, the model will usually call get_order_status twice (the API allows several tool calls in one turn), receive one success and one error, and then explain both to the customer. The exact wording varies between runs, so do not assert on it in tests; assert on the tool calls your code received instead.
Step 3: handle every stop reason
Treating anything that is not tool_use as "done" is fine for a prototype, but production code should look at the reason. The documented values are:
| stop_reason | Meaning | What to do |
|---|---|---|
end_turn | Claude finished its response naturally. | Return the text. |
tool_use | Claude is calling a tool. | Run tools, send results. |
max_tokens | The response reached your max_tokens limit. | Raise the limit or ask for a shorter answer; do not show a cut-off reply as final. |
stop_sequence | Claude emitted one of your stop sequences. | Handle as your stop sequence intends. |
pause_turn | A server-tool loop reached its iteration limit. | Only with server tools such as web search: send the response back as-is to continue. |
refusal | Claude declined to respond. | Read stop_details, show a safe message, optionally retry on a fallback model. |
model_context_window_exceeded | The response filled the model's context window. | Trim history or summarise older turns. |
What changed with the Claude 5.5 models
If you are porting older code, check how it uses tool_choice. The parameter accepts auto (the default when tools are present), any, tool and none. On Claude Opus 5.5, Claude Sonnet 5.5 and Claude Fable 5.1, forcing a tool with any or {"type": "tool", "name": ...} returns a 400 error. Anthropic's recommended replacement is auto together with strict tool use, or structured outputs when you need the whole response in a fixed JSON shape. Prompting still steers which tool auto picks, so say plainly in the system prompt when a tool must be used.
Two smaller points are worth knowing. Tool names must match ^[a-zA-Z0-9_-]{1,128}$. And for complex inputs you can add an optional input_examples array to a tool definition; each example must validate against the schema or the request fails, and each adds roughly 20 to 200 tokens to the prompt.
Security: tool results are untrusted input
Tool results often contain text you do not control: web pages, inbound emails, customer messages, third-party API responses. Anyone who can influence that text can try to plant instructions in it (indirect prompt injection). Anthropic's documentation advises keeping such content inside tool_result blocks rather than pasting it into the system prompt or a plain user text block. In practice, also:
- Authorise inside
runTool, not in the prompt. If a customer may only see their own orders, check the signed-in customer's ID against the order before returning it. - Give tools the narrowest permissions possible. A read-only lookup tool should use a read-only database user.
- Require a human confirmation step for tools that spend money, send messages or delete data.
- Cap the loop (the
maxTurnsguard above) and log every tool call with its input for auditing. - Keep the API key on the server. Never call the API directly from a browser bundle.

Keeping costs predictable
Every turn of the loop resends the whole conversation, including tool definitions, so long conversations grow in cost. The usage object on each response tells you how many input and output tokens you paid for. Two features help: prompt caching, which lets repeated prefixes such as tool definitions and a long system prompt be read from cache at a fraction of the input price, and the Message Batches API at 50% off for work that does not need an immediate answer. Changing tool_choice between requests invalidates cached message blocks, so keep it stable if you rely on caching.
If you would rather not write the loop yourself, the SDKs also ship a Tool Runner that manages the loop, result formatting and retries for you. Writing it by hand once, as above, makes it much easier to debug when something goes wrong.
Next steps
- Replace the in-memory
ordersobject with a real, read-only query and add an ownership check. - Add a second tool, prefixing names by service (for example
orders_get_status,tickets_create) as Anthropic suggests once you have several. - Write tests that stub
runTooland assert on the calls made, not on the model's wording. - Log
usageper request so you can see the cost of each conversation before it reaches production traffic.
Frequently asked questions
Why does my request with tool_choice set to a specific tool return a 400 error?
On Claude Opus 5.5, Claude Sonnet 5.5 and Claude Fable 5.1, forcing a tool with
tool_choiceofanyortoolis not supported and returns a 400 error. Useautowithstrict: truetool definitions, or structured outputs if you need a fixed JSON response.Where do tool results go in the conversation?
In a
usermessage sent immediately after the assistant message that requested the tools. Thetool_resultblocks must come first in that message's content; any extra text must come after them.How do I tell Claude that a tool failed?
Return a
tool_resultwithis_error: trueand a message that explains what went wrong and what to try next. Claude uses that text to recover or explain the problem to the user.Does Claude run my tool code?
No. For client tools Claude only returns a request with the tool name and input. Your application runs the function, enforces permissions and sends the result back.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Models overview — Anthropic
- Define tools — Anthropic
- Handle tool calls — Anthropic
- Strict tool use — Anthropic
- Handling stop reasons — Anthropic
- @anthropic-ai/sdk on npm — npm, 2026-09-30
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

Architecture guideAI and Automation
How to build a safe AI content pipeline with human fact review
An architecture for AI-assisted publishing where people stay responsible: untrusted source snapshots, claim checking with citations, a review queue AI cannot skip, labels and rollback.
By Cloudsoft Zone

TutorialNode.js
How to test a Node.js API with the built-in test runner (node:test)
Write, run, mock and measure coverage for a small Node.js HTTP API using only node:test and node:assert. Covers port-0 servers, fetch(), spies, t.mock.method(), fake dates and coverage thresholds.
By Cloudsoft Zone

TutorialNode.js
Node.js streams in practice: pipeline(), backpressure and async generators
Process files of any size in Node.js with flat memory use. A hands-on guide to stream/promises pipeline(), backpressure, async generator transforms, AbortSignal timeouts and streaming downloads over HTTP.
By Cloudsoft Zone
