RSS

Build a Basic AI Agent in TypeScript, Part 2: Tool Calling and LLM Intent Routing

December 29, 2025

Overview

Part 1 introduced the core agent loop. Part 2 adds tool calling. A weather tool is registered with the model, then the model decides whether to call that tool based on user intent. This is a common production pattern in major agent stacks: tool definitions are passed in context, and the model routes per turn.

TLDR: A tool performs deterministic external actions, while the LLM decides when to invoke the tool. The runtime executes tool calls, returns results, and continues the loop.

Full Series

Sections

What Changes from Part 1

  • Add a weather tool implementation backed by Open-Meteo.
  • Pass tool definitions to the model on each inference call.
  • Let the model route by choosing between natural-language answer and tool calls.
  • Add a tool execution loop that sends tool results back to the model.

Core Concepts and Keywords

Tool

A tool is a deterministic capability adapter, for example, get_weather(location). It executes external work and returns structured data.

Tool Calling

Tool calling is the request and response handshake where the model emits tool calls, the runtime executes them, then returns tool results back to the model.

Intent Routing

Intent routing is the decision of which path to use for a request. In this post, routing is model-driven. The model decides if the weather tool should be called.

Tool Call Loop

A tool call loop repeats until there are no more tool calls in the model response:

  1. Model response includes tool calls.
  2. Runtime executes tool calls.
  3. Runtime appends tool results to conversation.
  4. Model is called again with updated conversation.

Architecture

┌──────────────────────────────────────────────────────────────┐
│                Agent Loop with LLM Tool Routing              │
├──────────────────────────────────────────────────────────────┤
│ User message                                                 │
│   │                                                          │
│   ▼                                                          │
│ Append to conversation[]                                     │
│   │                                                          │
│   ▼                                                          │
│ Call model with messages + tools[]                           │
│   │                                                          │
│   ├── no tool_calls ──▶ return assistant text                │
│   │                                                          │
│   └── tool_calls ──▶ execute tool(s)                         │
│                        │                                     │
│                        ▼                                     │
│                  append tool results                         │
│                        │                                     │
│                        ▼                                     │
│            call model again with updated history             │
└──────────────────────────────────────────────────────────────┘

Tool Definition

The model needs a clear tool schema and description:

const tools: ToolDefinition[] = [
  {
    type: "function",
    function: {
      name: "get_weather",
      description:
        "Get current weather for a location. Use this when the user asks about current weather, temperature, forecast, rain, wind, or humidity.",
      parameters: {
        type: "object",
        properties: {
          location: {
            type: "string",
            description: "City or place name, for example Tokyo or London, UK"
          }
        },
        required: ["location"],
        additionalProperties: false
      }
    }
  }
];

Why LLM Intent Routing

This implementation intentionally uses model-driven routing, not code-based intent matching.

  • Better handling for varied natural language.
  • Better compatibility with mainstream tool-calling patterns.
  • Cleaner separation: model chooses tools, runtime executes tools.

The runtime still controls execution limits, error handling, and unknown tool behavior.

Context Budget and Tool Surface Area

Tool names, descriptions, and schemas are usually included in prompt context, so tool count and tool verbosity affect token usage.

Practical implications:

  • Keep tool descriptions precise and high-signal.
  • Scope tools to agent domain.
  • Avoid passing unrelated tools.

A weather-focused agent should not include unrelated operational tools like API key rotation tools. That increases cost and misrouting risk.

Runnable Example

Working file:

src/basic-weather-tool-routing-openrouter.ts

Run it:

bun src/basic-weather-tool-routing-openrouter.ts

Create .env first. Bun loads this automatically at runtime, see Bun environment variables.

OPENROUTER_API_KEY=your_api_key_here

Full Runnable File

import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

type Role = "system" | "user" | "assistant" | "tool";

interface ToolCall {
  id: string;
  name: string;
  argumentsJson: string;
}

interface Message {
  role: Role;
  content: string;
  toolCallId?: string;
  toolCalls?: Array<{
    id: string;
    type: "function";
    function: {
      name: string;
      arguments: string;
    };
  }>;
}

interface ToolDefinition {
  type: "function";
  function: {
    name: string;
    description: string;
    parameters: {
      type: "object";
      properties: Record<string, unknown>;
      required?: string[];
      additionalProperties?: boolean;
    };
  };
}

interface ModelClient {
  createMessage(input: {
    model: string;
    messages: Message[];
    tools: ToolDefinition[];
    maxTokens: number;
  }): Promise<{ text: string; toolCalls: ToolCall[] }>;
}

interface AgentConfig {
  model: string;
  maxTokens: number;
  maxTurns: number;
  maxToolRoundsPerTurn: number;
}

interface WeatherSnapshot {
  location: string;
  tempC: string;
  description: string;
  humidity: string;
  windKmph: string;
}

interface WeatherTool {
  getCurrentWeather(location: string): Promise<WeatherSnapshot | null>;
}

class OpenRouterModelClient implements ModelClient {
  constructor(private readonly apiKey: string) {}

