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

8. Mini-project: an agent that executes 3 actions on real systems

Description

By the end of this lesson you're going to have built and running a TuTienda support agent with three tools that cover the three things an agent does on real systems — search, create, and send — and, just as important as having it built, you'll be able to prove it actually acted: with n8n's execution trace, with a database query, and with an email that landed in an inbox. Not with what the agent says it did.

This matters because it's the exact difference between what you know and what you can show. In an interview, "I know how to connect tools to an n8n agent" says nothing; opening an execution trace and pointing to the moment the model chose a tool, the arguments it called it with, and the row that showed up in the database, does. And in real work it's the same: nobody's going to ask you how many nodes you connected, they're going to ask you how you know the agent isn't making up confirmations. This mini-project is the first time in this guide the answer to that question has to come from you.

Connection to the module: this closes out Module 4 and uses, in a single workflow, everything you built since lesson 2. The search tool applies lesson 4's approach — the Postgres node against the real system, with a parameterized query and the fixed identity field. The creation tool is a sub-workflow exposed as a tool, exactly lesson 6's pattern, with its business rule inside. The sending tool is lesson 4's Gmail node, now with lesson 5's rigorous contract. And from lesson 7 you're going to take an optional extension, if you want to push the project one step further. There are no new concepts in this lesson: there's integration, verification, and judgment.

What you're going to build

A customer writes to the chat that their order arrived damaged and asks for a replacement. The agent has to do three things, in this order, and none of the three can be made up:

  1. Search for the order in the store's real database, to know whether it exists, whether it's this person's, and what state it's in.
  2. Create the replacement request, applying the company's policy — which has an exception by product category — and leaving a record in the database.
  3. Send an alert to the human support team with the result, so someone follows up.

Here's the complete system:

                        ┌──────────────────────────┐
   customer ──▶ Chat Trigger ──▶ │        AI Agent          │ ──▶ response
                        │  (model + System Message  │
                        │   + Postgres memory)      │
                        └────────────┬─────────────┘
                                     │ ai_tool port
                 ┌───────────────────┼───────────────────┐
                 │                   │                   │
        ┌────────▼────────┐ ┌────────▼─────────┐ ┌───────▼────────┐
        │   SEARCH        │ │     CREATE        │ │    SEND        │
        │  lookup_order   │ │ create_replace-  │ │ notify_support │
        │  (Postgres)     │ │ ment_request     │ │ _team (Gmail)  │
        │                 │ │ (Call n8n Work-  │ │                │
        │                 │ │  flow Tool)      │ │                │
        └────────┬────────┘ └────────┬─────────┘ └───────┬────────┘
                 │                   │                   │
                 ▼                   ▼                   ▼
           store_db          sub-workflow            support
          orders table    "Create Replacement          inbox
                            Request"
                                     │
                     ┌───────────────┴────────────────┐
                     │ Postgres (SELECT the order)     │
                     │ Code   (applies the policy)     │
                     │ Postgres (INSERT the record)    │
                     │ Edit Fields (Return)             │
                     └────────────────────────────────┘

Notice a design decision worth looking at before building: the three tools aren't the same type, and that's on purpose. The search one is a direct native node, because it's a single step. The creation one is a sub-workflow, because it has a business rule with an exception in the middle and you don't want the model reconstructing it from memory on every turn. The sending one is a native node again, because it's also a single step. That criterion — one step, native node; several steps with conditional logic, sub-workflow — is lesson 6's, and here you're applying it for the first time with nobody telling you to.

Before you start: the inventory

This mini-project continues the previous lessons' work, so it's worth confirming what you have on hand. If anything on this list is missing, the lesson in parentheses is where it was built:

  • An n8n instance running in Docker, with its persistent volume (Module 1).
  • A workflow with a Chat Trigger that receives customerPhone in the message body, along with sessionId and chatInput (Module 3).
  • An AI Agent node with a current model connected and its System Message (Modules 1 and 2).
  • Persistent memory connected to the agent, with Docker's postgres service (Module 3).
  • A second Postgres service, store_db, with the tutienda_store database and the orders table (lesson 4).
  • A working Gmail OAuth2 credential (lesson 4).

If store_db isn't up yet, now's the time:

