Module 4: Tools: The Agent That Acts on Real Systems

5. Tool contracts and trust boundaries

Description

By the end of this lesson you'll be able to write a tool's name, description, and parameters so the agent uses it at the right moment and with the right data — what we call the tool's contract — and you'll be able to decide with a concrete criterion which of those tools' actions should never execute without a person approving them first, using n8n's human review mechanism.

This matters because in the previous lesson TuTienda's agent ended up connected to Gmail, to Google Sheets, to the order database, and to the shipping carrier's HTTP API. Technically it already knows how to call each one. The problem left over isn't technical, it's one of judgment: two tools with similar names make the model pick the wrong one, and a tool that can send an email or write to the database with no limit at all can end up promising a customer a discount or changing a product's price because nobody put a brake on it. This is exactly what separates a demo that works in the sales call from an agent you can leave running in production with no constant supervision.

Connection to the module: lesson 4 gave you access to the real systems. This lesson gives you control over that access — each tool's contract and the human-approval barrier for what can't be trusted to the model alone. Lesson 6 takes this same criterion and applies it to complete sub-workflows exposed as a single tool: they're going to need the same clear contract you practice here.

A tool's contract

Imagine you delegate a task to someone who just joined the team, someone who can't interrupt you mid-afternoon to ask something that wasn't clear. If you leave them a note that says "send the report," that person has to guess: to whom? in what format? with what numbers? If instead the note says "send this week's sales report to maria@company.com in PDF, with the numbers from the 'Weekly Close' sheet, before 5 p.m.," there's nothing to guess — everything that person needs to know is in the note.

The agent is in exactly that position facing every tool you connect. It doesn't read the Gmail node's code or know how your database's API is built internally. The only thing it has to decide whether to use that tool, when to use it, and with what data to call it, is text: the node's name, the Description field, and each parameter's description. That text is the complete contract. If the contract is vague, the model guesses, just like the new person on the team — and guessing in a system that sends real emails or writes to a real database gets expensive.

A tool's contract in n8n has three pieces:

  1. Name — the node's name as it appears on the canvas. The agent uses it to refer to the tool internally, and it's the same value you're later going to see in the $tool.name expression when you build the human review, later in this lesson.
  2. Description — the free-text field that tells the model what the tool does and, just as important, when NOT to use it. It's the part of the contract that shapes the agent's behavior the most, because it decides at what point in the conversation the tool doesn't even come up for consideration.
  3. Parameters — every piece of data the tool needs to execute. On nodes connected to the Tools Agent, they get filled in with the $fromAI(key, description, type, defaultValue) function: key is a 1-to-64-character identifier (letters, numbers, underscore, or hyphen); description is the text that tells the model what to look for for that specific piece of data; type can be string, number, boolean, or json (defaults to string); defaultValue is optional. The model uses key and description together as a clue — for example, with a key of email, the model is going to look for an email address in the conversation's context, in other tools' data, or in the workflow's input. This function only works connected to a Tools Agent, not in Code tools or other sub-nodes.

Worked example

TuTienda has a Gmail node connected as a tool so the agent can answer support tickets. Here's what the badly written contract looks like:

# Gmail node (used as Tool) — Name: Send_Email
# Description: "Sends an email."

To:      {{ $fromAI("to") }}
Subject: {{ $fromAI("subject") }}
Message: {{ $fromAI("body") }}

The name doesn't say what it's for, the description is so generic that any imaginable email fits, and the three parameters have no description — the model is going to infer the recipient, the subject, and the message body from whatever it finds in the context, with no guidance about what it's allowed to say. Here's that same node with the contract rewritten:

# Gmail node (used as Tool) — Name: reply_to_support_ticket
# Description: Sends a reply email to the customer who opened the
# current support ticket. Use only to answer questions about order
# status, shipping times, and return policy. Do NOT use this tool
# to offer discounts, refunds, or any form of compensation — those
# require human approval through the request_refund_approval tool.

To: {{ $fromAI("customerEmail", "The email address of the customer who opened the current ticket. Must come from the ticket record, never invented.", "string") }}

