data/skills/n8n-agents/CHAT_AGENT_PATTERNS.md
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.
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.
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:
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)).)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.Slack trigger-level example:
{
"parameters": {
"trigger": ["message"],
"channelId": { "__rl": true, "mode": "list", "value": "<CHANNEL_ID>" },
"options": { "userIds": "={{ [\"<BOT_USER_ID>\"] }}" }
},
"type": "n8n-nodes-base.slackTrigger"
}
When the trigger doesn't expose a usable exclusion filter, the first node after the trigger must drop the bot's own ID:
{
"parameters": {
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.user }}",
"rightValue": "<BOT_USER_ID>",
"operator": { "type": "string", "operation": "notEquals" }
}
]
}
},
"type": "n8n-nodes-base.filter"
}
The bot user ID is the API ID from your bot's auth (Slack bot_user_id, Discord application ID, Teams botId).
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:
If none apply, keep it in one workflow (filter still in place). The shape when you do split:
[chat-surface workflow] ──► [agent core workflow] ──► [sub-agent workflows]
("the shell") ("the brain") ("specialists")
- Trigger from the surface - Stateless - One narrow domain each
- Anti-loop filter - chatInput + threadId - chatInput only
- Routing / event types - Memory keyed on threadId - Their own tools + model
- Loading + error UX - Tools, sub-agents
- Render the reply - No surface concerns
See EXAMPLES.md for a Slack router shell and a domain sub-agent snippet.
Receives chat events, decides whether to respond, manages UX, calls the core, renders the reply. No reasoning, no LLM.
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:
"owner message" → Execute Workflow: agent-core
"owner reaction" → no-op (or a reaction handler)
"unknown user" → canned reply
"slash command: /summary" → Execute Workflow: summary-command
"button click" → Execute Workflow: interaction-handler
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.
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.
Users assume nothing is happening without acknowledgement. Pattern: add a loading indicator before the agent call, remove it on every exit path — including error.
[Trigger] → [Filter bot] → [Switch]
→ (owner message)
→ [Add loading reaction] (:spinner:, etc.)
→ [Execute Workflow: Agent core] onError: 'continueErrorOutput'
├── (success) → [Remove reaction] → [Send reply]
└── (error) → [Remove reaction] → [Send error message with link]
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.
Use the surface's thread primitive as the memory sessionKey:
"workflowInputs": {
"value": {
"chatInput": "={{ $('Filter bot').item.json.text }}",
"threadId": "={{ $('Filter bot').item.json.thread_ts || $('Filter bot').item.json.ts }}"
}
}
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).
The error branch sends a short message with a link to the failed execution:
There was a workflow error. https://<n8n-host>/workflow/<id>/executions/{{ $execution.id }}
$execution.id is the live execution ID at the time the error fires. Parameterize the host across environments.
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).
The only chat-specific wiring beyond MEMORY.md is plumbing threadId straight to sessionKey:
"sessionIdType": "customKey",
"sessionKey": "={{ $json.threadId }}"
threadId flows trigger → (pass-through nodes) → memory. Don't put it behind $fromAI.
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.
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.
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:
✅ ={{ { "blocks": $('Call Agent core').item.json.output.blocks } }}
❌ ={{ $('Call Agent core').item.json.output.blocks }}
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).
A sub-agent is its own workflow with its own Agent node, called from the router agent via .toolWorkflow. Reach for one when:
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):
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.
Without that, the router assumes implicit context and the sub-agent guesses. Everything else about wiring sub-workflows as tools → SUBWORKFLOW_AS_TOOL.md.
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:
[Execute Workflow Trigger]
↓
[Notion: Get Database] # fetches the live schema
↓
[Agent] system prompt template includes:
## Database Schema
{{ $('Get a database').first().json.properties.toJsonString() }}
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.
| Anti-pattern | What goes wrong | Fix |
|---|---|---|
| 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 |
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 |
| Loading indicator removed only on success | User sees the bot stuck "thinking" forever after any error | onError: 'continueErrorOutput' + remove on both branches |
| User/channel/workspace ID as the session key | Conversations cross threads in the same channel | Use the thread primitive (Slack `thread_ts |
| 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) |
| Sub-agent that reads/writes shared memory | Caller can't reason about behavior, not safely retryable | Sub-agents are stateless — full context in chatInput |
| Hardcoded domain schema in a sub-agent's prompt | Schema rots, sub-agent picks invalid options later | Re-fetch and template it at runtime |
Passing the bare blocks array to blocksUi | Slack posts an empty message, no error | Wrap as { "blocks": [...] } with a real array |
$fromAI → TOOLS.md.toolWorkflow shape and parameter mapping → SUBWORKFLOW_AS_TOOL.mdsessionKey persistence → MEMORY.mdonError: 'continueErrorOutput' and error UX → n8n-error-handlingNODE_FAMILY_GOTCHAS.md (Slack section)