Tutorial: build a tool-using agent
Build a working TypeScript agent that offers two tools, runs the calls the model returns, and loops on finish_reason until it answers.
In this tutorial you build a small agent: one TypeScript file that answers a
travel question by calling two functions of yours. It uses POST /api/v1/chat
with stream: false and the loop from
Handle tool calls. You finish with a program you
can run, and a loop you can reuse with your own tools.
Before you start
- Node.js 22 or later, for the built-in
fetch. - A developer API key in
CRUXY_API_KEY; see Get your API key. - A way to run TypeScript directly. The commands here use
npx tsx.
Step 1: Watch one tool call
Before writing the loop, send one request by hand and look at what comes back.
Offer a get_weather tool and ask about the weather:
curl https://api.cruxy.ai/api/v1/chat \
-H "Authorization: Bearer $CRUXY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "kavi",
"stream": false,
"messages": [
{ "role": "user", "content": "What is the weather in Paris?" }
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
}
}
]
}'const res = await fetch('https://api.cruxy.ai/api/v1/chat', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CRUXY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'kavi',
stream: false,
messages: [{ role: 'user', content: 'What is the weather in Paris?' }],
tools: [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get the current weather for a city',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
},
},
],
}),
})
const { message, finish_reason } = await res.json()
console.log(finish_reason) // "tool_calls"
console.log(message.tool_calls)When the model chooses the tool, finish_reason is "tool_calls" and
message.tool_calls holds the call: an id, the function name, and its
arguments as a JSON string. The model does not run anything. Your program
runs the function and sends the result back, and the rest of this tutorial
automates that.
Step 2: Describe the tools
Create agent.mts. Start with the types the loop passes around and the two
tools the model may call:
const BASE = 'https://api.cruxy.ai'
interface ToolCall {
id: string
type: 'function'
function: { name: string; arguments: string }
}
type Message =
| { role: 'system' | 'user'; content: string }
| { role: 'assistant'; content: string; tool_calls?: ToolCall[] }
| { role: 'tool'; tool_call_id: string; content: string }
interface Completion {
message: { role: 'assistant'; content: string; tool_calls?: ToolCall[] }
finish_reason: 'stop' | 'tool_calls' | 'length' | 'content_filter'
}
const tools = [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get the current weather for a city: temperature in Celsius and the sky.',
parameters: {
type: 'object',
properties: { city: { type: 'string', description: 'City name, for example Paris' } },
required: ['city'],
},
},
},
{
type: 'function',
function: {
name: 'get_local_time',
description: 'Get the current local time in a city.',
parameters: {
type: 'object',
properties: { city: { type: 'string', description: 'City name, for example Mumbai' } },
required: ['city'],
},
},
},
]Define tools covers every field of a tool.
Step 3: Write the handlers
Each tool name maps to a function of yours. runTool parses the call's
arguments, runs the matching handler, and always returns a string for the
tool message, even when the call names an unknown tool or the handler throws:
const TIME_ZONES: Record<string, string> = {
paris: 'Europe/Paris',
mumbai: 'Asia/Kolkata',
london: 'Europe/London',
}
const handlers: Record<string, (args: { city: string }) => Promise<unknown>> = {
async get_weather({ city }) {
// Replace with a call to your weather service.
return { city, tempC: 18, sky: 'clear' }
},
async get_local_time({ city }) {
const timeZone = TIME_ZONES[city.toLowerCase()]
if (!timeZone) return { city, error: 'unknown city' }
return { city, time: new Date().toLocaleTimeString('en-GB', { timeZone }) }
},
}
async function runTool(call: ToolCall): Promise<string> {
const handler = handlers[call.function.name]
if (!handler) return JSON.stringify({ error: `no tool named ${call.function.name}` })
try {
const result = await handler(JSON.parse(call.function.arguments))
return JSON.stringify(result)
} catch (err) {
return JSON.stringify({ error: String(err) })
}
}Step 4: Call the model
chat sends the conversation so far, with the tools, and returns the turn. It
throws on an error response, naming the stable code and the request_id:
async function chat(messages: Message[]): Promise<Completion> {
const res = await fetch(`${BASE}/api/v1/chat`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CRUXY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'kavi', stream: false, messages, tools }),
})
if (!res.ok) {
const { code, error, request_id } = await res.json()
throw new Error(`${res.status} ${code}: ${error} (request ${request_id})`)
}
return res.json()
}The request leaves max_tokens unset. kavi thinks by default, and its
thinking shares the max_tokens budget, so a small cap could end a turn with
finish_reason: "length" before the model writes a call or an answer; see
Reasoning effort.
Step 5: Run the loop
The loop calls the model, keeps the assistant turn, and branches on
finish_reason. On tool_calls it answers every call by id and goes around
again; on stop the turn's content is the answer:
async function runAgent(question: string, maxRounds = 8): Promise<string> {
const messages: Message[] = [{ role: 'user', content: question }]
for (let round = 0; round < maxRounds; round++) {
const { message, finish_reason } = await chat(messages)
messages.push(message) // the assistant turn, tool_calls and all
if (finish_reason === 'tool_calls') {
for (const call of message.tool_calls ?? []) {
messages.push({ role: 'tool', tool_call_id: call.id, content: await runTool(call) })
}
continue
}
if (finish_reason === 'stop') return message.content
throw new Error(`the turn ended with finish_reason "${finish_reason}"`)
}
throw new Error(`no answer after ${maxRounds} rounds`)
}
console.log(
await runAgent(
'I fly from Mumbai to Paris tonight. What is the weather in Paris, and what time is it there now?',
),
)Step 6: Run it
With the four pieces in agent.mts, in order, run:
npx tsx agent.mtsThe program prints the model's answer. Along the way the model asks for
get_weather and get_local_time, possibly both in one turn, and the loop
answers each call before it asks again.
Why the loop is shaped this way
- It branches on
finish_reason.tool_callsmeans the model is waiting on your results, andstopmeans it has answered.lengthandcontent_filterend the loop with an error, because neither carries a finished answer. - It appends the assistant turn whole. The model needs to see its own call
next to the result that answers it, so the turn goes into the history,
tool_callsincluded, before any result. - It answers every call by
id. One turn can request several calls. Each result carries its call'sidastool_call_id, which is how the model pairs results with calls. - It sends the same
toolsevery round. A replayed call to a tool the request does not declare reaches the model as prose, not as a tool call. - It always returns a string from
runTool. A failed or unknown call still gets a result, so no call goes unanswered. - It stops after
maxRounds. The bound is a backstop for a model that never settles; reaching it is worth logging, not presenting as an answer.
Next steps
- Handle tool calls: the response shapes the loop reads, and tool calls on a stream.
- Define tools:
tool_choiceand the schema bounds. - Errors and retries: decide which errors
chatshould retry.