Files
playbook/antigravity-awesome-skills/skills/n8n-agents/references/CHAT_AGENT_PATTERNS.md
T
2026-07-22 10:09:47 +00:00

229 lines
13 KiB
Markdown

# 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-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 || 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 |
---
## Cross-references
- Tool naming, descriptions, `$fromAI`**TOOLS.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**