Claude Tool Use in TypeScript: Agentic Loop Tutorial (2026)

May 29, 2026

Claude Tool Use in TypeScript: Agentic Loop Tutorial (2026)

To use tool use with the Claude API in TypeScript, install @anthropic-ai/sdk, define tools with name, description, and a JSON Schema input_schema, then loop on client.messages.create until stop_reason is end_turn, executing tools and sending the results back as tool_result content blocks on every tool_use turn1.

TL;DR

You'll build a single-file TypeScript script (roughly 140 lines including the two tool definitions and validation) that turns Claude Sonnet 4.6 into an agent that can call your own functions. The runtime is Node 24 LTS, the SDK is @anthropic-ai/sdk@0.98.0, and the tool inputs are validated by Zod. By the end you'll have a working agentic loop that fans out to two tools in parallel, handles errors cleanly, and exits on end_turn. Build time: 20-25 minutes.

What you'll learn

  • How the Claude messages.create API turns a list of tools into an agentic loop driven by stop_reason
  • How to write a strict JSON Schema input_schema and validate the model's input with Zod 4 at runtime
  • How parallel tool calls work — multiple tool_use blocks in one response, all answered in one user message
  • The "tool_result must come FIRST in the user content array" rule that causes 400 errors when violated
  • How to map each stop_reason (end_turn, tool_use, max_tokens, pause_turn, refusal, model_context_window_exceeded) to a recovery path
  • When to force tool use with tool_choice and when to leave it on auto

Prerequisites

  • Node.js 24 LTS (Active LTS as of May 2026, supported through April 2028)2 — Node 22 in Maintenance LTS works too, but Node 24 is recommended
  • An Anthropic API key from the Claude Platform console, exported as ANTHROPIC_API_KEY
  • A few cents of API budget — running the verification at the end of this tutorial costs a fraction of a cent at Sonnet 4.6 pricing ($3 per million input tokens, $15 per million output tokens)3

How do I use tool use with the Claude API in TypeScript?

Tool use in the Claude API is a request-response loop: you send messages.create with a tools array of JSON Schema definitions, Claude returns either a final answer (stop_reason: "end_turn") or one or more tool_use content blocks (stop_reason: "tool_use"), your code runs the tools, and you send the outputs back as tool_result blocks in the next user message1. The same call resumes the conversation; Claude doesn't keep server-side state between turns.

The tools parameter is the same shape Claude has used since the feature went GA: every tool is { name, description, input_schema }. The name matches ^[a-zA-Z0-9_-]{1,64}$, the input_schema is a JSON Schema 2020-12 object, and the description is plain text — Anthropic recommends 3-4+ sentences for anything non-trivial1.

Step 1 — Scaffold the project

mkdir claude-tools-agent && cd claude-tools-agent
npm init -y
npm pkg set type="module"
npm install --save-exact @anthropic-ai/sdk@0.98.0 zod@4.4.3
npm install --save-exact -D typescript@6.0.3 tsx@4.22.3 @types/node@24.12.4

--save-exact writes "@anthropic-ai/sdk": "0.98.0" (without a caret) so a future npm install won't silently bump you onto a fresh major or beta-channel release. The Anthropic TypeScript SDK ships frequent Stainless-generated patches, so an exact pin is the right default for a tutorial reader who will run this code weeks from now.

Now drop a strict tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

The two flags worth highlighting: noUncheckedIndexedAccess makes any index into response.content return ContentBlock | undefined, which pairs well with discriminated-union .filter(b => b.type === 'tool_use') and saves you from as casts; verbatimModuleSyntax requires explicit import type for type-only imports, which keeps the emitted JS clean under Node 24's native ESM resolver.

Step 2 — Define a typed tool with a JSON Schema

Create src/agent.ts and start with a single weather tool. The shape is identical to the tools array in the API: a name, a long description, and an input_schema.

import Anthropic from '@anthropic-ai/sdk';
import type {
  MessageParam,
  Tool,
  ToolResultBlockParam,
  ContentBlockParam,
} from '@anthropic-ai/sdk/resources/messages';
import { z } from 'zod';

const client = new Anthropic();

const GetWeatherInput = z.object({
  location: z.string(),
  unit: z.enum(['celsius', 'fahrenheit']).default('fahrenheit'),
});