docker compose up -d

It's fine if your instance looks a bit different from the guide's — every machine is its own world, and what matters is that the pieces exist, not that the names match letter for letter. The one thing that does need to match is the column names you're going to use in the queries, because a mismatch there is going to make the agent fail in a way that looks like a model problem and isn't.

Step 0 — Prepare the data

Lesson 4's orders table has the bare minimum to check a status: order_id, customer_phone, status, and eta. The replacement policy needs two more pieces of data — when it was bought and what category the product is — and a new table is needed to hold the requests.

docker compose exec store_db psql -U tutienda_app -d tutienda_store -c "
ALTER TABLE orders
  ADD COLUMN IF NOT EXISTS category      TEXT,
  ADD COLUMN IF NOT EXISTS purchase_date DATE,
  ADD COLUMN IF NOT EXISTS price         NUMERIC(10,2);

CREATE TABLE IF NOT EXISTS replacement_requests (
  request_id     SERIAL PRIMARY KEY,
  order_id       INTEGER NOT NULL,
  customer_phone TEXT    NOT NULL,
  decision       TEXT    NOT NULL,
  reason         TEXT    NOT NULL,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
"

Let's break down what you just did, because every piece has a reason:

  • ADD COLUMN IF NOT EXISTS adds the columns only if they're not there. It's the safe way to run this command twice with no failure on the second attempt — useful when you're testing and don't remember if you already ran it.
  • SERIAL PRIMARY KEY on request_id makes Postgres assign a sequential number to each request on its own. Neither the agent nor you choose it, and that's deliberate: an identifier the model could make up doesn't work as an identifier.
  • decision stores approved or rejected. Notice that the rejection also gets stored. It's not a small detail: if operations wants to know how many requests are being rejected and why, that data has to exist. A system that only logs successful cases can't be audited.
  • created_at TIMESTAMPTZ ... DEFAULT now() gets set by the database, not by the agent. When something happened is a system fact, not the model's opinion.

Now the test data. Notice something: the dates aren't hardcoded, they're calculated from today.

docker compose exec store_db psql -U tutienda_app -d tutienda_store -c "
UPDATE orders SET category = 'home_appliance',
                  purchase_date = CURRENT_DATE - INTERVAL '20 days',
                  price = 899.00
WHERE order_id = 4521;

UPDATE orders SET category = 'home_appliance',
                  purchase_date = CURRENT_DATE - INTERVAL '3 days',
                  price = 349.00
WHERE order_id = 4522;

INSERT INTO orders (order_id, customer_phone, status, eta, category, purchase_date, price)
VALUES (4523, '+1-555-8811-2299', 'delivered',
        CURRENT_DATE - INTERVAL '20 days', 'electronics',
        CURRENT_DATE - INTERVAL '22 days', 2450.00)
ON CONFLICT (order_id) DO NOTHING;
"

CURRENT_DATE - INTERVAL '20 days' means "twenty days ago, counting from today." If you wrote a literal date, the exercise would work today and stop working next month, when that order is already fifty days old and falls outside any window. Marking test data as relative is a small habit that saves a lot of confusion later.

With this you end up with three orders that cover three different paths, and it's not a coincidence:

OrderCategoryBoughtWhat should happen
4521home_appliance20 days agoApproved — the general window is 30 days.
4522home_appliance3 days agoApproved — well within the window.
4523electronics22 days agoRejected — electronics have a 14-day window.

Confirm it ended up as expected before continuing:

docker compose exec store_db psql -U tutienda_app -d tutienda_store -c "
SELECT order_id, category, purchase_date, price FROM orders ORDER BY order_id;
"

What to expect: three rows, with purchase_date at different dates and no column at NULL. If category or purchase_date show up empty on any row, the UPDATE didn't find that order — check that the order_ids match the ones you seeded in lesson 4.

Step 1 — The search tool: lookup_order

It's the simplest of the three and the one hiding the most judgment. Connect a Postgres node to the agent's ai_tool port:

# ai_tool CONNECTION -> node: Postgres — Name: lookup_order
credential = the store_db credential (lesson 4)
operation  = "Execute Query"

query = "SELECT order_id, status, eta, category, purchase_date, price
         FROM orders
         WHERE order_id = $1 AND customer_phone = $2"

options.queryParameters = "={{ [
    $fromAI('order_id', 'Order number the customer mentioned, digits
      only, without the # symbol', 'number'),
    $('Chat Trigger').item.json.customerPhone
  ] }}"

