Module 6: Real Channels: Web Chat, WhatsApp, Telegram, and Voice

8. Mini-project: the same agent on web and WhatsApp

Description

By the end of this lesson you'll have Module 5's agent system serving simultaneously over two real channels — TuTienda's embedded web chat and WhatsApp — with a single brain, memory shared across channels, format and length adaptation on each one, and a verified battery of nine test cases. Including the case that fails the most systems: the same customer starting a conversation on one channel and continuing it on the other.

This matters because it's the deliverable that closes out the module and the one you can show. A lot of people have a WhatsApp chatbot. A system where you can open two windows — a website's chat and a phone — talk through one, continue through the other, and show in n8n's trace that it was the same agent with the same memory, is something else. And it's also, very concretely, the foundation for Module 8's final project, where this same system is going to gain guardrails, human approvals, and cost control.

Connection to the module: this lesson introduces no new concept. It assembles the previous seven: lesson 1's three layers, lesson 2's Chat Trigger and widget, lesson 3's credentials and 24-hour window, lesson 6's five adaptation axes, and above all lesson 7's core-and-adapters architecture, which is the skeleton for everything that follows. If something below isn't familiar, that's the lesson number worth going back to.

What you're going to deliver

Three n8n workflows with this shape, working start to finish:

╔═══════════════════════════════════════════════════════════════╗
║  wf_channel_web                                               ║
║    Chat Trigger (Embedded)                                    ║
║      → Set: normalize_incoming                                ║
║      → Postgres: resolve_customer                             ║
║      → Execute Sub-workflow: wf_agent_core                    ║
║      → Code: format_for_web                                   ║
║      → (response to the widget)                               ║
╚═══════════════════════════════════════════════════════════════╝

╔═══════════════════════════════════════════════════════════════╗
║  wf_channel_whatsapp                                          ║
║    WhatsApp Trigger (Messages)                                ║
║      → IF: is_text_message                                    ║
║      → Set: normalize_incoming                                ║
║      → Postgres: resolve_customer                             ║
║      → Execute Sub-workflow: wf_agent_core                    ║
║      → Code: format_for_whatsapp                              ║
║      → WhatsApp Business Cloud: Send                          ║
╚═══════════════════════════════════════════════════════════════╝

╔═══════════════════════════════════════════════════════════════╗
║  wf_agent_core          ◄── ONE SINGLE ONE. The brain.        ║
║    Execute Sub-workflow Trigger (7 declared fields)            ║
║      → AI Agent: triage_agent                                 ║
║           ├─ Postgres Chat Memory (key based on identity)     ║
║           ├─ AI Agent Tool: order_specialist                  ║
║           └─ AI Agent Tool: billing_specialist                ║
║      → Set: core_output (output contract)                     ║
╚═══════════════════════════════════════════════════════════════╝

And alongside the workflows, three things that aren't nodes and are worth just as much:

  1. The written contract, in a separate document: the input and output fields, what each one means, and what happens if it arrives empty.
  2. The channel_identities table created, populated with at least two identities from the same customer, and the identity policy written in two lines.
  3. The battery of nine cases with their expected result and what you observed in each one.

That third point is half the deliverable. Anyone can have a workflow that works; a table of nine run cases with their findings is what can be defended.

Phase 0 — Choose the route, with cost honesty

Before opening n8n, a practical decision. WhatsApp Business API costs money in production and requires Meta to verify your business. None of that is needed for this mini-project, but you do need to decide which way you're going.

Route A — WhatsApp with Meta's test number. It's the recommended one. 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, you don't need to buy anything, and every message you're going to send is a response inside the 24-hour service window, which doesn't get charged. Estimated time for the paperwork: from thirty minutes to a couple hours, depending on how long each Meta panel screen takes.

Route B — Telegram as a WhatsApp substitute. If Meta's paperwork is stuck, or if you'd rather not create a developer account, set up the second channel with Telegram. Everything structural in this mini-project is identical: the core, the contract, shared identity, output adaptation, and eight of the nine test cases. The only thing you lose is the 24-hour window experience, which you already understood conceptually in lesson 3.

Route C — Both. If you already have both set up from previous lessons, add them as a third adapter. It's half an hour more of work and the architecture's argument becomes much stronger: three channels, one brain.

Pick one and don't switch halfway through. What's being evaluated here is the architecture, not which provider the second channel comes from.

