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"]
          }
        }
      }
    ]
  }'

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.mts

The 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_calls means the model is waiting on your results, and stop means it has answered. length and content_filter end 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_calls included, before any result.
  • It answers every call by id. One turn can request several calls. Each result carries its call's id as tool_call_id, which is how the model pairs results with calls.
  • It sends the same tools every 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

Was this page helpful?