Designing tools and permissions
An agent can only do what its tools let it do, so the tool set is where an agent becomes useful or dangerous. In this lesson you design the tools for Mangrove's operations assistant, make every call run as the signed-in person, put a policy layer between the model and the tools, and build an approval flow with idempotency keys and an audit log.
By the end you can
- Design a small tool set around the user's jobs, with descriptions, parameters, responses and errors a model can use well
- Sort tools into read, write and irreversible, and run each call with the signed-in person's own short-lived scopes
- Write a policy check that returns allow, approve or deny from the role, tool, store and amount
- Build an approval flow: a stored pending action, a card built from a dry run, expiry, and execution with an idempotency key
- Record every call in an append-only audit log that answers "who did what, and why was it allowed?"
The everyday picture
Think of a new teller at a bank branch. She does not get the vault keys and a login that opens every account. She gets a few forms, one for each job: deposit, withdrawal, balance check. Each form has boxes for exactly what that job needs, and the bank's system stamps her branch and her name on it. She cannot type in someone else's.
She can pay out up to a set amount on her own. Above it, a supervisor walks over, reads the slip with the exact amount on it, and turns a key. If the customer wanders off for an hour, the slip is void and they start again. Every slip carries a number, so if the printer jams and she presses the button twice, the customer is paid once. At the end of the day, every slip is in the journal with her name on it.
An agent needs the same things: job-shaped tools, an identity it cannot change, limits that a supervisor's key unlocks, slips that expire, numbered requests that cannot run twice, and a journal.
Tools shaped around jobs, not endpoints
In What makes an agent you built the loop: the model asks for a tool, your code runs it, the result goes back. How tool calling and MCP work is covered in Tools and the Model Context Protocol. This lesson is about what goes inside that loop: which tools exist, what they accept and return, and who decides whether a call runs.
Start from the jobs, not from your API. Ama, the store manager who pilots the assistant, asks things like "where is my bread delivery?", "how many 1-litre UHT milk cases do I have?", "what does the procedure say about a short delivery?" and "make sure someone recounts the rice tonight". Mangrove's platform already has REST endpoints for purchase orders, advance shipping notices, DC dispatch, truck arrival times and goods received notes (GRNs). If you wrap each endpoint as a tool, the model needs five rounds to answer "where is my delivery?", has to join ids across five responses itself, and reads thousands of tokens of fields it does not need. One get_deliveries tool that does the joining in your code answers in one round.
Cost grows faster than it looks. In most model APIs the whole conversation is sent again on every round of the loop, so a 3,000-token response is paid for again on each later round (prompt caching lowers this with some providers). At an illustrative $3 per million input tokens (check your provider's current price list), the difference per question is small, but across 320 stores asking dozens of questions a day it adds up, and long contexts also make the model slower and less accurate.
Then design each tool for a reader that cannot ask you questions:
- Names and descriptions say when to use it and when not to. "Status of deliveries to one store. Use for 'where is my delivery'. Do not use for stock on the shelf (use
get_stock)." The model chooses tools by reading these words, so write them like a note to a new colleague, and fix them when your evals show the wrong tool being picked. - Small parameter sets with enums and ids.
day: "yesterday" | "today" | "tomorrow"beats a free-text date.articleIdbeats an article name, because "milk" matches 40 of Mangrove's 25,000 articles. SetadditionalProperties: falseso invented fields fail validation. - Short, relevant, paginated responses. Return the five fields that answer the job, in words the model can quote ("delayed, now expected Sunday 07:00"), with a default page of 10 rows and a
nextCursor. Never return a 40-field record "in case". - Errors that tell the model how to fix the call. A bare
400 Bad Requestmakes the model guess or give up. Say what was wrong and what to do next:
{
"error": "ambiguous_article",
"message": "3 articles match 'milk 1L'. Call get_stock again with one articleId.",
"candidates": [
{ "articleId": 40211, "name": "UHT milk 1 L, case of 12" },
{ "articleId": 40207, "name": "Fresh milk 1 L" },
{ "articleId": 40388, "name": "Lactose-free milk 1 L" }
],
"retryable": true
}Quick check
The agent keeps calling get_stock when Ama asks "when does the bread arrive?". What is the best first fix?
The model picks tools from their names and descriptions, so the fix belongs there, and you confirm it with an eval case. A vague warning in the prompt gives no information about which tool to pick. Merging two different jobs into one tool with a mode makes the choice harder, and removing a tool Ama needs breaks a real job.
Read, write and irreversible tools, run as the user
Sort every tool by what it can change, because that decides how much checking it needs.
- Read:
search_procedures,get_stock,get_deliveries,get_grn. A wrong call wastes a round; nothing changes in the world. - Write, easy to undo:
create_task. A wrong task is closed in one tap. - Write, hard to undo:
request_stock_transfer. Once store 118 has packed 40 cases and a van has left, undoing it costs a second trip. - Irreversible or customer-facing:
change_price. A wrong price hits every till in the store and every app order within minutes, and customers have already paid it.
Next, decide who each call runs as. The rule is simple: the agent acts as the signed-in person, never as itself. Ama can read and act for her own store, 112. The area manager can act for her 12 stores. The assistant gets exactly that, and no more.
In practice, when Ama opens the assistant, your backend issues a short-lived token for her session: her user id, her store list, and only the scopes the assistant needs, such as procedures:read stock:read deliveries:read tasks:write transfers:request. There is no prices:write scope in it at all. Every tool calls Mangrove's normal APIs with that token, so the stock API applies the same row-level rules it applies when Ama uses the Store app. A shared admin key would turn one confused or manipulated step into access to all 320 stores; the OWASP Top 10 for LLM applications calls this excessive agency.
One more rule follows: identity comes from the session, not from the model. No tool schema has a userId or role field. Where a store must be named (the area manager picks one of her 12), the gateway fills in the session's default store and checks any store the model passes against the token's list. The model can be wrong, and text it reads (a supplier's note, a procedure someone edited) can try to steer it, as Security essentials and AI features people can trust explain. Nothing the model writes should be able to change who is acting.
Quick check
The area manager asks about store 131, which is not one of her 12. The model calls get_stock with storeId: 131. Where should this be stopped?
Scope is enforced by systems that cannot be talked out of it: the gateway's check and the API's own rules on the user's token. A prompt list and the model's judgement are helpful hints, but both fail when the model is confused or manipulated.
A policy layer between the model and the tools
Scopes say what Ama may do in general. A policy layer decides, for each call, whether it runs now, waits for a person, or is refused. It sits in your gateway, after schema validation and before any tool runs. Its inputs are facts the model cannot change: the user's role and stores from the session, the tool name, the validated arguments, and the amount computed by your own system.
Write the policy as data, one row per tool, so it can be reviewed like any other config:
| Tool | Kind | Decision | Who approves | Limits |
|---|---|---|---|---|
search_procedures | Read | Allow | Nobody | 60 calls per user per hour |
get_stock, get_deliveries, get_grn | Read | Allow, own stores | Nobody | Pages of 10 to 20 rows |
create_task | Write, easy to undo | Allow, own stores | Nobody | 30 per store per day |
request_stock_transfer | Write, hard to undo | Approve, up to $2,000; deny above | Store manager up to $500; area manager above | 5 per store per day; $2,000 per store per day |
change_price | Customer-facing | Deny | Not available to the agent | Prices go through the pricing team |
| Anything else | Deny |
The check runs in a fixed order and stops at the first failure: an unknown tool is denied (deny by default); a tool whose row says deny is denied; a role the row does not list is denied; a store outside the user's list is denied; an amount over the row's hard limit is denied. Only then does the row's own decision apply: allow, or approve.
Two details make this hold up. First, the amount comes from your system, not the model. For a transfer, the gateway runs a dry run that picks the source store and prices the quantity at cost; the model only says which article and how many. Second, change_price is never sent to the model in the tool list, and its row says deny. If a later config change or an injected instruction ever produces that call, the policy still refuses it. This is what OWASP calls complete mediation: the downstream system decides, every time.
Limits per tool sit next to the policy. They do not judge one call; they cap the damage of many. Count calls and money from the audit log: 30 tasks per store per day, 5 transfers and $2,000 of transfers per store per day. When a loop goes wrong at 2 am, these limits stop it at the fifth transfer instead of the five-hundredth. The step and token budget from What makes an agent caps what the model spends; these caps limit what the tools can do. Rate limiting shows how to count efficiently.
Quick check
Ama's agent calls request_stock_transfer for her own store, and the dry run values it at $2,400. The policy row says approve, with a hard limit of $2,000. What does the policy return?
The hard limit is checked before the row's own decision, so anything over $2,000 never reaches an approval card. The area manager route applies only to transfers from $500 up to the limit. Own-store scope is necessary but not enough, and what the user typed in chat is not an approval.
Approvals that mean something
"Approve" is only a control if the person approving sees the real effect and the system runs exactly what was approved. Five mechanisms make that true.
- A stored pending action. When the policy says approve, the gateway writes a row with the exact arguments that will run, a hash of them, the approver role, a status of
pendingand an expiry time. The model gets back "waiting for approval", not a result. - A card built from the dry run. The card text comes from the preview, never from the model's summary: "Move 40 cases of UHT milk 1 L ($384.00 at cost) from store 118 to store 112, arriving tomorrow by 10:00." Plain words, the real numbers, the source the system chose. If the model wrote "move some milk from nearby", that sentence goes nowhere near the card.
- Expiry. Stock, prices and the approver's attention all go stale. A card expires after 30 minutes; after that it cannot be approved, and the agent must propose again with a fresh preview. A card found the next morning is not an approval.
- Execute what was approved. On approve, your code loads the stored arguments, checks their hash, and runs those. It never asks the model again, because the model might now produce different arguments.
- An idempotency key. Each pending action gets a random key when it is created. The transfer service stores keys with a unique constraint and, for a key it has seen, returns the first result instead of creating a second transfer. This is the same pattern payment APIs use for safe retries.
Why the last one matters: agents retry. The network drops after the transfer service committed but before the reply arrived; the loop sees a timeout and calls again; Ama double-taps Approve on a slow connection. Without a key, each of these creates a second request, and store 118 packs 80 cases. With a key, the retry gets back the same transfer number. Use the same idea for create_task, keyed by the step that created it, so a retried step does not give the night shift two recount tasks.
Who approves follows the rules people already use. Transfers up to $500 go to the receiving store's manager, which for Ama's own request means Ama confirms the card. From $500 to $2,000 they go to the area manager, as they do today on paper. The approver's role and stores are checked when they tap Approve, against their own session, not the requester's. How to design cards people actually read, and how to spot rubber-stamping, is covered from the product side in AI agents in operations.
Quick check
Ama opens an approval card 45 minutes after it was created (expiry is 30 minutes) and taps Approve. What should happen?
Expiry exists because the card's facts go stale: stock, value and context can change in 45 minutes. A fresh proposal gets a fresh preview that Ama approves knowingly. Running anyway, or approving on her behalf, removes the human check the card was for.
The audit log
Every decision writes one row: allowed, sent for approval, denied, approved, rejected, expired and executed. Each row says who (user, role, session), which tool, the arguments after the gateway filled the session ids, the decision and its reason (store_scope, over_limit, ok), the pending action id and idempotency key when there is one, and the result. The table is append-only: the app's database role can insert and read, never update or delete.
The log has three jobs. During an incident it answers "why did store 112 get two milk transfers on Friday?" in one query, as Observability describes for any service. It feeds the rate and spend limits, which count today's rows per tool and store. And it is the raw material for Evaluating agents: denied calls show where the model misunderstands its tools, and rejected cards show where its proposals are wrong.
Build it
The tool definitions, the policy table, the gateway, the approval tables and flow, and a test run, in TypeScript and Postgres.
Define the read tools
Each tool is a name, a description that says when and when not to use it, and a JSON Schema for its arguments. Providers name the schema field differently (for example
input_schemaorparameters); the content is the same.TypeScript// tools-read.ts: what the model sees for reading export const searchProcedures = { name: 'search_procedures', description: 'Search Mangrove store procedures (SOPs). Use for "how do I" and "what is the rule" ' + 'questions. Returns up to 5 short passages with their SOP id; cite the id in your answer.', inputSchema: { type: 'object', additionalProperties: false, required: ['query'], properties: { query: { type: 'string', maxLength: 200 } } }, }; export const getDeliveries = { name: 'get_deliveries', description: 'Status of supplier and DC deliveries to one store: expected, on_the_way, arrived, ' + 'received (with GRN id) or delayed, with ETA and cartons. Use for "where is my delivery" or ' + '"when does X arrive". Not for stock on the shelf (use get_stock) or for differences on a ' + 'received delivery (use get_grn).', inputSchema: { type: 'object', additionalProperties: false, properties: { storeId: { type: 'integer', description: 'Leave out to use your own store.' }, day: { type: 'string', enum: ['yesterday', 'today', 'tomorrow'], default: 'today' }, status: { type: 'string', enum: ['expected', 'on_the_way', 'arrived', 'received', 'delayed'] }, limit: { type: 'integer', minimum: 1, maximum: 20, default: 10 }, cursor: { type: 'string', description: 'nextCursor from the previous page.' }, }, }, }; // get_stock and get_grn follow the same pattern: storeId optional, articleId or grnId required.Define the write tools
Write tools say what they change, that the system chooses the details the model must not, and whether a person approves.
TypeScript// tools-write.ts export const createTask = { name: 'create_task', description: 'Create a follow-up task for staff at one store, such as a recount or a shelf check. ' + 'Use when the user wants a person to do something later. It does not change stock or prices.', inputSchema: { type: 'object', additionalProperties: false, required: ['kind', 'dueBy'], properties: { storeId: { type: 'integer', description: 'Leave out to use your own store.' }, kind: { type: 'string', enum: ['recount', 'shelf_check', 'expiry_check', 'call_supplier', 'pricing_desk'] }, articleId: { type: 'integer', description: 'From get_stock results. Never guess an id.' }, dueBy: { type: 'string', enum: ['today', 'tomorrow', 'this_week'] }, note: { type: 'string', maxLength: 200 }, }, }, }; export const requestStockTransfer = { name: 'request_stock_transfer', description: 'Ask for stock to be moved to a store from a nearby Mangrove store. The system picks ' + 'the source store and the value. A person always approves a card first. Use only when stock ' + 'will run out before the next delivery; check get_stock and get_deliveries first.', inputSchema: { type: 'object', additionalProperties: false, required: ['articleId', 'quantity', 'reason'], properties: { storeId: { type: 'integer', description: 'Receiving store. Leave out for your own.' }, articleId: { type: 'integer' }, quantity: { type: 'integer', minimum: 1, maximum: 500 }, reason: { type: 'string', enum: ['stockout_risk', 'festival_peak', 'damaged_delivery'] }, }, }, };Write the policy table and limits as data
Anything not in the table is denied. Approver routing and limits live next to it so one review covers all of them.
TypeScript// policy.ts export type Decision = 'allow' | 'approve' | 'deny'; export type Role = 'store_manager' | 'area_manager'; type Row = { roles: Role[]; base: Decision; maxCents?: number }; const managers: Role[] = ['store_manager', 'area_manager']; export const policy: Record<string, Row> = { search_procedures: { roles: managers, base: 'allow' }, get_stock: { roles: managers, base: 'allow' }, get_deliveries: { roles: managers, base: 'allow' }, get_grn: { roles: managers, base: 'allow' }, create_task: { roles: managers, base: 'allow' }, request_stock_transfer: { roles: managers, base: 'approve', maxCents: 200_000 }, change_price: { roles: [], base: 'deny' }, // never sent to the model either }; // Who approves a transfer, from the value the dry run computed export const approverFor = (valueCents: number): Role => valueCents <= 50_000 ? 'store_manager' : 'area_manager'; // Caps on many calls, counted from today's audit rows export const limits: Record<string, { perUserPerHour?: number; perStorePerDay?: number; centsPerStorePerDay?: number }> = { search_procedures: { perUserPerHour: 60 }, create_task: { perStorePerDay: 30 }, request_stock_transfer: { perStorePerDay: 5, centsPerStorePerDay: 200_000 }, };Route every call through one gateway
It validates, fills ids from the session, prices writes with a dry run, asks the policy, applies limits, and records the outcome. You write
authorizeitself in Practice.TypeScript// gateway.ts: the only path from a model's tool call to a real action export async function handleToolCall(session: Session, raw: { name: string; args: unknown }) { const tool = toolsByName[raw.name]; // the allow-list of tools const parsed = tool?.schema.safeParse(raw.args); // JSON Schema or zod validation if (!tool || !parsed?.success) { return audit(session, raw, 'denied', 'bad_call', explainBadCall(raw, parsed)); } // Identity and default store come from the session; the schema has no userId field const args = { storeId: session.storeIds[0], ...parsed.data }; // Dry run with the user's own token: picks the source and values it, writes nothing const preview = tool.preview ? await tool.preview(session.token, args) : null; const call = { tool: raw.name, args, valueCents: preview?.valueCents ?? 0 }; const { decision, reason } = authorize(session.user, call, policy); if (decision === 'deny') return audit(session, call, 'denied', reason, denyMessage(call, reason)); if (await overLimit(session, call)) { return audit(session, call, 'denied', 'rate_limit', { error: 'rate_limit', message: 'Daily limit reached for this tool. Tell the user; do not retry.' }); } if (decision === 'approve') { const pending = await createPending(session, call, preview); // step 6 return audit(session, call, 'needs_approval', reason, { status: 'waiting_for_approval', card: pending.card, expiresAt: pending.expires_at }); } const result = await tool.run(session.token, args); // runs as the user return audit(session, call, 'allowed', reason, result); }Create the pending action and audit tables
The pending action holds exactly what will run. The audit log is append-only: grant the app role insert and select, nothing else.
SQLCREATE TABLE pending_action ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), requested_by uuid NOT NULL, -- the signed-in user the agent acts for store_id int NOT NULL, tool text NOT NULL, args jsonb NOT NULL, -- exactly what will run, incl. the chosen source args_hash text NOT NULL, -- sha256 of the canonical args card_text text NOT NULL, -- built from the dry run, not from the model value_cents int NOT NULL DEFAULT 0, approver_role text NOT NULL CHECK (approver_role IN ('store_manager', 'area_manager')), status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'expired', 'executed', 'failed')), idempotency_key uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(), expires_at timestamptz NOT NULL DEFAULT now() + interval '30 minutes', decided_by uuid, decided_at timestamptz, result jsonb ); CREATE TABLE audit_log ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, at timestamptz NOT NULL DEFAULT now(), user_id uuid NOT NULL, user_role text NOT NULL, session_id uuid NOT NULL, tool text NOT NULL, args jsonb NOT NULL, event text NOT NULL CHECK (event IN ('allowed', 'needs_approval', 'denied', 'approved', 'rejected', 'expired', 'executed')), reason text NOT NULL, pending_action_id uuid REFERENCES pending_action (id), result jsonb ); CREATE INDEX audit_log_store_day ON audit_log (tool, ((args ->> 'storeId')::int), at); GRANT SELECT, INSERT ON audit_log TO agent_app; -- no UPDATE or DELETE grantStore, approve and execute with an idempotency key
The approve step is one guarded
UPDATE, so a double tap, an expired card or the wrong approver all fail in the database. Execution sends the stored key.TypeScript// approvals.ts const canonical = (a: Record<string, unknown>) => JSON.stringify(Object.keys(a).sort().map((k) => [k, a[k]])); // same args, any order const hashArgs = (a: Record<string, unknown>) => createHash('sha256').update(canonical(a)).digest('hex'); // from node:crypto export async function createPending(session: Session, call: Call, p: Preview) { const args = { ...call.args, sourceStoreId: p.sourceStoreId }; // run what the card shows const card = `Move ${p.quantity} cases of ${p.articleName} (${dollars(p.valueCents)} at cost) ` + `from store ${p.sourceStoreId} to store ${call.args.storeId}, arriving ${p.arrives}.`; return db.one(`INSERT INTO pending_action (requested_by, store_id, tool, args, args_hash, card_text, value_cents, approver_role) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id, card_text AS card, expires_at`, [session.user.id, call.args.storeId, call.tool, args, hashArgs(args), card, call.valueCents, approverFor(call.valueCents)]); } export async function approve(actionId: string, approver: User) { const action = await db.oneOrNone(`UPDATE pending_action SET status = 'approved', decided_by = $2, decided_at = now() WHERE id = $1 AND status = 'pending' AND expires_at > now() AND approver_role = $3 AND store_id = ANY($4) RETURNING *`, [actionId, approver.id, approver.role, approver.storeIds]); if (!action) return { ok: false, message: 'Expired, already handled, or not yours to approve.' }; if (hashArgs(action.args) !== action.args_hash) throw new Error('args changed after the card'); await auditEvent(action, approver, 'approved'); const result = await transfers.create(action.args, { idempotencyKey: action.idempotency_key }); await db.none(`UPDATE pending_action SET status = 'executed', result = $2 WHERE id = $1`, [action.id, result]); await auditEvent(action, approver, 'executed', result); return { ok: true, result }; }Inside the transfer service, the key is a unique column, so a retry inserts nothing and reads the first row:
SQLINSERT INTO transfer_request (idempotency_key, from_store, to_store, article_id, quantity, value_cents) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id; -- no row returned: this key was seen before, so return the existing transfer SELECT id FROM transfer_request WHERE idempotency_key = $1;Test the gateway and read the audit log
Run these as Ama (store 112) in a test database, then query the log: a read at her store, a read at store 118, a transfer she approves, and a
change_pricecall you send to the gateway by hand to prove the policy refuses it even though the model never sees that tool.SQLSELECT to_char(at, 'HH24:MI:SS') AS at, tool, event, reason, args ->> 'storeId' AS store FROM audit_log WHERE user_id = $1 ORDER BY id;Outputat | tool | event | reason | store ---------+------------------------+----------------+-------------+------- 16:41:02 | get_stock | allowed | ok | 112 16:41:05 | get_stock | denied | store_scope | 118 16:41:09 | request_stock_transfer | needs_approval | ok | 112 16:41:30 | request_stock_transfer | approved | ok | 112 16:41:31 | request_stock_transfer | executed | ok | 112 16:42:10 | change_price | denied | tool_denied | 112
Bring it to your team
Ask these when someone proposes a new tool or a new agent action.
- Which user job does this tool serve, and could an existing tool serve it with a better description?
- Does the description say when not to use it, and what does the model see when a call is wrong?
- Is this tool read, write or irreversible, and what is its row in the policy table?
- Whose token does it run with, and which scope does it need that the session does not already have?
- Does any argument let the model choose who is acting, or set an amount the policy relies on?
- What happens if this call is retried three times, and which idempotency key stops a duplicate?
Red flags
- "The agent uses the service account so it can see everything."
- A tool per REST endpoint, or one tool that runs any SQL or any HTTP request.
- Limits and store lists written only in the system prompt.
- Approval cards that show the model's summary instead of the exact effect.
- An approve button that re-asks the model for the arguments.
- An audit table the app can update or delete.
Practice
Write the policy check
Write authorize(user, call, policy) for the gateway in step 4. Return { decision, reason }, checking in this order and stopping at the first failure: the tool is not an own key of policy ('deny', 'unknown_tool'); the row's base is 'deny' ('deny', 'tool_denied'); the user's role is not in row.roles ('deny', 'role'); call.args.storeId is not in user.storeIds ('deny', 'store_scope'); the row has maxCents and call.valueCents is missing or above it ('deny', 'over_limit'). Otherwise return the row's base with reason 'ok'.
// user: { id, role: 'store_manager' | 'area_manager', storeIds: [112] }
// call: { tool, args: { storeId, ... }, valueCents } (valueCents comes from your dry run)
// policy: { toolName: { roles: [...], base: 'allow' | 'approve' | 'deny', maxCents? } }
// Return { decision, reason }. Reasons: 'unknown_tool', 'tool_denied', 'role',
// 'store_scope', 'over_limit', or 'ok' when the row's base decision applies.
function authorize(user, call, policy) {
return { decision: policy[call.tool].base, reason: 'ok' };
}
const policy = {
get_stock: { roles: ['store_manager', 'area_manager'], base: 'allow' },
create_task: { roles: ['store_manager', 'area_manager'], base: 'allow' },
request_stock_transfer: { roles: ['store_manager', 'area_manager'], base: 'approve', maxCents: 200000 },
approve_write_off: { roles: ['area_manager'], base: 'approve' },
change_price: { roles: [], base: 'deny' },
};
const ama = { id: 'u-ama', role: 'store_manager', storeIds: [112] };
const area = { id: 'u-area', role: 'area_manager', storeIds: [104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 118] };
const call = (tool, storeId, valueCents) => ({ tool, args: { storeId }, valueCents });
test('reads and tasks at your own store are allowed', () => {
expect(authorize(ama, call('get_stock', 112, 0), policy)).toEqual({ decision: 'allow', reason: 'ok' });
expect(authorize(ama, call('create_task', 112, 0), policy)).toEqual({ decision: 'allow', reason: 'ok' });
});
test('a store outside the user\'s list is denied, even for a read', () => {
expect(authorize(ama, call('get_stock', 118, 0), policy)).toEqual({ decision: 'deny', reason: 'store_scope' });
expect(authorize(area, call('get_stock', 118, 0), policy)).toEqual({ decision: 'allow', reason: 'ok' });
expect(authorize(area, call('get_stock', 131, 0), policy)).toEqual({ decision: 'deny', reason: 'store_scope' });
});
test('transfers need approval up to the limit and are denied above it or without a value', () => {
expect(authorize(ama, call('request_stock_transfer', 112, 38400), policy)).toEqual({ decision: 'approve', reason: 'ok' });
expect(authorize(ama, call('request_stock_transfer', 112, 200000), policy)).toEqual({ decision: 'approve', reason: 'ok' });
expect(authorize(ama, call('request_stock_transfer', 112, 200001), policy)).toEqual({ decision: 'deny', reason: 'over_limit' });
expect(authorize(ama, call('request_stock_transfer', 112, undefined), policy)).toEqual({ decision: 'deny', reason: 'over_limit' });
});
test('unknown tools are denied, including names like constructor', () => {
expect(authorize(ama, call('refund_customer', 112, 500), policy)).toEqual({ decision: 'deny', reason: 'unknown_tool' });
expect(authorize(ama, call('constructor', 112, 0), policy)).toEqual({ decision: 'deny', reason: 'unknown_tool' });
});
test('a denied tool, or a role the row does not list, is denied', () => {
expect(authorize(area, call('change_price', 112, 0), policy)).toEqual({ decision: 'deny', reason: 'tool_denied' });
expect(authorize(ama, call('approve_write_off', 112, 9000), policy)).toEqual({ decision: 'deny', reason: 'role' });
expect(authorize(area, call('approve_write_off', 112, 9000), policy)).toEqual({ decision: 'approve', reason: 'ok' });
});
Show a solution
function authorize(user, call, policy) {
// Deny by default: only tools with their own row exist (not 'constructor' from the prototype)
if (!Object.prototype.hasOwnProperty.call(policy, call.tool)) {
return { decision: 'deny', reason: 'unknown_tool' };
}
const row = policy[call.tool];
if (row.base === 'deny') return { decision: 'deny', reason: 'tool_denied' };
if (!row.roles.includes(user.role)) return { decision: 'deny', reason: 'role' };
if (!user.storeIds.includes(call.args.storeId)) {
return { decision: 'deny', reason: 'store_scope' };
}
// !(x <= max) is also true when x is undefined or NaN, so a missing value fails closed
if (row.maxCents !== undefined && !(call.valueCents <= row.maxCents)) {
return { decision: 'deny', reason: 'over_limit' };
}
return { decision: row.base, reason: 'ok' };
}Redesign a proposed tool set
A teammate proposes four tools for the assistant: call_api(method, path, body) "so it can reach anything"; get_store(storeId), which returns the full 40-field store record; update_stock(storeId, articleId, newQuantity) so the agent can "fix" counts it thinks are wrong; and notify(userId, text) to message any member of staff. For each one, say what is wrong and what you would build instead: the tool name, its kind (read, write, irreversible), its parameters, and its row in the policy table.
- You replace
call_apiwith job-shaped tools and explain that an open-ended tool gives the model every permission the key has - You cut
get_storedown to the fields a job needs, with a short, paginated response - You replace
update_stockwith a recount task (or an approved adjustment), because a stock change based on the model's belief is a hard-to-undo write - You limit messaging to people in the user's own stores, with the sender taken from the session and a daily limit
- No parameter lets the model choose who is acting
Show a model answer
call_api Remove. Any method on any path = every permission the key has.
Replace with get_deliveries, get_stock, get_grn (read, allow, own stores).
get_store Replace with get_store_hours(storeId?) or the fields a real job needs.
Read, allow, own stores; 5 fields, no staff phone numbers.
update_stock Replace with create_task(kind: 'recount', articleId, dueBy).
Write, easy to undo, allow, 30 per store per day. A count changes
only when a person scans the shelf in the Store app.
notify Replace with message_store_staff(storeId?, role: enum, text max 300).
Write, allow, own stores only; sender = session user; 10 per day.
No userId parameter: the model never picks who is acting.The pattern behind every change: each tool does one job, takes ids and enums, returns little, and runs as Ama inside her store list. Writes that act on what the model believes about the world become tasks for a person, unless they go through an approval card built from a dry run.
Check yourself
Before a festival weekend, the agent proposes a rice transfer to Ama's store that the dry run values at $1,850. Transfers are approve up to $2,000, with the store manager approving up to $500 and the area manager above. What happens next?
$1,850 is under the hard limit, so the policy returns approve, and the value routes it to the area manager. A "yes" typed in chat comes back through the model and proves nothing about the exact arguments; the approval must be a stored action, approved by the right role before it expires.
Your policy has a limit on transfer value. Where should the value that the policy checks come from?
A limit is only as good as the number it checks. If the model or the chat supplies the value, a confused or manipulated model can write $300 for a $3,000 transfer. The card is built from the dry run, so it cannot be the source of the number either.
On Monday, the area manager asks why store 112 received two milk transfers on Friday evening. Which design answers that fastest and most reliably?
With every decision recorded, one query shows whether there were two approved actions (two keys) or one action retried (one key, and a bug in the transfer service). The model's answers and memory are not records of what systems did, and logging only denials misses exactly the calls that ran.
Flashcards
Key takeaways
- Build a few tools around the user's jobs, with descriptions that say when not to use them, enums and ids, short paginated responses and errors that say how to fix the call.
- The agent acts as the signed-in person with a short-lived, narrow token; identity and default stores come from the session, never from the model.
- A policy layer in code decides allow, approve or deny from role, tool, store and a system-computed amount, denying by default, with per-tool rate and spend limits.
- Approvals mean something when the card comes from a dry run, it expires, and execution runs the stored arguments with an idempotency key.
- Every decision goes into an append-only audit log, which also feeds your limits and your evals.
Sources
- Writing effective tools for agents, with agents, Anthropic engineering blog, 2025
- LLM06:2025 Excessive Agency, OWASP Gen AI Security Project, 2025
- Tools, Model Context Protocol specification, version 2025-06-18
- Idempotent requests, Stripe API reference, read in 2026
- RFC 8693: OAuth 2.0 Token Exchange, IETF, 2020
- Understanding JSON Schema, json-schema.org, read in 2026