|
| 1 | +# Chat agent patterns: shell + core + sub-agents |
| 2 | + |
| 3 | +For external chat surfaces — Slack, Discord, Microsoft Teams, Telegram, embedded webhook chats. The building blocks (memory, tools, sub-workflow-as-tool, structured output) live in their own references; this file covers the **multi-workflow composition** production chat agents grow into, plus chat-surface gotchas the other refs don't. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## The one non-negotiable: anti-loop filtering |
| 8 | + |
| 9 | +**Any chat-triggered workflow that posts a reply MUST filter out the bot's own user ID right after the trigger, or it triggers itself forever** — every reply fires another run, until rate limits or n8n concurrency stop it (and it can take n8n down with it). That's the minimum bar for **every** bot, simple or complex. |
| 10 | + |
| 11 | +**Prefer trigger-level filtering when the trigger supports it** — the loop then breaks before any downstream node runs. Semantics differ per surface; verify against your version: |
| 12 | + |
| 13 | +- **Slack** (`n8n-nodes-base.slackTrigger`): `options.userIds` is an **exclusion list** — listed users are dropped before the workflow runs. Put the bot's user ID here. (Verified in the trigger source: it returns early `if (userIds.includes(event.user))`.) |
| 14 | +- **Telegram** (`n8n-nodes-base.telegramTrigger`): `additionalFields.userIds` is an **inclusion / allowlist** (only listed users fire). NOT a bot-exclusion filter — and Telegram bots don't see their own messages by default, so anti-loop usually isn't needed. Use the allowlist to restrict a private bot to specific humans. |
| 15 | +- **Discord, Teams**: no native user-level trigger filter — use the downstream Filter node. |
| 16 | + |
| 17 | +Slack trigger-level example: |
| 18 | + |
| 19 | +```json |
| 20 | +{ |
| 21 | + "parameters": { |
| 22 | + "trigger": ["message"], |
| 23 | + "channelId": { "__rl": true, "mode": "list", "value": "<CHANNEL_ID>" }, |
| 24 | + "options": { "userIds": "={{ [\"<BOT_USER_ID>\"] }}" } |
| 25 | + }, |
| 26 | + "type": "n8n-nodes-base.slackTrigger" |
| 27 | +} |
| 28 | +``` |
| 29 | + |
| 30 | +When the trigger doesn't expose a usable exclusion filter, the first node after the trigger must drop the bot's own ID: |
| 31 | + |
| 32 | +```json |
| 33 | +{ |
| 34 | + "parameters": { |
| 35 | + "conditions": { |
| 36 | + "conditions": [ |
| 37 | + { |
| 38 | + "leftValue": "={{ $json.user }}", |
| 39 | + "rightValue": "<BOT_USER_ID>", |
| 40 | + "operator": { "type": "string", "operation": "notEquals" } |
| 41 | + } |
| 42 | + ] |
| 43 | + } |
| 44 | + }, |
| 45 | + "type": "n8n-nodes-base.filter" |
| 46 | +} |
| 47 | +``` |
| 48 | + |
| 49 | +The bot user ID is the API ID from your bot's auth (Slack `bot_user_id`, Discord application ID, Teams `botId`). |
| 50 | + |
| 51 | +--- |
| 52 | + |
| 53 | +## When to split into shell + core + sub-agents |
| 54 | + |
| 55 | +Beyond the anti-loop filter, a **simple bot (one trigger → one agent → one reply, with the filter)** lives fine in a single workflow. The shell + core + sub-agents split is for production robustness — it earns its keep once any of these is true: |
| 56 | + |
| 57 | +- The bot needs loading-state UX (typing indicator, reaction, placeholder) and graceful error handling beyond a single message. |
| 58 | +- It's invoked from more than one surface (Slack AND Discord). |
| 59 | +- There are specialist domains the agent shouldn't carry inline (Notion DB schema, CRM custom fields, Linear labels). |
| 60 | +- The agent or its tools will be reused across workflows. |
| 61 | + |
| 62 | +If none apply, keep it in one workflow (filter still in place). The shape when you do split: |
| 63 | + |
| 64 | +``` |
| 65 | +[chat-surface workflow] ──► [agent core workflow] ──► [sub-agent workflows] |
| 66 | +("the shell") ("the brain") ("specialists") |
| 67 | +
|
| 68 | +- Trigger from the surface - Stateless - One narrow domain each |
| 69 | +- Anti-loop filter - chatInput + threadId - chatInput only |
| 70 | +- Routing / event types - Memory keyed on threadId - Their own tools + model |
| 71 | +- Loading + error UX - Tools, sub-agents |
| 72 | +- Render the reply - No surface concerns |
| 73 | +``` |
| 74 | + |
| 75 | +See **EXAMPLES.md** for a Slack router shell and a domain sub-agent snippet. |
| 76 | + |
| 77 | +--- |
| 78 | + |
| 79 | +## The shell |
| 80 | + |
| 81 | +Receives chat events, decides whether to respond, manages UX, calls the core, renders the reply. No reasoning, no LLM. |
| 82 | + |
| 83 | +### Switch on event type |
| 84 | + |
| 85 | +The same trigger fires for messages, reactions, mentions, slash commands, button clicks. One Switch right after the anti-loop filter routes each to the right handler: |
| 86 | + |
| 87 | +``` |
| 88 | +"owner message" → Execute Workflow: agent-core |
| 89 | +"owner reaction" → no-op (or a reaction handler) |
| 90 | +"unknown user" → canned reply |
| 91 | +"slash command: /summary" → Execute Workflow: summary-command |
| 92 | +"button click" → Execute Workflow: interaction-handler |
| 93 | +``` |
| 94 | + |
| 95 | +Each case is its own sub-workflow because the routing decision and the work are different concerns (different models, timeouts, memory shapes). Adding a slash command means one Switch output + one sub-workflow, not a new top-level trigger. |
| 96 | + |
| 97 | +Slack-specific notes (payload shapes evolve — verify against a live event before hardcoding paths): reactions/mentions flow through the Slack Trigger as Events API events; **slash commands and Block Kit button clicks generally don't** (Slack delivers those to separate Request URLs). Bring them in via a second Webhook node feeding the same Switch, or a community Socket Mode node. Slash commands expose a `command` field; Block Kit interactions arrive with `type === 'block_actions'` and an `actions` array. |
| 98 | + |
| 99 | +### Loading-state UX |
| 100 | + |
| 101 | +Users assume nothing is happening without acknowledgement. Pattern: **add a loading indicator before the agent call, remove it on every exit path — including error.** |
| 102 | + |
| 103 | +``` |
| 104 | +[Trigger] → [Filter bot] → [Switch] |
| 105 | + → (owner message) |
| 106 | + → [Add loading reaction] (:spinner:, etc.) |
| 107 | + → [Execute Workflow: Agent core] onError: 'continueErrorOutput' |
| 108 | + ├── (success) → [Remove reaction] → [Send reply] |
| 109 | + └── (error) → [Remove reaction] → [Send error message with link] |
| 110 | +``` |
| 111 | + |
| 112 | +The error path is the easy one to forget — without it the indicator sits forever and the user thinks the bot is still working. `onError: 'continueErrorOutput'` on the Execute Workflow node enables the second branch (→ **n8n-error-handling**). For Discord/Telegram, typing indicators are time-bounded; for long agents send a placeholder message and edit it. |
| 113 | + |
| 114 | +### Threading as session continuity |
| 115 | + |
| 116 | +Use the surface's thread primitive as the memory `sessionKey`: |
| 117 | + |
| 118 | +```json |
| 119 | +"workflowInputs": { |
| 120 | + "value": { |
| 121 | + "chatInput": "={{ $('Filter bot').item.json.text }}", |
| 122 | + "threadId": "={{ $('Filter bot').item.json.thread_ts || $('Filter bot').item.json.ts }}" |
| 123 | + } |
| 124 | +} |
| 125 | +``` |
| 126 | + |
| 127 | +`thread_ts || ts` is the canonical Slack idiom: replies in a thread carry `thread_ts` (referencing the parent), the parent itself only has `ts`. Falling back to `ts` makes the parent message the session key for its thread, so each thread is a fresh conversation and memory doesn't leak across threads. **User ID, channel ID, or workspace ID alone are wrong — they cross conversations.** When sending the reply, target the same thread (`otherOptions.thread_ts.replyValues.thread_ts` = the same `thread_ts || ts`). |
| 128 | + |
| 129 | +### Error UX: surface, don't hang |
| 130 | + |
| 131 | +The error branch sends a short message with a link to the failed execution: |
| 132 | + |
| 133 | +``` |
| 134 | +There was a workflow error. https://<n8n-host>/workflow/<id>/executions/{{ $execution.id }} |
| 135 | +``` |
| 136 | + |
| 137 | +`$execution.id` is the live execution ID at the time the error fires. Parameterize the host across environments. |
| 138 | + |
| 139 | +--- |
| 140 | + |
| 141 | +## The agent core |
| 142 | + |
| 143 | +A sub-workflow with two declared inputs: `chatInput` (the user's message) and `threadId` (the surface's thread/session ID). Returns the agent's final output — a string, a structured object, or a surface-specific envelope (Block Kit, adaptive card). |
| 144 | + |
| 145 | +The only chat-specific wiring beyond **MEMORY.md** is plumbing `threadId` straight to `sessionKey`: |
| 146 | + |
| 147 | +```json |
| 148 | +"sessionIdType": "customKey", |
| 149 | +"sessionKey": "={{ $json.threadId }}" |
| 150 | +``` |
| 151 | + |
| 152 | +`threadId` flows trigger → (pass-through nodes) → memory. Don't put it behind `$fromAI`. |
| 153 | + |
| 154 | +Per-execution context (user identity, attached files) goes in a Set node before the agent and gets templated into the system prompt (→ **SYSTEM_PROMPT.md** "file-handling injection" and "piecing"). Don't add a Set node speculatively — inline in `systemMessage` is fine until reuse is real. |
| 155 | + |
| 156 | +**Block Kit / adaptive cards: pair the agent with `outputParserStructured`** (→ **STRUCTURED_OUTPUT.md**). The "use `schemaType: 'manual'` with a real JSON Schema" guidance applies even harder here: Block Kit and adaptive cards lean on `oneOf` union types across block kinds plus per-block enums (`style`, etc.) — `jsonSchemaExample` can't express any of it, and will produce confidently-wrong block trees the surface rejects. |
| 157 | + |
| 158 | +### Block Kit envelope gotcha (Slack) |
| 159 | + |
| 160 | +When the agent returns Block Kit and you post it via the Slack node's `blocksUi`, the value must be an object shaped `{ "blocks": [...] }` where the value is a **real array**, not the array alone and not a stringified one: |
| 161 | + |
| 162 | +``` |
| 163 | +✅ ={{ { "blocks": $('Call Agent core').item.json.output.blocks } }} |
| 164 | +❌ ={{ $('Call Agent core').item.json.output.blocks }} |
| 165 | +``` |
| 166 | + |
| 167 | +Passing only the array fails **silently** — the Slack node accepts the input, the message posts with no rich content, and there's no error or warning. → **n8n-node-configuration** `NODE_FAMILY_GOTCHAS.md` (Slack section). |
| 168 | + |
| 169 | +--- |
| 170 | + |
| 171 | +## Sub-agents (an agent as a tool) |
| 172 | + |
| 173 | +A sub-agent is its own workflow with its own Agent node, called from the router agent via `.toolWorkflow`. Reach for one when: |
| 174 | + |
| 175 | +- The domain has a schema/enum set the router shouldn't carry (Notion DB properties, Linear labels, CRM fields). |
| 176 | +- The domain has 5+ tools that would clutter the router's tool list. |
| 177 | +- The capability is reused across more than one router. |
| 178 | +- The domain warrants a different (cheaper, faster) model than the router. |
| 179 | + |
| 180 | +**The contract is stateless.** The router sends the full request in `chatInput` — no shared memory, no implicit context. Reinforce it in both the tool description (router-side) AND the sub-agent's system prompt (callee-side): |
| 181 | + |
| 182 | +> IMPORTANT: This tool is stateless. Send all relevant context in a single message. If you need to create an entry, include ALL required fields upfront. |
| 183 | +
|
| 184 | +Without that, the router assumes implicit context and the sub-agent guesses. Everything else about wiring sub-workflows as tools → **SUBWORKFLOW_AS_TOOL.md**. |
| 185 | + |
| 186 | +### Fresh schema injection |
| 187 | + |
| 188 | +When the domain schema can change at runtime (Notion DB options evolve, Linear teams add labels), refetch it on every sub-agent call instead of hardcoding it: |
| 189 | + |
| 190 | +``` |
| 191 | +[Execute Workflow Trigger] |
| 192 | + ↓ |
| 193 | +[Notion: Get Database] # fetches the live schema |
| 194 | + ↓ |
| 195 | +[Agent] system prompt template includes: |
| 196 | + ## Database Schema |
| 197 | + {{ $('Get a database').first().json.properties.toJsonString() }} |
| 198 | +``` |
| 199 | + |
| 200 | +One extra API call per invocation; in exchange the sub-agent never returns "that property doesn't exist" because the prompt is stale. Worth it for low-volume chat assistants. For high-volume hot paths, cache the schema in a Data Table with a TTL. |
| 201 | + |
| 202 | +--- |
| 203 | + |
| 204 | +## Anti-patterns |
| 205 | + |
| 206 | +| Anti-pattern | What goes wrong | Fix | |
| 207 | +|---|---|---| |
| 208 | +| No bot-user-ID filter at the top of the shell | Bot's own messages re-trigger the workflow — infinite loop | Trigger-level exclusion (Slack `options.userIds`) or a Filter on `$json.user !== '<BOT_USER_ID>'` first | |
| 209 | +| Bot ID in Telegram's `userIds` expecting exclusion | It's an **allowlist** — only the bot would fire, so no human gets through; looks "fixed" but is silent | Telegram bots don't see their own messages; use `userIds` only to allowlist humans | |
| 210 | +| Loading indicator removed only on success | User sees the bot stuck "thinking" forever after any error | `onError: 'continueErrorOutput'` + remove on both branches | |
| 211 | +| User/channel/workspace ID as the session key | Conversations cross threads in the same channel | Use the thread primitive (Slack `thread_ts || ts`) | |
| 212 | +| One workflow when multi-surface/sub-agent/reuse is already needed | Can't reuse, UX leaks into reasoning, hard to test in isolation | Split into shell + core + sub-agents (only once a need is real) | |
| 213 | +| Sub-agent that reads/writes shared memory | Caller can't reason about behavior, not safely retryable | Sub-agents are stateless — full context in `chatInput` | |
| 214 | +| Hardcoded domain schema in a sub-agent's prompt | Schema rots, sub-agent picks invalid options later | Re-fetch and template it at runtime | |
| 215 | +| Passing the bare blocks array to `blocksUi` | Slack posts an empty message, no error | Wrap as `{ "blocks": [...] }` with a real array | |
| 216 | + |
| 217 | +--- |
| 218 | + |
| 219 | +## Cross-references |
| 220 | + |
| 221 | +- Tool naming, descriptions, `$fromAI` → **TOOLS.md** |
| 222 | +- The `.toolWorkflow` shape and parameter mapping → **SUBWORKFLOW_AS_TOOL.md** |
| 223 | +- Per-execution context, file injection, prompt storage → **SYSTEM_PROMPT.md** |
| 224 | +- Parser config, autoFix, fixer model → **STRUCTURED_OUTPUT.md** |
| 225 | +- Memory types, `sessionKey` persistence → **MEMORY.md** |
| 226 | +- `onError: 'continueErrorOutput'` and error UX → **n8n-error-handling** |
| 227 | +- Slack node parameter shapes (Block Kit) → **n8n-node-configuration** `NODE_FAMILY_GOTCHAS.md` (Slack section) |
| 228 | +- Receiving uploaded files / returning generated files per surface → **n8n-binary-and-data** |
0 commit comments