const tools: Tool[] = [
  {
    name: 'get_weather',
    description:
      'Get the current weather for a city. Returns a short string ' +
      'like "72°F, partly cloudy". Use this whenever the user asks ' +
      'about current weather, conditions, or temperature.',
    input_schema: {
      type: 'object',
      properties: {
        location: {
          type: 'string',
          description: 'City and state, e.g. "San Francisco, CA"',
        },
        unit: {
          type: 'string',
          enum: ['celsius', 'fahrenheit'],
          description: 'Temperature unit (default fahrenheit)',
        },
      },
      required: ['location'],
    },
  },
];

function runWeather(input: unknown): string {
  // The SDK types `input` as `unknown` because the schema is JSON Schema,
  // not a TS type. Zod gives us a runtime check + an inferred TS type.
  const args = GetWeatherInput.parse(input);
  const temp = args.unit === 'celsius' ? '22°C' : '72°F';
  return `${temp}, partly cloudy in ${args.location}`;
}

Two design choices worth explaining. First, the description is deliberately verbose — Anthropic's own docs call tool descriptions "by far the most important factor in tool performance" and recommend at least 3-4 sentences for non-trivial tools1. Second, the SDK types block.input as unknown, not as the inferred TypeScript shape of your schema. Zod is doing real validation here — if Claude hallucinates a field, parse() throws, and you'll catch it in Step 6's error path.

What is the stop_reason loop for Claude tool use?

The agentic loop is a while (true) keyed on stop_reason. After every messages.create call you inspect response.stop_reason: end_turn means Claude is finished and you can return its final text; tool_use means Claude wants to invoke one or more tools and you must reply with matching tool_result blocks; max_tokens, pause_turn, and refusal are recovery paths covered in Step 64.

Step 3 — Add the agentic loop

Append this to src/agent.ts:

async function runAgent(userMessage: string): Promise<string> {
  const messages: MessageParam[] = [{ role: 'user', content: userMessage }];

  for (let turn = 0; turn < 10; turn++) {
    const response = await client.messages.create({
      model: 'claude-sonnet-4-6',
      max_tokens: 1024,
      tools,
      messages,
    });

    // Always push the assistant turn into history BEFORE we react to it.
    // The next call needs the original tool_use blocks present so that the
    // tool_result blocks we send back have matching tool_use_ids.
    messages.push({ role: 'assistant', content: response.content });

    if (response.stop_reason === 'end_turn') {
      const textBlocks = response.content.filter((b) => b.type === 'text');
      return textBlocks.map((b) => b.text).join('\n');
    }

    if (response.stop_reason === 'tool_use') {
      const toolResults: ToolResultBlockParam[] = response.content
        .filter((b) => b.type === 'tool_use')
        .map((block) => {
          try {
            const output =
              block.name === 'get_weather'
                ? runWeather(block.input)
                : `Unknown tool: ${block.name}`;
            return {
              type: 'tool_result' as const,
              tool_use_id: block.id,
              content: output,
            };
          } catch (err) {
            return {
              type: 'tool_result' as const,
              tool_use_id: block.id,
              content: err instanceof Error ? err.message : String(err),
              is_error: true,
            };
          }
        });

      // CRITICAL: tool_result blocks must come FIRST in the user content
      // array. If you append a text block before them you get a 400 with
      // "tool_use ids were found without tool_result blocks immediately
      // after". Trailing text after the tool_results is fine.
      const content: ContentBlockParam[] = toolResults;
      messages.push({ role: 'user', content });
      continue;
    }

    if (response.stop_reason === 'max_tokens') {
      throw new Error(
        'Response truncated. Retry with a higher max_tokens.',
      );
    }

    throw new Error(`Unexpected stop_reason: ${response.stop_reason}`);
  }

  throw new Error('Tool loop exceeded 10 turns — possible infinite loop');
}

const out = await runAgent('What is the weather in Tokyo and Paris?');
console.log(out);

A turn budget (turn < 10) is the cheapest way to bound your bill against a runaway agent. In production you'd track input + output token totals instead, but a hard turn cap is the right tutorial-level safeguard.

Step 4 — Run it and watch the loop

export ANTHROPIC_API_KEY="sk-ant-..."
npx tsx src/agent.ts

Expected output shape — the exact wording from Claude will vary, but the structure is stable:

Here's the current weather for both cities:

Tokyo: 72°F, partly cloudy
Paris: 72°F, partly cloudy

Both cities are experiencing similar mild conditions today.

What just happened: Claude sent back ONE assistant message with TWO tool_use blocks (one per city), our loop ran runWeather twice, packaged both results into a single user message containing TWO tool_result blocks, and Claude's next response had stop_reason: "end_turn" with a natural-language summary. That's parallel tool use — covered in Step 5.

How do I handle parallel tool calls from Claude?

Parallel tool calls are the default on Claude 4 models. When Claude needs to call two tools to answer a question, it emits both tool_use blocks in the same assistant message, and your reply must include tool_result blocks for ALL of them — in a single user message, with the tool_result blocks before any text1. To opt out, set disable_parallel_tool_use: true on the tool_choice object — e.g. tool_choice: { type: 'auto', disable_parallel_tool_use: true }1.

Step 5 — Add a second tool to make parallelism visible

Add a get_time tool that returns the current time for a city. The cleanest demo is to ask Claude a question that requires both tools at once:

const GetTimeInput = z.object({
  timezone: z.string(),
});

tools.push({
  name: 'get_time',
  description:
    'Get the current local time for an IANA timezone string like ' +
    '"America/Los_Angeles" or "Asia/Tokyo". Returns a string like ' +
    '"2026-05-29 18:39:48" (YYYY-MM-DD HH:MM:SS, local to that timezone, ' +
    '24-hour). Use this whenever the user asks what time it is somewhere.',
  input_schema: {
    type: 'object',
    properties: {
      timezone: {
        type: 'string',
        description: 'IANA timezone, e.g. "Asia/Tokyo"',
      },
    },
    required: ['timezone'],
  },
});

function runTime(input: unknown): string {
  const args = GetTimeInput.parse(input);
  // sv-SE renders YYYY-MM-DD HH:MM:SS, which is what the tool description
  // promises. Intl is built into Node, no extra deps needed.
  return new Intl.DateTimeFormat('sv-SE', {
    timeZone: args.timezone,
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
    hour12: false,
  }).format(new Date());
}

Wire it into the dispatcher in runAgent:

const output =
  block.name === 'get_weather' ? runWeather(block.input)
  : block.name === 'get_time' ? runTime(block.input)
  : `Unknown tool: ${block.name}`;

Now change the prompt to force both tools:

const out = await runAgent(
  'What time is it in Tokyo and what is the weather in Paris?',
);

Re-run with npx tsx src/agent.ts. The first turn's response.content will contain two tool_use blocks side by side; our loop fans out, runs both, sends one user message with two tool_result blocks, and Claude's second turn closes with end_turn. If you log response.content.filter(b => b.type === 'tool_use').length after the first call you'll see 2.

What is the difference between tool_use and tool_result blocks?

tool_use blocks appear in assistant messages — they describe the call Claude wants to make, with an id, a name, and an input object. tool_result blocks appear in user messages — they carry the tool's output back to Claude, referencing the original call by tool_use_id. Every tool_use block in an assistant turn must have a matching tool_result block (same tool_use_id) in the very next user turn, before any text, or the API returns a 4001.

Step 6 — Tool errors and unexpected stop_reasons

The loop already handles the common error case — if runWeather throws (because Zod parse failed, or because the city is unknown), the catch branch returns is_error: true in the tool_result block. Claude sees the error message and decides whether to retry with different inputs, ask the user to clarify, or give up.

Four less-common stop_reason values worth handling explicitly4:

Stop reasonWhat it meansRecovery
max_tokensThe response was truncated, possibly mid-tool_useRetry the same request with a higher max_tokens; if the last content block is a tool_use, you cannot run that tool until you have the full block1
pause_turnReturned when the server-side sampling loop reaches its iteration limit while running server tools (default 10 iterations); the client tools you defined here will never see this4Append response.content to history and re-send with the same parameters to continue
refusalClaude declined to answer for safety reasonsSurface the refusal text to the user; do not auto-retry, you'll just get the same answer4
model_context_window_exceededThe response hit the model's context window before reaching max_tokens. Available by default on Sonnet 4.5 and newer models, including Sonnet 4.64The partial response is valid; treat like max_tokens (warn the user, or continue with a shorter prompt)

The Unexpected stop_reason branch in our loop catches anything we haven't explicitly handled, including refusal and model_context_window_exceeded — promote these to dedicated if blocks once you know how you want to surface them.

Verification

Run the final script once more with the parallel-tools prompt and capture the structure:

npx tsx src/agent.ts

You're looking for three things:

  1. The script exits with code 0 — no thrown errors, no 400s about missing tool_result blocks
  2. The printed output mentions both Tokyo and Paris (one tool ran for each)
  3. The loop ran for exactly two turns — first turn stop_reason: "tool_use", second turn stop_reason: "end_turn"

To assert the turn count programmatically, log turn at the top of the loop or replace console.log(out) with console.log({ out, turns: ... }). If you ever see more than two turns for the prompt above, Claude probably called one tool, looked at the result, and decided to call another — that's the agentic loop doing its job, not a bug.