Subject: {{ $fromAI("subject", "Short subject line that references the ticket ID, e.g. 'Re: Ticket #4521'", "string") }}

Message: {{ $fromAI("body", "Plain-text reply that answers only the customer's question about order status, shipping, or return policy. No discounts, no refunds, no promises of compensation.", "string") }}

What to expect. A customer writes in the chat: "order #4521 arrived damaged, can you give me a discount on my next purchase?"

With the bad contract, nothing stops the agent from interpreting "resolving the customer's question" as including a promised discount, and it can end up calling Send_Email offering 15% off the next purchase — nobody forbade it in writing. With the good contract, the agent reads in its own contract that reply_to_support_ticket explicitly must not be used for discounts, and instead of calling the tool to promise something, responds directly in the chat with no action executed against Gmail:

"I'm sorry about the trouble with order #4521. I can confirm the shipping status and the return policy, but a discount or compensation needs review from someone on the team — I'm going to escalate your case."

The difference between the two outcomes didn't come from a different model or a different system prompt — it came only from the tool's contract text. But notice the word "escalate" in that response: the agent saying it's going to escalate the case doesn't mean the discount is actually blocked. If at some other point in the conversation the customer pushes in a different way, nothing yet stops the model from changing its mind and calling the tool anyway. That takes us to this lesson's second problem.

Trust boundaries: what a description alone doesn't solve

Think of a new person on the team who can answer customer questions and check an order's status with nobody supervising them line by line — that's exactly why you hired them. But to authorize a refund or sign off on a big discount, they need a supervisor's sign-off, not because you don't trust their judgment in general, but because the cost of a mistake in that specific action is too high to leave to one person's judgment, human or not.