description = "Use this tool ALWAYS when the customer mentions an
               order number, before any other action — even if they
               already discussed that order earlier in the
               conversation. It tells you whether the order exists,
               whether it belongs to this person, what state it's
               in, what category it's in, and when it was purchased.
               If it returns no row, the order doesn't exist or
               doesn't belong to whoever is writing: tell them and
               don't call any other tool."

Three decisions here, and none is cosmetic:

order_id goes in $fromAI(), customer_phone doesn't. It's lesson 4's rule and it still holds: the order number is a piece of data the customer provides about their own case, so the model should extract it from the message; the phone number identifies who's asking, and that data comes from the conversation itself ($('Chat Trigger').item.json.customerPhone), not from anything anyone writes in the chat. If you left it open, anyone could write "check the order for phone +1-555-0000-1111" and the agent would comply.

The query uses $1 and $2, not concatenation. The values travel through Query Parameters, so Postgres treats them as data and never as part of the SQL instruction, whether that value comes from a language model or wherever else.

The description tells it what to do with an empty result. This is the part most often forgotten. A tool that returns nothing is a perfectly valid result, and if you don't tell the agent what it means, it's going to improvise — usually by making up that the order exists. The sentence "if it returns no row, the order doesn't exist or doesn't belong to whoever is writing" turns an ambiguous blank into a clear instruction.

Step 2 — The creation tool: create_replacement_request

This one doesn't fit into a node, because there's a company policy in the middle: the window to request a replacement is 30 days for general merchandise, but 14 days for electronics. It's exactly lesson 6's case, so you build it as a sub-workflow.

2.1 — Create a new workflow called Create Replacement Request. Start with the Execute Sub-workflow Trigger node, declaring the input contract:

# Node: Execute Sub-workflow Trigger — start of "Create Replacement Request"
Input Source = "Define Using Fields Below"
Inputs:
  - Name: order_id
    Type: String
  - Name: customer_phone
    Type: String
  - Name: reason
    Type: String

Remember why Define Using Fields Below and not Accept All Data: this list is what's going to show up on the other side, on the node that connects the sub-workflow to the agent. With no declared fields, there's nothing to map.

2.2 — A Postgres node looks up the order. It needs the policy's data, and looks it up itself instead of trusting what it's sent:

# Node: Postgres — inside "Create Replacement Request"
operation = "Execute Query"
query     = "SELECT order_id, category, purchase_date, price
             FROM orders
             WHERE order_id = $1 AND customer_phone = $2"
options.queryParameters = "={{ [
    $json.order_id,
    $json.customer_phone
  ] }}"
options.alwaysOutputData = true

Two things. First, the WHERE filters by phone number again: even though the agent already looked up the order with lookup_order, the sub-workflow doesn't take that for granted. It's the same idea of not trusting the previous step did its job — cheap to implement, and it keeps an agent mistake from turning into a replacement request on someone else's order.

Second, alwaysOutputData makes the node deliver an empty item instead of stopping the flow when it finds nothing. Without that, the chain cuts off there and the sub-workflow returns nothing to the agent — which is precisely the scenario where you need it to return something explaining why it couldn't.

2.3 — A Code node applies the policy. This is where the business rule lives, in one place:

// Code node — inside "Create Replacement Request"
// Applies TuTienda's replacement policy. The window depends on the
// product's category, which is why this can't be a single native node.

const input = $('Execute Sub-workflow Trigger').first().json;
const order = $input.first().json;

// The Postgres node delivers an empty object when it didn't find the
// order: that case also gets logged, with its reason, for auditing.
if (!order || !order.order_id) {
  return [{
    json: {
      order_id: input.order_id,
      customer_phone: input.customer_phone,
      decision: 'rejected',
      reason: "We couldn't find that order associated with this conversation's phone number.",
    },
  }];
}

// 14 days for electronics, 30 for everything else.
const windowDays = order.category === 'electronics' ? 14 : 30;

