Reading time: about 20 minutes
An agent is only as good as its tools, and that is not a nice phrase: it is literally where the work is. The loop from the previous lesson is thirty lines and you never touch it again. Everything you write from here on is tools, and everything that breaks on you will be too.
What is inside
- The six rules of a description that works
- The parameter schema, field by field
- What it returns: shape, size and errors
- Tools that WRITE: the contract of four conditions
- Idempotency, with code
- Prepare and confirm: why they are two tools
- Limits go in the code, never in the prompt
- How many tools fit
- When the tool calls another system
- Scope: what the tool is allowed to see
- The ordering agent, end to end
- Testing a tool without spending a single call
1. The six rules of a description that works
They all come from the same place: the model chooses using what it reads. A description is not documentation for you — it is the interface.
-
Say when to use it, not just what it does
"Returns the stock of a product" describes. "Use it when you are asked how much is left of something or whether it needs ordering" decides. That second half is what the model needs to choose between five similar tools.
-
Say where the arguments come from
If a field has to come from another tool, say so in those words. That is what prevents the invented argument, which is the most common failure of all.
-
Say what it does NOT do
"Does not include withdrawn products" and "not for looking up prices" save whole turns. A model that does not know something is out of scope will try anyway.
-
Warn about the expensive path
"With no filter it returns the whole catalogue; avoid this" works. Models take those warnings surprisingly seriously, and it saves you the bill from lesson 1.
-
Name the tool as a verb
find_product,stock_status,prepare_order. The model reads the name as often as the description.tool_2orqueryDataGeneralthrow away information for free. -
Write it short
It is sent on every turn, like the instructions. Four good sentences beat fifteen lines — and cost three times less.
| Bad | Good |
|---|---|
"Looks up stock." |
"Stock, minimum and days of cover for ONE product. Use it when asked how
much is left or whether to order. The code must come from
find_product." |
"Sends an order to the supplier." |
"ACTUALLY sends an already prepared order. It can only be called with the
identifier returned by prepare_order and after the user has said yes. Do not use
it to calculate anything." |
2. The parameter schema, field by field
The schema is not bureaucracy: it is the only thing keeping rubbish out of your function. And every field carries its own description, which the model also reads.
{
name: 'prepare_order',
description: 'Calculates the lines of an order for a supplier and returns a draft with an '
+ 'identifier. It sends NOTHING. It is the mandatory step before confirm_order.',
parameters: {
type: 'object',
properties: {
supplier: {
type: 'string',
// ⚠️ enum: the closed list stops it inventing a supplier
enum: ['artsana', 'kenvue', 'johnson'],
description: 'Supplier the order goes to',
},
cover_days: {
type: 'integer',
description: 'Days of sales to cover. Between 3 and 30. If not told, use 14',
},
only_codes: {
type: 'array',
items: { type: 'string' },
description: 'Restrict to these codes. Empty = everything below its minimum',
},
},
required: ['supplier'],
},
}enum is the most underrated tool there is. It turns "the model
could write anything" into "the model can only write one of these three". Any time a field
has a finite set of valid values — a supplier, a status, a document type — it takes an
enum. It is free and it removes a whole family of failures.
between 3 and 30
does not stop a 900 arriving: that is a sentence, not a check. Real validation goes on the
first line of your function, and this applies to everything that follows.
3. What it returns: shape, size and errors
What a tool returns goes into the context and stays there for the rest of the loop. Three rules.
3.1. Return an object, never loose text
// ❌ The model has to interpret prose
return 'There are 12 units, below the minimum of 30.';
// ✅ Data, with names
return { stock: 12, minimum: 30, below_minimum: true, days_of_cover: 3.8 };With the second, the model can quote the exact figure and compare it. With the first it has to re-read a sentence and extract the numbers again — which is precisely the moment the summary that does not match appears.
3.2. Return little, and say what you cut
const CAP = 25;
if (rows.length > CAP) {
return {
found: true,
total: rows.length,
shown: CAP,
// ⚠️ saying so is mandatory: otherwise the model thinks that is all of them
note: `There are ${rows.length} rows; the ${CAP} most urgent are shown. Narrow the filter.`,
rows: rows.slice(0, CAP),
};
}3.3. Errors are returned, not thrown
This was already in the previous lesson; here is the exact shape worth using every time:
return {
ok: false,
reason: 'Supplier pfizer has not responded since 14:02.',
retryable: true, // does trying again make sense?
alternative: 'stock_status has the last known figure, from this morning.',
};alternative is the field almost nobody adds and the one that changes
behaviour most. Without it the model gives up or improvises; with it, it knows
exactly what to try next. It is the difference between the agent at turn 4 of lesson 1 and
one that answers "I could not" with two tools left unused.
4. Tools that WRITE: the contract
Here everything changes. A tool that reads, when it gets it wrong, wastes half a penny. One that writes sends an order, changes a price or deletes a line. Four conditions, and they are not optional and not "best practice": they are the four things without which this does not go into production.
| Condition | What it means | What happens without it |
|---|---|---|
| 1. Idempotent | Calling it twice with the same input produces ONE effect | Two real orders. The double-call failure |
| 2. Confirmed | A person says yes, having seen what and why | The agent decides things that cost money on its own |
| 3. Capped | Hard limits in your code: amount, quantity, recipient | An extra zero sails through unnoticed |
| 4. Logged | It is written down what it did, with what arguments and when | It cannot be audited or undone. And it will need to be |
5. Idempotency, with code
The word is frightening and the idea is primary school: if this runs twice, only one thing happens. You get there with a key that identifies the operation, not the call.
const sent = new Map(); // seriously: a table, not memory
function confirm_order({ draft_id }) {
// 1) has this draft already been sent? Then return what we did before.
if (sent.has(draft_id)) {
const before = sent.get(draft_id);
return { ok: true, duplicate: true, order_number: before.number,
note: 'This order was already sent; no second one has been sent.' };
}
const draft = drafts.get(draft_id);
if (!draft) return { ok: false, reason: 'That draft does not exist or has expired.' };
if (!draft.approved_by) return { ok: false, reason: 'Nobody has approved it yet.' };
const number = sendToSupplier(draft); // the only irreversible bit
sent.set(draft_id, { number, when: new Date().toISOString() });
log('confirm_order', { draft_id, number });
return { ok: true, order_number: number, lines: draft.lines.length };
}ok: true. It is not an error:
the state being asked for — "this order sent" — already holds. Returning an error would make
the model retry, which is exactly the opposite of what you want. duplicate: true
is there for the log, not to alarm anybody.
Map in the example is a lie. It lives in the
program's memory: it is wiped every time the process starts, which on a real server is
constantly. Deduplication is stored where data is stored — a table with
draft_id as a unique key — and that uniqueness constraint is the real lock.
Everything else is politeness.
6. Prepare and confirm: why they are TWO tools
draft_id somebody approved. Notice that the approval screen comes for free: it is the draft the first tool already returned.
The temptation is a single place_order tool that calculates and sends.
Splitting it in two is the highest-return design decision in this whole series.
prepare_order | confirm_order | |
|---|---|---|
| What it does | Calculates and stores a draft | Sends it |
| Reversible? | Completely | No |
| Can the agent call it alone? | Yes, as often as it likes | Only with human approval |
| What it returns | The lines, the total and a draft_id |
The order number |
With this, the agent can work freely — prepare, recalculate, compare three suppliers, discard — without anything leaving your pharmacy. And the only irreversible step sits behind a button pressed by a person with the lines in front of them.
7. Limits go in the code, never in the prompt
This is what separates a toy from something allowed near your business.
const CAP_AMOUNT = 1500;
const CAP_LINES = 40;
function prepare_order({ supplier, cover_days = 14, only_codes = [] }) {
// Real validation, not the schema's
if (!['artsana', 'kenvue', 'johnson'].includes(supplier))
return { ok: false, reason: `Invalid supplier: ${supplier}` };
if (cover_days < 3 || cover_days > 30)
return { ok: false, reason: 'cover_days must be between 3 and 30.' };
const lines = buildLines(supplier, cover_days, only_codes);
const amount = lines.reduce((s, l) => s + l.amount, 0);
// ⚠️ The cap does not let it through. It does not warn: it refuses.
if (amount > CAP_AMOUNT || lines.length > CAP_LINES) {
return { ok: false, reason: `The order comes to ${amount.toFixed(2)} in `
+ `${lines.length} lines and exceeds the cap. Narrow it with only_codes or lower `
+ `cover_days.`, retryable: true };
}
const id = crypto.randomUUID();
drafts.set(id, { supplier, lines, amount, created: Date.now() });
return { ok: true, draft_id: id, supplier, lines, amount: +amount.toFixed(2) };
}if.
only_codes or lower cover_days" hands it two concrete routes. A
well-written refusal is information, exactly like an error.
8. How many tools fit
Fewer than you think. There is no magic number, but there is a very clear pattern:
| How many | What happens |
|---|---|
| 2 – 6 | It picks well nearly always. The comfortable range |
| 7 – 12 | It starts confusing similar ones. Names and the "when to use it" become critical |
| More than 15 | It picks badly often, and the descriptions now weigh more than the data on every turn |
what field of type enum. The model
picks one of five values of one field instead of one of five tools, which it is noticeably
better at — and the descriptions stop being repeated five times on every turn.
8.1. Grouping, with code
// ❌ Five near-identical declarations, resent on every turn
query_stock · query_sales · query_expiry · query_prices · query_suppliers
// ✅ One, with an enum
{
name: 'query',
description: 'Queries one item of data from the dispensing system. Pick "what" according to '
+ 'what you need. Always bounded: with no code and no filter it returns only the 25 most '
+ 'relevant rows.',
parameters: {
type: 'object',
properties: {
what: { type: 'string', enum: ['stock', 'sales', 'expiry', 'prices', 'suppliers'],
description: 'What you want to query' },
code: { type: 'string', description: 'Product code, where it applies' },
since: { type: 'string', description: 'ISO start date, for sales and expiry' },
},
required: ['what'],
},
}
And in your code, a five-branch switch. You have swapped "the model picks among
five tools" for "the model picks a value from a closed list", which it is much better at,
and the descriptions have gone from five paragraphs to one.
query(what:'stock') and
query(what:'send_order') would be one door for both, and the whole boundary
from lesson 1 — the human between reading and writing — falls with it. Grouping is by
similarity, not by convenience.
9. When the tool calls another system
Reading a CSV does not fail. Calling your dispensing software, the supplier service or email does — and it fails in ways that do not look like programming errors.
-
Set a maximum time. Always
Without one, a call that never answers leaves the agent hanging indefinitely and you with no idea whether it is working. Ten or fifteen seconds, and if it is exceeded, return it as data:
{ ok: false, reason: 'the service took more than 15 s', retryable: true }.const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 15000); try { const r = await fetch(url, { signal: ctrl.signal }); return await r.json(); } catch (e) { return { ok: false, reason: 'The service did not respond in 15 s.', retryable: true }; } finally { clearTimeout(t); } -
You retry, not the model
A transient failure gets retried inside your function — twice, waiting a little longer each time — and the model never finds out. Leave it to the model and you spend a whole turn of the loop on something that took two hundred milliseconds, and it may well decide to do something else instead.
But only for reads. Retrying a write on its own is the double call under another name: there the retry goes behind the idempotency key or it does not go at all.
-
Tell "there is none" apart from "we do not know"
If the expiry system does not respond and you return an empty list, the model will conclude nothing is expiring. It is the easiest lie to commit and the hardest to spot:
{ ok: false }and{ ok: true, rows: [] }mean opposite things and are written almost identically.
10. Scope: what the tool is allowed to see
A tool should not be able to do anything it does not need for its job. That is not paranoia: the day the model asks for something odd — or somebody makes it ask — the only thing stopping it is what the tool cannot do.
| Instead of | Do |
|---|---|
| One database connection with full privileges | A read-only user for the tools that read, and a separate one — with rights on one table — for the ones that write |
A run_sql tool |
Specific queries with parameters. Never, ever, SQL coming from the model |
A send_email(to, subject, body) |
send_email(template, data), with the templates written by you and
the recipient taken from your database, not from the argument |
| The supplier key inside the agent | The supplier call behind your own endpoint, which validates first |
run_sql row looks like the most practical one and is the worst idea
of all. It gives the model a tool able to read any table — including patient ones —
and, depending on the user, to delete them. It is always justified the same way: "then I do
not have to write a tool per query". Write them.
11. The ordering agent, end to end
All of the above together, working. Four tools: query,
prepare_order, confirm_order and nothing else.
$ node agent.js "prepare the artsana order for two weeks"
turn 1 ASKS query {"what":"stock"}
turn 2 ASKS prepare_order {"supplier":"artsana","cover_days":14}
Draft ready (id 8f2c...), 14 lines, 612.40. The three largest:
- OMEPRAZOLE 20MG 28 CAPS: 70 units (4 left, you sell 71/month)
- IBUPROFEN 600MG 40 TABS: 90 units (12 left, you sell 96/month)
- DEXKETOPROFEN 25MG 20 TABS: 45 units (none left, you sell 44/month)
Review and approve it on the orders screen.
(3 turns)
The agent has finished there. It has sent nothing and it cannot:
confirm_order checks draft.approved_by and without that it returns
the reason. Approval does not go through the agent — it goes through a screen of yours, with
the 14 lines and the total in front of you.
And if you ask for too much:
$ node agent.js "prepare the artsana order for two months"
turn 1 ASKS prepare_order {"supplier":"artsana","cover_days":60}
turn 2 ASKS prepare_order {"supplier":"artsana","cover_days":30}
I could not prepare 60 days: the maximum is 30. At 30 days the order comes to
2,180.90 and exceeds the cap of 1,500, so that does not work either. Tell me
which products you want covered, or we drop to 14 days.
(3 turns)if statements inside the code, understood the reasons because they
were written out, tried a sensible alternative and stopped asking for a decision. Not one
line of that conversation was scripted in advance — and the outcome is still completely
bounded.
12. Testing a tool without spending a single call
A tool is an ordinary function, so you test it like an ordinary function: no model, no key and no quota. This is what makes an agent maintainable, and it is the part almost nobody does.
import { prepare_order, confirm_order } from './agent.js';
import assert from 'node:assert';
// 1) An invented supplier does NOT blow up: it returns the reason
const a = prepare_order({ supplier: 'the_one_round_the_corner' });
assert.equal(a.ok, false);
assert.match(a.reason, /Invalid supplier/);
// 2) The amount cap does NOT let it through
const b = prepare_order({ supplier: 'artsana', cover_days: 30 });
if (!b.ok) assert.match(b.reason, /cap/);
// 3) Confirming the same draft twice sends it ONCE
const c = prepare_order({ supplier: 'artsana', cover_days: 7 });
approve(c.draft_id);
const p1 = confirm_order({ draft_id: c.draft_id });
const p2 = confirm_order({ draft_id: c.draft_id });
assert.equal(p1.order_number, p2.order_number); // ⚠️ the same number
assert.equal(p2.duplicate, true);
console.log('all three pass');12.1. Paste one here
The twelve things checked below are the same twelve you have been reading about since the start of the lesson. Paste a contract into it — the one above, one of yours, or the one a model has just written you — and it tells you which ones it fails. It all runs in your browser: nothing leaves this page.
The contract, in nine lines
- The description says when to use it, not just what it does.
- Fields with finite values use
enum. - Defaults are written in the field description.
- It returns an object with names, not prose.
- If it trims, it says so in the data itself.
- Errors come back with a
reasonand, where possible, analternative. - If it writes: it is idempotent, confirmed, capped and logged.
- The caps are in an
if, not in the prompt. - There is a test that runs it with no model.
In the last lesson we take all of this off the laptop: where it lives, what it really costs, how you watch it, what prompt injection is and what happens when the agent gets it wrong in a pharmacy.