Module 8: Project: Multichannel Customer Support System
5. Persistent memory per customer + web and WhatsApp
Description
By the end of this lesson the system's going to serve two real channels with one single brain and one single memory. You're going to turn lessons 3 and 4's workflow into wf_agent_core with its Execute Sub-workflow Trigger and its eight declared fields; you're going to connect memory with the key grouping by customer and not by channel; you're going to populate the identities table making WhatsApp and the web the same conversation; and you're going to set up two thin adapters with not a single line of business logic.
And you're going to close something left open in lesson 4. Every customer_id filter you set up there — the ones keeping a customer from seeing another's orders — depends on that value being a verified identity. Until today it was text you typed by hand into a test panel. By the end of this lesson it's going to come from a table, with a record of how it got established. That difference is what turns the permission matrix from an intention into a guarantee.
This matters because it's the lesson producing the project's flashiest moment and also its most dangerous one. The flashy one: opening two windows — a web chat and a phone — talking through one, continuing through the other, and showing in the trace it was the same agent with the same memory. The dangerous one: if identity resolution gets it wrong, a customer reads someone else's conversation, and that produces no visible error. Both things live in the same decision.
Connection to the module: lesson 2 decided the memory key, the identity policy, and the eight-field contract; this one executes them. Lessons 3 and 4 built the brain and the hands over a temporary Chat Trigger disappearing today. And everything you do today's a prerequisite for lesson 6: the human-approval message's going to show what method the customer's identity got verified with, and that fact's born here.
The hotel that recognizes you
At a well-run hotel, the guest is one single person no matter which door they show up at.
You call from the room asking for towels: the switchboard sees who you are before you say it. You go down to the lobby and ask for a dinner reservation: the concierge already knows you checked in today and that breakfast is included. You text through the app asking what time checkout is: the answer arrives with your name. Three doors, one guest.
What makes that work isn't that the three people know each other. It's that all three consult the same record, and that record is tied to an identifier — your room number — that each door knows how to resolve its own way: the switchboard by the extension you're calling from, the concierge because they recognize you or ask, the app because you logged in.
Now notice two details a hotel solves that your system also has to solve.
Not every door gives the same certainty. The room's extension is strong: only whoever's there can call from there. The concierge recognizing you by sight is reasonable. Someone walking up to the counter and saying "I'm from room 402" is weak. And that's why a serious hotel gives you towels if you say you're from 402, and doesn't give you a new key or charge dinner to the room without verifying. The identity for remembering and the identity for acting aren't the same thing, and the second one's threshold is much higher.
And there are guests with no record. Someone walks in off the street to ask if there are rooms available. They're nobody yet, and that's a legitimate case, not an error. The hotel serves them just the same, with no made-up room number. Your system has anonymous visitors on the web chat, and a contract that doesn't allow for them isn't a contract: it's a wish.
With that in mind, let's build the hotel.
Phase 1 — The core
The workflow you've been building becomes wf_agent_core. Only its ends change: input and output.
Step 1.1 — Swap the trigger
Delete the Chat Trigger and put an Execute Sub-workflow Trigger in its place. Rename it core_input.
What this node is. It's a trigger that doesn't listen to the internet: it only fires when another workflow calls it. In the node list it shows up with a label like "when called by another workflow." Its key parameter is how it declares the data it expects: it can accept whatever gets sent, or declare named, typed fields. Declare the fields. It costs a minute and turns a contract written on paper into a contract n8n verifies for you.
# Node: Execute Sub-workflow Trigger — Name: core_input
# Input data mode: define fields below
# (confirm the exact label on your version: the option you
# want is the one that lets you name fields, not the one that
# accepts anything)
channel string
channel_user_id string
customer_id string
display_name string
text string
locale string
message_id string
verified_by string ← the eighth, new in this project
Step 1.2 — Connect the agent to the right field
triage_agent used to read chatInput from Chat Trigger. Now it reads text:
# 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' }}
identity verified by: {{ $json.verified_by || 'none' }}
channel: {{ $json.channel }}
That context block at the end is what lets the agent greet by name and know whether it has an identity, without the system prompt changing per channel. And it carries an explicit mark — "not written by the customer" — because a customer message containing something resembling that block is exactly the attack Module 7 calls injection disguised as system. The mark doesn't stop it; it helps.
And triage_agent's System Message gains one single block, the verbosity one:
# Added to triage_agent's System Message
The `channel` field in the context indicates which channel the
message arrived through. Use it ONLY to decide how much text
to write:
- web: up to three paragraphs.
- whatsapp: four lines maximum, one topic per message.
Always write standard Markdown; the system does the format
conversion, not you.
Write the values identical to what your contract produces. If the contract says whatsapp and the prompt says WhatsApp, some models resolve it and others don't — and that "others don't" produces three-paragraph responses on a phone with nothing failing.
And watch what that block does not say: it says nothing about format, buttons, or emojis. It's the bounded exception to layer separation, and it's deliberately bounded to length. Markdown-to-WhatsApp-markup conversion lives in the adapter, and if you ever see the word whatsapp in the core outside this block, something snuck into the wrong layer.
Step 1.3 — Compose the output
The core's last node 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 }}
status and needs_human stay fixed for now. In lesson 7 the agent itself is going to produce them with its structured output, which is the correct version; starting with fixed values and evolving to that once the rest works is the order that causes the fewest problems.
Phase 2 — Memory and the key
Here's lesson 2's decision, turned into an expression.
# Node: Postgres Chat Memory
Session ID: Define below
Key: {{ $json.customer_id
? 'customer:' + $json.customer_id
: $json.channel + ':' + $json.channel_user_id }}
Read it out loud, because it says exactly what you decided: if the customer's 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.
That prefix isn't decorative. Without it, a web chat sessionId that happened to match a phone number would produce two people sharing memory. It's unlikely and it's free to prevent.
A configuration detail worth verifying: the memory node has an option for how many messages it keeps in the context window. The default is usually conservative. For customer support, a window of ten to twenty exchanges is a good starting point: enough that the customer doesn't repeat data, and not so much that the context fills up with old conversations. Confirm the exact label on your version's panel and note down the value you chose — in lesson 7 it's going to show up in the cost sheet, because every message in the window gets resent on every iteration.
Phase 3 — Identity
This is the phase making the project worth it, and it's the one almost nobody does.
Step 3.1 — The table
-- Ties each channel identity to TuTienda's real customer.
CREATE TABLE IF NOT EXISTS channel_identities (
channel TEXT NOT NULL, -- 'web' | 'whatsapp'
channel_user_id TEXT NOT NULL, -- sessionId or phone
customer_id TEXT NOT NULL, -- 'C-9931'
verified_by TEXT NOT NULL, -- 'session'|'crm_phone'|'declared'
verified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (channel, channel_user_id)
);
-- Two identities from the SAME customer. This is what's going to make
-- the channel-switch case work.
INSERT INTO channel_identities
(channel, channel_user_id, customer_id, verified_by)
VALUES
('whatsapp', '5215512345678', 'C-9931', 'crm_phone'),
('web', 'C-9931', 'C-9931', 'session')
ON CONFLICT DO NOTHING;
The verified_by column isn't decorative: it records how that identity got established, which is what lets you later decide whether it's enough for a sensitive action. session is strong — it came from an authenticated session — crm_phone is reasonable — the phone's in the CRM — declared is weak — the customer said so in the chat.
The hotel analogy applies literally: session is the room's extension, crm_phone is the concierge who recognizes you, declared is someone saying "I'm from 402."
And that credential also has to be given out:
GRANT SELECT ON channel_identities TO n8n_agent_ro;
-- The agent does NOT write to this table. Your application writes to
-- it when someone logs in, or an onboarding process writes to it
-- when a customer registers. If the agent could write to it, it
-- could grant itself whatever identity it wanted.
That comment deserves reading twice. It's the table deciding who's who; if the agent could write to it, every lever from lesson 4 becomes decorative.
Step 3.2 — Resolution, on each adapter
Two nodes between normalization and the call to the core:
# Node: Postgres — Name: resolve_customer
# Operation: Execute Query (fixed query, parameters as
# parameters — same as the KB)
SELECT customer_id, verified_by
FROM channel_identities
WHERE channel = $1 AND channel_user_id = $2;
# Query Parameters:
# {{ $json.channel }}
# {{ $json.channel_user_id }}
#
# If there's no row, the result comes back empty. That's NOT an
# error: it's a customer not yet identified, which is a valid case.
# Node: Set — Name: merge_identity
# If there was a row, use that customer_id. If not, keep whatever
# was already there (on the web it can come from metadata) or empty.
customer_id = {{ $json.customer_id
|| $('normalize_incoming').item.json.customer_id
|| "" }}
verified_by = {{ $json.verified_by
|| ($('normalize_incoming').item.json.customer_id
? 'session' : '') }}
What to expect. Write over WhatsApp from the registered number and look at resolve_customer's output: one row, with customer_id: "C-9931" and verified_by: "crm_phone". Write from a number not in the table and the output comes back empty, no error — and the core's going to receive an empty customer_id, which is exactly what the contract allows.
Phase 4 — The web adapter
Five nodes and no surprises.
# Node: Chat Trigger — Name: web_chat_in
Mode: Embedded Chat
Response Mode: When Last Node Finishes
Authentication: None
Allowed Origin (CORS): https://tutienda.example, http://localhost:8080
Load Previous Session: Memory Connected to Agent
# Node: Set — Name: normalize_incoming
channel = "web"
channel_user_id = {{ $json.sessionId }}
customer_id = {{ $json.metadata?.customer_id || "" }}
display_name = {{ $json.metadata?.display_name || "" }}
text = {{ $json.chatInput }}
locale = "en-US"
message_id = {{ $json.sessionId + '-' + $now.toMillis() }}
verified_by = ""
Confirm the path to metadata on a real execution on your version: it's one of the things that changes and produces an empty customer_id with no visible error.
Notice something this node does that's an adapter's entire job: it translates the channel's names into the contract's names. chatInput becomes text, sessionId becomes channel_user_id. From here on the core doesn't know a Chat Trigger exists.
The call to the core:
# Node: Execute Sub-workflow — Name: call_core
Workflow: wf_agent_core
Wait For Sub-Workflow Completion: ON
← for a conversation, always. If this is left off, the channel
keeps going and responds with empty data, and both
executions show up successful in n8n.
Workflow Inputs: the eight fields, one by one.
These parameters' exact names change between versions. Open both nodes on your installation and confirm the labels before treating the configuration as final.
And the test page, which can be a local HTML file served at localhost:8080:
<!-- TuTienda chat test page -->
<link href="https://cdn.jsdelivr.net/npm/@n8n/chat/dist/style.css" rel="stylesheet" />
<script type="module">
import { createChat } from 'https://cdn.jsdelivr.net/npm/@n8n/chat/dist/chat.bundle.es.js';
createChat({
webhookUrl: 'https://YOUR-N8N-INSTANCE/webhook/xxxxxxxx/chat',
mode: 'window',
// In production, your server fills this in from the
// authenticated session. For testing, it's set by hand.
metadata: {
customer_id: 'C-9931',
display_name: 'Ana'
},
initialMessages: [
'Hi! I\'m TuTienda\'s assistant.',
'I can help with orders, returns, and charges.'
]
});
</script>
Save two versions of that page: one with metadata (identified customer) and one without (anonymous visitor). You're going to need them in testing, and having them ready saves edits mid-run.
Phase 5 — The WhatsApp adapter, with cost honesty
Before the nodes, the practical decision, because this channel costs money in production and requires Meta to verify your business.
Route A — Meta's test number (recommended). You create a developer account, an app, add the WhatsApp product, and Meta gives you a free test number you can converse with a short list of recipients you register (typically five). You don't need to verify the business or buy anything, and every message you're going to send is a response inside the 24-hour service window. Paperwork time: from half an hour to a couple hours, depending on how long each Meta panel screen takes.
Route B — Telegram as a substitute. If the paperwork gets stuck, set up the second channel with Telegram. Everything structural is identical: the core, the contract, shared identity, output adaptation, and the test cases. The only thing you lose is the 24-hour window experience, which you already understood conceptually in Module 6.
And cost honesty, which is part of the deliverable. Once this system serves real customers, the WhatsApp Business API charges per conversation — not per loose message — with prices varying by country and by conversation category, which Meta periodically adjusts. Conversations the customer starts and get answered within the 24-hour service window get treated differently from ones the business starts with an approved template, which are the expensive ones. For a customer-support system like this one, the vast majority of traffic is the first kind, which is the good news. The concrete numbers you have to look up in Meta's current pricing table for your country the day you put it into production — any figure I write here is going to be stale. What you can write into your document today is the structure: WhatsApp cost per conversation, plus token cost per conversation, and in lesson 7 you're going to measure the second.
The nodes:
# Credentials: there are TWO and they're easy to confuse.
# WhatsApp API (Access Token + Business Account ID) → sending
# WhatsApp OAuth2 (App ID + App Secret) → trigger
# If it sends but doesn't receive, or the reverse, it's one of the two.
# Node: IF — Name: is_text_message
Condition 1: {{ $json.entry[0].changes[0].value.messages }} → exists
Condition 2: {{ $json.entry[0].changes[0].value.messages[0].type }}
→ equals "text"
Combine: AND
# The false branch ends in a NoOp. WhatsApp sends status events
# (delivered, read) through the same webhook, and responding to
# those produces a loop. Never respond through it.
# Node: Set — Name: normalize_incoming
channel = "whatsapp"
channel_user_id = {{ $json.entry[0].changes[0].value.messages[0].from }}
customer_id = ""
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 }}
verified_by = ""
# Node: Code — Name: format_for_whatsapp
// The agent writes standard Markdown. WhatsApp uses its own
// markup and cuts off long messages.
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; // margin under the limit
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);
// One item per chunk: the sending node sends them in order.
return chunks.map(c => ({ json: { text: c } }));
# Node: WhatsApp Business Cloud — Name: send_whatsapp_reply
Resource: Message · Operation: Send
Phone Number ID: <YOUR business number's ID>
Recipient Phone Number: {{ $('normalize_incoming').item.json.channel_user_id }}
Message Type: Text
Text Body: {{ $json.text }}
Remember to register your own phone as a test recipient in Meta's panel before trying anything. Without that, sending fails with an error that doesn't clearly say that's the problem.
And the web adapter needs its mirror node, even if it does nothing:
# Node: Code — Name: format_for_web
// The widget interprets Markdown, so nothing needs converting.
// This node exists for symmetry: the day something needs
// adapting, there's already a place to put it.
return [{ json: { text: $json.text } }];
It looks useless and it isn't. Having the same skeleton in both adapters means adding the third channel is copying a known pattern instead of inventing one.
Phase 6 — Verify the graph
One minute preventing hard-to-diagnose problems. Seven points:
1. There's exactly ONE root-type AI Agent node in the whole
solution, and it's in wf_agent_core.
2. There's exactly ONE memory node, and it's in wf_agent_core.
3. No channel workflow contains an AI Agent, a tool,
a business rule, a deadline, or an amount.
4. wf_agent_core does NOT contain the word "whatsapp" outside
the system prompt's verbosity block.
5. Both Execute Sub-workflow nodes have the wait-for-completion
option TURNED ON.
6. Both normalize_incoming nodes produce EXACTLY the
same eight field names.
7. The customer_id expressions in lesson 4's tools now
read from core_input, not from the Chat Trigger that no longer exists.
Point 6 gets verified by opening both nodes side by side. If a name differs by one letter, the core receives an empty field and doesn't fail: it just behaves as if the data doesn't exist, which is the most expensive kind of error to find.
Point 7 is the one most forgotten in this specific project, because the tools got set up when the trigger was a different one. If lookup_order still points to a node that no longer exists, the expression returns empty and the customer_id filter stops filtering. It's a silent failure with privacy consequences, so check it tool by tool.
Phase 7 — The four cases verifying this lesson
Of the battery's twelve, these four depend on what you built today.
C1 and C2 — The same question through both channels.
Open the page with metadata and write "Hi, how's my order #4521 doing?" Then write the same thing from your registered phone.
Expected: the same information, and WhatsApp's response noticeably shorter — four lines maximum. Compare them side by side: it's the visible proof the channel variable works. If they come out equally long, the verbosity block isn't taking effect, and the suspect is almost always that channel's value doesn't match the prompt's letter for letter.
C6 — Anonymous visitor.
Open the page without metadata and write "how's my order doing?"
Expected: the agent asks for the email or the order number before checking anything. If it responds with some customer's data, you have a serious problem and it needs fixing before you continue. Also verify in the trace that lookup_order wasn't called: with an empty customer_id, the tool's filter wouldn't filter anything, so the defense here is the agent not even trying.
C7 — The customer who switches channels. This is the central case.
Write over WhatsApp from the registered number: "Hi, I want to return the headphones I bought last month." Let the agent respond. Then open the web page with C-9931's metadata and write: "and how long does the refund for that take?"
Expected: the agent knows what "that" refers to. It doesn't ask which headphones or which return.
What to expect in C7's trace, this project's visual deliverable:
Execution A — wf_channel_whatsapp
normalize_incoming → channel_user_id: "5215512345678"
resolve_customer → customer_id "C-9931", verified_by "crm_phone"
call_core → sub-execution B
format_for_whatsapp → 1 chunk, 3 lines
send → delivered
Execution B — wf_agent_core
memory: session_key "customer:C-9931" ← the key
triage_agent → order_specialist
→ lookup_order + check_return_eligibility
core_output → status resolved
Execution C — wf_channel_web (a few minutes later)
normalize_incoming → channel_user_id "sess-abc",
customer_id from metadata "C-9931"
resolve_customer → customer_id "C-9931", verified_by "session"
call_core → sub-execution D
Execution D — wf_agent_core
memory: session_key "customer:C-9931" ← THE SAME
the loaded history includes the WhatsApp turn
triage_agent responds without asking again
That repeated line — the same session_key from two different channels — is literally this lesson's deliverable. It's what gets pointed out in lesson 8's demo, and it's what keeps the project from being "two chatbots."
If the agent asks what it's about, check in this order: that the table has both rows, that resolve_customer is returning the customer_id on both channels, and that the memory key is using the customer: prefix when there's a customer_id.
When identity gets it wrong
The lesson has an uncomfortable moment and it's worth looking at it head-on, because it's what separates a project that defends itself from one that falls apart at the first hard question.
Unifying the conversation by customer has a failure mode producing no error at all: if the resolution gets it wrong, a customer reads someone else's conversation. No red node, no alert, the execution comes out green. And these are the three paths it genuinely takes:
The shared phone. A family with one phone, two people who buy from TuTienda. Both write from the same number. Your table ties that number to a single customer_id, so one of them sees the other's history and orders. It's common and the fix isn't technical: the agent should ask a disambiguating question when the query doesn't match the resolved customer's orders, instead of assuming.
The reassigned phone. Someone changes numbers and months later a carrier assigns it to another person, who writes to TuTienda for the first time and inherits an identity. It's rare and it's real. The fix: expire identities with verified_by = 'crm_phone' after a certain time with no activity, and re-verify.
The shared web session. An office or café computer where someone didn't log out. The next person who opens the chat gets the previous person's customer_id. Here the responsibility is your web application's, not the agent's — but it's worth knowing the agent inherits the trust of whatever session feeds it, no more and no less.
None of the three gets fully solved in a project at this scale, and that's the honest answer. What does get done, and what turns a gap into a documented limitation, are two things.
The first: the written policy, which you already decided in lesson 2 and which now has a table backing it. For remembering and personalizing, any identity works. For reading customer data, session or crm_phone is needed. For sensitive actions, none is enough: a person's needed, and that person's going to see the verified_by in lesson 6's approval message.
The second: having the agent know it. One line in the system prompt closing out the shared-phone case:
# Added to triage_agent's System Message
If the customer mentions an order, a charge, or a piece of data
that doesn't show up associated with their account, do NOT assume
they got the number wrong or that the system failed. It could be
another person using the same device. Tell them that data doesn't
show up on their account and ask them to confirm the email they
bought with.
That line doesn't fix the problem. It turns a silent failure — the system serving the wrong person — into a question to the customer, which is a much better outcome and costs four lines.
Common mistakes
Building the channels before the core (practical). What happens: someone starts with the WhatsApp adapter because it's visible, and when something fails there are five suspects: Meta's credentials, the event filter, normalization, the contract, and the prompt. Why it happens: the channel is the part that shows and gives a sense of progress. How to spot it: if you've spent half an hour changing things without being able to say what changed what, this is it. How to fix it: complete phase 1 — the core run by hand from the editor with a fixed eight-field payload — before touching any trigger. With a tested brain, every channel has only one new suspect.
Testing the channel-switch case without having populated the table (practical). What happens: C7 gets run, the agent remembers nothing, and people start checking the memory configuration, the session key, and the Postgres node. Everything's fine: what's missing is the two rows. Why it happens: the INSERT is three lines in the middle of a phase full of nodes and it's extremely easy to skip. How to spot it: query the table before blaming anything else. How to fix it: run the INSERT, and verify the WhatsApp row's channel_user_id matches exactly what your adapter produces — with the number's format exactly as Meta sends it, no + and no spaces. It's the difference between it working and not, and it gives no hint.
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, the customer gets a blank message, and in n8n both executions show up successful. Why it happens: the option exists because there are cases where you don't want to wait, and its default 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. For a conversation, always.
Formatting in the core "because it's more convenient" (conceptual). What happens: someone puts the asterisk conversion inside wf_agent_core with a Switch per channel, because that way it's all in one place. The core learns about WhatsApp again and grows with every new channel. Why it happens: centralizing feels tidy. How to spot it: the graph-verification's point 4 exists exactly for this. How to fix it: formatting lives in the output adapter. The mental test is simple: if tomorrow you add a channel, do you have to open the core? If the answer's yes, something's in the wrong layer.
Leaving tools pointing at the old trigger (practical). What happens: lookup_order still has {{ $('Chat Trigger').item.json.customer_id }} in its WHERE condition, and that node no longer exists. The expression returns empty, the filter doesn't filter, and the query ends up returning the table's first five orders regardless of whose they are. The response to the customer looks perfectly normal. Why it happens: changing the trigger is one node and it isn't obvious there are five expressions pointing at it. How to spot it: search for Chat Trigger in the core's exported JSON; the correct number of appearances is zero. How to fix it: the graph verification's point 7, tool by tool, and a concrete test — ask for an order that's not the identified customer's and confirm it returns zero rows.
Exercises
Exercise 1 — Add the third channel. With the system working, add Telegram as a third adapter. Time how long it takes from opening n8n to C1 passing over Telegram. Then answer: what did you have to touch in wf_agent_core?
See solution
The typical time is between twenty and forty minutes, and most of it goes into the trigger, the credential, and Telegram's payload expressions — not into the agent.
The answer to the second question should be almost nothing, and that's the exercise's entire point:
- The system prompt's verbosity block gains one line:
- telegram: same as whatsapp. - Nothing else. Not a tool, not a specialist, not a rule, not memory.
If you had to touch anything else, look at what it was: it's a sign that thing was already in the wrong layer and the third channel exposed it. The two most frequent culprits: some format conversion that snuck into the core, and some field name an adapter produced differently that the core was tolerating with a chained fallback.
That's exactly the value of adding a third channel even if you don't need it: the third one is what audits the architecture. With two, a lot of impurities go unnoticed.
And a detail that comes up and is worth resolving well: Telegram sends buttons as callback_query, not as text. The contract rule is that text is always language, so the adapter translates order:4521 into "Check the status of order 4521." before calling the core. The core never finds out a button existed.
Why it works: the exercise turns "the architecture is good" into a number of minutes and a list of things touched, which is a much stronger argument than an opinion — and it's exactly the kind of fact that gets cited in an interview.
Exercise 2 — Break identity on purpose. Set up the shared-phone scenario: add a second row to channel_identities tying the same WhatsApp channel_user_id to a different customer (you're going to need to temporarily remove the primary key, or use another number). Write from that number asking about the other customer's order and document exactly what happens, layer by layer.
See solution
What gets observed, if your system's well set up:
At resolve_customer. With the primary key removed, the query returns two rows. The merge_identity node takes the first one — which is an arbitrary order, and that arbitrariness is already a finding. The system resolved an identity with no basis for choosing that one and not the other.
At the tool. The customer asks about order 4498, which belongs to the other customer_id. lookup_order filters by whatever customer_id got resolved and returns zero rows. Here's the exercise's good news: lesson 4's lever 3 works, and it works precisely in the case where identity failed. Two independent layers, and the second one covered the first one's failure.
In the response. This is where you see whether your prompt's complete. Without the line you added in this lesson, the agent usually responds "I can't find that order, are you sure about the number?" — which blames the customer for a system problem. With the line, it responds that order doesn't show up on their account and asks to confirm the email they bought with, which is honest and actionable.
And the finding not visible at any layer: memory. Both phone users share channel_user_id, so if the resolution sends them to the same customer_id, they share session_key and therefore share history. The tool protected them from seeing each other's orders; memory doesn't protect them from reading what the other wrote. It's this scenario's real gap, and the mitigation appropriate to the project's scale is documenting it — with the note that the product solution is asking for explicit identification on the first turn of every WhatsApp conversation, with the friction cost that has.
Restore the primary key when you're done. And note the finding in your document: a system where you can say "this fails in this case, I mitigated it this way, and this other case I left open on purpose because the cost of covering it isn't justified yet" defends itself infinitely better than one that never got questioned.
Why it works: identity is where a multichannel system genuinely breaks, and it's the part no tutorial covers. Having one documented failure with its fix and its limit is the best possible answer to "what did you do to it to know it works?"
Exercise 3 — Calculate TuTienda's WhatsApp cost. With Meta's current prices for your country, calculate how much it would cost to operate this system with 2,000 conversations a month, and write the result the way you'd tell it to the store's owner. Tell apart what depends on the channel from what depends on the model.
See solution
The exact number depends on your country and on the month you look at it, so what matters is the calculation's structure and how it gets presented.
The structure, which is stable:
Monthly cost = (conversations × WhatsApp price per conversation)
+ (conversations × tokens per conversation × price
per token)
+ infrastructure (self-hosted n8n: $0 + the server)
Three things worth saying about that calculation that almost nobody says:
WhatsApp conversations get counted per window, not per message. A customer sending six messages and getting six responses in one afternoon is one conversation, not twelve. That changes the number by a large factor, and confusing it is the most common estimation mistake.
This system's traffic is almost entirely customer-initiated. It's the service category, which in Meta's pricing structure gets different — and in several countries more favorable — treatment than marketing or utility conversations the business starts with a template. A customer support system's on the good side of that distinction, and it's worth saying because whoever's heard about "how expensive WhatsApp is" probably heard it from someone running campaigns.
The web channel costs zero. Every conversation happening in your site's chat pays no channel, just model. That turns a product decision — where to put the chat, how visible — into a direct cost lever, and it's an argument worth more in a meeting than any prompt optimization.
How you tell the owner, which is what the exercise asks for:
"The cost has two parts that behave differently. WhatsApp charges per conversation — not per message — and only when the customer writes first, which is 100% of our case; the price is set by Meta per country and it's worth reviewing every quarter because they adjust it. The model charges per token, and there I have the number measured on our own system: you have it in the cost sheet. With 2,000 conversations a month, the model's part is X and WhatsApp's is Y. And there's a lever that costs nothing: every conversation happening in the web chat instead of WhatsApp saves the entire channel part. If the widget were more visible on the order-tracking pages, some of the traffic would shift on its own."
Why it works: presenting cost by separating what depends on the channel from what depends on the model allows a conversation about levers instead of one about resignation. And the last sentence turns an expense report into a proposal, which is a difference in role.
Summary and next step
You now have the complete multichannel system. Lessons 3 and 4's brain lives in wf_agent_core, triggered by an Execute Sub-workflow Trigger declaring the contract's eight fields. Two thin adapters of five or six nodes each: they receive, normalize to the contract, resolve identity against channel_identities, call the core, format the output for their channel, and send. Memory lives in one single place, grouped by customer when known and by channel when not. And lesson 4's tools now filter by a customer_id coming from a table, with a record of how it got established.
And you have the three real-world paths where identity gets it wrong documented, with each one's mitigation and the honest limit of what this project covers.
Before moving on you should be able to: draw from memory the two nodes connecting a channel to the core; explain why the customer: prefix exists in the memory key; say what happens, at each layer, when customer_id arrives empty; and point out, in C7's trace, the line proving it was the same conversation.
What's next is the locks. Lesson 6 puts the input guardrail calibrated against legitimate cases and not against attacks, and sets up human approval over issue_refund with the five-field message — including the verified_by born today — its threshold policy calculated against the team's real capacity, and the clause stopping a rejection from getting renegotiated within the conversation. By the end of that lesson, lesson 4's table's uncomfortable line — "taking out money, no limit, no verification" — is going to get rewritten.
Resources
- Execute Sub-workflow Trigger — n8n Docs — the core's trigger and the field declaration turning your contract into something n8n verifies.
- Execute Sub-workflow node — n8n Docs — the node calling the core from every channel; confirm the wait-for-completion option on your version there.
- Chat Trigger — n8n Docs — the embedded mode, CORS, and
Load Previous Sessionfrom the web adapter. - @n8n/chat — npm —
createChat's options, includingmetadata, which is what makes the channel-switch case possible. - WhatsApp Trigger — n8n Docs — the webhook events and the warning about status events that need filtering.
- WhatsApp Business Cloud — n8n Docs — the sending operations and the two number fields that are easy to confuse.
- Postgres Chat Memory — n8n Docs — the shared store and the session-key selector where the identity decision lives.
- WhatsApp Business Platform — pricing — the current per-country, per-conversation-category pricing table; check it the day you put the system into production, because it changes.