How does function calling (tool use) work under the hood?
127 views
57 of 83
1
The problem
Question presented to candidate:
"When we pass a list of tools to the model API and it 'calls' one, what is actually happening? Walk me through one tool call end to end: what the model sees, what it produces, who runs the code, and how the result gets back."
What a strong answer should cover:
The model never executes anything: tool definitions are rendered into the prompt as text, and a model post-trained on tool-use data emits a special tool-call format with a name and JSON arguments
The API server parses that output into structured blocks (Anthropic tool_use blocks, OpenAI tool_calls or function_call items) and signals it with a stop reason such as tool_use; strict mode constrains the argument tokens to the schema
Your runtime validates the arguments, checks permissions, executes the function, and sends the result back tied to the call id; errors go back as data (is_error) so the model can correct itself
It is a loop: the whole history, including tool definitions and every result, is re-sent on each call until the model answers in text or a step limit stops it, so cost and latency grow per step
Details that break naive implementations: parallel calls in one turn, tool_choice (auto, any or a named tool, none), ordering rules for results, truncated calls at max_tokens, and tool results as untrusted input
Code / implementation expected: Optional โ a minimal loop that executes tool calls and appends results, or pseudo-code of the message sequence.
function-calling
tool-use
agents
2
Try it yourself
Sketch your approach before you read the solution. That is what the interview tests.
Stuck? Get a hint.
A short nudge toward the approach. It will not give the answer away.
3
Solution
Target Audience: Engineers who have passed a tools array to an LLM API and want to know exactly what happens between "the model decided to call a tool" and "the answer came back".
Difficulty: Medium
Verified with: Node 22.22.0 and Python 3.12.3 (a full tool-use loop with a stub model that reads and writes plain text). API behaviour is quoted from the Claude docs ("Define tools", "Handle tool calls", "Stop reasons"), checked October 2026, and other vendors where cited.
How to read this doc: Every idea is explained in plain language first, then tagged with ๐ Interview term: so you also learn the words an interviewer expects. All program output shown was produced by actually running the example; the Verified section quotes it in full.
1. Why This Even Matters โ A Story First
Picture a manager who works only by passing notes under a door. They cannot leave th
The solution is hidden so you can try first
16 min read ยท 10 sections
4
Read the code
Tool use under the hood: render, generate text, parse, execute, loop
// Tool use under the hood: the model only reads and writes TEXT. The "API" renders// tools + history into a prompt, parses tool-call markup out of the model text, and// your runtime validates, executes and appends results until the model stops.constTOOLS = [{
name: "get_weather",
description: "Current temperature for one city. Call once per city.",
input_schema: { type: "object", properties: { city: { type: "string" }, unit: { type: "string", enum: ["celsius", "fahrenheit"] } }, required: ["city", "unit"] },
}];
constCELSIUS = { Paris: 20, Oslo: 5 };
constIMPL = { get_weather: ({ city, unit }) => ({ city, temp: unit === "fahrenheit" ? CELSIUS[city] * 9 / 5 + 32 : CELSIUS[city], unit }) };
// 1. What the model actually receives: one long string.functionrender(messages) {
const lines = ["[system]", "You can call tools. To call one, write <tool_call>{json}</tool_call>.", "Tools (JSON Schema):"];
for (const t ofTOOLS) lines.push(JSON.stringify(t));
for (const m of messages) {
if (m.content.some((b) => b.type !== "tool_result")) lines.push(`[${m.role}]`);
for (const b of m.content) {
if (b.type === "text") lines.push(b.text);
if (b.type === "tool_use") lines.push(`<tool_call id="${b.id}">${JSON.stringify({ name: b.name, input: b.input })}</tool_call>`);
if (b.type === "tool_result") lines.push(`[tool_result id="${b.tool_use_id}"${b.is_error ? " is_error" : ""}]`, b.content);
}
}
lines.push("[assistant]");
return lines.join("\n");
}
// 2. Stub model: text in, text out (a real model predicts these tokens).functionmodel(prompt) {
if (!prompt.includes("[tool_result")) return'I will check both cities.<tool_call>{"name":"get_weather","input":{"city":"Paris","unit":"fahrenheit"}}</tool_call><tool_call>{"name":"get_weather","input":{"city":"Oslo","unit":"kelvin"}}</tool_call>';
if (!prompt.includes('"city":"Oslo","temp"')) return'<tool_call>{"name":"get_weather","input":{"city":"Oslo","unit":"fahrenheit"}}</tool_call>';
return"Paris is warmer: 68 F against 41 F in Oslo.";
}
// 3. The API server parses the markup into structured blocks and a stop reason.let nextId = 1;
functionparseReply(text) {
const content = [];
const first = text.indexOf("<tool_call>");
const prose = first === -1 ? text : text.slice(0, first);
if (prose) content.push({ type: "text", text: prose });
for (const m of text.matchAll(/<tool_call>(.*?)<\/tool_call>/g)) {
const call = JSON.parse(m[1]);
content.push({ type: "tool_use", id: `toolu_${String(nextId++).padStart(2, "0")}`, name: call.name, input: call.input });
}
return { stop_reason: content.some((b) => b.type === "tool_use") ? "tool_use" : "end_turn", content };
}
// 4. Your runtime: validate arguments (the model wrote them), execute, report errors as data.functionvalidate(tool, input) {
const s = tool.input_schema, errs = [];
for (const k of s.required) if (!(k in input)) errs.push(`missing ${k}`);
for (const [k, v] ofObject.entries(input)) {
const p = s.properties[k];
if (!p) errs.push(`unknown argument ${k}`);
elseif (p.enum && !p.enum.includes(v)) errs.push(`${k} must be one of ${p.enum.join("|")}, got ${v}`);
}
return errs;
}
functionrunTool(block) {
const tool = TOOLS.find((t) => t.name === block.name);
if (!tool) return { type: "tool_result", tool_use_id: block.id, is_error: true, content: `unknown tool ${block.name}` };
const errs = validate(tool, block.input);
if (errs.length) return { type: "tool_result", tool_use_id: block.id, is_error: true, content: `invalid arguments: ${errs.join("; ")}. Fix and call again.` };
return { type: "tool_result", tool_use_id: block.id, content: JSON.stringify(IMPL[block.name](block.input)) };
}
const messages = [{ role: "user", content: [{ type: "text", text: "Is it warmer in Paris or Oslo right now? Answer in Fahrenheit." }] }];
const toolTokens = Math.ceil(TOOLS.map((t) =>JSON.stringify(t)).join("\n").length / 4);
let totalIn = 0;
for (let turn = 1; turn <= 5; turn++) {
const prompt = render(messages);
const inTok = Math.ceil(prompt.length / 4);
totalIn += inTok;
const raw = model(prompt);
const reply = parseReply(raw);
console.log(`\n--- model call ${turn}: prompt ${prompt.split("\n").length} lines, ~${inTok} input tokens (tool definitions ~${toolTokens})`);
console.log(`raw model text : ${raw}`);
console.log(`parsed by API : stop_reason=${reply.stop_reason} blocks=${reply.content.map((b) => b.type === "tool_use" ? `tool_use(${b.id})` : b.type).join(",")}`);
messages.push({ role: "assistant", content: reply.content });
if (reply.stop_reason !== "tool_use") { console.log(`final answer : ${reply.content[0].text}`); break; }
const results = reply.content.filter((b) => b.type === "tool_use").map(runTool);
for (const r of results) console.log(`runtime : ${r.tool_use_id} -> ${r.is_error ? "ERROR " : ""}${r.content}`);
messages.push({ role: "user", content: results });
if (turn === 2) console.log(`\nfull prompt the model will read on call 3:\n${render(messages)}`);
}
console.log(`\ntotal input tokens over the loop ~${totalIn} (history is re-sent on every call)`);
5
Discussion
Other approaches, follow-ups and how it went in real interviews.