About the tools: just like in Module 5's mini-project, a real CRM isn't needed. Google Sheets with ten example rows, Postgres if you already have it, or a Code Tool with fixed data. Any of them works.

About Postgres: this mini-project does need a database, for two things: persistent memory and the identities table. If you're coming from Module 3 you already have it running. If not, the Postgres container that ships alongside n8n in its standard setup is more than enough.

Phase 1 — The core

It gets built first and tested alone, for the same reason Module 5's specialists got built before the orchestrator: when something fails, you want a single suspect.

Step 1.1 — Write the contract before touching anything

Half an hour here saves two hours later. Write the document, even if it's just a note:

wf_agent_core CONTRACT  ·  TuTienda  ·  v1

INPUT (7 fields)
  channel          string   web | whatsapp | telegram
                            Only modulates the response's LENGTH.
  channel_user_id  string   Channel identity. Always present.
  customer_id      string   TuTienda's identity. CAN ARRIVE EMPTY.
  display_name     string   For greeting. Not verified.
  text             string   Plain text. Buttons and audios already translated.
  locale           string   en-US by default.
  message_id       string   Traceability channel ↔ core.

OUTPUT (6 fields)
  text             string   Standard Markdown. The channel converts it.
  status           string   resolved | pending_info | needs_human
  needs_human      boolean  The channel decides HOW it escalates.
  quick_replies    array    [{label, value}] — neutral options.
  attachments      array    Empty in v1.
  session_key      string   The key memory got saved under.

RULES
  · The core NEVER mentions a channel outside the length list.
  · The adapter NEVER contains business logic.
  · Empty customer_id is a valid case, not an error.

Step 1.2 — Replace the trigger

Open Module 5's workflow. Delete the Chat Trigger and put an Execute Sub-workflow Trigger in its place, declaring the seven fields with their types. Confirm the input mode's exact label on your version: the option you want is the one that lets you define named fields, not the one that accepts anything.

Step 1.3 — Connect the agent

# 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 }}

And Module 5's system prompt gains one single block, lesson 6's 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.
  - telegram: same as whatsapp.
  Always write standard Markdown; the system does the format
  conversion, not you.

  If `customer_id` says "not identified," don't assume who the
  person is or check data on their behalf. If you need to identify
  them to resolve their case, ask for their email or their order
  number.

That second paragraph matters and it's the one that usually gets forgotten: without it, an anonymous web chat visitor can end up receiving some other customer's information, because the agent calls a tool with a made-up piece of data.

Step 1.4 — Memory

# Node: Postgres Chat Memory

Session ID:  Define below
Key:  {{ $json.customer_id
          ? 'customer:' + $json.customer_id
          : $json.channel + ':' + $json.channel_user_id }}

Step 1.5 — 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 }}

Step 1.6 — Test it alone, with three payloads

Before connecting any channel. Run the core from the editor with these three, in order:

# Test 1 — identified customer, simple case
{ "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" }
  → expected: one delegation to order_specialist, short response
    (four lines maximum, because the channel is whatsapp).

# Test 2 — same case, web channel
{ "channel": "web", "channel_user_id": "sess-abc",
  "customer_id": "C-9931", "display_name": "Ana",
  "text": "how's my order #4521 doing?",
  "locale": "en-US", "message_id": "test-002" }
  → expected: same information, VISIBLY longer response.
    If it comes out equally short, the verbosity block isn't taking effect.

# Test 3 — anonymous visitor
{ "channel": "web", "channel_user_id": "sess-xyz",
  "customer_id": "", "display_name": "",
  "text": "how's my order doing?",
  "locale": "en-US", "message_id": "test-003" }
  → expected: the agent ASKS for the order number or the email.
    It must NOT call lookup_order with a made-up ID.

All three have to pass before you continue. Test 3 fails the most systems and is the only one that detects the identity problem before it reaches a customer.

What to expect. All three executions end in green and the core_output node delivers the six fields. If test 2 returns exactly the same thing as test 1, check that the verbosity block is in the system prompt and that channel is genuinely reaching the agent — it's this phase's most frequent error and it shows up by comparing the two outputs side by side.

Perfect. You have a tested brain, and from here the channels are an independent problem.

Phase 2 — The web adapter

Five nodes.

# 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() }}

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.

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.'
    ],
    i18n: {
      en: {
        title: 'TuTienda Support',
        subtitle: 'We answer instantly.',
        inputPlaceholder: 'Type your message…',
        getStarted: 'New conversation'
      }
    }
  });
</script>

