---
name: forge-tool-use
description: LLM function/tool calling discipline. Tool selection, parameter design, structured returns, error-as-data, parallel tool calls, iteration caps, context budget across turns, agentic loops. Contains ready-to-paste tool-use loop with Anthropic SDK + retry-on-bad-call. Use when an LLM in your application calls functions or tools, regardless of provider.
license: MIT
---

# forge-tool-use

You are building an LLM application where the model calls tools. Default agent-written tool-use loops fire tools without validation, panic on the first bad call, ignore parallel-call opportunities, and re-feed the entire tool history into every iteration until the context dies. This skill exists to fix the whole loop.

The mental model: **tool calling is a constrained generation problem.** The model decides what to call from a fixed set; you execute, return results, the model decides what next. Every step has well-known failure modes that good loop design prevents.

## Quick reference (the things you must never ship)

1. A tool-use loop with no iteration cap.
2. Tool execution that throws into the loop framework instead of returning a structured error.
3. Tool result passed back as a stringified prose blob.
4. >20 tools registered with the model at once.
5. `temperature: 1` on a tool-use loop.
6. Model alias (`latest`) instead of a pinned version.
7. Parallel tool calls awaited individually, blocking on each.
8. Same `[full conversation + all tool results]` re-sent every turn with no summarization.
9. Tool that throws on invalid arguments (model can fix; let it).
10. No logging of which tools were called, with what argument hash, and what outcome.

## Hard rules

### Tool definitions

**1. Tool schemas follow [`forge-mcp-tool-design`](../../mcp/forge-mcp-tool-design/SKILL.md).** Verb_object names, parameter descriptions, closed value sets via enum, structured errors. The model does not distinguish "MCP tool" from "function call" - all are tools.

**2. Tool count under 15 per turn.** Beyond, tool selection accuracy drops measurably. If you need more, scope them to the relevant subset per request.

**3. Tool descriptions written for the model, not for you.**

```
GOOD: "Searches the user directory by name or email; returns up to 50 matches."
BAD:  "Wraps the UserService.search method."
```

### The loop

**4. Tool-use loop has a hard iteration cap.** 10-20 iterations for most agents. Without a cap, a bad loop can run for hours.

**5. Loop emits structured events: `tool_call`, `tool_result`, `model_message`, `finish`, `error`.** Useful for UI streaming and logging.

**6. Every iteration logs: tools called, argument hash, outcome.** Without this, debugging a stuck loop is impossible.

**7. Model can call multiple tools in parallel when the API supports it.** Anthropic tool_use blocks, OpenAI parallel_tool_calls. Take advantage; sequential only when dependent.

```ts
// reference: parallel tool execution within one model turn
const toolCalls = response.content.filter((c) => c.type === "tool_use");
const toolResults = await Promise.all(
  toolCalls.map(async (call) => {
    try {
      const result = await dispatch(call.name, call.input);
      return { type: "tool_result", tool_use_id: call.id, content: JSON.stringify(result) };
    } catch (err) {
      // Errors as data, NOT exceptions thrown out of the loop.
      return {
        type: "tool_result",
        tool_use_id: call.id,
        content: JSON.stringify({ error: { code: "internal_error", message: String(err) } }),
        is_error: true,
      };
    }
  }),
);
```

### Result formatting

**8. Tool results are structured content, not stringified prose.** `{ "data": [...], "truncated": true }` not `"data was: [object Object]"`.

**9. Long tool results truncated with explicit marker.** 100KB tool result degrades reasoning even before OOM. Truncate at 5KB:

```ts
function truncateForLLM(data: unknown, maxBytes = 5000): { content: string; truncated: boolean; total_bytes: number } {
  const full = JSON.stringify(data);
  if (full.length <= maxBytes) return { content: full, truncated: false, total_bytes: full.length };
  return {
    content: JSON.stringify({ data: data, _note: `truncated, ${full.length} bytes original` }).slice(0, maxBytes) + "...",
    truncated: true,
    total_bytes: full.length,
  };
}
```

**10. Empty results return `{ "data": [] }`, not `{ "error": "not found" }`.** Empty is valid.

### Error handling in the loop

**11. Tool failures return as data, not as exceptions.** The model can recover from `{ "error": { "code": "rate_limited", "retryable": true } }`. Cannot recover from your `await tool()` throwing.

**12. Distinguish "tool called wrong" from "tool execution failed."** Same code table as [`forge-mcp-tool-design`](../../mcp/forge-mcp-tool-design/SKILL.md) rule 23. Different recovery paths.

**13. After 2-3 consecutive failures of the same tool with similar arguments, break the loop.** The model is stuck. Surface failure rather than burn iterations.

### Context budget

