Back to N8n Mcp

Chat agent patterns: shell + core + sub-agents

data/skills/n8n-agents/CHAT_AGENT_PATTERNS.md

2.63.112.7 KB
Original Source

Chat agent patterns: shell + core + sub-agents

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.


The one non-negotiable: anti-loop filtering

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:

  • 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)).)
  • 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.
  • Discord, Teams: no native user-level trigger filter — use the downstream Filter node.

Slack trigger-level example:

json
{
  "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:

json
{
  "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).


When to split into shell + core + sub-agents

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:

  • The bot needs loading-state UX (typing indicator, reaction, placeholder) and graceful error handling beyond a single message.
  • It's invoked from more than one surface (Slack AND Discord).
  • There are specialist domains the agent shouldn't carry inline (Notion DB schema, CRM custom fields, Linear labels).
  • The agent or its tools will be reused across workflows.

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.


The shell

Receives chat events, decides whether to respond, manages UX, calls the core, renders the reply. No reasoning, no LLM.

Switch on event type

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.

Loading-state UX

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.

Threading as session continuity

Use the surface's thread primitive as the memory sessionKey:

json
"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).

Error UX: surface, don't hang

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.


The agent core

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:

json
"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.

Block Kit envelope gotcha (Slack)

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).


Sub-agents (an agent as a tool)

A sub-agent is its own workflow with its own Agent node, called from the router agent via .toolWorkflow. Reach for one when:

  • The domain has a schema/enum set the router shouldn't carry (Notion DB properties, Linear labels, CRM fields).
  • The domain has 5+ tools that would clutter the router's tool list.
  • The capability is reused across more than one router.
  • The domain warrants a different (cheaper, faster) model than the router.

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.

Fresh schema injection

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-patterns

Anti-patternWhat goes wrongFix
No bot-user-ID filter at the top of the shellBot's own messages re-trigger the workflow — infinite loopTrigger-level exclusion (Slack options.userIds) or a Filter on $json.user !== '<BOT_USER_ID>' first
Bot ID in Telegram's userIds expecting exclusionIt's an allowlist — only the bot would fire, so no human gets through; looks "fixed" but is silentTelegram bots don't see their own messages; use userIds only to allowlist humans
Loading indicator removed only on successUser sees the bot stuck "thinking" forever after any erroronError: 'continueErrorOutput' + remove on both branches
User/channel/workspace ID as the session keyConversations cross threads in the same channelUse the thread primitive (Slack `thread_ts
One workflow when multi-surface/sub-agent/reuse is already neededCan't reuse, UX leaks into reasoning, hard to test in isolationSplit into shell + core + sub-agents (only once a need is real)
Sub-agent that reads/writes shared memoryCaller can't reason about behavior, not safely retryableSub-agents are stateless — full context in chatInput
Hardcoded domain schema in a sub-agent's promptSchema rots, sub-agent picks invalid options laterRe-fetch and template it at runtime
Passing the bare blocks array to blocksUiSlack posts an empty message, no errorWrap as { "blocks": [...] } with a real array

Cross-references

  • Tool naming, descriptions, $fromAITOOLS.md
  • The .toolWorkflow shape and parameter mapping → SUBWORKFLOW_AS_TOOL.md
  • Per-execution context, file injection, prompt storage → SYSTEM_PROMPT.md
  • Parser config, autoFix, fixer model → STRUCTURED_OUTPUT.md
  • Memory types, sessionKey persistence → MEMORY.md
  • onError: 'continueErrorOutput' and error UX → n8n-error-handling
  • Slack node parameter shapes (Block Kit) → n8n-node-configuration NODE_FAMILY_GOTCHAS.md (Slack section)
  • Receiving uploaded files / returning generated files per surface → n8n-binary-and-data