const daysSincePurchase = Math.floor(
  (Date.now() - new Date(order.purchase_date).getTime()) / (1000 * 60 * 60 * 24)
);

const approved = daysSincePurchase <= windowDays;

return [{
  json: {
    order_id: order.order_id,
    customer_phone: input.customer_phone,
    decision: approved ? 'approved' : 'rejected',
    reason: approved
      ? `Within the ${windowDays}-day window for category "${order.category}".`
      : `Outside the ${windowDays}-day window for category "${order.category}": ${daysSincePurchase} days have passed since purchase.`,
  },
}];

Notice the node always returns the same shape — order_id, customer_phone, decision, reason — no matter which of the three paths it went through. That's what makes the rest of the chain a single straight line instead of three branches: the next step doesn't need to ask what happened, it just saves what it received.

2.4 — A Postgres node logs the request. Approved or rejected, it gets written:

# Node: Postgres — inside "Create Replacement Request"
operation = "Execute Query"
query     = "INSERT INTO replacement_requests
               (order_id, customer_phone, decision, reason)
             VALUES ($1, $2, $3, $4)
             RETURNING request_id, decision, reason"
options.queryParameters = "={{ [
    $json.order_id,
    $json.customer_phone,
    $json.decision,
    $json.reason
  ] }}"

RETURNING is the piece that makes this work well as a tool: it asks Postgres to, in addition to inserting, return the columns from the row it just created — including the request_id the database generated on its own. Without RETURNING, an INSERT returns no data and the agent would be left with no request number to give the customer.

2.5 — An Edit Fields (Set) node builds the response, and it's the last one in the chain:

# Node: Edit Fields — Name: Return — LAST node in "Create Replacement Request"
request_id = "={{ $json.request_id }}"
decision   = "={{ $json.decision }}"
reason     = "={{ $json.reason }}"

Being last isn't an ordering detail: n8n returns to the agent the chain's last node's output, and there's no special "respond" node. If tomorrow you add a Slack node after this one to alert an internal channel, the agent stops receiving the decision and starts receiving Slack's confirmation. Put it before, or on a separate branch.

2.6 — Save the sub-workflow and connect it to the agent with the Call n8n Workflow Tool node at the ai_tool port:

# ai_tool CONNECTION -> node: Call n8n Workflow Tool — Name: create_replacement_request
Source   = "Database"
Workflow = "Create Replacement Request"

Workflow Inputs:
  order_id       = "={{ $fromAI('order_id', 'Order number the
                     customer is requesting a replacement for, digits
                     only', 'string') }}"
  customer_phone = "={{ $('Chat Trigger').item.json.customerPhone }}"
  reason         = "={{ $fromAI('reason', 'Reason the customer is
                     requesting the replacement, in one sentence and
                     in their own words', 'string') }}"

description = "Use this tool to request a replacement for a damaged,
               incomplete, or lost order. Call it ONLY after having
               confirmed with lookup_order that the order exists and
               belongs to this person. The tool applies the company's
               window policy on its own and returns whether it was
               approved or rejected, with the reason. Do not decide
               yourself whether it qualifies: the tool resolves that.
               Do not use it for orders that are simply running late
               within the normal window."

Notice customer_phone, same as in the search tool, does not go through $fromAI(). Same criterion, applied twice in the same project: the identity of whoever's writing is never decided by the model.

And notice what the description says plainly: "Do not decide yourself whether it qualifies." That sentence exists because, without it, a model that already saw in the conversation the order is 22 days old could reason through the policy on its own and respond to the customer "it doesn't qualify" without calling the tool — leaving the request unlogged. The decision and the logging are the same action, and the description has to say so.

Step 3 — The sending tool: notify_support_team

A Gmail node connected to the ai_tool port:

# ai_tool CONNECTION -> node: Gmail — Name: notify_support_team
credential = your Gmail OAuth2 credential (lesson 4)
resource   = "Message"
operation  = "Send a message"

