Skip to content

Chapter 6: Building Tool Chains and Complex Workflows #6

@yagop

Description

@yagop

Part of the Build Your Own Coding Agent tutorial. One issue = one chapter (chapters/06-tool-chains.md) plus its examples/06-tool-chains/ samples.

Goal (1 sentence): Build the core agent engine - a loop that drives client.messages.create until stop_reason === "end_turn" - handling sequential chains and parallel tool calls.

After this chapter you can

  • Drive a multi-step agent loop that branches on stop_reason, accumulates conversation history correctly, never breaks on the first response, and terminates cleanly.
  • Handle both sequential tool chains (one result feeds the next call) and parallel tool_use blocks executed (optionally concurrently) via Promise.all, collecting one tool_result per block.
  • Reason about dependencies and ordering: return independent results in one batch; let the model naturally serialize dependent calls across turns.
  • Steer the model with tool_choice and extract a reusable runner with a handler registry, maxIterations guard, and is_error recovery.

What to cover (ONE paragraph, not a list)

Chapter 5 returned a single tool result; here you build the loop that keeps going. Start with the control-flow skeleton: branch on stop_reason (tool_use -> keep going, end_turn -> done, max_tokens/pause_turn -> handle) and never break on the first response - always append the full response.content before the matching tool_result blocks so every tool_use_id is answered exactly once. Then show sequential chaining - a tool whose output the model reads and feeds into a second call on the next iteration (for example, look up an ID, then fetch details for that ID) - followed by parallel tool use, where a single turn yields several tool_use blocks that you filter with b.type === "tool_use" and resolve optionally concurrently with Promise.all, collecting one tool_result per block. Cover ordering and dependencies: when results are independent return them in one batch; when one call depends on another the model naturally serializes across turns. Next, cover tool_choice modes (auto, any, tool + name, none) and disable_parallel_tool_use: true, noting that forcing a tool changes when end_turn arrives. Close by extracting a generic runner: a Map of tool name -> handler, the Anthropic.Tool[] definitions, a maxIterations cap, and is_error: true on failures; then wire the same runner to client.messages.stream + finalMessage(), and briefly note where betaZodTool + client.beta.messages.toolRunner fits and why writing the loop by hand first makes its behavior legible.

Going deeper (optional asides - keep OFF the main line)

  • betaZodTool + client.beta.messages.toolRunner: once you have written the loop by hand, the SDK helper is a thin wrapper - show the mapping in a callout so readers can choose either path.

Out of scope (defer - do NOT preview)

  • Persisting conversation state across process restarts (later chapter on memory).
  • Plugging the runner into the live Telegram surface from Chapter 3 (integration shown there, not here).

Code samples - examples/06-tool-chains/

  • agent-loop.ts - minimal hand-written loop that runs until end_turn.
  • sequential-chain.ts - two tools where one result feeds the next call.
  • parallel-tools.ts - multiple tool_use blocks executed via Promise.all.
  • tool-choice.ts - auto / any / forced tool / disable_parallel_tool_use.
  • agent-runner.ts - reusable runner with handler registry, maxIterations, and is_error.
  • runner-with-stream.ts - runner wired to client.messages.stream + finalMessage().

Must-keep for a beginner (floor - never cut for brevity)

  • The run command for agent-loop.ts (the first sample).
  • "Never hardcode your key; it comes from the environment" (once, in prose).
  • That the full response.content must be appended before any tool_result blocks - a beginner cannot infer this ordering from the type signatures.
  • The one genuinely non-obvious gotcha: forcing a specific tool with tool_choice means the model may never emit end_turn on that turn, so the loop condition must account for it.

Friendliness floor (never cut - terse is not friendly)

  • The chapter addresses the reader as "you", never "the user" or "one".
  • The intro AND at least one section open with a warm, second-person sentence.

Key APIs (flat list, reference only)

client.messages.create, client.messages.stream, finalMessage(), stop_reason (tool_use/end_turn/max_tokens/pause_turn), response.content, tool_use, tool_result, tool_use_id, is_error, tool_choice, Anthropic.MessageParam, client.beta.messages.toolRunner

Prerequisites

Chapter 5 (direct foundation); Chapter 2 helps for the streaming runner.

Definition of done

  • Chapter at chapters/06-tool-chains.md, <=120 lines, <=4 main-line H2s plus an optional "What's next" closer (paste wc -l AND grep -c '^## ' in the PR).
  • Every sample runnable with bun run, imported via <<< @/examples/06-tool-chains/file.ts, <=35 lines, comment:code <=0.30.
  • One-home rule held: no prose sentence restates an inline code comment.
  • Friendliness floor held: reader addressed as "you"; intro + >=1 section open warm.
  • Samples use only real @anthropic-ai/sdk surface; ASCII punctuation only.
  • Optional material lives in Going-deeper asides, not main-line H2s.
  • Linked from README.md and the .vitepress/config.ts sidebar; bun x vitepress build passes.
  • The reusable runner is genuinely reusable (drop-in for Ch.3 surfaces).

Metadata

Metadata

Assignees

No one assigned

    Labels

    documentationImprovements or additions to documentation

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions