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
tools parameter, exactly as the OpenAI-compatible wire format specifies.How the loop works
- 1
Send the conversation with your tool schemas
POST the message history plus atoolsarray describing the functions the model may call. - 2
Check the reply for tool calls
Ifmessage.tool_callsis empty, the model answered directly — return the content and stop. Otherwise, continue. - 3
Execute each tool call
Parsefunction.arguments(a JSON string), run your implementation, and capture the result — including failures. - 4
Feed the results back
Append onerole: "tool"message per call, carrying the matchingtool_call_id, then request another completion. - 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 arguments —
function.argumentsis 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
maxRoundscap turns a pathological conversation into a clean, reportable failure.
Always answer every tool_call_id
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
Next: extract structured data
Turn free-form text into validated JSON with a schema, a low temperature, and a retry.
Read the extraction recipe