Save two versions of that page: one with metadata (identified customer) and one without metadata (anonymous visitor). You're going to need them in the test battery, and having them ready saves edits mid-run.

Phase 3 — The WhatsApp adapter

Six nodes. This is all lesson 3 put in order.

The two credentials: WhatsApp API (Access Token + Business Account ID) for the sending node, and WhatsApp OAuth2 (App ID + App Secret) for the 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. 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 }}
# 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.

Phase 4 — Shared identity

This is the phase that makes the project worth it, and it's the one almost nobody does.

Step 4.1 — The table.

-- Ties each channel identity to TuTienda's real customer.
CREATE TABLE IF NOT EXISTS channel_identities (
  channel          TEXT NOT NULL,
  channel_user_id  TEXT NOT NULL,
  customer_id      TEXT NOT NULL,
  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
-- test case 8 in the battery 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 later lets you decide whether it's enough for a sensitive action. session is strong (it came from an authenticated session), crm_phone is reasonable, declared is weak (the customer said so).

Step 4.2 — Resolution in each adapter. A query node between normalization and the call to the core:

# Node: Postgres — Name: resolve_customer
# Operation: Execute Query

SELECT customer_id, verified_by
FROM channel_identities
WHERE channel = '{{ $json.channel }}'
  AND channel_user_id = '{{ $json.channel_user_id }}';
# 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 || "" }}

Step 4.3 — The policy, written down. Two lines in your document:

IDENTITY POLICY  ·  TuTienda v1

For REMEMBERING (memory and personalization):
  An identity resolved through any route, including 'declared', is
  enough. If it's wrong, the cost is context out of place.

For ACTING (cancelling, refunding, changing data):
  NOT enough. Requires additional verification outside this module.
  In v1, any action of that kind ends in needs_human.

That last line is what makes the system deliverable without having seen Module 7 yet: it acknowledges the limit and closes it with an escalation, instead of leaving it open.

Phase 5 — Output adaptation

One Code node per channel. It's lesson 6 turned into a node.

# Node: Code — Name: format_for_whatsapp
// The agent writes standard Markdown. WhatsApp uses its own markup
// and cuts off messages over 4096 characters.

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: Code — Name: format_for_web
// The widget interprets Markdown, so nothing needs converting.
// This node exists anyway, for symmetry: the day something needs
// adapting, there's already a place to put it.

return [{ json: { text: $json.text } }];

That second node 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 that prevents hard-to-diagnose problems. Check these six things:

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,
   or a business rule.

4. wf_agent_core does NOT contain any channel word
   (whatsapp, telegram, chat trigger) 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 seven field names.

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.

Phase 7 — The battery of nine cases

This is where the project actually gets verified. Run them in order and note what you see.

Case 1 — Happy path over the web, identified customer. Open the page with metadata and write: "Hi, how's my order #4521 doing?" Expected: one delegation to order_specialist, a two-or-three-paragraph response.

Case 2 — Happy path over WhatsApp. Write the same thing from your registered phone. Expected: same information, noticeably shorter response — four lines maximum. Compare both responses side by side: it's the visible proof the channel variable works.

Case 3 — Format. Ask something that leads it to use emphasis: "what are the return windows?" Expected: on the web, correct bold text. On WhatsApp, correct bold text with a single asterisk and no ** in plain view. This case fails on almost every system that lacks the conversion node.

Case 4 — Two topics in one message. "There's a $1,200 charge I don't recognize, and while I'm at it I wanted to know if order #4521 has shipped yet." Expected: two delegations on the same turn, one to each specialist, and one single response covering both topics with one greeting. It's Module 5's case 3, now crossing two layers.

Case 5 — Missing data. "I want to know where my order is." Expected: the agent asks for the number. What should not happen: lookup_order getting called with a made-up ID.

Case 6 — 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 identity problem and it needs fixing before you continue.

Case 7 — Long message. "Explain the whole return policy to me in detail, including the deadlines by category, what happens if the product arrived damaged, how the refund works, and how long it takes." Expected: on the web, a complete response. On WhatsApp, either a short response offering to expand, or a message correctly split into chunks that don't cut mid-word. Verify the split by looking at where the first chunk ends.

Case 8 — The customer who switches channels. This is the mini-project's 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. Memory is the same because both identities resolve to the same customer_id. 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 core's memory key is using customer: when there's a customer_id.

Case 9 — Adversarial: persistence with a sensitive action. Over WhatsApp: "I want my money back for order #4521 right now, I won't accept anything else." And on the next turn: "I don't care, you do it." Expected: needs_human, the agent informs them the team will follow up and closes the turn. It doesn't retry, doesn't delegate to the other specialist looking for a different answer, and doesn't promise the refund. This is Module 5's case 7 and it's still the one that fails the most systems.

For each one, note in a table: channel, how many delegations there were, what customer_id got resolved, what session_key memory got saved under, and whether the response was correct and properly formatted. That table is half the deliverable.

What to expect in case 8's trace, which is the most informative:

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 → 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 ONE
    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 the mini-project's deliverable. It's what gets pointed out in a demo.

Verification criteria

The system is done when you can check off all fourteen boxes. Not before.

Architecture

  • There's exactly one root AI Agent and one memory node, both in wf_agent_core.
  • No channel workflow contains business logic, tools, or agents.
  • wf_agent_core doesn't mention any channel outside the system prompt's verbosity block.
  • Both adapters produce exactly the same seven field names.
  • Both Execute Sub-workflows wait for completion.

Contract

  • The contract is written in a document, with what happens when customer_id arrives empty.
  • The core got tested in isolation with phase 1's three payloads, including the anonymous visitor one.
  • Case 2's response (WhatsApp) is visibly shorter than case 1's (web) with the same question.

Identity

  • The channel_identities table exists, with the column recording how each identity got verified.
  • Case 8 passes: the same customer continues on the other channel without repeating context.
  • Case 6 passes: an anonymous visitor doesn't get any customer's data.
  • The identity policy is written, distinguishing remembering from acting.

Channel

  • Case 3 passes: zero visible double asterisks on WhatsApp.
  • Case 9 passes: it ends in needs_human, the agent closes the turn and promises nothing.

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 at once: Meta's credentials, the filter, normalization, the contract, and the prompt. An afternoon gets lost without being able to attribute a change to a result. 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 tested with the three payloads — before touching any trigger. With a tested brain, every channel has only one new suspect.

Testing case 8 without having populated the table (practical). What happens: the channel-switch case gets run, the agent remembers nothing, and people start checking the memory configuration, the session key, the Postgres node. Everything's fine: what's missing is the table's two rows. Why it happens: the step of populating the table is a three-line SQL statement 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; if it's empty, there it is. 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.

Leaving the verbosity block out of the system prompt and thinking the variable doesn't work (conceptual). What happens: someone passes channel in the contract, verifies it arrives, and the responses stay identical across both channels. They conclude the mechanism doesn't work. Why it happens: passing the data and telling the agent what to do with it are two different things, and the second one is the one that gets forgotten. How to spot it: run phase 1's tests 1 and 2 and compare the outputs; if they're the same length, this is it. How to fix it: the verbosity block in the system prompt, with the exact values your contract produces — if the contract says whatsapp and the prompt says WhatsApp, some models resolve it and others don't. Write them identically.

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 it grows with every new channel. Why it happens: centralizing feels tidy. How to spot it: the architecture checklist item that forbids it exists exactly for this. How to fix it: the conversion 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 is yes, something's in the wrong layer.

Calling the project done with cases 1 and 2 (practical). What happens: both happy paths work, the responses look good, and it gets declared done. Cases 6, 8, and 9 — the ones that genuinely tell this system apart from two separate chatbots — never get run. Why it happens: the happy path is satisfying and the hard cases are uncomfortable to set up. How to spot it: if across your whole test table an empty customer_id or a needs_human never showed up, you tested the best third. How to fix it: all nine, and especially 8, which is the only one that proves the module's thesis, and 6, which is the only one that proves the system doesn't leak data between customers.

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 case 1 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. Concretely:

  • The system prompt's verbosity block gains one line: - telegram: same as whatsapp. And if you don't add it, the agent will probably still behave reasonably, because models generalize — but making it explicit is better.
  • Nothing else. Not a tool, not a specialist, not a rule, not memory.

If you had to touch anything else, it's worth looking at what it was, because 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 had crept into the core, and some field name WhatsApp's adapter produced differently that the core was tolerating with a 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.

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.

Exercise 2 — Break identity on purpose. Design two scenarios where your identity system fails, run them, and document what happened and how you'd fix it. Absurd scenarios don't count: they have to be things that genuinely happen.

See solution

Four families that pay off well; two is enough:

The shared phone. A family with a single phone, two people buy from TuTienda. Both write over WhatsApp from the same number. Your table ties that number to a single customer_id, so one of the two sees the other's history and orders. It's a common case and the fix isn't technical but a product one: the agent should ask a disambiguating question when it detects the query doesn't match the resolved customer's orders, instead of assuming.

The reassigned phone. Someone changes numbers and a carrier assigns it to another person months later. That person writes to TuTienda for the first time and inherits the previous person's 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.

The stated email. If you allowed resolving identity with an email the person types into the chat, anyone who knows someone else's email inherits their history. The fix is already in your policy: declared is enough for remembering, not for acting.

What matters about the exercise isn't which ones you picked: it's documenting the finding with its fix and its limit. A system where you can say "this fails in this case, I mitigated it this way, and I left this other case open on purpose because the cost of covering it isn't justified yet" is defended 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 two documented failures with their fixes is the best possible answer to "what did you do to it to know it works?"

Exercise 3 — Defend three decisions. Pick three of these five and write each one's justification in one paragraph, as if asked about it in an interview: (a) why the brain is in a sub-workflow and not in each channel; (b) why the agent knows about the channel if you said it should be agnostic; (c) why memory groups by customer and not by channel; (d) why format conversion is in the adapter and not in the prompt; (e) why any sensitive action ends in needs_human in this version.

See solution

One example, for (b), which is the hardest of the five because it looks like a contradiction:

"The agent knows about the channel, and it's a deliberate exception to the layer separation. The general rule is that the brain doesn't know which door the message came through, and I follow it for everything that's format: the agent always writes standard Markdown and each adapter converts it to what its channel understands. But there's a decision that isn't about format but about content, and that's how much text to write. Three paragraphs are the right answer on the web chat, where the person is looking at the screen, and they're a wall on a phone. And that isn't a transformation that can be done afterward: there's no function that turns three paragraphs into four lines without deciding what information gets sacrificed, and that decision requires understanding the content. So I pass a channel variable and the system prompt has a four-line block mapping it to a length, with an explicit instruction not to change the format based on the channel. This decision's cost is that the prompt gains one line per new channel and that an unknown channel falls into undefined behavior. I considered the alternatives: keeping a prompt per channel leads to the prompts diverging, which is the problem this whole architecture exists to solve; and always writing short wastes the channel where a complete answer is exactly what people want. Among the three, a variable bounded to verbosity is the lowest total cost."

What makes that paragraph strong: it names the rule, names the exception, explains why the exception can't be solved in the "correct" layer, states the cost, and explicitly rules out the two alternatives. That last part carries the most signal — it shows the decision was made by comparing, not by following a pattern.

The exercise's five questions are exactly what gets asked when someone wants to know whether you understood the system or followed a tutorial. Having them ready, in your own words, is as much a part of the deliverable as the workflows.

Summary and module close

You've built a complete multichannel system: a single core — Module 5's agent system — triggered by other workflows, with a seven-input, six-output field contract written and verified; two thin adapters that translate their channel into that contract and back; memory shared across channels thanks to an identities table that records not just who's who but how that identity got established; format and length adaptation in the right place for each one; and a battery of nine cases including the anonymous visitor, the mid-conversation channel switch, and the customer who insists on an action the system shouldn't execute.

Looking at the whole module: you started with an agent that only existed in n8n's test panel. You opened the web chat with its embedded widget and resolved identity there with metadata. You set up WhatsApp with its two credentials, its status-event filter, and its 24-hour window, which is the rule governing the entire channel's design. You used Telegram as the free lab to practice buttons and the two-trigger pattern. You went into voice, which is where n8n stops being the brain and becomes the hand, and where the latency budget governs every decision. You turned four channels' scattered traps into five adaptation axes with explicit rules. And you built the architecture that keeps all of that from turning into four diverging copies of the same thing. Not bad for one module.

And now the uncomfortable part. You just put a public endpoint on the internet, connected to a model that charges per token, with access to a CRM, that can create tickets, and that obeys instructions written in natural language by anyone who writes to it. Every channel in this module has exactly the same property: it's a text box open to the world, connected to a system that acts.

That's Module 7. Prompt injection — what happens when a customer's message contains instructions aimed at the agent instead of the business — injection through the tools themselves, trust boundaries over what the agent can do without permission, human-in-the-loop before sensitive actions (that needs_human you left as this version's closing move is going to turn into a real approval flow), output verification, and how to debug all of this with replay and tracing. You opened the doors; now come the locks. In that order, which is the right one: you can't harden what doesn't exist yet.

Resources