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.
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.
{
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.
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.
/* 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_emailis an incident; a retriedget_orderis nothing. - Confirm the irreversible ones with a human. Refunds, deletions, anything that sends. The model proposes; a person disposes.