**14. Each iteration costs the full input context. Tool results accumulate.** A 10-iteration loop where every tool result is 2KB ends with 20KB of accumulated tool context plus the prompt.

**15. Summarize or drop old tool results past iteration 5.** Most loops only need the last 1-2 turns. Older can be summarized into "Here is what I learned so far: ..."

**16. Monitor per-iteration token budget. If approaching the model's context limit, stop and summarize.** A loop that runs out of context partway is an outage; one that knows to wrap up is graceful.

### Validation

**17. Validate tool arguments against the schema before executing.** Model occasionally produces malformed arguments. Validation catches it; return `invalid_argument` error; model retries.

**18. Validate tool outputs against the schema before passing back to the model.** A tool that returns malformed output produces undefined model behavior.

### Parallelism

**19. Loop awaits all parallel tool calls before continuing to the next model turn.** Partial results mid-turn confuses the model.

**20. Parallelism within one tool call (e.g., `search` that fans out) is the tool's responsibility, not the loop's.**

### Reproducibility

**21. Set `temperature: 0` for tool-use loops.** Higher temperature occasionally causes wrong tool selection or wrong arguments.

**22. Log the model's reasoning text alongside the tool call.** Useful for debugging "why did it call this tool here?"

**23. Pin the model version.** Tool-calling behavior changes meaningfully across versions; do not let "latest" silently change your agent.

### Streaming

**24. Tool calls in streaming output arrive as partial events.** Buffer until complete; do not execute partial.

**25. Stream tool arguments to the UI as they form. Stream tool results back as they arrive.** Users tolerate slow tool execution if they can see what's happening.

### Cost

**26. Each iteration is a billable inference. Track per-conversation cost.** Tool-heavy loops cost 10-50x a single completion.

**27. Use the smallest model that passes your eval.** Haiku 4.5 is excellent at tool calling and significantly cheaper than Sonnet for high-volume agentic loops.

### Provider-specific notes

- **Anthropic Claude.** `tool_use` and `tool_result` content blocks. Native parallel calls. `tool_choice: "auto"` is the default; `"any"` to force a tool call (rarely needed).
- **OpenAI.** `tools` + `tool_choice`, `parallel_tool_calls: true` enabled by default.
- **OpenRouter / proxies.** Behavior varies by upstream; test before committing.
- **Local models via Ollama / vLLM.** Tool-calling support varies. Test against your specific model + version. JSON-mode fallback often more reliable than function-calling on smaller models.

## Common AI-output patterns to reject

| Pattern | Why wrong | Fix |
| --- | --- | --- |
| `while(true) { ... }` no cap | Infinite loop on stuck model | Hard cap (max 20 iterations) |
| `await tool()` throws | Kills the loop request | Wrap in try/catch, return error as tool_result |
| Result as prose | Model re-parses | Structured JSON wrapped in tool_result |
| Same context re-sent every iteration | Token blow-up | Summarize older turns |
| Tool with 30 parameters | Model picks wrong values | Split into smaller tools |
| `temperature: 1.0` | Random tool choices | `temperature: 0` |
| `model: "claude-sonnet-latest"` | Silent breakage on updates | Pin version |
| Synchronous parallel calls (await one at a time) | Misses parallelism | `Promise.all(toolCalls.map(...))` |
| No iteration logging | Cannot debug | Log each iteration: tools, duration, outcome |
| Tool returns 50KB string | Lost-in-the-middle | Truncate at 5KB with marker |

## Worked example: complete tool-use loop with Anthropic SDK

