Tool calling

Let a model use your functions safely: declare them well, run the loop, and treat every argument as untrusted input.

08of 24
≈ 14Min read
5Parts

What actually happens

The model never executes anything. It emits a structured request — a name and some JSON arguments — and your code decides whether and how to honour it. That is the entire security model, and it is a good one, because the decision stays with you.

Which also means: the model choosing to call delete_order is not authorisation. Whatever checks you would apply to a request from an untrusted client apply here, unchanged.

text
1. You send the messages AND a list of tool declarations.
2. The model replies either with text, or with a request to call a tool.
3. YOU run the function. The model cannot run anything.
4. You send the result back as another message.
5. Repeat until it replies with text.

The declaration is a prompt

The description is read by the model on every call, and it is how the model decides when to use this tool rather than another one. Most tool-selection failures are description failures: say what it does, when to use it, what it returns, and — the part everyone omits — what it does not do.

Keep the argument list small and flat. Every field is a chance to get it wrong, and a deeply nested schema is filled in badly far more often than a flat one.

js
{
  name: "lookup_order",
  description: "Find one order by its id. Use when the user mentions an order number. " +
               "Returns the status, the items and the delivery date. " +
               "Does not search by customer name — use find_customer_orders for that.",
  parameters: {
    type: "object",
    properties: {
      order_id: { type: "string", description: "The order number, like ORD-1234" },
    },
    required: ["order_id"],
    additionalProperties: false,
  },
}

The loop

Note what goes back on a failure: an error message, not an exception. A model that is told "no such tool" or "order_id must be a string" will usually correct itself on the next step; one that gets an exception thrown out of the loop cannot.

And note the step ceiling. Without it, a model that keeps calling the same tool loops until your budget runs out.

js
async function run(messages, tools, { maxSteps = 8 } = {}) {
  for (let step = 0; step < maxSteps; step++) {
    const reply = await model.complete({ messages, tools: declarations });

    if (!reply.toolCall) return { text: reply.text, steps: step + 1 };

    const { name, args } = reply.toolCall;
    const tool = tools[name];

    const result = !tool
      ? { error: "no such tool: " + name }
      : validate(tool.schema, args).length
        ? { error: "bad arguments: " + validate(tool.schema, args)[0].message }
        : await tool.run(args);

    messages = [...messages,
      { role: "assistant", toolCall: reply.toolCall },
      { role: "tool", name, content: JSON.stringify(result) }];
  }

  return { text: null, error: "step limit reached" };
}

Validate everything it sends you

Three separate layers, and they are not interchangeable: the schema says the shape is right, authorisation says this user may do it, and limits stop one call doing unbounded work. A schema alone will happily pass {"order_id": "ORD-1234"} for someone else’s order.

This matters more than it looks, because tool arguments can be influenced by content the model read — a retrieved document, a web page, an email. That is the injection path in the safety lesson, and the validation here is what stops it becoming an incident.

js
/* the model asked for this. That is not a reason to trust it. */
const errors = validate(tool.schema, args);
if (errors.length) return { error: errors.map((e) => e.path + " " + e.message).join("; ") };

/* and the schema is not the whole check */
if (!currentUser.canSee(args.order_id)) return { error: "not permitted" };
if (args.limit > 100) args.limit = 100;

Designing a good tool set

  • Few tools, clearly distinct. Selection accuracy falls as the list grows and as descriptions overlap. Twenty similar tools is a worse design than five general ones.
  • Return what the model needs, and no more. A whole database row wastes context and buries the answer.
  • Make errors instructive. "no order with that id — check the format ORD-0000" teaches; "error" does not.
  • Make them idempotent where you can. A retried send_email is an incident; a retried get_order is nothing.
  • Confirm the irreversible ones with a human. Refunds, deletions, anything that sends. The model proposes; a person disposes.

Practice