A trust boundary is exactly that point: an action whose cost of executing badly — because it's irreversible, because it moves money, because it commits the company in front of a customer, or because it deletes data — is high enough that it needs a person's explicit approval before the workflow continues. And here's the important part: that approval can't depend only on an instruction in the system prompt, like "never offer discounts without asking." Text in the prompt is a strong instruction the model follows almost always — but it's still text, competing with the rest of the conversation's context. Under an unusual phrasing, a long conversation, or a deliberate attempt to manipulate the agent (territory you're going to study in depth in the security module, later in this guide), that text can lose. For what genuinely can't fail, the barrier has to be in the workflow's structure, not just in the model's judgment.

n8n solves this with the human review for tools pattern, available directly from the AI Agent node's Tools connector:

  1. You click the AI Agent node's Tools connector to open the tools panel.
  2. There you find the Human review section and choose your preferred approval channel — Slack, Discord, Telegram, Microsoft Teams, Gmail, WhatsApp Business Cloud, Google Chat, Microsoft Outlook, or n8n's own Chat.
  3. You connect the tools that require approval to that human review step's tools connector — not directly to the agent.

When the agent tries to call a tool connected this way, the workflow stops and waits. Inside the review step you have the $tool variable available, with two properties: $tool.name (the name of the tool the agent is trying to call) and $tool.parameters (the parameters it's trying to call it with). If the person approves, the tool executes normally and the result goes back to the agent. If they deny it, the action gets cancelled, and the agent receives the rejection — that's why the system prompt also needs to tell it what to do with that refusal: inform the customer, suggest an alternative, or ask for more context.

Before building it, it's worth having an explicit criterion for what does get trusted to the agent with no supervision and what doesn't:

Type of actionExecutes on its own?Example at TuTienda
Query or read dataYesLook up an order's status, read a product's price on Sheets
Reply with already-validated informationYesConfirm the return policy
Low-impact reversible actionUsually yesMark a ticket as "under review"
Irreversible actionNo — requires approvalCancel an order, delete a row
Financial impactNo — requires approvalRefund, discount, price change
External communication that commits the companyNo — requires approvalPromise compensation, confirm a legal deadline

Worked example

TuTienda has an issue_refund tool — an HTTP Request Tool that calls the payments API — connected behind a Slack human review instead of going straight to the agent:

# Slack node (human review step, connected to the AI Agent's
# Tools connector — issue_refund connects to THIS node, not
# directly to the agent)

Message:
The agent wants to use {{ $tool.name }} with these parameters:
{{ JSON.stringify($tool.parameters, null, 2) }}

Ticket: {{ $json.ticketId }}

And a snippet added to the agent's system prompt, so it knows what to do if Slack denies the action:

# Snippet from the agent's system prompt
If a tool requires human approval and is denied, do not retry it.
Tell the customer their request needs additional review and that
someone from the team will follow up; leave the ticket marked as
"pending manual review."

What to expect. The agent decides to call issue_refund with { orderId: "4521", amount: 15000, reason: "product damaged in transit" }. The workflow stops right there — the HTTP Request Tool hasn't executed yet. In the approvals Slack channel this shows up: "The agent wants to use issue_refund with the following parameters: { orderId: '4521', amount: 15000, reason: 'product damaged in transit' }. Ticket: 4521", with buttons to approve or deny. If someone on the team approves, the HTTP Request Tool executes against the payments API and the result — refund confirmed — goes back to the agent, which relays it to the customer. If they deny it, the action gets cancelled, and the agent, following the system-prompt snippet above, tells the customer their case is in manual review, without pushing further or making up an alternative on its own.

The difference from the previous example is the one that matters: there, everything blocking the discount was a sentence in the tool's description. Here, even if the agent decided to call issue_refund without hesitating, the real execution against the payments API doesn't happen until a person approves it. The contract tells the agent what to do; the trust boundary guarantees what happens even if the agent gets it wrong.

Common mistakes

Confusing a system-prompt restriction with a structural barrier (conceptual). What happens: someone adds "never offer discounts without authorization" to the system prompt and considers the matter closed, without connecting any sensitive tool behind a human review. Why it happens: the model respects that instruction the vast majority of the time, so in normal testing everything works and it seems sufficient. How to spot it: test with a deliberately adversarial conversation — a pushy customer, a different phrasing of the same request, several turns of pressure — and check whether at some point the agent ends up calling the sensitive tool anyway. How to fix it: any irreversible action, one with financial impact, or one that commits the company in front of a customer goes behind this lesson's human review pattern, not just behind a sentence in the prompt; the prompt is still useful for the agent to know how to respond when approval is denied, but it doesn't replace the barrier.

Letting two tools with similar descriptions compete for the same user intent (conceptual). What happens: TuTienda has update_order (changes any field of an order) and update_order_status (changes only the status), with nearly identical descriptions — "Updates an order" and "Updates the status of an order" — and the agent starts calling the wrong one, or alternates between the two in similar conversations. Why it happens: the model chooses which tool to call by semantically comparing the user's request against each description's text; if two descriptions overlap, the probability of choosing splits between both instead of resolving cleanly. How to spot it: check the agent's execution logs for cases where the invoked tool doesn't match what the user asked for, or where the same request triggers different tools in separate runs. How to fix it: make the descriptions explicitly mutually exclusive — "Use this tool only to change the order status field (pending, shipped, delivered, canceled). To change any other field of the order, use update_order instead" — instead of leaving the difference implicit in the node's name.

Leaving $fromAI() with no description on a sensitive parameter (practical). What happens: a parameter like refundAmount gets defined only as {{ $fromAI("refundAmount") }}, with no second argument, and the model has to guess where to get that number from — sometimes it gets it right with a value the customer mentioned in the conversation, sometimes it makes up a reasonable but incorrect one. Why it happens: with no description, the only clue the model has is the key's name, which rarely is enough for a numeric value where a mistake costs real money. How to spot it: compare the JSON that actually reached the real node — not what the agent said it was going to do — against the data that did show up in the conversation; an amount that doesn't match anything the customer or the order mentioned is the signal. How to fix it: on every parameter that moves money, deletes data, or identifies a person, always add an explicit description and the correct type, and use defaultValue when there's a safe default value — for example, $fromAI("refundAmount", "The refund amount in the store's currency, must never exceed the order's original total", "number", 0).

Exercises

Exercise 1 — Rewrite the contract. TuTienda has a tool connected to Google Sheets with this contract: Name update_inventory, Description "Updates a product.", and a single parameter {{ $fromAI("value") }}. The tool should only be able to adjust a product's stock quantity after a confirmed return — never the price or the product's name. Rewrite the Name, the Description, and the $fromAI() parameter(s) following this lesson's pattern.

See solution
# Google Sheets node (used as Tool) — Name: adjust_stock_after_return

# Description: Adjusts the stock quantity of a product after a
# confirmed return. Use only to increase stock when a returned
# item has been verified as received. Do NOT use this tool to
# change price, product name, or any other column.

Row match: {{ $fromAI("productId", "The product ID (SKU) of the returned item, taken from the order record", "string") }}

Column to update: quantity_in_stock

New value: {{ $fromAI("newQuantity", "The updated stock quantity after adding back the returned item. Must be greater than or equal to the current quantity.", "number") }}

Why it works: the Name is no longer generic, the Description says what it does and explicitly forbids what it doesn't (price and name are ruled out), and the parameter has its own description that anchors the number to a specific case — confirmed return — instead of leaving "value" open to any interpretation.

Exercise 2 — Classify the actions. TuTienda's agent has these five actions available: (a) check an order's status, (b) cancel an order, (c) answer a frequently asked question about shipping, (d) apply a discount code to a purchase, (e) update the shipping address for an order that hasn't left the distribution center yet. For each one, decide whether the agent can execute it on its own or needs to go through human review, and justify your answer with this lesson's table's criterion (reversibility, financial impact, external communication).

See solution

(a) On its own — it's a read, it changes nothing. (b) Human review — it's irreversible once the order enters cancellation processing with the carrier. (c) On its own — it's already-validated information, not an action on a system. (d) Human review — it has direct financial impact. (e) Depends on status: if the order genuinely hasn't left the distribution center, it's a low-impact reversible action and can go on its own; if there's any doubt about whether it's already left, the cost of getting it wrong (the package ends up at the old address) pushes this action toward human review or, at least, toward an additional check before executing it.

Why it works: the criterion isn't "how technically complex is the action" — updating an address is as simple as checking a status — it's how much it costs to undo the error if the agent gets it wrong, and who pays that cost.

Exercise 3 — The refund parameter. Write the complete $fromAI() expression for the refundAmount parameter on this lesson's issue_refund tool. It should be numeric type, have a description that reduces the model's margin of error, and a reasonable defaultValue.

See solution
{{ $fromAI("refundAmount", "The refund amount in the store's currency (COP). Must never exceed the order's original total and must match an amount the customer or the order record actually mentions — never estimate or round up.", "number", 0) }}

Why it works: type: "number" keeps the value from arriving as text; the description sets an explicit ceiling (never exceed the order's total) and forbids making up the number; defaultValue: 0 is a reasonable safeguard if the model can't find a clear amount in the context, instead of letting it guess one.

Summary and next step

You now have the two pieces missing from the access you built in lesson 4: the tool contract — Name, Description, and each parameter with its own description via $fromAI() — which tells the agent what each tool does and when NOT to use it, and the trust boundary — the human review pattern on the AI Agent's Tools connector, with $tool.name and $tool.parameters available at the approval step — which guarantees that irreversible actions, ones with financial impact, or ones that commit the company don't depend solely on the model's judgment.

Before moving on you should be able to: write a tool's Description so it doesn't overlap with another similar tool; use $fromAI() with an explicit description for every sensitive parameter; classify an action as "the agent executes it on its own" or "requires human approval" using the reversibility-and-impact criterion, not intuition; and connect a tool behind the human review step instead of directly to the agent.

Lesson 6 takes this same contract criterion and pushes it one step further: instead of exposing an individual node as a tool, you're going to encapsulate a complete sequence of steps in a sub-workflow and expose that sub-workflow as a single tool for the agent. It's going to need exactly the same care you practiced here — an unambiguous name and description — except now behind that description there are several steps instead of one.

Resources