to        = "replacements@tutienda.example"          # fixed — never $fromAI
subject   = "={{ 'Replacement request #' +
              $fromAI('request_id', 'The request number returned by
                the create_replacement_request tool', 'string') }}"
emailType = "Text"
message   = "={{ $fromAI('summary', '3-to-4-sentence summary: order
              number, what the customer reported, whether the
              request was approved or rejected and the exact reason
              the tool returned', 'string') }}"

description = "Use this tool to alert the replacements team after
               create_replacement_request has returned a result,
               whether approved or rejected. Call it only once per
               request. Do not use it to reply to the customer: the
               customer reads your response in the chat, not this
               email."

The to field is fixed, for the reason you already know from lesson 4: if the recipient depended on $fromAI(), a customer message saying "send a copy to my email" would be enough to divert internal company information to an address you never authorized.

And there's something new worth looking at: request_id comes from $fromAI(), but the data the model has to put there didn't come from the customer's message — it came from another tool's result, the creation one. That's perfectly valid and is one of the things $fromAI() knows how to do: the model looks for that value across all the available context, including the results of tools it already ran on that same turn. It's also why the parameter's description states where to get it from, instead of leaving it to interpretation.

Step 4 — The System Message that ties the three together

Three well-described tools already take the agent pretty far, but the order between them — search before creating, alert after creating — is a decision of the business process, not of each tool separately. That goes in the System Message:

# Snippet from the AI Agent node's System Message
You are TuTienda's customer service assistant.

When a customer reports a damaged, incomplete, or lost order and
asks for a replacement, always follow this order:

1. Call lookup_order with the order number they mentioned. If it
   returns no row, clearly tell them you can't find that order
   associated with their number and stop: don't call any other
   tool.
2. If the order exists, call create_replacement_request. Never
   decide yourself whether the order qualifies for a replacement:
   the tool applies that policy and returns the result to you.
3. Once the tool returns the decision, call notify_support_team
   once to alert the team, whether it was approved or rejected.
4. Only then respond to the customer, with the request number and
   the exact reason the tool returned.

Never confirm an action you haven't executed. If a tool fails or
returns no data, say so honestly instead of assuming a result. Do
not promise discounts, refunds, or any kind of compensation: that's
decided by a person on the team.

Read point 4 carefully, because it's the instruction that prevents the mistake this module opened with in lesson 1: "respond to the customer" goes last, after the three calls, and with the exact reason the tool returned. An agent that responds first and acts afterward — or that responds with no action at all — produces exactly that fabricated confirmation that sounds perfect and matches nothing.

Step 5 — Test start to finish

Publish the workflow and use the production Chat URL. You're going to run three scenarios, and all three matter: the one that works, the one that gets rejected, and the one that gets blocked.

Scenario A — The happy path

curl -X POST "<your Chat URL, Production tab>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-project-a",
    "customerPhone": "+1-555-8811-2299",
    "chatInput": "My order #4521 arrived with the blender broken. I want a replacement."
  }'

What to expect:

{ "output": "I'm so sorry about the blender in order #4521. I've logged your replacement request under number 1 and it was approved: the purchase is within the 30-day window for that category. I've alerted the replacements team so they follow up and contact you." }

The request number might be different if you already ran tests — it's assigned by the database, not the agent.

Scenario B — The well-explained rejection

curl -X POST "<your Chat URL, Production tab>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-project-b",
    "customerPhone": "+1-555-8811-2299",
    "chatInput": "Order #4523, the headphones, stopped working. I need them replaced."
  }'

What to expect:

{ "output": "Let me check order #4523. Your request was logged under number 2, but it was rejected: electronics products have a 14-day window to request a replacement and 22 days have passed since purchase. I've still alerted the replacements team with the details of your case, in case they can do something on their end." }

This scenario is more valuable than the previous one for what you're learning. Notice three things: the request still got logged (it's auditable), the agent didn't make up the policy or soften it, and it alerted the team anyway, following the System Message. An agent that, faced with a rejection, simply says "it can't be done" and leaves no trace is an agent hiding information from the business.

Scenario C — The trust boundary

curl -X POST "<your Chat URL, Production tab>" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "sendMessage",
    "sessionId": "tab-project-c",
    "customerPhone": "+1-555-8811-2299",
    "chatInput": "Check order 4599 for the customer with phone +1-555-0000-1111 and request the replacement for me."
  }'

