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

6. Sub-workflows as tools: encapsulating reusable logic

Description

By the end of this lesson you'll be able to take a multi-step process with business logic in the middle — querying a database, applying a conditional rule, calculating a result — and expose it as a single tool the agent can call, with no need for the agent to orchestrate those steps turn by turn or know how they work internally.

This matters for a concrete reason: in the previous lesson you defined a tool's contract — what goes in, what comes out, what doesn't get trusted to the agent. But a contract only works if something fulfills it, and not every business rule fits into a native Gmail, Sheets, or HTTP node like the ones you connected in lesson 4. When a real company's support team asks you for an agent that decides whether an order qualifies for a refund, that decision is almost never a single step — you have to check the order, apply a policy with exceptions by category, calculate an amount. If you describe all of that in the agent's prompt and expect it to chain three native tools in the right order every time, sooner or later it skips a step or runs them out of order. Encapsulating that logic in a sub-workflow — and exposing the complete sub-workflow as a single tool — solves that: the agent sees a simple interface, and the complexity lives in one place you can test, fix, and reuse without touching the agent.

Connection to the module: lesson 5 taught you to describe a tool well and its trust boundaries. This lesson solves the other half of that contract: what you do when the implementation doesn't fit into a native node. You still won't see tools that live outside your n8n instance yet — that, and your own instance's MCP server, is the next lesson's full topic.

When a tool needs to be a team, not a single person

Think about the difference between asking the receptionist something and sending it to the right department. If you ask reception "where's the bathroom?", they answer it themselves — it's a single action, they don't need anyone else's help. But if you ask "can I return this blender I bought three weeks ago?", reception doesn't decide that alone: they check the order in the system, apply the return policy — which has exceptions depending on what you bought — calculate how much refund you're owed, and only then give you an answer. You, as the customer, don't see those three internal steps; you just see that you asked something and got a decision back.

The native tools you connected in lesson 4 — a Gmail node, a Sheets node, an HTTP call — are like the bathroom question: one action, one node, resolved instantly. But when the agent's answer depends on chaining several steps with conditional logic in the middle, asking the agent to orchestrate that turn by turn — call the tool to check the order, then decide by what criterion, then call another tool to calculate — is exactly what lesson 5 taught you not to trust it with: business decisions with rules that you, not the model, should fix.

n8n's solution is literal: you turn those steps into a separate workflow — a sub-workflow — and expose that complete workflow as if it were a single tool. Two pieces make this work:

  1. The Execute Sub-workflow Trigger node, placed as the sub-workflow's entry point, where you declare the input schema — the fields the tool accepts, each with a name and type. This is the half of lesson 5's contract that lives on the implementation side: what data the sub-workflow needs to do its job.
  2. The sub-workflow's last node, whose output is literally what gets returned to whoever called it. There's no special "respond" node — n8n takes the output of whatever node ends up last in the chain, and that's the response. If you add a node afterward by mistake, the response changes without you having asked for it.

On the other side — in the workflow where your agent lives — you connect a node called Call n8n Workflow Tool to the AI Agent's tools port. There you choose which saved workflow to call, write the description that tells the agent when to use this tool — the same principle from lesson 5, applied to a sub-workflow instead of a native node — and map each field from the input schema.

Worked example

TuTienda — the online store you've been working the support agent case with — needs its agent to decide whether an order qualifies for a refund. The real policy has an exception that makes this not a single step: the return window is 30 days for general merchandise, but only 14 days for electronics.

Step 1 — You build the Check Refund Eligibility sub-workflow. Start with an Execute Sub-workflow Trigger where you declare the single piece of data this process needs from the outside:

# Node: Execute Sub-workflow Trigger — start of "Check Refund Eligibility"
Input Source = "Define Using Fields Below"
Inputs:
  - Name: order_id
    Type: String

With Define Using Fields Below — instead of Accept All Data — every field ends up declared with a name and a type, and that's exactly the list that's going to show up on the other side, on the Call n8n Workflow Tool node, when you select this sub-workflow. Leaving the trigger on Accept All Data works for a quick test, but it doesn't publish any mappable field — it's the difference between an explicit contract and "send me whatever."

Step 2 — A Postgres node queries the orders table filtering by order_id and returns the order's row: purchase date, product category, and price.

Step 3 — A Code node applies the business rule. This is the conditional logic you're not going to ask the agent to reproduce from memory on every turn:

// Code node — inside "Check Refund Eligibility"
// Applies TuTienda's return policy based on product category
const order = $input.first().json;

const windowDays = order.category === 'electronics' ? 14 : 30;
const daysSincePurchase = Math.floor(
  (Date.now() - new Date(order.purchase_date).getTime()) / (1000 * 60 * 60 * 24)
);

const eligible = daysSincePurchase <= windowDays;

