Skip to the solution
MediumAI Engineering16 min read

How does function calling (tool use) work under the hood?

127 views

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

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.

Solution

The solution is hidden so you can try first

16 min read ยท 10 sections

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.
const TOOLS = [{
  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"] },
}];
const CELSIUS = { Paris: 20, Oslo: 5 };
const IMPL = { 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.
function render(messages) {
  const lines = ["[system]", "You can call tools. To call one, write <tool_call>{json}</tool_call>.", "Tools (JSON Schema):"];
  for (const t of TOOLS) 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).
function model(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;
function parseReply(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.
function validate(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] of Object.entries(input)) {
    const p = s.properties[k];
    if (!p) errs.push(`unknown argument ${k}`);
    else if (p.enum && !p.enum.includes(v)) errs.push(`${k} must be one of ${p.enum.join("|")}, got ${v}`);
  }
  return errs;
}
function runTool(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)`);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 57 of 83 in AI engineering

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

How does the transformer architecture and attention work?

Continue