Skip to content

Commit b604bea

Browse files
czlonkowskiclaude
andauthored
chore: sync skills pack v1.22.0 — validator guidance aligned with 2.63.0 (Concieved by Romuald Członkowski - www.aiadvisors.pl/en) (czlonkowski#916)
Syncs data/skills from n8n-skills v1.22.0 (czlonkowski/n8n-skills#36): validator guidance now describes 2.63.0 behavior (real profile gating, fixed false-positive classes, precise template-literal/optional-chaining guidance, bare-object return auto-wrap), plus all skills added to the pack since the last sync (agents, error-handling, binary-and-data, code-tool, subworkflows, multi-instance, self-hosting, router). sync-skills.ts now skips skill-creator eval workspace directories (skills/*-workspace/) so local eval debris never ships in artifacts. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 672d667 commit b604bea

76 files changed

Lines changed: 9959 additions & 3415 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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

Comments
 (0)