Module 6: Real Channels: Web Chat, WhatsApp, Telegram, and Voice
7. One agent, several channels: reusable architecture
Description
By the end of this lesson you'll be able to separate your agent's brain from the channel layer: move Module 5's system into a reusable sub-workflow with Execute Sub-workflow Trigger, write the data contract flowing in and out of that core, build thin per-channel adapters that speak that contract, and resolve the identity decision running through the whole module — whether the conversation history belongs to the channel or to the customer — with judgment instead of by accident. You'll also know what this architecture costs and in which cases it isn't worth it.
This matters because it's what turns four demos into a system. With what you've got from the module you can set up an agent on web chat, another on WhatsApp, another on Telegram, and a voice tool, and all four work. The problem shows up the first time something needs to change: a new business rule, one more tool, a tweak to triage_agent's prompt. Across four copies, that change is four edits, four rounds of testing, and a very high probability that one gets left out of date with nobody noticing until a customer gets a stale response. It's also, very concretely, what shows in thirty seconds of demo: a core with three adapters versus four nearly identical workflows.
Connection to the module: this lesson is the one that ties everything together. Lesson 1 drew the three layers; lessons 2 through 5 each gave you one channel, and across all four you wrote a Set node called normalize_incoming with exactly the same field names — that wasn't a coincidence, it was the seed of the contract you're going to formalize here. Lesson 6 defined what gets adapted per channel. And from Module 5 you're bringing something that applies almost literally: if the contract between an orchestrator and a specialist lets two agents understand each other without guessing, a contract between the channel and the core does exactly the same thing between two layers.
The central kitchen
Think of a food chain serving customers four ways: people eating in the restaurant, people ordering takeout at the counter, delivery-app couriers, and phone orders.
The naive version is setting up four kitchens. One for dine-in, one for takeout, one for delivery, one for phone. It works on day one. And by the end of the week the dine-in dish has a sauce the delivery one doesn't, because someone changed the recipe in one kitchen and not the others. Nobody decided to have two recipes: it just happened.
The version any real chain uses is one single kitchen and four ways of delivering. The kitchen doesn't know or care which door the order came through: it receives a ticket with a fixed format — which dish, how many, what modifications — and produces a dish. What changes is on the outside: the dine-in dish goes on china with a plated garnish; the delivery one goes in a container that holds up for twenty minutes and without the garnish that goes soggy; the phone one gets confirmed verbally before it's prepared.
Notice the two pieces that make that work, because they're exactly the two you're going to build.
The ticket. A fixed format every input produces and the kitchen understands. Whoever takes the phone order and whoever receives it from the app write the same ticket, even though the source is completely different. Without that common format, the kitchen would have to know how to interpret four different things — and then the kitchen would go back to knowing about channels.
The plating. What happens after the dish is ready: plating, packaging, deciding what goes with it. It's specific to each output and doesn't change the recipe.
Ticket and plating. Input adapter and output adapter. The recipe — your triage_agent with its specialists, its tools, and its memory — lives in one single place and finds out about none of this.
The real cost of four copies
Before building, it's worth putting numbers on what gets avoided, because it's an argument that gets used in an interview and also gets used to convince a team.
Suppose a modest system: triage_agent, two specialists, four domain tools, one memory node. That's eight configured nodes, plus the prompts. Now across four channels:
With copies With core + adapters
────────────────────────── ──────────────────────────
4 workflows × 8 nodes = 32 1 core × 8 nodes = 8
4 copies of the system prompt 1 system prompt
4 copies of each Description 1 of each Description
Changing a business rule: Changing a business rule:
4 edits + 4 tests 1 edit + 1 test
Adding a tool: Adding a tool:
4 times, or it goes inconsistent 1 time
Adding a channel: Adding a channel:
copy 8 nodes and adapt 4 adapter nodes
The number that stings the most isn't in that table, and it's probability. With four copies, the probability all four stay in sync after three months of changes is low. And the failure mode is silent: nobody sees an error, the WhatsApp customer just receives a policy that got changed six weeks ago on the web's copy.
The architecture, in n8n nodes
n8n has the exact mechanism for this: one workflow can call another and wait for its result. It's two nodes, one on each side.
╔═══════════════════════════════════════════════════════════════╗
║ CHANNEL WORKFLOWS (one per channel, thin) ║
╠═══════════════════════════════════════════════════════════════╣
║ ║
║ wf_channel_web ║
║ Chat Trigger → Set(normalize) → Execute Sub-workflow ║
║ → Set(format) → respond ║
║ ║
║ wf_channel_whatsapp ║
║ WhatsApp Trigger → IF → Set(normalize) ║
║ → Execute Sub-workflow ║
║ → Code(format) → WhatsApp Send ║
║ ║
║ wf_channel_telegram ║
║ Telegram Trigger → IF → Set(normalize) ║
║ → Execute Sub-workflow ║
║ → Code(format) → Telegram Send ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
│
│ input contract ▼
│ output contract ▲
│
╔═══════════════════════════════════════════════════════════════╗
║ wf_agent_core (ONE SINGLE ONE — Module 5's brain) ║
╠═══════════════════════════════════════════════════════════════╣
║ Execute Sub-workflow Trigger ║
║ └─► AI Agent: triage_agent ║
║ ├─ Postgres Chat Memory ║
║ ├─ AI Agent Tool: order_specialist ║
║ └─ AI Agent Tool: billing_specialist ║
╚═══════════════════════════════════════════════════════════════╝
On the core's side, the Execute Sub-workflow Trigger node (which shows up in the list as "When Executed by Another Workflow") replaces Chat Trigger. It's a trigger that doesn't listen to the internet: it only fires when another workflow calls it. Its key parameter is how it declares the data it expects to receive: it can accept whatever gets sent, or declare named, typed fields. Declare the fields. It costs a minute and turns an implicit contract into one n8n verifies for you.
On the channel's side, the Execute Sub-workflow node calls the core. The parameters that matter:
- Source / Workflow — which workflow to call. Picked from a list.
- Workflow Inputs — the values you pass it, one per field the trigger declared.
- Mode — whether it processes all items together or one at a time. For a conversation there's always one item, so it doesn't matter; worth knowing it exists.
- Wait For Sub-Workflow Completion — whether the channel waits for the result. For a conversational agent, yes: you need the response to be able to send it.
These parameters' exact names change between versions. Open both nodes on your installation and confirm the labels before treating the configuration below as final.
The contract
Here's the lesson's heart. A contract is an agreement about what fields travel, under what name, and what each one means. It's the same thing you did in Module 5 between agents; now it's between layers.
Input contract — from the channel to the core
{
"channel": "whatsapp",
"channel_user_id": "5215512345678",
"customer_id": "C-9931",
"display_name": "Ana",
"text": "Hi, how's my order #4521 doing?",
"locale": "en-US",
"message_id": "wamid.HBgN..."
}
Field by field, and why each one is there:
| Field | What it is | Why |
|---|---|---|
channel | web, whatsapp, telegram, voice | The core uses it only to modulate the response's length (lesson 6's bounded exception). Nothing else. |
channel_user_id | The identity the channel hands over | Phone, chat ID, sessionId. Always present. |
customer_id | TuTienda's real identity, if known | Can arrive empty: an anonymous web chat visitor doesn't have one. |
display_name | Name to greet by | Comes from the channel's profile. Not a verified fact. |
text | What the person said, in plain text | If an audio or a button came in, the adapter already converted it to text here. |
locale | Language and region | Useful for formatting dates and amounts, and if you ever serve two languages. |
message_id | The message's identifier on the channel | For traceability: being able to tie an execution to a specific message during an investigation. |
Two decisions in that contract deserve explicit defense.
text is always plain text. A Telegram button doesn't arrive as order:4521: the adapter already translated it into "Check the status of order 4521." A WhatsApp audio has already been transcribed. The core always receives language, never a channel format. That's the rule keeping the brain speaking a single language.
customer_id can arrive empty, and the core has to tolerate it. It's tempting to always require it, but web chat with anonymous visitors exists and is legitimate. A contract that doesn't allow for a channel's most common real case isn't a contract: it's a wish.
Output contract — from the core to the channel
{
"text": "Hi, Ana! I checked both things. The $1,200 charge doesn't match any of your purchases, so we opened dispute #D-8842…",
"status": "resolved",
"needs_human": false,
"quick_replies": [
{ "label": "See tracking", "value": "track:4521" },
{ "label": "I need something else", "value": "menu:other" }
],
"attachments": [],
"session_key": "customer:C-9931"
}
Notice quick_replies. The core doesn't know whether the channel has buttons, so it doesn't build them: it proposes options, in a neutral format. Telegram's adapter converts them into an inline keyboard; WhatsApp's into interactive buttons or a list, depending on what its version supports; the web chat's ignores them or shows them as suggestions; voice's converts them into a three-option spoken sentence. One structure, four platings.
That's the general pattern for a good output contract: say the intent, not the implementation. "Offer these two options" is intent. "Send an inline_keyboard with two rows" is implementation, and the moment you put that in the core, the core learned about Telegram.
needs_human plays the same role: the core says this case needs escalating, and each channel decides how — on the web it can show a chat-with-a-person button, on WhatsApp notify an internal group, in voice transfer the call.
Worked example: TuTienda's four workflows
Let's build it. We start with the core, because just like in Module 5, the bottom piece gets tested alone.
The core — wf_agent_core
Step 1 — Replace the trigger. Open Module 5's workflow, delete the Chat Trigger, and put an Execute Sub-workflow Trigger in its place. Declare the fields:
# Node: Execute Sub-workflow Trigger — Name: core_input
# Input data mode: Define using Fields Below
# (confirm the exact label on your version)
channel string
channel_user_id string
customer_id string
display_name string
text string
locale string
message_id string
Step 2 — Connect the agent to the right field. Since the trigger is no longer a Chat Trigger, you have to tell the agent where to read from — the same adjustment you made on WhatsApp and Telegram:
# Node: AI Agent — Name: triage_agent
Source for Prompt (User Message): Define below
Prompt (User Message):
{{ $json.text }}
# System context (not written by the customer):
# customer: {{ $json.display_name || 'unknown' }}
# customer_id: {{ $json.customer_id || 'not identified' }}
# channel: {{ $json.channel }}
That context block at the end is what lets the agent greet by name and know whether it has a customer identity, without the system prompt changing per channel. And channel is what lesson 6 uses to modulate length.
Step 3 — The memory key. Here's the decision running through the module, and it deserves its own section further below. For now, the version that works:
# Node: Postgres Chat Memory
Session ID: Define below
Key: {{ $json.customer_id
? 'customer:' + $json.customer_id
: $json.channel + ':' + $json.channel_user_id }}
If the customer is known, the conversation belongs to the customer and gets shared across channels. If not, it belongs to the channel, with its prefix so two numeric identifiers from different channels never collide.
Step 4 — Compose the output. The core's last node is a Set that builds the output contract:
# Node: Set — Name: core_output
text = {{ $json.output }}
status = "resolved"
needs_human = false
quick_replies = []
attachments = []
session_key = {{ $('core_input').item.json.customer_id
? 'customer:' + $('core_input').item.json.customer_id
: $('core_input').item.json.channel + ':' + $('core_input').item.json.channel_user_id }}
In a more elaborate version, status, needs_human, and quick_replies get produced by the agent itself with a Structured Output Parser — exactly like Module 5's specialists produce their JSON — instead of being fixed here. Start with fixed values and evolve into that once the basic system works.
Step 5 — Test it alone. Same as you tested each specialist in isolation in Module 5. Run the core from the editor with fixed data:
# Test execution of the core, with no channel at all
{
"channel": "whatsapp",
"channel_user_id": "5215512345678",
"customer_id": "C-9931",
"display_name": "Ana",
"text": "how's my order #4521 doing?",
"locale": "en-US",
"message_id": "test-001"
}
What to expect. The agent delegates, resolves, and the core_output node hands back the contract object with the composed text. If this works, you have a tested brain and the channels become a separate problem. Perfect: you just turned a four-variable problem into two two-variable ones.
One adapter — wf_channel_whatsapp
Four nodes and no surprises, because you already wrote them in lesson 3:
WhatsApp Trigger
→ IF: is_text_message
→ Set: normalize_incoming
→ Execute Sub-workflow: wf_agent_core
→ Code: format_for_whatsapp
→ WhatsApp Business Cloud: Send
The normalization Set, now producing the complete contract:
# Node: Set — Name: normalize_incoming
channel = "whatsapp"
channel_user_id = {{ $json.entry[0].changes[0].value.messages[0].from }}
customer_id = "" # resolved in the next step
display_name = {{ $json.entry[0].changes[0].value.contacts[0].profile.name }}
text = {{ $json.entry[0].changes[0].value.messages[0].text.body }}
locale = "en-US"
message_id = {{ $json.entry[0].changes[0].value.messages[0].id }}
The call to the core:
# Node: Execute Sub-workflow — Name: call_core
Workflow: wf_agent_core
Wait For Sub-Workflow Completion: on
Workflow Inputs:
channel = {{ $json.channel }}
channel_user_id = {{ $json.channel_user_id }}
customer_id = {{ $json.customer_id }}
display_name = {{ $json.display_name }}
text = {{ $json.text }}
locale = {{ $json.locale }}
message_id = {{ $json.message_id }}
And the output formatting, which is lesson 6 turned into a node:
# Node: Code — Name: format_for_whatsapp
// Converts the agent's standard Markdown into WhatsApp's markup
// and splits the message if it exceeds the channel's hard limit.
let text = $json.text;
text = text.replace(/\*\*(.+?)\*\*/g, '*$1*'); // **bold** → *bold*
text = text.replace(/^#{1,6}\s+/gm, ''); // strip headers
text = text.replace(/^[\-\*]\s+/gm, '• '); // bullets → middle dot
const MAX = 4000;
const chunks = [];
let current = '';
for (const p of text.split('\n\n')) {
if ((current + '\n\n' + p).length > MAX && current) { chunks.push(current); current = p; }
else { current = current ? current + '\n\n' + p : p; }
}
if (current) chunks.push(current);
return chunks.map(c => ({ json: { text: c } }));
Five nodes. That's a complete channel. And Telegram's is the same thing with three other names.
The identity decision
This is the question lesson 1 left planted and that needs deliberate resolution: does the conversation history belong to the channel or to the customer?
Both answers are defensible and lead to different systems.
Option A — The conversation belongs to the channel
session_key = "whatsapp:5215512345678"
session_key = "telegram:987654321"
session_key = "web:a2f0c8b1e4d7"
In favor: it's simple, requires no additional table, can never confuse two people, and every channel is a sealed compartment. It's right when channels serve different audiences — for example, WhatsApp for customers and Telegram as an internal technical-support channel — or when the real identity isn't known.
Against: the same customer on two channels is two strangers. What they told you over WhatsApp on Monday doesn't exist when they write on the web Wednesday.
Option B — The conversation belongs to the customer
session_key = "customer:C-9931" ← from any channel
In favor: it's what people expect. Nobody understands why they have to explain their case again just because they switched apps. In a support system, this continuity gets noticed a lot.
Against: it requires resolving channel_user_id → customer_id, which isn't always possible. And it has a real risk: if the resolution gets it wrong, a customer reads someone else's conversation. That's a privacy incident, not a functionality bug.
Identity resolution
For option B you need a table tying each channel identity to a customer:
-- Table: channel_identities
-- Ties each identity a channel hands over to TuTienda's real customer.
CREATE TABLE channel_identities (
channel TEXT NOT NULL, -- 'whatsapp' | 'telegram' | 'web' | 'voice'
channel_user_id TEXT NOT NULL, -- phone, chat id, session id
customer_id TEXT NOT NULL, -- 'C-9931'
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (channel, channel_user_id)
);
And in the adapter, a query node before calling the core:
# Node: Postgres — Name: resolve_customer
# Goes between normalize_incoming and the call to the core.
SELECT customer_id
FROM channel_identities
WHERE channel = '{{ $json.channel }}'
AND channel_user_id = '{{ $json.channel_user_id }}';
# If there's no row, customer_id stays empty and the core uses the
# channel's key. It's not an error: it's a customer not yet identified.
Now, the important part: how that table gets filled. There are three ways, with very different levels of trust:
- From an authenticated session. The customer logged into tutienda.example and the chat is embedded there. Your server knows the
customer_idwith certainty and passes it viametadata, as you saw in lesson 2. High trust. - From the phone number, if the number is already in your CRM. A customer who bought while giving their number and writes from that same number is, almost certainly, that person. Reasonable trust for questions about their own orders; insufficient for sensitive actions.
- By asking the customer. The agent asks for an email or an order number and resolves it with that. It's the most common route and also the weakest one: anyone can say someone else's email.
And from there comes the rule that makes this architecture defensible:
The identity for remembering and the identity for acting aren't the same.
Recognizing someone to pick a conversation back up has a low threshold: if you get it wrong, someone sees context that isn't theirs, uncomfortable but bounded. Executing a cancellation or a refund on someone's behalf has a much higher threshold and demands real verification.
The sensible version for this guide's level: use the resolved identity for memory and for personalizing, and require additional verification — a code sent to the registered email, for instance — before any action that moves money or cancels something. The complete treatment of trust boundaries and permissions is Module 7, which comes right after this; this lesson leaves you at the exact point where that topic comes in.
What this architecture costs
No design decision is free and it's worth being able to name the price.
Additional latency. Calling a sub-workflow adds one more execution's startup. In practice it's on the order of tenths of a second — negligible against the eight to fifteen seconds a multi-agent system takes, and perfectly relevant if your agent responded in 400 milliseconds. This is one of the reasons lesson 5 recommends exposing domain tools directly to the voice platform instead of routing them through the core: in voice, every layer costs.
A trace split in two. Now there's a channel execution and a core execution, in separate logs. Debugging requires jumping between the two. n8n links them, but it's real friction. The practical mitigation is propagating message_id on both sides: with it you can find both halves of a specific case.
One more indirection to understand. Whoever's new to the project has to understand the brain isn't in the channel's workflow. It's solved with clear names — wf_channel_* and wf_agent_core — and a note in the workflow's description.
When it isn't worth it
Three honest cases where this architecture is complexity with no return:
A single channel, with no plan to add another. If you only serve over WhatsApp and it's going to stay that way, splitting the workflow in two gives you the indirection and none of the benefits. Build it as one piece and split it the day a second channel shows up — which is half an hour of work if the field names were already normalized.
Channels with genuinely distinct business logic. If the operations team's internal Telegram bot does things the customer agent should never do — checking margins, seeing other customers' data — they're not the same agent with two doors: they're two agents. Forcing them to share a core produces a prompt full of conditionals, which is worse than two prompts.
Real-time voice. Already covered: the platform is the brain and n8n is the tools. This lesson's conversational core doesn't participate in the call, though it does participate afterward — the end-of-call webhook can write to the same memory and the same identity table, and that's what makes a customer who called Monday get recognized when they write on Tuesday.
Common mistakes
Putting channel logic inside the core with a Switch (conceptual). What happens: someone sets up the core and, inside it, adds a Switch on channel that formats differently on each branch. The core learns about Telegram and WhatsApp again, and it grows every time you add a channel — exactly what the architecture existed to avoid, now with a sub-workflow in the way. Why it happens: it's tempting to centralize "everything about the response" in one place. How to spot it: search for the word whatsapp or telegram inside the core; if it shows up anywhere other than the system prompt's verbosity list, this is it. How to fix it: formatting lives in each channel's output adapter. The core produces a neutral contract and that's where its responsibility ends.
Different field names in each adapter (practical). What happens: WhatsApp's adapter produces phone, Telegram's produces chat_id, the web's produces session. The core has to accept all three, and ends up full of expressions with chained fallbacks nobody understands two months later. Why it happens: each adapter gets written at a different time, and each channel's "natural" name is different. How to spot it: open your channels' normalization Sets and compare them side by side; if the names aren't identical, this is it. How to fix it: the contract gets written once and first, before building the second adapter. Every adapter produces exactly those names, even if on its channel they're called something else. That's an adapter's entire job.
Forgetting to turn on the sub-workflow's wait (practical). What happens: the channel calls the core and moves on. The sending node runs with empty data or with whatever was there before, the customer gets a blank message or an error, and in n8n both executions show as successful. Why it happens: the wait-for-completion option exists because there are cases where you don't want to wait — triggering a background process — and its default value might not be the one you need. How to spot it: if the message goes out empty but the core's execution looks correct and has its response, this is it. How to fix it: turn on the wait-for-completion option on the Execute Sub-workflow node. For a conversation, always.
Resolving identity with data the customer controls (conceptual). What happens: someone resolves customer_id from an email the customer typed in the chat, and stores that association in the table as if it were verified. From then on, anyone who says another person's email inherits their conversation history. Why it happens: it's the simplest way to identify and it works 99% of the time, which is for honest customers. How to spot it: ask yourself what it would take for someone to impersonate another customer in your system; if the answer is "knowing their email," this is it. How to fix it: tell apart the identity for remembering from the identity for acting. You can use a stated piece of data to personalize and pick up context; for any action with consequences, require real verification. And store in the table how each identity got verified, not just what it is — the example's verified_at column should be paired with one saying through what method.
Duplicating the memory node in every channel (practical). What happens: someone leaves memory in the channel workflows instead of in the core, "so each channel handles its own." The result is the same customer has as many histories as channels, and the session-key logic gets written four times with four subtly different variants. Why it happens: memory feels like part of the conversation, and the conversation feels like part of the channel. How to spot it: count how many memory nodes exist in your instance for this system; if there's more than one, this is it. How to fix it: memory belongs to the agent, and the agent lives in the core. The only thing the channel contributes to memory is the identity data that travels in the contract.
Exercises
Exercise 1 — Write the Telegram adapter. With this lesson's contract, write the Telegram adapter's four nodes: the filter, the normalization, the call to the core, and the output formatting. It has to handle both cases: a text message and a button (callback_query).
See solution
Telegram Trigger (updates: Message, Callback Query)
│
├── branch A: does it carry `message`?
│ └─► Set: normalize_from_message
│
└── branch B: does it carry `callback_query`?
├─► Telegram → Callback → Answer Query (first, always)
└─► Set: normalize_from_callback
│
└─► (both branches converge)
└─► Execute Sub-workflow: wf_agent_core
└─► Code: format_for_telegram
└─► Telegram → Send Message
Both normalizations produce the same contract, which is the entire point of the exercise:
# Set: normalize_from_message
channel = "telegram"
channel_user_id = {{ $json.message.from.id }}
customer_id = ""
display_name = {{ $json.message.from.first_name }}
text = {{ $json.message.text }}
locale = {{ $json.message.from.language_code }}
message_id = {{ $json.message.message_id }}
# Set: normalize_from_callback
# The key difference: `text` is NOT the raw callback_data.
# It gets translated to language here, because the core only understands English.
channel = "telegram"
channel_user_id = {{ $json.callback_query.from.id }}
customer_id = ""
display_name = {{ $json.callback_query.from.first_name }}
text = {{ $json.callback_query.data.startsWith('order:')
? 'Check the status of order ' + $json.callback_query.data.split(':')[1] + '.'
: 'The customer chose the option: ' + $json.callback_query.data }}
locale = "en-US"
message_id = {{ $json.callback_query.message.message_id }}
And the output formatting, which also builds the buttons from quick_replies:
# Code: format_for_telegram
// Plain text: with no Parse Mode nothing ever fails.
let text = $json.text
.replace(/\*\*(.+?)\*\*/g, '$1') // strip Markdown bold
.replace(/^#{1,6}\s+/gm, '')
.replace(/^[\-\*]\s+/gm, '• ');
// The core's neutral options turn into a Telegram keyboard.
const keyboard = ($json.quick_replies || []).map(
qr => [{ text: qr.label, callback_data: qr.value }]
);
return [{ json: { text, reply_markup: { inline_keyboard: keyboard } } }];
What to notice in that solution: the core never finds out a button exists. It receives an English sentence and returns a text plus a neutral list of options. All of Telegram's mechanics — callback_query, Answer Query, inline_keyboard — live in the adapter, which is where they never leave.
Confirm the exact shape your node's version expects for the keyboard: on some it's built with the visual builder and not with a JSON object.
Why it works: two very different inputs produce the same contract, and that's literally the definition of an adapter.
Exercise 2 — Decide identity for three scenarios. For each one, say whether you'd use per-channel identity (option A) or unified per-customer identity (option B), how you'd resolve it, and what additional verification you'd require before a sensitive action.
- TuTienda: customer support over WhatsApp and the web chat, where many visitors haven't logged in.
- An internal Telegram bot for the operations team to check inventory.
- A voice agent serving a support number that also answers over WhatsApp.
See solution
1. Unified (B), with whatever resolution is possible. It's the case where continuity is noticed the most. On WhatsApp, resolve customer_id by looking up the phone in the CRM. On the web, from the authenticated session when there is one, and empty customer_id when there isn't — which is correct: an anonymous visitor isn't anyone yet, and the per-channel key is the right answer. For sensitive actions (cancelling, refunding, changing data), a code sent to the registered email. The phone number as proof of identity is reasonable for checking one's own order and not for moving money.
2. Per channel (A), and the table isn't even needed. Telegram's from.id is the employee's identity, and the list of who can use the bot is a short, explicit list — Restrict to User IDs on the trigger does half the work. There's no customer_id to resolve because there are no customers. This case is also the example from the "when it isn't worth it" section: if this bot checks margins and internal data, it probably shouldn't share a core with the customer agent.
3. Unified (B), and this is where it has the most value. The number someone calls from and the number they write from on WhatsApp are usually the same, so unification comes almost for free: one row in the identities table serves both channels. The result is remarkable — the customer calls, it doesn't get fully resolved, and the next day they write over WhatsApp and the agent already knows what it's about. For sensitive actions, in voice it's worth being stricter than in text, because speech recognition can mix up digits: repetition and explicit confirmation of the critical piece of data, plus the same email code.
The pattern running through all three: unified identity is worth it when the channels serve the same person about the same topics. When they serve different audiences or different topics, unifying adds nothing and does add risk.
Why it works: the exercise shows the decision isn't technical but a product one, and that the answer changes depending on who each channel serves — something you can't see by looking at the node diagram.
Exercise 3 — Migrate and measure. Take two of the channels you set up in previous lessons and migrate them to this architecture. Then measure and note: (a) how many nodes you had before in total and how many you have now; (b) how long a response used to take and how long it takes now, with five runs of each; (c) make a real change to triage_agent's system prompt and time how long it takes to apply and verify it.
See solution
The numbers depend on your system, but the pattern that comes out is this:
(a) With two channels, the node savings are modest — maybe you go from sixteen to thirteen — and there's an honest observation worth making here: with two channels this architecture almost doesn't pay for itself in node count. It pays off at point (c). The node savings grow non-linearly with every channel you add, because the core doesn't grow.
(b) The latency difference should be tenths of a second, imperceptible against the eight to fifteen seconds of a multi-agent system. If you measure more than a second of difference, check you're not calling the core twice or that the wait-for-completion option is properly configured.
(c) Here's the result that matters. Before: two edits, two saves, two tests, and the responsibility of remembering there were two. Now: one edit, one save, and one test per channel to confirm the output still formats correctly — which is a different, shorter test, because you're only verifying the adapter, not the reasoning.
The observation that usually comes up and is worth noting: the type of possible error also changed. Before, the risk was silent divergence between copies, which doesn't get detected until a customer suffers it. Now, the risk is breaking both channels at once with a bad change, which gets detected immediately because nothing works. The second type of error is enormously preferable: a loud, immediate failure is always worth more than a silent, delayed one.
Why it works: the exercise gives you the three numbers this decision gets defended with in front of anyone asking why the system has one more workflow than it seemed to need.
Summary and next step
You now have the complete architecture. Module 5's brain lives in a single workflow, wf_agent_core, triggered by an Execute Sub-workflow Trigger that declares its input fields. Every channel is a thin four-or-five-node workflow: it receives, normalizes to the contract, calls the core, formats the output for its channel, and sends. Between the two layers there's an explicit contract — seven input fields, six output ones — where the core states the intent (quick_replies, needs_human) and each adapter decides the implementation.
And you resolved the identity question running through the module, with a rule worth more than this one case: the identity for remembering and the identity for acting aren't the same thing, and the second one's trust threshold is much higher.
Before moving on you should be able to: draw from memory the two nodes connecting a channel to the core; explain why quick_replies travels as a neutral list and not as a Telegram keyboard; and name two concrete cases where this architecture is complexity with no return.
What's next is the mini-project. Lesson 8 puts the whole module together: the same agent serving simultaneously over web chat and WhatsApp, with shared memory, per-channel adaptation, and a battery of test cases that includes the one that fails the most systems — the same customer starting a conversation on one channel and continuing it on the other. And it closes out the module with the bridge to Module 7, which is where these channels you just opened to the internet get their locks.
Resources
- Execute Sub-workflow node — n8n Docs — the node that calls the core from every channel, with its modes and the wait-for-completion option.
- Execute Sub-workflow Trigger — n8n Docs — the core's trigger and the input-field declaration that turns your contract into something n8n verifies.
- Sub-workflows — n8n Docs — the conceptual guide, with the detail of how data travels between the parent and child workflow.
- Set node (Edit Fields) — n8n Docs — the node both adapters get written with, and the one that makes the contract explicit.
- Memory in n8n — n8n Docs — the session key, which in this architecture becomes an expression with a fallback and is the identity decision made into code.
- Postgres node — n8n Docs — for the channel-identities table and its resolution query.