  async createMessage(input: {
    model: string;
    messages: Message[];
    tools: ToolDefinition[];
    maxTokens: number;
  }): Promise<{ text: string; toolCalls: ToolCall[] }> {
    const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${this.apiKey}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        model: input.model,
        messages: input.messages.map((m) => {
          if (m.role === "assistant" && m.toolCalls) {
            return {
              role: "assistant",
              content: m.content,
              tool_calls: m.toolCalls
            };
          }

          if (m.role === "tool") {
            return {
              role: "tool",
              tool_call_id: m.toolCallId,
              content: m.content
            };
          }

          return {
            role: m.role,
            content: m.content
          };
        }),
        tools: input.tools,
        tool_choice: "auto",
        max_tokens: input.maxTokens
      })
    });

    if (!response.ok) {
      const errorBody = await response.text();
      throw new Error(`OpenRouter error ${response.status}: ${errorBody}`);
    }

    const data = (await response.json()) as {
      choices?: Array<{
        message?: {
          content?: string | null;
          tool_calls?: Array<{
            id: string;
            type: "function";
            function: {
              name: string;
              arguments: string;
            };
          }>;
        };
      }>;
    };

    const message = data.choices?.[0]?.message;
    const text = (message?.content || "").trim();
    const toolCalls =
      message?.tool_calls?.map((call) => ({
        id: call.id,
        name: call.function.name,
        argumentsJson: call.function.arguments
      })) || [];

    return { text, toolCalls };
  }
}