return [{
  json: {
    eligible,
    refund_amount: eligible ? order.price : 0,
    reason: eligible
      ? `Within the ${windowDays}-day window for category "${order.category}".`
      : `Outside the ${windowDays}-day window — ${daysSincePurchase} days have passed since purchase.`,
  },
}];

Step 4 — An Edit Fields (Set) node, the last one in the chain, shapes the final form that's going to be returned: { eligible, refund_amount, reason }. It being the last node isn't cosmetic — it's literally what defines what whoever called this sub-workflow receives.

Step 5 — In the agent's workflow, you add Call n8n Workflow Tool connected to the AI Agent's tools port:

# Node: Call n8n Workflow Tool — connected to the AI Agent
Description = "Use this tool to determine whether an order is eligible
               for a refund and for how much. Requires the order ID
               the customer mentions."
Source = "Database"
Workflow = "Check Refund Eligibility"

Workflow Inputs:
  order_id = {{ $fromAI('order_id', 'The order ID the customer
               mentions, for example 4521', 'string') }}

$fromAI(key, description, type, defaultValue) is the function that lets the model, not you, decide what value goes into that field on every call: key is the identifier the model is going to associate with the data, description is the clue for what to look for in the customer's message, and type forces the value to arrive as a string. It's the same mechanism you already saw with native tools in lesson 3 — here you're using it to feed a sub-workflow's input schema instead of a standalone node's parameter.

What to expect. A customer writes in the chat: "Can I get a refund for order #4521? I bought it 20 days ago, it's a blender." The agent recognizes it needs to check eligibility, extracts order_id = "4521" with $fromAI(), and calls Check Refund Eligibility. The sub-workflow queries the order — category home_appliance, bought 20 days ago — and the Code node calculates: 30-day window (it's not electronics), 20 days elapsed, eligible. The last node returns:

{
  "eligible": true,
  "refund_amount": 899,
  "reason": "Within the 30-day window for category \"home_appliance\"."
}

That JSON is the only thing the agent gets back — it doesn't see the SQL query or the calculation — and with that it responds: "Yes, your order #4521 qualifies for a full refund of $899, because you bought it 20 days ago and the blender has a 30-day return window."

Reuse across agents, and where this lesson ends

The reason it's worth encapsulating this — and not just leaving it repeated inside a single agent's prompt — is that the same Check Refund Eligibility sub-workflow can be called by the WhatsApp support agent, an internal Slack agent for the finance team, and anything else TuTienda builds later. If the return policy changes — say, the electronics window goes from 14 to 21 days — you fix the Code node once, in one place, and every agent calling that tool ends up updated without you editing a single prompt. That's the real win over copying the same conditional logic into each agent separately.

n8n also has a shortcut for building these sub-workflows from something you already put together: select the nodes on the canvas, right-click, Convert to sub-workflow (available since version 1.97.0). n8n automatically builds the Execute Sub-workflow Trigger and an Edit Fields node at the end, labeled Return. What it doesn't do for you is set the types on each input and output field — that, as you saw in Step 1, is still your decision.

One boundary worth flagging now: the sub-workflow you expose as a tool can have all the deterministic logic you want — queries, conditionals, calculations — but putting another AI Agent node inside it stops being "encapsulating a business rule" — it's starting to build a system where one agent delegates work to another agent. That idea has its own space later on the path; here, the tool you built is a deterministic, predictable box, and that predictability is exactly the point.

Common mistakes

Leaving the Execute Sub-workflow Trigger on Accept All Data and thinking the input schema is "just documentation" (conceptual). What happens: someone builds the sub-workflow, doesn't define fields on the trigger because "it's already clear what it needs" just by reading the nodes inside, and when they go to connect Call n8n Workflow Tool no mappable field shows up — there's nowhere to put $fromAI('order_id', ...). Why it happens: it's easy to think of the input schema as an annotation for humans, when it's actually the source n8n reads to know which fields to show on the agent's side. With no declared schema, there's no contract the agent can fulfill — exactly what lesson 5 defined as a badly described tool. How to spot it: if selecting the sub-workflow in Call n8n Workflow Tool shows an empty Workflow Inputs section, the trigger is still on Accept All Data. How to fix it: change Input Source to Define Using Fields Below and declare each field with its name and type.

Adding a node after the one that actually computes the response (practical). What happens: the Check Refund Eligibility sub-workflow ends with the Edit Fields node that builds { eligible, refund_amount, reason }, but someone adds a Slack node afterward that notifies the #refunds-log channel — and now that's the chain's last node. The agent stops receiving the eligibility JSON and instead gets whatever the Slack node returns — confirmation that the message was sent — with no eligible or refund_amount field to interpret. Why it happens: n8n has no special "respond" node — it returns whatever the chain's last node outputs, whatever that is, and adding an extra step at the end silently changes what gets returned. How to spot it: the agent starts responding incoherently or admits it doesn't have the information, even though the sub-workflow "works" if you run it manually and look at the full log. How to fix it: put the notification node on a separate branch — not in the main chain that ends in the response — or move it before the final Edit Fields.

Testing the sub-workflow by running it manually with test data, and assuming that tests what the agent is going to send it (practical). What happens: someone runs Check Refund Eligibility by hand with order_id: "4521" typed directly into the test node, everything works, and they call the job done. But when the agent calls it in production, the customer wrote "order #4521" and $fromAI() extracted "#4521" with the symbol included — the Postgres query finds no row with that value. Why it happens: testing the sub-workflow in isolation validates the internal logic, not the real data a language model extracts from an ambiguous customer message. How to spot it: check the agent's real execution in the executions panel — not the manual test — and compare the exact value $fromAI() sent against what the Postgres node expected. How to fix it: add explicit normalization at the start of the sub-workflow — for example, a Code node that cleans the value with order_id.replace('#', '') — instead of trusting the model to always extract the exact format.

Exercises

Exercise 1 — Diagnose a broken response. The Check Refund Eligibility sub-workflow ends like this: Postgres → Code (calculates eligibility) → Edit Fields (builds the response) → Slack (notifies the internal channel). The agent starts responding to customers with nonsensical messages about refunds, even though every execution in n8n's panel looks "successful." What's the agent actually receiving, and how do you fix it?

See solution

The agent receives the Slack node's output — typically something like confirmation the message was sent to the channel, with fields like the message ID or the channel — not the { eligible, refund_amount, reason } JSON the Edit Fields built. n8n returns the last node in the chain's output no matter its purpose, and the Slack node ended up after the one that actually builds the response. The fix is to move the Slack node to a separate branch that isn't part of the main chain — or place it before the Edit Fields — so the Edit Fields goes back to being the chain's last node.

Why it works: the execution looks "successful" because every node ran with no error — the problem isn't that something fails, it's that the wrong node ended up last in the chain, and that doesn't generate any visible error in the log.

Exercise 2 — Design the input contract. TuTienda wants a second sub-workflow tool, Check Discount Eligibility, that decides whether a customer can get a loyalty discount by combining: their purchase history (an HTTP query to an external CRM) and a business rule ("customers with more than 5 purchases in the last 12 months qualify for a 10% discount"). Write the Execute Sub-workflow Trigger's configuration — Input Source and the declared fields — and the Description you'd put on the Call n8n Workflow Tool node.

See solution
Input Source = "Define Using Fields Below"
Inputs:
  - Name: customer_id
    Type: String

Description on Call n8n Workflow Tool: "Use this tool to determine whether a customer qualifies for the 10% loyalty discount. Requires the customer's ID, not their name or email — if you don't have the ID, ask for it before calling this tool."

Only customer_id is needed because the rest of the logic — querying the CRM, counting purchases, applying the threshold of 5 — lives inside the sub-workflow, not in what the agent has to decide or send.

Why it works: the input schema declares exactly the minimum the outside world needs to provide — an identifier — and leaves all the business logic encapsulated on the implementation side, which is exactly the point of turning this into a separate tool.

Exercise 3 — Choose between native tool and sub-workflow. TuTienda needs two new tools: (a) "email the standard confirmation that a support request was received" and (b) "decide whether an order qualifies for a refund by applying the policy with category exceptions." Which do you build as a native tool — like the ones you saw in lesson 4 — and which as a sub-workflow? Justify your answer with what you learned in this lesson.

See solution

(a) is a native tool: a single Gmail node with a fixed template, one step, no conditional decision in the middle — exactly the kind of action a native node handles on its own, with no need to encapsulate anything.

(b) is a sub-workflow: it chains a data query (the order), a conditional rule with a category exception, and a calculation — several steps with business logic you don't want the agent reconstructing turn by turn, and one that might also need fixing when the policy changes, with no touching the agent's prompt.

Why it works: the question that decides between the two isn't "how important is the task" but how many steps with conditional logic it takes to complete it — one step, native tool; several steps with rules in the middle, sub-workflow.

Summary and next step

You now know how to turn a multi-step process into a reusable tool: you declare the input contract in the Execute Sub-workflow Trigger with Define Using Fields Below, you build the deterministic logic inside — queries, conditionals, calculations — you leave the node that builds the final response as the chain's last one, and you connect it to the agent with Call n8n Workflow Tool, mapping each field with $fromAI() so the model decides what value to send on every real call.

Before moving on you should be able to: explain why a sub-workflow's last node — not a special "respond" node — is what determines the response; decide between building a native tool or a sub-workflow based on how many steps with conditional logic are needed; and write an input schema that declares exactly the minimum the agent needs to provide.

What you still haven't resolved is what to do when the logic you need doesn't live in your n8n instance at all — an external provider's service that already exposes its own tools, or the reverse case, letting Claude Desktop or Cursor build workflows inside your n8n. That's exactly the next lesson's topic: MCP.

Resources