Docs
Cookbook

Build an assistant with tools

An end-to-end tool-calling loop in Node.js: take a user message, let the model call your functions, feed results back, and return the final answer.

This recipe builds the smallest useful assistant: a single function that takes a user message, lets tase-8b call tools you implement, executes those tools, feeds the results back, and returns the final reply. This loop is the backbone of every tool-using application — everything bigger is this pattern with more tools.

Built-in vs. custom tools

The built-in Tase tools are trained into the model, so they work without sending schemas. For your own tools — like the two in this recipe — you describe them in the tools parameter, exactly as the OpenAI-compatible wire format specifies.

How the loop works

  1. 1

    Send the conversation with your tool schemas

    POST the message history plus a tools array describing the functions the model may call.
  2. 2

    Check the reply for tool calls

    If message.tool_calls is empty, the model answered directly — return the content and stop. Otherwise, continue.
  3. 3

    Execute each tool call

    Parse function.arguments (a JSON string), run your implementation, and capture the result — including failures.
  4. 4

    Feed the results back

    Append one role: "tool" message per call, carrying the matching tool_call_id, then request another completion.
  5. 5

    Repeat until the model answers in text

    The model may chain several tool rounds. Bound the loop so a confused conversation can never spin forever.

The full example

A complete, runnable Node.js module. It needs the openai package (npm install openai) and TASE_API_KEY in the environment. The two tool implementations are stubs — swap in real calls to your own systems.

import OpenAI from 'openai';

const client = new OpenAI({
  apiKey: process.env.TASE_API_KEY, // tase_sk_...
  baseURL: 'https://api.tase.app/v1',
});

// 1. Your tool implementations - plain async functions.
const toolImplementations = {
  create_task: async ({ title, due_date }) => {
    // Replace with a real write into your task system.
    const id = Math.random().toString(36).slice(2, 8);
    return { id, title, due_date: due_date ?? null, status: 'open' };
  },
  list_tasks: async () => {
    // Replace with a real read from your task system.
    return [{ id: 'a1b2c3', title: 'Ship the quarterly report', status: 'open' }];
  },
};

// 2. The schemas the model sees for your custom tools.
const tools = [
  {
    type: 'function',
    function: {
      name: 'create_task',
      description: 'Create a task in the user task list.',
      parameters: {
        type: 'object',
        properties: {
          title: { type: 'string', description: 'Short task title' },
          due_date: {
            type: 'string',
            description: 'Due date as an ISO 8601 date (YYYY-MM-DD), if the user gave one',
          },
        },
        required: ['title'],
      },
    },
  },
  {
    type: 'function',
    function: {
      name: 'list_tasks',
      description: 'List the user open tasks.',
      parameters: { type: 'object', properties: {} },
    },
  },
];

// 3. Execute one tool call, never letting an error escape to the loop.
async function executeToolCall(call) {
  const impl = toolImplementations[call.function.name];
  if (!impl) {
    return { error: 'Unknown tool: ' + call.function.name };
  }

  let args;
  try {
    args = JSON.parse(call.function.arguments || '{}');
  } catch {
    return { error: 'Tool arguments were not valid JSON' };
  }

  try {
    return await impl(args);
  } catch (err) {
    // Return the failure to the model so it can recover or explain.
    return { error: String(err instanceof Error ? err.message : err) };
  }
}

// 4. The assistant loop.
export async function runAssistant(userText, { maxRounds = 5 } = {}) {
  const messages = [
    {
      role: 'system',
      content: 'You are a task assistant. Use the available tools to act, then answer briefly.',
    },
    { role: 'user', content: userText },
  ];

  for (let round = 0; round < maxRounds; round++) {
    const completion = await client.chat.completions.create({
      model: 'tase-8b',
      messages,
      tools,
    });

    const message = completion.choices[0].message;
    messages.push(message);

    // No tool calls means the model answered directly - done.
    if (!message.tool_calls || message.tool_calls.length === 0) {
      return message.content;
    }

    // Run every requested tool and feed each result back by id.
    for (const call of message.tool_calls) {
      const result = await executeToolCall(call);
      messages.push({
        role: 'tool',
        tool_call_id: call.id,
        content: JSON.stringify(result),
      });
    }
  }

  throw new Error('Assistant did not finish within ' + maxRounds + ' rounds');
}

// Try it.
const answer = await runAssistant(
  'Add "renew passport" for 2026-07-24, then tell me what is on my list.'
);
console.log(answer);

What a run looks like

For the request above, the model typically calls create_task and list_tasks in one round, receives both results, and closes with a text reply:

$ node assistant.js
Added "renew passport" (due 2026-07-24). Your open tasks: renew passport,
and Ship the quarterly report.

Error handling that keeps the loop alive

  • Unknown tool names — return an error object instead of throwing. The model reads it and corrects itself on the next round.
  • Malformed argumentsfunction.arguments is a string the model wrote, so parse it defensively and report a parse failure back as a tool result.
  • Tool crashes — catch exceptions from your own implementations and serialize the message. An error the model can see is an error it can explain to the user.
  • Runaway loops — the maxRounds cap turns a pathological conversation into a clean, reportable failure.

Always answer every tool_call_id

Each entry in tool_calls must get exactly one role: "tool" message with the matching id before the next completion request. Skipping one — even for a failed tool — is a wire-format error.

Keep tool results small

Tool results are prompt tokens on every following round. Return only the fields the model needs to answer — ids, titles, statuses — not entire database rows.

Next: extract structured data

Turn free-form text into validated JSON with a schema, a low temperature, and a retry.

Read the extraction recipe