```ts
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";

const client = new Anthropic();
const MAX_ITERATIONS = 15;
const MODEL = "claude-sonnet-4-6" as const;

// Tool schemas - see forge-mcp-tool-design for the discipline
const tools: Anthropic.Messages.Tool[] = [
  {
    name: "search_users",
    description: "Searches the user directory by name or email. Use when the user asks to find a person by partial information. Do not use for exact ID lookups; use get_user_by_id instead.",
    input_schema: {
      type: "object",
      properties: {
        query: { type: "string", description: "Full-text query against name and email" },
        limit: { type: "integer", default: 20, minimum: 1, maximum: 100 },
      },
      required: ["query"],
      additionalProperties: false,
    },
  },
  // ... more tools
];

async function dispatch(name: string, input: unknown): Promise<unknown> {
  switch (name) {
    case "search_users": {
      const parsed = z.object({ query: z.string(), limit: z.number().default(20) }).safeParse(input);
      if (!parsed.success) {
        return { error: { code: "invalid_argument", message: parsed.error.issues[0]?.message, retryable: false } };
      }
      const users = await db.users.search(parsed.data.query, parsed.data.limit);
      return { data: users, has_more: users.length === parsed.data.limit };
    }
    default:
      return { error: { code: "unknown_tool", message: `No tool: ${name}`, retryable: false } };
  }
}

export async function agentLoop(userMessage: string): Promise<string> {
  const messages: Anthropic.Messages.MessageParam[] = [
    { role: "user", content: userMessage },
  ];

  for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
    const t0 = performance.now();
    const response = await client.messages.create({
      model: MODEL,
      max_tokens: 2000,
      temperature: 0,
      system: "You are a customer support assistant. Use the provided tools to find information; do not make up user data.",
      tools,
      messages,
    });

    logger.info({
      iteration,
      stop_reason: response.stop_reason,
      input_tokens: response.usage.input_tokens,
      output_tokens: response.usage.output_tokens,
      duration_ms: Math.round(performance.now() - t0),
    }, "agent.iteration");

    // Append the assistant message to history
    messages.push({ role: "assistant", content: response.content });

    if (response.stop_reason !== "tool_use") {
      // Model is done - extract text and return
      const text = response.content.filter((c) => c.type === "text").map((c) => c.text).join("");
      return text;
    }

    // Execute all parallel tool calls
    const toolCalls = response.content.filter((c) => c.type === "tool_use");
    const toolResults = await Promise.all(
      toolCalls.map(async (call) => {
        const t = performance.now();
        try {
          const result = await dispatch(call.name, call.input);
          const truncated = truncateForLLM(result);
          logger.info({
            tool: call.name,
            outcome: "ok",
            truncated: truncated.truncated,
            duration_ms: Math.round(performance.now() - t),
          }, "agent.tool.call");
          return {
            type: "tool_result" as const,
            tool_use_id: call.id,
            content: truncated.content,
          };
        } catch (err) {
          logger.error({ tool: call.name, err }, "agent.tool.exception");
          return {
            type: "tool_result" as const,
            tool_use_id: call.id,
            content: JSON.stringify({ error: { code: "internal_error", message: "Tool execution failed.", retryable: true } }),
            is_error: true,
          };
        }
      }),
    );

    messages.push({ role: "user", content: toolResults });
  }

  // Hit the iteration cap
  logger.warn({ iterations: MAX_ITERATIONS }, "agent.iteration_cap_hit");
  return "I was unable to complete the task within my iteration limit. Please try a simpler request.";
}

function truncateForLLM(data: unknown, maxBytes = 5000) {
  const full = JSON.stringify(data);
  if (full.length <= maxBytes) return { content: full, truncated: false };
  return {
    content: JSON.stringify({ data: JSON.parse(full.slice(0, maxBytes - 200)), _truncated: true, _total_bytes: full.length }),
    truncated: true,
  };
}
```

What this demonstrates: iteration cap (rule 4); structured event logging per iteration AND per tool (rule 6); parallel tool execution within one model turn (rule 7); error-as-data for both validation failures and exceptions (rule 11); truncation of long results (rule 9); `temperature: 0` (rule 21); pinned model version (rule 23); graceful exit on iteration cap (rule 4).

## Workflow

When building or auditing a tool-use loop:

1. **Define the tool surface.** 5-15 tools, verb-object names, structured parameters, descriptions.
2. **Define the loop.** Hard iteration cap. Per-tool timeout. Aggregated cost cap.
3. **Implement validation on tool arguments AND outputs.**
4. **Implement structured error returns mapped to model recovery paths.**
5. **Add observability: log each iteration with reasoning, tool calls, outcomes.**
6. **Run against a representative eval set.** Measure: success rate, average iterations, average cost.
7. **Tune.** Often the win is in tool description, not loop logic.

## Verification

```bash
bash skills/llm/forge-tool-use/verify/check_tool_use.sh path/to/loop.ts
```

Flags: tool-use loops without iteration cap, exceptions thrown from tool execution into the loop, JSON.stringify result tagged as tool_result.

## When to skip this skill

- Single-shot LLM calls without tool use.
- Tool calls implemented entirely by a framework you do not customize (Vercel AI SDK with default loop, OpenAI Assistants API).
- Pure code-generation agents where "tool" means "shell command" - use [`forge-multi-agent`](../../agents/forge-multi-agent/SKILL.md) or [`forge-agent-prompt`](../../agents/forge-agent-prompt/SKILL.md) instead.

## Related skills

- [`forge-mcp-tool-design`](../../mcp/forge-mcp-tool-design/SKILL.md) - tool schema discipline.
- [`forge-prompt-engineering`](../forge-prompt-engineering/SKILL.md) - the system prompt around the loop.
- [`forge-multi-agent`](../../agents/forge-multi-agent/SKILL.md) - when the loop becomes a multi-agent system.
- [`forge-evals`](../forge-evals/SKILL.md) - measuring loop quality.
