---
title: 'Claude Tool Use in TypeScript: Agentic Loop Tutorial (2026)'
description: 'Hands-on Claude Sonnet 4.6 tool use tutorial: build a typed agentic loop in TypeScript with the Anthropic SDK 0.98, parallel tool calls, and JSON Schema.'
url: https://nerdleveltech.com/claude-sonnet-4-6-tool-use-typescript-agentic-loop-tutorial
language: en
date: '2026-05-29'
category: ai-ml
tags:
  - claude
  - anthropic
  - tool-use
  - typescript
  - agentic
  - llm
  - sdk
  - sonnet-4.6
  - tutorial
  - ai-ml
markdown_url: https://nerdleveltech.com/claude-sonnet-4-6-tool-use-typescript-agentic-loop-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/claude-sonnet-4-6-tool-use-typescript-agentic-loop-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# 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` turn[^1].

## 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](https://platform.claude.com/settings/keys), 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 message[^1]. 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-trivial[^1].

## Step 1 — Scaffold the project

```bash
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`:

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

```typescript
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 tools[^1]. 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 6[^4].

## Step 3 — Add the agentic loop

Append this to `src/agent.ts`:

```typescript
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

```bash
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 text[^1]. 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:

```typescript
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`:

```typescript
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:

```typescript
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 400[^1].

## 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 explicitly[^4]:

| Stop reason | What it means | Recovery |
|---|---|---|
| `max_tokens` | The response was truncated, possibly mid-`tool_use` | Retry 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 block[^1] |
| `pause_turn` | Returned 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 this[^4] | Append `response.content` to history and re-send with the same parameters to continue |
| `refusal` | Claude declined to answer for safety reasons | Surface the refusal text to the user; do not auto-retry, you'll just get the same answer[^4] |
| `model_context_window_exceeded` | The response hit the model's context window before reaching `max_tokens`. Available by default on Sonnet 4.5 and newer models, including Sonnet 4.6[^4] | The 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:

```bash
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_result`s in the user content array (the API requires `tool_result`s 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 inputs[^5] — 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 arguments[^1].

**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 tools[^1]. Note that `any` and `tool` are incompatible with extended thinking on Sonnet 4.6 — only `auto` and `none` work when extended thinking is enabled[^1].

## 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` cancellation[^6]. 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](https://nerdleveltech.com/mcp-server-typescript-oauth-streamable-http-production-tutorial) 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](https://nerdleveltech.com/promptfoo-test-llm-prompts-ci-tutorial) 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](https://nerdleveltech.com/litellm-proxy-production-llm-gateway-tutorial) gives you the same Messages API surface across providers.

## Related reads

- [Token-Aware Text Chunking for RAG in TypeScript (2026)](https://nerdleveltech.com/text-chunking-rag-typescript-tutorial)
- [Claude Batch API in TypeScript: Bulk Extraction (2026)](https://nerdleveltech.com/claude-batch-api-bulk-extraction-typescript-tutorial)
- [Claude Opus 5.5 tool_choice 400: 3 SDKs Tested (2026)](https://nerdleveltech.com/claude-opus-5-5-tool-choice-400-sdks)

[^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]: 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)
[^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-outputs — `strict: 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)
