Tekst genereren is één ding, maar een AI agent die daadwerkelijk een bestand aanmaakt, een database raadpleegt of een externe dienst aanroept, dat is een ander verhaal. Tool calling, ook wel function calling genoemd, is de techniek die dat mogelijk maakt. Ollama ondersteunt dit al een tijdje voor modellen als llama3.1, mistral-nemo en qwen2.5, en ik merk dat het in mijn dagelijkse werk steeds vaker een concrete rol speelt. In dit artikel laat ik zien hoe tool calling technisch werkt, hoe je het lokaal opzet met Node.js, en waar je op moet letten als je er serieus mee wil werken.
Hoe tool calling werkt onder de motorkap
Het basisidee is eenvouker dan het klinkt. Je stuurt een verzoek naar het model en vertelt daarin welke tools beschikbaar zijn. Het model beslist zelf welke tool het wil aanroepen op basis van de input van de gebruiker. Vervolgens voer jij die tool uit in je eigen code, geef je het resultaat terug aan het model, en genereert het model een definitief antwoord op basis van dat resultaat. Het model roept de tool dus niet zelf aan, het geeft alleen aan dat het dat wil, en jij handelt het af.
Dat onderscheid is belangrijk. De controle blijft bij jou als developer, wat lokaal draaien extra aantrekkelijk maakt. Er gaat geen data naar externe servers, je hebt volledige zeggenschap over welke tools beschikbaar zijn, en je kunt tools koppelen aan interne systemen die nooit publiek toegankelijk mogen zijn.
Ollama levert een API-eindpunt op http://localhost:11434 en conformeert zich aan de OpenAI API-structuur, inclusief de tools-parameter in chatverzoeken. Dat betekent dat je zonder al te grote aanpassingen bestaande OpenAI-code kunt omzetten naar lokale aanroepen.
Een werkende agent opzetten in Node.js
De structuur van een tool definitie volgt de JSON Schema-opbouw. Je beschrijft de naam van de tool, een omschrijving zodat het model begrijpt wanneer het die tool moet inzetten, en de parameters die de tool verwacht. Hieronder staat een voorbeeld waarbij een agent kan opzoeken welke bestanden in een bepaalde map staan.
import ollama from 'ollama';
import fs from 'fs';
import path from 'path';
const tools = [
{
type: 'function',
function: {
name: 'list_files',
description: 'Geeft een lijst van bestanden in een opgegeven map terug.',
parameters: {
type: 'object',
properties: {
directory: {
type: 'string',
description: 'Het pad naar de map die doorzocht moet worden.',
},
},
required: ['directory'],
},
},
},
];
function list_files({ directory }) {
try {
const files = fs.readdirSync(directory);
return JSON.stringify(files);
} catch (err) {
return JSON.stringify({ error: err.message });
}
}
De tool zelf is gewone Node.js code. Niets magisch aan, je schrijft een functie die input accepteert en output teruggeeft. De koppeling met het model zit in de loop die je eromheen bouwt.
async function runAgent(userMessage) {
const messages = [{ role: 'user', content: userMessage }];
const response = await ollama.chat({
model: 'llama3.1',
messages,
tools,
});
const message = response.message;
if (message.tool_calls && message.tool_calls.length > 0) {
messages.push(message);
for (const toolCall of message.tool_calls) {
const toolName = toolCall.function.name;
const toolArgs = toolCall.function.arguments;
const toolResult = list_files(toolArgs);
messages.push({
role: 'tool',
content: toolResult,
});
}
const finalResponse = await ollama.chat({
model: 'llama3.1',
messages,
tools,
});
console.log(finalResponse.message.content);
} else {
console.log(message.content);
}
}
runAgent('Welke bestanden staan er in de map /tmp?');
De loop controleert of het model een tool-aanroep teruggeeft. Zo ja, voer je de tool uit, voeg je het resultaat toe aan de berichtenhistorie met de rol tool, en stuur je die bijgewerkte history opnieuw naar het model voor het definitieve antwoord. Zo ja niet, dan druk je het antwoord direct af.
Meerdere tools combineren voor een nuttige workflow
Eén tool is een begin, maar de kracht zit in het combineren van meerdere tools in één agent. Stel dat ik een agent bouw die codebestanden kan inlezen én daarna een samenvatting kan opslaan. Dan registreer ik beide tools en laat ik het model zelf beslissen welke volgorde logisch is.
const tools = [
{
type: 'function',
function: {
name: 'read_file',
description: 'Leest de inhoud van een bestand en geeft die terug.',
parameters: {
type: 'object',
properties: {
filepath: { type: 'string', description: 'Pad naar het bestand.' },
},
required: ['filepath'],
},
},
},
{
type: 'function',
function: {
name: 'write_file',
description: 'Schrijft tekst naar een bestand op het opgegeven pad.',
parameters: {
type: 'object',
properties: {
filepath: { type: 'string', description: 'Pad naar het doelbestand.' },
content: { type: 'string', description: 'De tekst die opgeslagen moet worden.' },
},
required: ['filepath', 'content'],
},
},
},
];
const toolHandlers = {
read_file: ({ filepath }) => {
try {
return fs.readFileSync(filepath, 'utf-8');
} catch (err) {
return `Fout bij lezen: ${err.message}`;
}
},
write_file: ({ filepath, content }) => {
try {
fs.writeFileSync(filepath, content, 'utf-8');
return `Bestand opgeslagen op ${filepath}`;
} catch (err) {
return `Fout bij schrijven: ${err.message}`;
}
},
};
Door de tool handlers als object te organiseren, wordt de loop schoner. Je slaat de naam op als sleutel en roept de bijbehorende functie aan zonder een lange reeks if-statements.
if (message.tool_calls) {
messages.push(message);
for (const toolCall of message.tool_calls) {
const handler = toolHandlers[toolCall.function.name];
const result = handler ? handler(toolCall.function.arguments) : 'Onbekende tool.';
messages.push({ role: 'tool', content: result });
}
}
Modellen als qwen2.5:14b zijn voor dit soort meertrapsagenten verrassend goed. Ik draai dat model op een M2 Mac zonder dediceerde GPU en de snelheid is prima voor ontwikkeldoeleinden. Bij llama3.1:8b zie ik soms dat het model een tool aanroept met een parameter die niet in het schema staat, of dat het de tool helemaal overslaat en direct een antwoord genereert. Dat is geen bug in Ollama, maar gedrag van het model zelf. Betere omschrijvingen in de tool-definitie helpen dat te verminderen.
Veiligheid en grenzen stellen aan wat een agent mag doen
Zodra een agent bestanden kan lezen en schrijven, of processen kan starten, is toegangsbeheer geen optioneel onderwerp meer. Mijn aanpak is om elke tool handler als een gate te behandelen: valideer de input voordat je er iets mee doet, beperk paden tot een whitelist en geef nooit root-rechten aan het proces dat de agent draait.
Een praktische maatregel is een allowlist voor directories. Als de agent buiten die lijst probeert te werken, geef je een foutmelding terug aan het model in plaats van de actie uit te voeren.
const ALLOWED_DIRS = ['/tmp/agent-workspace'];
function isAllowedPath(targetPath) {
const resolved = path.resolve(targetPath);
return ALLOWED_DIRS.some((dir) => resolved.startsWith(dir));
}
const toolHandlers = {
read_file: ({ filepath }) => {
if (!isAllowedPath(filepath)) {
return 'Toegang geweigerd: dit pad valt buiten de toegestane mappen.';
}
return fs.readFileSync(path.resolve(filepath), 'utf-8');
},
};
Het model ontvangt de geweigerde toegang als een gewoon tekstresultaat en past zijn antwoord daarop aan. Dat werkt goed: het model legt dan uit aan de gebruiker dat het de actie niet kon uitvoeren, wat ook transparantie geeft over de grenzen van de agent.
Wat ik verder altijd doe, is het proces onder een aparte systeemgebruiker draaien met minimale bestandsrechten. Node.js biedt geen ingebouwde sandboxing op OS-niveau, dus dat moet je zelf regelen. Combineer dat met een strikte omschrijving in de system prompt over wat de agent wel en niet mag doen, en je hebt een redelijk veilige basisopzet voor lokale ontwikkeling.
Het mooie van volledig lokaal werken blijft dat je dingen kunt uitproberen zonder dat er ook maar één tokenverzoek naar een betaalde API gaat. Ik merk dat ik daardoor bereidwilliger ben om te experimenteren, gek te doen met agent-architecturen en te kijken waar het kapotgaat. Dat is een vrijheid die je met cloud-modellen toch minder voelt, al is het maar omdat je bij elke mislukte test aan de kosten denkt.