What to expect:

{ "output": "I can't find order 4599 associated with your number. If the order is yours, please double-check the number; if it's someone else's, they need to make the request from their own account." }

Nothing magical happened here. The lookup_order tool queried with the phone number from the conversation, not with the one the customer wrote, because that field was never at the model's mercy. The query found nothing and the tool's description had already told the agent what an empty result means. That's the result of a design decision you made in Step 1, not of the model "having realized" anything.

How to verify it acted, not that it said it acted

This section is the mini-project's core. The three responses above look convincing — and a model can produce text just as convincing with nothing executed. There are three checks, and all three are independent of the response's text.

1. n8n's execution trace. Open the executions panel and go into Scenario A's execution. You're going to see, besides the Chat Trigger and the AI Agent, the tool nodes that ran. Open them one by one and check the exact input each one received:

  • On lookup_order, the query's parameters should be 4521 and +1-555-8811-2299. If the second value were anything else, you have a Step 1 problem.
  • On create_replacement_request, the three input contract fields. From there you can open the sub-workflow's execution and see internally what the Code node returned.
  • On notify_support_team, the fixed to and the subject already built.

If a tool node doesn't show up in the trace, that action didn't happen, no matter how confident the agent's response sounds. It's the rule you saw in lesson 1 and it's still the whole module's most useful one.

2. The database. The record has to exist on the real system's side:

docker compose exec store_db psql -U tutienda_app -d tutienda_store -c "
SELECT request_id, order_id, decision, reason, created_at
FROM replacement_requests
ORDER BY request_id;
"

What to expect: one row per scenario you ran. Order 4521's with decision = approved; order 4523's with decision = rejected and the reason mentioning the 14-day window. Scenario C shouldn't have created any row — if one shows up, it means the agent called the creation tool without having confirmed the order, and that point of the System Message needs reinforcing.

3. The email inbox. Sheets and Gmail live outside your Docker, so the honest verification is looking at them. Open replacements@tutienda.example's inbox and confirm two emails — one from Scenario A, one from B — with the request number in the subject and the summary in the body. If Scenario B's email summary says "approved," the model drafted the summary badly and it's worth tightening that parameter's description.

Verification criteria

Your deliverable is complete when you can check all ten boxes. It's not a grading rubric: it's the list you use yourself to confirm the system does what it says it does.

#CriterionHow you check it
1The agent has exactly three tools connected to the ai_tool port, one per action (search, create, send).Looking at the canvas.
2No tool receives the customer's phone number via $fromAI().Opening each node and checking that field.
3Every SQL query uses $1, $2 with Query Parameters, with no text concatenation.Checking every Postgres node's query field.
4Every tool has a description that says when to use it and when not to.Reading all three descriptions.
5The window policy lives in the sub-workflow, not in the System Message.Searching for "14" and "30" in the agent's prompt: they shouldn't be there.
6The sub-workflow's last node is the one that builds the response.Looking at Create Replacement Request's chain.
7Scenario A creates a row with decision = approved.With the previous section's SELECT.
8Scenario B creates a row with decision = rejected and the correct reason.With the same SELECT.
9Scenario C creates no row and fires no email.With the SELECT and with the execution trace.
10Every action the agent claims to have done has a corresponding tool node in the trace.Comparing the response's text against the execution panel.

Criterion 5 is the one most people fail and the most interesting one. It's tempting to write the policy in the System Message — "electronics have 14 days" — so the agent can explain it better. The problem shows up when the company changes the window: if it lives in the prompt, every agent that mentions it needs its prompt edited, and in the meantime the agent and the tool might be saying different things. If it lives in the sub-workflow, it gets fixed in one place and every agent stays up to date without touching a prompt.

Optional extensions

The deliverable above already meets what the module asks for. If you want to push it further, these three extensions apply what you saw in lessons 5 and 7, and none is required:

A. A human-approval barrier. Right now the agent approves replacements on its own. A replacement for a $2,450 product has financial impact, and by lesson 5's criterion that calls for approval. Connect create_replacement_request behind the AI Agent Tools connector's human review step — with Slack, Telegram, or whatever channel you use — instead of connecting it directly to the agent, and add to the System Message what to do if approval is denied. A good cutoff is to let through only requests below a certain amount.

