Narrow beats general, always
The instinct is to expose one flexible tool. Resist it. Narrow tools are easier for the model to use correctly and far easier for you to reason about.
- Do not
- run_query(sql). You have handed arbitrary database access to a language model. There is no prompt that makes this safe.
- Do
- find_invoices_by_vendor(vendor_id, from_date, to_date). Enumerable, bounded, indexable, and the failure modes are ones you can name.
- Do not
- update_record(table, id, fields). Same problem wearing a different hat.
- Do
- set_invoice_gl_code(invoice_id, gl_code). One thing, auditable, reversible.
The practical test: can you write down every effect this tool can have, as a finite list? If not, it is too general.
Validate as if the caller is hostile
Not because the model is adversarial, but because its input may be. Anything the model has read, a document, a customer email, a web page, can carry instructions, and the tool call is where that becomes an action.
- Use strict schemas so the shape is guaranteed before your code sees it. Enumerate what can be enumerated.
- Re-validate server-side anyway. A schema guarantees shape, not that the id belongs to this tenant.
- Never take permissions from the model. The asker’s identity comes from your session, resolved server-side. A tool that accepts a user_id parameter is a tool that can be told to act as somebody else.
- Bound everything: result counts, date ranges, amounts. An unbounded query is an outage waiting for the right input.
Assume any text the model has read is untrusted. The rule that holds is: content can inform an answer, it can never authorise an action.
Separate reading from writing
Reads and writes deserve different treatment, and merging them is how a summarisation feature becomes a posting feature.
- Different credentials. The read path gets a role that cannot write, enforced by the database, not by intention.
- Writes are individually enumerated, individually logged, individually reversible.
- Every write carries an idempotency key derived from the work, so a retried tool call is a no-op.
- Writes above a threshold you choose go through an approval gate. What the threshold is depends on the domain; that there is one does not.
Errors are part of the interface
The model reads your error and decides what to do next. A good message produces a recovery; a bad one produces a retry loop or an invented answer.
- Say what went wrong and what would work instead. "No invoices between those dates; the earliest is 2026-03-01" lets it correct itself. "Error 400" does not.
- Distinguish a wrong input from a system failure. The first should be corrected, the second should be surrendered to a human.
- Never return a stack trace or an internal identifier. It ends up in an answer.
- Make a failed call visibly failed in the transcript, so the loop does not treat an error string as data.
We treat tool error messages as prompt engineering, because that is what they are. They are read by the same model, in the same context, and they change behaviour just as much.
Want us to run this with you?
The Audit is this method pointed at your systems, with a costed build plan at the end of it.
Schedule call
