Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 228 additions & 0 deletions data/skills/n8n-agents/CHAT_AGENT_PATTERNS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
# 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**
Loading
Loading