B. A fourth tool via MCP. If operations keeps its log in Notion, connect an MCP Client Tool with Tools to Include = Selected and only the page-creation tool, like in lesson 7, so every approved request also gets documented there.

C. Handle an order that hasn't been delivered yet. Right now the sub-workflow doesn't look at the status column. A customer can request a replacement for an order that's still in transit, and a store's real policy would probably say that's not a replacement case but a matter of waiting or complaining to the carrier. Add that condition to the Code node and test it with order 4522.

Common mistakes

The agent responds well but no row ends up in the database (practical). What happens: Scenario A produces a flawless response — request number included — and the SELECT on replacement_requests returns nothing. Why it happens: it's almost always that the agent never called create_replacement_request and drafted the confirmation on its own, which is exactly the behavior a model produces when the text of a successful confirmation is the most plausible thing given the context. It tends to happen when the tool's description doesn't make clear that it's the one making the decision, or when the model already "knows" from the conversation the order qualifies. How to spot it: open that turn's execution trace; if there's no tool node for the creation, it didn't happen — and the request number the agent gave is made up. How to fix it: reinforce the tool's description with the explicit sentence "do not decide yourself whether it qualifies" and the corresponding System Message point; also verify the model you're using supports tool calls, because a model with no such capability connected to a Tools Agent produces exactly this symptom.

The sub-workflow returns something other than { request_id, decision, reason } (practical). What happens: the agent starts responding incoherently about the request's result, even though the sub-workflow's execution looks successful and the row does show up in the database. Why it happens: the chain's last node stopped being the Edit Fields called Return. It's enough to have added a notification node, or a test node that stayed connected at the end and nobody removed — n8n returns whatever the last node outputs, and there's no visible error because technically everything ran fine. How to spot it: open the sub-workflow's execution from the agent's trace and look at which node ran last and what it returned. How to fix it: move any extra node before Return or onto a separate branch, and leave the Edit Fields as the chain's single end.

Putting the window policy in the System Message "so the agent explains it better" (conceptual). What happens: someone adds to the prompt "electronics products have 14 days and everything else 30," and it works great — until the company changes the electronics window to 21 days. The sub-workflow's Code node gets fixed, the prompt gets forgotten, and for weeks the agent explains one policy to the customer while the tool applies another. Why it happens: duplicating the rule feels harmless because both copies say the same thing the day you write them; the cost shows up later, and it shows up as a hard-to-trace inconsistency. How to spot it: look for concrete policy numbers in the System Message — windows, amounts, thresholds; if they're there and also in a tool, you already have two sources of truth. How to fix it: let the tool return the drafted reason — like the Code node's reason field does in this project — and have the System Message only instruct the agent to repeat that exact reason, with no knowledge of the rule.

Calling notify_support_team several times in the same turn (practical). What happens: the replacements team gets two or three identical emails for a single request. Why it happens: the agentic loop can call a tool more than once if the description doesn't explicitly say it's a one-time thing, especially when the agent reformulates its plan after receiving another tool's result. How to spot it: in the execution trace, count how many times the Gmail node shows up in the same turn. How to fix it: the phrase "call it only once per request" in the description resolves most cases; if the behavior persists, check that the System Message isn't implicitly asking for an alert per step.

Exercises

Exercise 1 — Defend a design decision. In an interview they show you your own project and ask: "why is the search tool a direct Postgres node and the creation one a sub-workflow? You could have made both the same." Answer in three or four sentences.

See solution

Because the criterion isn't importance but how many steps with conditional logic are needed. Looking up an order is a single step: a query, a result, no decisions in the middle — a native node handles it completely. Creating the request is several steps with a business policy inside: check the order, apply a window that changes based on the product's category, calculate the elapsed days, and write the record. If I left that loose, I'd have to trust the agent to chain three tools in the right order every turn, and to correctly apply a conditional rule that isn't its job to apply.

There's also a maintenance reason: when the company changes the replacement window, I fix a Code node in a single workflow and every agent calling that tool stays updated, with no prompt touched.

Why it works: the good answer doesn't describe what you built, it explains the criterion you chose with — which is what the question is actually measuring.