class OpenMeteoWeatherTool implements WeatherTool {
  async getCurrentWeather(location: string): Promise<WeatherSnapshot | null> {
    const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1`;
    const geoResponse = await fetch(geoUrl, { signal: AbortSignal.timeout(8000) });
    if (!geoResponse.ok) return null;

    const geo = (await geoResponse.json()) as {
      results?: Array<{
        name?: string;
        country?: string;
        latitude?: number;
        longitude?: number;
      }>;
    };

    const place = geo.results?.[0];
    if (!place?.latitude || !place?.longitude) return null;

    const weatherUrl =
      `https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}` +
      `&longitude=${place.longitude}` +
      "&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m";

    const weatherResponse = await fetch(weatherUrl, { signal: AbortSignal.timeout(8000) });
    if (!weatherResponse.ok) return null;

    const weatherData = (await weatherResponse.json()) as {
      current?: {
        temperature_2m?: number;
        relative_humidity_2m?: number;
        weather_code?: number;
        wind_speed_10m?: number;
      };
    };

    const current = weatherData.current;
    if (typeof current?.temperature_2m !== "number") return null;

    return {
      location: `${place.name || location}${place.country ? `, ${place.country}` : ""}`,
      tempC: String(current.temperature_2m),
      description: this.describeWeatherCode(current.weather_code),
      humidity:
        typeof current.relative_humidity_2m === "number"
          ? String(current.relative_humidity_2m)
          : "unknown",
      windKmph:
        typeof current.wind_speed_10m === "number"
          ? String(current.wind_speed_10m)
          : "unknown"
    };
  }

  private describeWeatherCode(code?: number): string {
    const labels: Record<number, string> = {
      0: "Clear sky",
      1: "Mainly clear",
      2: "Partly cloudy",
      3: "Overcast",
      45: "Fog",
      48: "Depositing rime fog",
      51: "Light drizzle",
      53: "Moderate drizzle",
      55: "Dense drizzle",
      61: "Slight rain",
      63: "Moderate rain",
      65: "Heavy rain",
      71: "Slight snow",
      73: "Moderate snow",
      75: "Heavy snow",
      80: "Rain showers",
      95: "Thunderstorm"
    };

    if (typeof code !== "number") return "Unknown conditions";
    return labels[code] || `Weather code ${code}`;
  }
}

class Agent {
  private conversation: Message[] = [];
  private readonly tools: ToolDefinition[];

  constructor(
    private readonly modelClient: ModelClient,
    private readonly config: AgentConfig,
    private readonly weatherTool: WeatherTool,
    private readonly getUserInput: () => Promise<string | null>,
    private readonly render: (text: string) => void
  ) {
    this.tools = [
      {
        type: "function",
        function: {
          name: "get_weather",
          description:
            "Get current weather for a location. Use this when the user asks about current weather, temperature, forecast, rain, wind, or humidity.",
          parameters: {
            type: "object",
            properties: {
              location: {
                type: "string",
                description: "City or place name, for example Tokyo or London, UK"
              }
            },
            required: ["location"],
            additionalProperties: false
          }
        }
      }
    ];
  }

  async run(): Promise<void> {
    let turnCount = 0;
    this.render("Chat started. Submit empty input to exit.");

    while (turnCount < this.config.maxTurns) {
      const userInput = await this.getUserInput();
      if (!userInput || userInput.trim() === "") {
        this.render("Session ended by input.");
        break;
      }

      this.conversation.push({ role: "user", content: userInput });

      try {
        const reply = await this.runTurnWithTools();
        this.conversation.push({ role: "assistant", content: reply });
        this.render(reply);
      } catch (error) {
        const message = error instanceof Error ? error.message : "Unknown runtime error";
        this.render(`Request failed: ${message}`);
      }

      turnCount += 1;
    }

    if (turnCount >= this.config.maxTurns) {
      this.render("Session ended at max turn limit.");
    }
  }

  private async runTurnWithTools(): Promise<string> {
    for (let round = 0; round < this.config.maxToolRoundsPerTurn; round += 1) {
      const response = await this.modelClient.createMessage({
        model: this.config.model,
        maxTokens: this.config.maxTokens,
        messages: this.conversation,
        tools: this.tools
      });

      if (response.toolCalls.length === 0) {
        return response.text || "[No text response]";
      }

      this.conversation.push({
        role: "assistant",
        content: response.text || "",
        toolCalls: response.toolCalls.map((call) => ({
          id: call.id,
          type: "function",
          function: {
            name: call.name,
            arguments: call.argumentsJson
          }
        }))
      });

      for (const toolCall of response.toolCalls) {
        const result = await this.executeTool(toolCall);
        this.conversation.push({
          role: "tool",
          toolCallId: toolCall.id,
          content: result
        });
      }
    }

    return "The agent reached max tool rounds for this turn.";
  }

  private async executeTool(toolCall: ToolCall): Promise<string> {
    if (toolCall.name !== "get_weather") {
      return JSON.stringify({ error: `Unknown tool: ${toolCall.name}` });
    }

    let parsedArgs: { location?: string } = {};
    try {
      parsedArgs = JSON.parse(toolCall.argumentsJson) as { location?: string };
    } catch {
      return JSON.stringify({ error: "Invalid JSON arguments for get_weather" });
    }

    const location = parsedArgs.location?.trim();
    if (!location) {
      return JSON.stringify({ error: "Missing location argument for get_weather" });
    }

    const weather = await this.weatherTool.getCurrentWeather(location);
    if (!weather) {
      return JSON.stringify({ error: `Weather lookup failed for ${location}` });
    }

    return JSON.stringify(weather);
  }
}

async function main(): Promise<void> {
  const apiKey = process.env.OPENROUTER_API_KEY;
  if (!apiKey) {
    throw new Error("Missing OPENROUTER_API_KEY in environment");
  }

  const modelClient = new OpenRouterModelClient(apiKey);
  const weatherTool: WeatherTool = new OpenMeteoWeatherTool();

  const readline = createInterface({ input, output });
  const getUserInput = async (): Promise<string | null> => {
    try {
      return await readline.question("You: ");
    } catch {
      return null;
    }
  };

  const render = (text: string): void => {
    console.log(`Assistant: ${text}`);
  };

  const agent = new Agent(
    modelClient,
    {
      model: "openai/gpt-4o-mini",
      maxTokens: 1024,
      maxTurns: 20,
      maxToolRoundsPerTurn: 4
    },
    weatherTool,
    getUserInput,
    render
  );

  try {
    await agent.run();
  } finally {
    readline.close();
  }
}

main().catch((error) => {
  const message = error instanceof Error ? error.message : "Unknown startup error";
  console.error(`Fatal error: ${message}`);
  process.exit(1);
});

Real Conversation Example

Assistant: Chat started. Submit empty input to exit.
You: what is the weather in tokyo
Assistant: The current weather in Tokyo is as follows:
- Temperature: 4.9°C
- Description: Overcast
- Humidity: 81%
- Wind Speed: 9.8 km/h
You: what is the current stock price of nvidia
Assistant: I currently don't have access to real-time stock prices. You can check the latest stock price of NVIDIA on financial news websites, stock market apps, or your brokerage platform.

The weather request triggers get_weather. The stock request does not match available tools, so the model answers directly.

Why This Matters in Production

1. Natural-Language Flexibility

Model-driven routing handles varied user phrasing better than brittle keyword matching.

2. Standard Tool-Calling Pattern

This approach matches modern agent SDK workflows: pass tools to the model, run the tool loop, and return tool results.

3. Better Extensibility

Adding new capabilities becomes a tool-definition problem instead of accumulating route-condition code.

4. Clear Runtime Control Points

Even with model-driven routing, runtime controls remain explicit: max tool rounds, tool execution validation, and unknown-tool handling.

Common Pitfalls

Overloading Tool Surface

Too many unrelated tools in a single agent increases token cost and tool-selection ambiguity.

Weak Tool Descriptions

Sparse descriptions reduce routing quality. Tool descriptions should include when to use and when not to use.

Missing Tool Loop Limits

Without max tool rounds per turn, badly formed loops can become expensive and unstable.

Unvalidated Tool Arguments

Tool arguments can be malformed. Always parse and validate input before external calls.

Lessons Learned

  • Tool calling is the bridge from conversational agents to real-world actions.
  • LLM intent routing is practical when paired with strict runtime controls.
  • Tool quality depends heavily on schema and description quality.
  • Scoped tool sets improve both cost and reliability.

What Comes Next in Part 3

Part 3 adds multi-tool and multi-skill orchestration, then introduces stronger controls for observability, idempotency, human approval gates, and production runbook readiness.

Official References