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
- Part 1, The Core Loop
- Part 2, Tool Calling and LLM Intent Routing (you are here)
- Part 3, Multi-Step Skill Orchestration
- Part 4, Production Readiness and Operational Guardrails
Sections
- What Changes from Part 1
- Core Concepts and Keywords
- Architecture
- Tool Definition
- Why LLM Intent Routing
- Context Budget and Tool Surface Area
- Runnable Example
- Real Conversation Example
- Why This Matters in Production
- Common Pitfalls
- Lessons Learned
- What Comes Next in Part 3
- Official References
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:
- Model response includes tool calls.
- Runtime executes tool calls.
- Runtime appends tool results to conversation.
- 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}` +
"¤t=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.