Troubleshooting

"tool_use ids were found without tool_result blocks immediately after" — the assistant message containing tool_use blocks isn't immediately followed by a user message whose content STARTS with the matching tool_result blocks. The two most common causes: appending a text block before the tool_results in the user content array (the API requires tool_results first, text after), or skipping the assistant message altogether after one with stop_reason: "tool_use".

"Input tag does not match any valid tag" or schema mismatch — Claude generated an input that doesn't match your input_schema. Add strict: true to the tool definition to make the API guarantee schema validation on tool names and inputs5 — note that strict tool use also requires additionalProperties: false on each object schema and only supports a constrained JSON Schema subset, so check the strict-mode docs before enabling. And check your description — Anthropic's tool-use guide repeatedly points out that vague descriptions are the root cause of bad arguments1.

Loop runs forever — Claude calls the same tool over and over because it isn't satisfied with the result. Make the tool's return string include the answer Claude needs ("temperature: 22°C, conditions: partly cloudy") rather than acknowledgements ("weather lookup complete"); and keep the turn cap in runAgent short until you trust your tool surface.

429 rate_limit_error — you're hitting the per-minute request limit on your tier. Add await new Promise(r => setTimeout(r, 1000)) between turns, or use the SDK's built-in retry hook by passing maxRetries: 3 to new Anthropic({ maxRetries: 3 }).

How do I force Claude to use a specific tool?

Pass tool_choice in the request. {type: 'auto'} (the default when tools is set) lets Claude decide; {type: 'any'} forces SOME tool but not a specific one; {type: 'tool', name: 'get_weather'} forces that exact tool; {type: 'none'} disables all tools1. Note that any and tool are incompatible with extended thinking on Sonnet 4.6 — only auto and none work when extended thinking is enabled1.

Next steps and further reading

The Anthropic TypeScript SDK ships a beta helper, client.beta.messages.toolRunner, that wraps the manual loop above and adds Zod-typed betaZodTool, automatic compaction at long contexts, and built-in AbortSignal cancellation6. It's the right production choice once you've internalised what the manual loop is doing — but the manual loop is what's running underneath, and the stable, non-beta messages.create surface is what production agents have been shipping on since tool use went GA in 2024.

If you're moving beyond a single script, the natural next step is to expose this same toolset through an MCP server in TypeScript with OAuth and streamable HTTP so any MCP-aware client (Claude Code, Cursor, your own UI) can plug in. If you want to evaluate the prompts driving your agent, promptfoo with assertions and CI gates gives you per-prompt regression tests. And if you want to swap Sonnet 4.6 out for GPT-5.4 or Gemini 2.5 Pro without changing your code, LiteLLM Proxy gives you the same Messages API surface across providers.

Footnotes

  1. Anthropic, "How to implement tool use" — https://platform.claude.com/docs/en/agents-and-tools/tool-use/implement-tool-use (verified 2026-05-29) 2 3 4 5 6 7 8 9 10 11

  2. Node.js Release Schedule — https://nodejs.org/en/about/previous-releases (Node 24 LTS, Active LTS through April 2028; verified 2026-05-29)

  3. Anthropic, "Models overview" — https://platform.claude.com/docs/en/about-claude/models/overview (Claude Sonnet 4.6: $3 / $15 per million input/output tokens, 1M context window, 64k max output; verified 2026-05-29)

  4. Anthropic, "Handling stop reasons" — https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons (StopReason values: end_turn, max_tokens, stop_sequence, tool_use, pause_turn, refusal, and model_context_window_exceeded which is available by default on Sonnet 4.5 and newer; the SDK 0.98.0 StopReason type currently enumerates the first six and the seventh value falls through to the loop's "Unexpected stop_reason" branch; verified 2026-05-29) 2 3 4 5

  5. Anthropic, "Strict tool use" — https://platform.claude.com/docs/en/agents-and-tools/tool-use/strict-tool-use (and the parent "Structured outputs" page at https://platform.claude.com/docs/en/build-with-claude/structured-outputsstrict: true on tool definitions enables grammar-constrained validation on tool names and inputs; requires additionalProperties: false and the supported JSON Schema subset; GA on Sonnet 4.6; verified 2026-05-29)

  6. Anthropic TypeScript SDK helpers — https://github.com/anthropics/anthropic-sdk-typescript/blob/main/helpers.md#tool-helpers (client.beta.messages.toolRunner, betaZodTool, ToolError; verified 2026-05-29)


FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.