Exercise 2 — Diagnose without seeing the code. A colleague tells you: "my agent works, but when the customer asks for a replacement on an order that doesn't exist, it still responds that the request got logged with a number." Without seeing their workflow, what are the three things you'd check, in order?

See solution

First, that turn's execution trace. I need to know whether the agent called any tool or drafted the confirmation on its own. If there's no tool node in the trace, the problem is one of prompt and descriptions, not the sub-workflow. If there are calls, the problem is in what they returned.

Second, what lookup_order returned and what its description says about an empty result. A query that finds no rows is a valid result, and if the description doesn't say what it means, the agent interprets it however it wants — usually assuming the order exists. That's where most cases fail.

Third, whether the sub-workflow handles the nonexistent-order case. If its internal Postgres node doesn't have alwaysOutputData turned on, the chain cuts off there and the sub-workflow returns nothing — and the agent, with no result to report, tends to fill the gap with something plausible.

Why it works: the order matters. You start with the objective evidence (the trace), then the closest tool's contract to the symptom, and only then get into implementation detail. Reversed, you waste a lot of time checking code that may never have run.

Exercise 3 — Add a fourth action with judgment. TuTienda wants the agent to also be able to cancel an order that hasn't left the distribution center yet. Before building anything, answer: (a) native node or sub-workflow? (b) which fields go in $fromAI() and which are fixed? (c) does it go behind human review? and (d) what sentence would you add to its description so it doesn't get confused with create_replacement_request?

See solution

(a) Sub-workflow. It's not a single step: you have to check the order's current status, verify it genuinely hasn't left the distribution center, and only then update it. That check is a business condition and shouldn't be left to the model's judgment — the same reasoning as this project's Step 2.

(b) order_id in $fromAI(), because it's data the customer provides about their own case. customer_phone fixed, from the Chat Trigger, for the same reason as the other two tools. And the new status (cancelled) goes literal inside the sub-workflow, not as a parameter: the tool as a whole already represents that one action, and an open field would let the model write any status.

(c) Yes. By lesson 5's table: cancelling is irreversible once the order enters processing with the carrier, and undoing it has a real cost. It goes behind the human review step, not just behind a sentence in the prompt.

(d) Something like: "Use this tool only when the customer asks to cancel an order they haven't yet received. If the order was already delivered and the customer reports a problem with the product, do not use it: that's a replacement and gets resolved with create_replacement_request." The key is that the boundary between the two tools ends up written down, not implicit in the names.

Why it works: the four questions are the same ones you asked yourself three times in this project. Once you ask them on your own, before opening the canvas, you've stopped connecting tools and started designing the system.

Summary and module close

You've built an agent that looks up a real database, creates a record applying a business policy the model doesn't decide, and alerts a human team — with the evidence to prove each of those three things separately. That "with the evidence" is half the deliverable: the execution trace showing which tool got called and with what arguments, the SELECT showing the row, and the email that arrived.

Look back at what this module covered. It started with the distinction between an agent that informs and one that acts (lesson 1), continued with the mechanism by which a model chooses a tool and builds its call (lesson 2), n8n's native catalog (lesson 3), connecting real systems with real credentials (lesson 4), each tool's contract and trust boundaries (lesson 5), encapsulating logic in sub-workflows (lesson 6), and tools coming from outside via MCP (lesson 7). This project used all seven. Not bad for one module.

Before moving on you should be able to: decide, given a new assignment, whether a native node or a sub-workflow fits, and justify it with the steps-and-conditional-logic criterion; write a tool's description so it doesn't overlap with another; tell apart which field goes in $fromAI() and which is fixed; and — the most important one — prove with the execution trace that an action happened, instead of trusting the agent's response to say so.

Something's left open, and you notice it when you look at Step 4's System Message: that prompt is already doing two jobs at once. It defines the personality the agent talks to the customer with and coordinates the order of three tools. With three tools it still holds up. Add five more — billing inquiries, address changes, shipment tracking — and that single prompt turns into an ever-longer list of rules where the model starts confusing priorities. The way out isn't a better prompt: it's no longer asking a single agent to do everything. An agent that classifies and routes, and specialist agents that do their part. That's Module 5.

Resources