📦 deps(thirdparty): update snapshots

This commit is contained in:
ci[bot]
2026-07-22 10:09:47 +00:00
parent 316c012df0
commit 60364c6660
353 changed files with 24740 additions and 1264 deletions
@@ -0,0 +1,301 @@
---
name: n8n-agents
description: Design n8n AI agents, chains, classifiers, extractors, tool calling, memory, RAG, structured output, and human-review flows.
risk: critical
source: https://github.com/czlonkowski/n8n-skills/tree/main/skills/n8n-agents
source_repo: czlonkowski/n8n-skills
source_type: community
date_added: "2026-07-21"
author: Romuald Czlonkowski
license: MIT
license_source: https://github.com/czlonkowski/n8n-skills/blob/main/LICENSE
---
# n8n Agents
## When to Use
Use this skill for n8n AI Agent, LangChain, classifier, extractor, memory, RAG, tool-calling, structured-output, or human-review design. Confirm the target n8n instance and inspect the live node schema before applying version-sensitive configuration.
Before activating or testing a workflow that can send messages, write data, make purchases, change accounts, or call external services, show the user the exact effects and obtain approval. Store provider keys and tokens only in n8n credentials; never place them in prompts, Set nodes, workflow JSON, examples, or logs.
The n8n AI Agent node (`@n8n/n8n-nodes-langchain.agent`) is a multi-turn LLM driver with sub-nodes for the model, memory, tools, and an optional output parser. This skill is the **deep** guide to designing agents and the LangChain family around them. For the high-level "where an agent fits in a workflow" picture, see the **n8n-workflow-patterns** skill — this skill goes one level down into *how to build it well*.
For node-type formats: in workflow JSON the LangChain nodes use the long `@n8n/n8n-nodes-langchain.*` form (`.agent`, `.lmChatOpenAi`, `.memoryBufferWindow`, `.outputParserStructured`, `.toolWorkflow`, `.toolHttpRequest`, `.toolCode`). When you call `get_node` / `validate_node`, use the **short** form (`nodes-langchain.agent`). See **n8n-mcp-tools-expert** for the format rules.
---
## Pick the right node first
Reaching for an Agent when the task is one-shot classification or extraction is the most common over-build. Decide before you wire anything:
| You need to… | Use | Why |
|---|---|---|
| Call tools, reason over multiple turns, or hold memory | **AI Agent** (`.agent`) | The full loop: model + tools + memory + optional parser. Also a fine default when you'd rather standardize. |
| One-shot text in → text out, no tools | **Basic LLM Chain** (`.chainLlm`) | No agent loop, easier to debug. Still accepts an `outputParserStructured` sub-node. |
| Route a natural-language input to one of **N branches** | **Text Classifier** (`.textClassifier`) | ONE node, N output handles, downstream wires directly into each. Not Agent + Switch. |
| Pull structured fields out of free text | **Information Extractor** (`.informationExtractor`) | Purpose-built field extraction with a schema. |
| 3-way positive/neutral/negative split | **Sentiment Analysis** (`.sentimentAnalysis`) | Built-in branch outputs. |
| Condense a long document | **Summarization Chain** (`.chainSummarization`) | Map-reduce summarization built in. |
| Generate an image / audio / video | **The provider's native single-call node** (OpenAI, Gemini, ElevenLabs…) | NEVER wrap media generation in an Agent — see "Binary and the agent boundary". |
**Text Classifier detail (the Agent + Switch anti-pattern):** every category needs both a **name AND a description**. The model routes against the *description*, not the name — a category with no description gets picked by coin-flip. Set `options.enableAutoFixing: true` for robustness on edge inputs. One node, N branches, done. Reaching for an Agent that "decides" then a Switch that "routes" is two nodes plus prompt boilerplate for what Text Classifier does natively.
Chat-model nodes (`.lmChatOpenAi`, `.lmChatAnthropic`, `.lmChatOpenRouter`, …) are **sub-nodes** — they don't run standalone. They wire into a chain, agent, classifier, or extractor via the `ai_languageModel` connection.
---
## The sub-node pattern
The Agent has a **main input** (the prompt / user message) and up to four **sub-node slots**, each wired by its own `ai_*` connection type:
| Slot | Connection type | Required? | Node example |
|---|---|---|---|
| **model** | `ai_languageModel` | Yes | `.lmChatOpenAi`, `.lmChatAnthropic`, `.lmChatOpenRouter` |
| **memory** | `ai_memory` | Optional | `.memoryBufferWindow`, `.memoryPostgresChat` |
| **tools** | `ai_tool` | Optional (but the point of an agent) | `slackTool`, `.toolWorkflow`, `.toolHttpRequest`, `.toolCode` |
| **outputParser** | `ai_outputParser` | Optional | `.outputParserStructured` |
A sub-node connects FROM itself TO the agent. In workflow JSON the connection lives on the **sub-node**, keyed by the `ai_*` type:
```json
"Main LLM": {
"ai_languageModel": [[{ "node": "AI Agent", "type": "ai_languageModel", "index": 0 }]]
},
"Simple Memory": {
"ai_memory": [[{ "node": "AI Agent", "type": "ai_memory", "index": 0 }]]
},
"Search customer DB": {
"ai_tool": [[{ "node": "AI Agent", "type": "ai_tool", "index": 0 }]]
}
```
Multiple tools all connect into the same `ai_tool` index 0 — they stack, they don't fan into separate indices. With `n8n_update_partial_workflow` you wire each with an `addConnection` op using `sourceOutput: "ai_tool"`. The agent puts its final answer in **`$json.output`** (not `.text`, not `.response`) — downstream nodes read `{{ $json.output }}`.
See **references/EXAMPLES.md** for a complete stateless agent-core node-object snippet.
---
## Two non-negotiables
1. **Tool names and descriptions ARE part of the prompt.** The model picks a tool by reading its name and description — nothing else. A tool named `tool1` with an empty description is invisible to the model: it skips it, mis-selects it, or hallucinates parameters. There's usually no error — just an agent that "won't use my tool". Treat both like API design. → **references/TOOLS.md**
2. **Structured output must parse AND autoFix.** An `outputParserStructured` with `autoFix: true` and a **coding-capable fixer model** is the production pattern. Without autoFix, one malformed JSON response halts the whole workflow. → **references/STRUCTURED_OUTPUT.md**
---
## Strong defaults
- **Per-tool usage goes in the tool description, not the system prompt.** Anything about *how to call this specific tool* belongs with the tool, so it travels across agents and keeps the system prompt focused. → **references/SYSTEM_PROMPT.md**
- **Sub-workflow tools (`.toolWorkflow`) for anything multi-step.** Any workflow becomes a tool with typed `$fromAI()` inputs, and composes with branching, error handling, and reuse. Default here when in doubt. → **references/SUBWORKFLOW_AS_TOOL.md** and **n8n-subworkflows**.
- **Wrap tools with user-visible side effects in human review.** Sends, payments, refunds, account changes get gated behind an approval node so a human signs off before the tool fires. → **references/HUMAN_REVIEW.md**
- **Raise `maxIterations`.** The default tool-call cap is **low** (single digits on most versions) — fine for a one-tool agent, far too low for a multi-tool agent that chains several calls per turn. It surfaces as "max iterations reached" or empty output. Set `options.maxIterations` to a realistic ceiling (15 for a focused sub-agent, 50-200 for a broad orchestrator).
- **Put the current date in the system prompt** via `{{ $now }}` (or `{{ $now.format('DDDD') }}`). A hardcoded date is stale immediately.
---
## The four tool types
Pick the lightest option that covers the job:
| Tool type | Node | Use when |
|---|---|---|
| **Native tool node** | `slackTool`, `gmailTool`, `toolCalculator`, … | The capability maps to one existing node + one operation. Lowest overhead. |
| **Sub-workflow as tool** | `.toolWorkflow` | More than one node, reusable logic, or you want independent testability. The canonical n8n way — **default when in doubt**. |
| **HTTP Request Tool** | `.toolHttpRequest` | A single external HTTP API the agent should orchestrate directly. Reuse the service's predefined credential to cover operations a native node doesn't expose. |
| **MCP Client Tool** | `.mcpClientTool` | A maintained MCP server already covers it, or you want one published workflow to serve many agents. |
There is also a **Custom Code Tool** (`.toolCode`) for pure inline computation — but its runtime contract (string in / string out, no `$fromAI`, no `$helpers`) is owned by the **n8n-code-tool** skill. Read that before writing one. Rule of thumb: if you find yourself reaching for `$fromAI()` inside the code, you want `.toolWorkflow` instead.
### `$fromAI()`: how the agent fills tool parameters
Tool parameters the agent should decide are wrapped in `$fromAI()`. It is a **real n8n expression helper**, used inside a tool node's parameter expressions:
```
={{ $fromAI('paramName', 'what to put here — be specific: format, range, example', 'string') }}
```
- **paramName** — the name the model uses internally (snake_case or camelCase, be consistent).
- **description** — tells the model what value to produce. **It is part of the prompt** — write it like JSDoc.
- **type** (optional) — `'string'` (default), `'number'`, `'boolean'`, `'json'`. A wrong-typed value fails the call.
- **defaultValue** (optional) — used when the model omits it.
`$fromAI()` carries JSON only — it **cannot carry binary** (no base64, no file bytes). And not every parameter has to be `$fromAI`: plumb identity, authority limits, and correlation IDs (`userId`, refund caps, `sessionId`) deterministically from workflow context so the agent can't get them wrong or even see them. → **references/TOOLS.md** for the full anatomy and the "give the agent a button, not a steering wheel" pattern.
---
## System prompt vs tool description
| Belongs in the **system prompt** | Belongs in the **tool's description** |
|---|---|
| Persona, role, voice | What this specific tool does |
| Global output/format rules ("respond in markdown") | When to use it vs other tools |
| Refusal / safety behavior | What each parameter means and its shape |
| Display protocols (`![]()` for images) | Examples of good vs bad invocations |
| Universal context (current date via `$now`, user role) | Tool-specific gotchas (rate limits, edge cases) |
| Inter-tool flow ("after generating, always display") | Tool-specific input transformations |
Why split it: a well-described tool works in **any** agent that drops it in, tool details only "load" when the model considers that tool (token efficiency), and you update one tool description instead of a paragraph buried in a 5000-token prompt. → **references/SYSTEM_PROMPT.md**
---
## Structured output: when and how
Add an `outputParserStructured` sub-node (wired `ai_outputParser`) when downstream needs strict JSON, not free-form text. Two rules:
1. **Use `schemaType: 'manual'` with a real JSON Schema, not `jsonSchemaExample`.** An example can't express required-vs-optional, enums, numeric ranges, or array constraints — you outgrow it the first time the shape gets non-trivial. Reach for `fromJson` + an example only for throwaway shapes.
2. **`autoFix: true` with a coding-capable fixer model.** Wire a *second* model into the parser's `ai_languageModel` slot. Reconciling broken JSON against a schema is a coding task — a weak fixer just produces another malformed retry and burns tokens.
**references/STRUCTURED_OUTPUT.md** for the schema patterns, the load-bearing "DO NOT wrap in markdown" retry line, and the parse-failure cookbook.
---
## Memory: brief mental model
Memory is a sub-node (`ai_memory`). Without it, every call is stateless — correct for one-shot tasks (classify, summarize). With it, the agent holds a conversation, keyed by whatever expression you bind to `sessionKey`.
- **`memoryBufferWindow`** — keeps the last N exchanges per key and persists across executions via n8n's store. The default for chat. **`contextWindowLength` defaults to 5, which is very low** — 50 is a saner starting point. Messages past the window are gone entirely.
- **`memoryPostgresChat` / `memoryRedisChat`** — only when memory must be read *outside* the agent (your own UI, analytics, cross-system). Not needed just to survive restarts; BufferWindow already does that.
**Plumb a stable key from the trigger to memory consistently.** Chat triggers fill `sessionId` automatically; for other surfaces derive one (Slack `thread_ts`, a webhook conversation ID). Never hardcode `sessionId: 'default'` and never put `sessionId` behind `$fromAI` (the model will fabricate a UUID). → **references/MEMORY.md**
---
## Binary and the agent boundary
This is the seam that trips people up:
- **The model CAN see uploaded images** (vision) via `options.passthroughBinaryImages: true` on the agent.
- **Tools CANNOT receive binary.** `$fromAI()` is JSON-only — no base64, no bytes, even through non-AI bindings.
- **The agent's output is text-shaped** (or structured-text with a parser). When a model returns image/audio/video bytes, the Agent doesn't surface them at all — there's nothing to recover downstream.
**Workaround:** pre-stage uploads to storage before the agent runs, inject the storage keys into the system prompt, and let tools accept the key as a string parameter and re-fetch internally. For one-shot media generation, skip the agent and call the provider's native single-call node directly.
The binary mechanics (which storage, how to stage, how to re-fetch) are owned by **n8n-binary-and-data** — see its agent-tool binary reference. This skill only marks the boundary; don't re-derive the mechanics here.
---
## Human review (gate destructive tools)
When a tool's effect needs human sign-off before execution (sends, payments, refunds, account changes), wrap it with a review tool node — `slackHitlTool`, `discordHitlTool`, `telegramHitlTool`, `gmailHitlTool`, etc. (n8n names these "Hitl" / human-in-the-loop). The review node sits **between** the wrapped tool and the agent on the `ai_tool` connection: wrapped tool → review node → Agent.
Whether sign-off is needed is a product/policy call — **surface the question to the user**, recommend based on blast radius, and let them decide.
**The critical rule: show the actual parameters the wrapped tool will receive.** Use the literal `{{ $tool.parameters.<name> }}` in the approval message, never a `$fromAI()` paraphrase — otherwise the human approves text the model made up, not the call about to fire. → **references/HUMAN_REVIEW.md**
---
## Chat agents (Slack, Discord, Teams, Telegram)
**The one non-negotiable, regardless of complexity:** any chat-triggered workflow that posts a reply MUST **filter out the bot's own user ID**, or its own replies re-trigger it in an infinite loop that burns runs and tokens. Prefer trigger-level filtering when available (Slack Trigger's `options.userIds` is an **exclusion list** — put the bot ID there); otherwise filter `$json.user !== '<BOT_USER_ID>'` in the first node after the trigger.
Beyond the filter, a simple bot (trigger → agent → reply) lives fine in one workflow. Split into **shell + core + sub-agents** only once you need loading UX, sub-agents, multi-surface reuse, or robust error handling:
- **Shell** — trigger, anti-loop filter, event-type Switch, loading/error UX, renders the reply. No LLM.
- **Core** — stateless agent, `chatInput` + `threadId` inputs, memory keyed on `threadId`, tools and sub-agents.
- **Sub-agents** — one narrow domain each, called via `.toolWorkflow`, **stateless** (full context in `chatInput`).
**references/CHAT_AGENT_PATTERNS.md** for per-surface semantics, threading-as-session, and the full topology.
---
## RAG (retrieval augmented generation)
n8n ships the LangChain RAG primitives (document loaders, splitters, embeddings, vector stores, retrievers). Two opinions worth stating up front:
1. **Rule out cheaper lookups first.** Exact lookups → a database or Data Table query, not RAG. Freshness → a live search tool. A small/structured doc set → give the agent list/fetch tools. Reach for a vector store only when there are too many docs to list and queries are semantic.
2. **Wire the vector store as a retrieval tool** (`mode: 'retrieve-as-tool'`, `ai_tool`) so the agent decides when retrieval is relevant and can phrase the query itself. Embed query and documents with the **same** model.
**references/RAG.md** (intentionally thin — defaults depend on data shape and scale).
---
## Reference files
| File | Read when |
|---|---|
| **references/TOOLS.md** | Adding tools, choosing among the four types, writing names/descriptions, `$fromAI` anatomy |
| **references/SUBWORKFLOW_AS_TOOL.md** | Wiring a sub-workflow as a tool via `.toolWorkflow`, mapping agent-filled vs plumbed params |
| **references/SYSTEM_PROMPT.md** | Writing/refactoring a system prompt, the system-prompt-vs-tool-description split |
| **references/STRUCTURED_OUTPUT.md** | Forcing JSON output, configuring autoFix, the fixer model, parse-failure fixes |
| **references/MEMORY.md** | Choosing a memory type, persistence, sessionId handling |
| **references/HUMAN_REVIEW.md** | Adding human approval, approval-message content, multi-channel approver |
| **references/CHAT_AGENT_PATTERNS.md** | Building a Slack/Discord/Teams/Telegram bot, shell + core + sub-agents topology |
| **references/RAG.md** | Retrieval-augmented agents (thin by design) |
| **references/EXAMPLES.md** | Concrete node-object snippets: stateless agent core, Slack router shell, domain sub-agent |
---
## Anti-patterns
| Anti-pattern | What goes wrong | Fix |
|---|---|---|
| Generic tool names (`tool1`, `doStuff`, `runQuery`) | Model can't tell which tool to pick — skips them or hallucinates params | Verb-first specific names: `Search customer database`, `Generate image with Veo` |
| Empty or one-line tool descriptions | Model has no idea when to invoke; bad selection, no error | Write a real description: what it does, when to use, what each param means |
| Cramming per-tool instructions into the system prompt | Bloated prompt, no reuse, per-tool guidance buried | Move tool-specific instructions into tool descriptions |
| Agent + Switch to route on natural language | Two nodes + prompt boilerplate where Text Classifier is one node | Use Text Classifier — each category gets its own output handle (name **and** description) |
| Wrapping image/audio/video generation in an Agent | Binary doesn't flow through tools or out of the agent output | Use the provider's native single-call node directly |
| `outputParserStructured` without `autoFix` | One malformed response halts the workflow | `autoFix: true` + a coding-capable fixer model |
| Passing binary directly to a tool | Doesn't work — binary can't cross the tool boundary | Pre-stage to storage, pass keys; see **n8n-binary-and-data** |
| Hardcoded `sessionId` / no sessionId / `sessionId` behind `$fromAI` | Conversations cross, or the model fabricates a UUID | Plumb a stable key from the trigger to memory and tools |
| Two near-identical tools | Selection is non-deterministic, model gets confused | One tool with internal branching driven by a parameter |
| Chat bot with no bot-user filter | Its own replies re-trigger it → infinite loop | Exclude the bot user ID at the trigger or first node |
| `maxIterations` left at the low default on a multi-tool agent | "Max iterations reached" / empty output | Raise `options.maxIterations` |
| Filling the human-review message via `$fromAI()` | Approver signs off on a paraphrase, not the real call | Use literal `{{ $tool.parameters.<name> }}` |
---
## What's NOT available via the community MCP
| Want to do | Reality |
|---|---|
| Run / chat-test the agent end-to-end with live tokens | `n8n_test_workflow` runs the workflow, but a true multi-turn chat session is a UI activity (canvas chat tester). |
| Set credentials' actual secret values | `n8n_manage_credentials` creates/updates credential records, but the agent provider keys themselves are entered/verified in the UI. |
| Assign a workflow's Error Workflow | UI only — see **n8n-error-handling**. Build the catch-all, then hand the user the UI step. |
| Pin the exact model availability per instance | Model lists shift between versions — `search_nodes`/`get_node` reflect what's installed. Verify on the target instance. |
What the MCP **can** do: search and inspect every LangChain node (`search_nodes`, `get_node`), validate node config and the whole graph (`validate_node`, `validate_workflow`), build and patch the agent and its sub-nodes (`n8n_update_partial_workflow` with `addConnection` on `ai_*` outputs), test (`n8n_test_workflow`), and pull the saved JSON to verify wiring (`n8n_get_workflow`). The deep AI-agent guide also lives in `tools_documentation({topic: "ai_agents_guide", depth: "full"})`.
---
## Integration with other skills
- **n8n-workflow-patterns** — the high-level "agent in a workflow" shape. This skill is the deep dive; start there for architecture.
- **n8n-mcp-tools-expert** — node-type formats (short form for `get_node`, long form in JSON) and tool-selection guidance. Consult before any MCP call.
- **n8n-node-configuration** — `displayOptions`-driven fields on the agent and sub-nodes; Slack/Block Kit message shapes (`NODE_FAMILY_GOTCHAS.md`, Slack section).
- **n8n-expression-syntax** — `{{ }}`, `$json.output`, `$now`, and `$fromAI`/`$tool.parameters` all rely on correct expression syntax.
- **n8n-code-tool** — the Custom Code Tool's runtime contract (string in/out, no `$fromAI`). Read it before writing a `.toolCode`.
- **n8n-subworkflows** — the sub-workflow primitive that `.toolWorkflow` builds on (Execute Workflow Trigger inputs/outputs, naming, search-before-build).
- **n8n-binary-and-data** — owns the agent-tool binary boundary mechanics (staging uploads, returning generated files).
- **n8n-validation-expert** — interpreting `validate_workflow` results, including AI-connection issues (a tool wired into `main` instead of `ai_tool` flags as disconnected).
- **n8n-error-handling** — `onError: 'continueErrorOutput'` on tool sub-workflows and the agent-core call; error UX on chat shells.
- **n8n-code-javascript / n8n-code-python** — for Code-node logic *inside* a tool sub-workflow (different sandbox from the Code Tool).
---
## Quick reference checklist
Before shipping an agent:
- [ ] **Right node**: Agent for tools/memory/multi-turn; Text Classifier for routing; Information Extractor for fields; native node for media
- [ ] **Model** wired via `ai_languageModel`
- [ ] **Every tool** has a verb-first specific name AND a real description
- [ ] **`$fromAI()` descriptions** are specific (format, range, example); identity/limits/sessionId plumbed deterministically, not via `$fromAI`
- [ ] **Per-tool guidance** lives in tool descriptions, not the system prompt
- [ ] **`$now`** in the system prompt (no hardcoded date)
- [ ] **`maxIterations`** raised for multi-tool agents
- [ ] **Memory** keyed on a stable `sessionKey` from the trigger (not `'default'`, not `$fromAI`); `contextWindowLength` raised from 5
- [ ] **Structured output**: `schemaType: 'manual'` + `autoFix: true` + a coding-capable fixer model
- [ ] **Destructive tools** wrapped in human review; approval message uses `$tool.parameters`, not `$fromAI`
- [ ] **Chat bots** filter the bot's own user ID (trigger-level or first node)
- [ ] **Binary**: model vision via `passthroughBinaryImages`; tools get storage keys, never bytes
- [ ] **Validated** with `validate_workflow` and verified with `n8n_get_workflow` (sub-nodes on `ai_*`, not `main`)
---
**Remember**: an agent is only as good as its tool names, descriptions, and system-prompt discipline. The model can't see your wiring — it sees a system prompt and a list of named, described tools. Design those like an API and most "the agent won't behave" problems disappear.
## Limitations
- Node types, parameters, model availability, and defaults vary by n8n version; verify them against the target instance.
- This guidance cannot set provider secret values or prove a live multi-turn agent works without an authorized execution.
- Validation does not prove tool selection quality, correct wiring, idempotency, or safe side effects; inspect and test those separately.
@@ -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**
@@ -0,0 +1,432 @@
# Examples
Three practical node-object snippets for the shell + core + sub-agent topology. These are **community n8n JSON fragments** to adapt, not full importable exports — credential IDs, workflow IDs, and channel/bot IDs are placeholders. Build with `n8n_update_partial_workflow` (`addNode` + `addConnection` on the `ai_*` outputs), then verify with `n8n_get_workflow` and `validate_workflow`.
For the architecture these fit into, see **CHAT_AGENT_PATTERNS.md**.
---
## 1. Stateless agent core
A reusable agent sub-workflow: `chatInput` + `threadId` in, agent output out. Memory keyed on `threadId`, native tools, a sub-agent tool, and Block Kit structured output with an autoFix fixer model. This is the "brain" called by the shell.
```json
{
"name": "Chat agent core",
"nodes": [
{
"parameters": {
"workflowInputs": {
"values": [{ "name": "chatInput" }, { "name": "threadId" }]
}
},
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [-480, -96],
"id": "core-trigger",
"name": "When Executed by Another Workflow"
},
{
"parameters": {
"promptType": "define",
"text": "={{ $json.chatInput }}",
"hasOutputParser": true,
"options": {
"systemMessage": "=You are a concise, direct assistant. Be a thinking partner, not an answer machine.\n\nCurrent date: {{ $now.format('DDDD') }}\n\n## Output\nYou are replying in Slack using Block Kit. Your entire response must be valid JSON with a 'blocks' array at the root. Bold is *single asterisks*. Links are <https://url|text>. Max 10 blocks.\n\n## Tool usage\nFact-check verifiable claims with the web search tool before answering. Use the idea database manager for anything about content ideas.",
"maxIterations": 50
}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [-48, -96],
"id": "core-agent",
"name": "AI Agent"
},
{
"parameters": { "model": "anthropic/claude-opus-4.6", "options": { "temperature": 0.1 } },
"type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
"typeVersion": 1,
"position": [-288, 192],
"id": "core-main-llm",
"name": "Main LLM",
"credentials": { "openRouterApi": { "id": "REPLACE_OPENROUTER_CRED", "name": "OpenRouter" } }
},
{
"parameters": {
"sessionIdType": "customKey",
"sessionKey": "={{ $json.threadId }}",
"contextWindowLength": 50
},
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1.3,
"position": [-128, 192],
"id": "core-memory",
"name": "Simple Memory"
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "Search the web fast to fact-check a claim or find a source. Use for verifying anything from training data.",
"query": "={{ $fromAI('query', 'The search query, phrased to match relevant sources', 'string') }}",
"options": { "search_depth": "fast" }
},
"type": "@tavily/n8n-nodes-tavily.tavilyTool",
"typeVersion": 1,
"position": [32, 192],
"id": "core-web-search",
"name": "Search the web",
"credentials": { "tavilyApi": { "id": "REPLACE_TAVILY_CRED", "name": "Tavily" } }
},
{
"parameters": {},
"type": "@n8n/n8n-nodes-langchain.toolCalculator",
"typeVersion": 1,
"position": [192, 192],
"id": "core-calc",
"name": "Calculator"
},
{
"parameters": {
"description": "Manages the content-ideas database. Use for ANY task about content ideas: querying, creating, dedupe-checks.\n\nIMPORTANT: This tool is stateless. Send all relevant context in a single message. If creating, include ALL required fields upfront. Returns the page URL for anything referenced or created.",
"workflowId": { "__rl": true, "value": "REPLACE_SUBAGENT_WF_ID", "mode": "list", "cachedResultName": "Notion ideas sub-agent" },
"workflowInputs": {
"mappingMode": "defineBelow",
"value": { "chatInput": "={{ $fromAI('chatInput', 'The full request to the ideas database, with all context', 'string') }}" },
"schema": [
{ "id": "chatInput", "displayName": "chatInput", "type": "string", "display": true, "canBeUsedToMatch": true }
]
}
},
"type": "@n8n/n8n-nodes-langchain.toolWorkflow",
"typeVersion": 2.2,
"position": [352, 192],
"id": "core-idea-tool",
"name": "Idea database manager"
},
{
"parameters": {
"schemaType": "manual",
"inputSchema": "{ \"type\": \"object\", \"properties\": { \"text\": { \"type\": \"string\" }, \"blocks\": { \"type\": \"array\", \"items\": { \"oneOf\": [ { \"type\": \"object\", \"properties\": { \"type\": { \"const\": \"header\" }, \"text\": { \"type\": \"object\" } }, \"required\": [\"type\", \"text\"] }, { \"type\": \"object\", \"properties\": { \"type\": { \"const\": \"section\" }, \"text\": { \"type\": \"object\" } }, \"required\": [\"type\", \"text\"] }, { \"type\": \"object\", \"properties\": { \"type\": { \"const\": \"divider\" } }, \"required\": [\"type\"] } ] } } }, \"required\": [\"text\", \"blocks\"] }",
"autoFix": true
},
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"typeVersion": 1.3,
"position": [560, 176],
"id": "core-parser",
"name": "Structured Output Parser (Block Kit)"
},
{
"parameters": { "model": "anthropic/claude-sonnet-4.6", "options": { "temperature": 0 } },
"type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
"typeVersion": 1,
"position": [620, 336],
"id": "core-fixer-llm",
"name": "Fixer LLM (coding-capable)",
"credentials": { "openRouterApi": { "id": "REPLACE_OPENROUTER_CRED", "name": "OpenRouter" } }
}
],
"connections": {
"When Executed by Another Workflow": { "main": [[{ "node": "AI Agent", "type": "main", "index": 0 }]] },
"Main LLM": { "ai_languageModel": [[{ "node": "AI Agent", "type": "ai_languageModel", "index": 0 }]] },
"Simple Memory": { "ai_memory": [[{ "node": "AI Agent", "type": "ai_memory", "index": 0 }]] },
"Search the web": { "ai_tool": [[{ "node": "AI Agent", "type": "ai_tool", "index": 0 }]] },
"Calculator": { "ai_tool": [[{ "node": "AI Agent", "type": "ai_tool", "index": 0 }]] },
"Idea database manager": { "ai_tool": [[{ "node": "AI Agent", "type": "ai_tool", "index": 0 }]] },
"Structured Output Parser (Block Kit)": { "ai_outputParser": [[{ "node": "AI Agent", "type": "ai_outputParser", "index": 0 }]] },
"Fixer LLM (coding-capable)": { "ai_languageModel": [[{ "node": "Structured Output Parser (Block Kit)", "type": "ai_languageModel", "index": 0 }]] }
}
}
```
What to notice:
- **Memory keyed on `threadId`**, not on a user/channel ID (those cross conversations). The shell supplies `threadId`.
- **`maxIterations: 50`** — raised from the low default because this agent chains several tools per turn.
- **`$now.format('DDDD')`** in the system prompt — no hardcoded date.
- **Two models**: the main model on the agent, a separate coding-capable fixer wired into the parser. Both connect via `ai_languageModel` but to different nodes.
- **`hasOutputParser: true`** on the agent activates the `ai_outputParser` slot.
- The sub-agent tool's description repeats **"This tool is stateless"** — the router can't rely on shared context.
---
## 2. Slack router shell
The "shell": trigger, trigger-level anti-loop filter, event-type Switch, loading reaction, the agent-core call with an error branch, and the Block Kit reply envelope. No LLM here.
```json
{
"name": "Slack chat router",
"nodes": [
{
"parameters": {
"trigger": ["message"],
"watchWorkspace": true,
"options": { "userIds": "={{ [\"U00000000BOT\"] }}" }
},
"type": "n8n-nodes-base.slackTrigger",
"typeVersion": 1,
"position": [-288, 48],
"id": "shell-trigger",
"name": "Slack Trigger",
"credentials": { "slackApi": { "id": "REPLACE_SLACK_CRED", "name": "Slack" } }
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": { "version": 3 },
"conditions": [{ "leftValue": "={{ $json.user === \"U00000000OWNER\" && $json.type === \"message\" }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } }],
"combinator": "and"
},
"renameOutput": true, "outputKey": "Owner message"
},
{
"conditions": {
"options": { "version": 3 },
"conditions": [{ "leftValue": "={{ $json.user !== \"U00000000OWNER\" && $json.type === \"message\" }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } }],
"combinator": "and"
},
"renameOutput": true, "outputKey": "Unknown user"
}
]
}
},
"type": "n8n-nodes-base.switch",
"typeVersion": 3.4,
"position": [-32, 48],
"id": "shell-switch",
"name": "Switch"
},
{
"parameters": {
"resource": "reaction",
"channelId": { "__rl": true, "value": "={{ $json.channel }}", "mode": "id" },
"timestamp": "={{ $json.ts }}",
"name": "spinner"
},
"type": "n8n-nodes-base.slack",
"typeVersion": 2.4,
"position": [240, -64],
"id": "shell-add-reaction",
"name": "Add Loading Reaction",
"credentials": { "slackApi": { "id": "REPLACE_SLACK_CRED", "name": "Slack" } }
},
{
"parameters": {
"workflowId": { "__rl": true, "value": "REPLACE_AGENT_CORE_WF_ID", "mode": "list", "cachedResultName": "Chat agent core" },
"workflowInputs": {
"mappingMode": "defineBelow",
"value": {
"chatInput": "={{ $('Slack Trigger').item.json.text }}",
"threadId": "={{ $('Slack Trigger').item.json.thread_ts || $('Slack Trigger').item.json.ts }}"
},
"schema": [
{ "id": "chatInput", "displayName": "chatInput", "type": "string", "display": true },
{ "id": "threadId", "displayName": "threadId", "type": "string", "display": true }
]
}
},
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.3,
"position": [480, -64],
"id": "shell-call-core",
"name": "Call Agent core",
"retryOnFail": true,
"maxTries": 2,
"waitBetweenTries": 5000,
"onError": "continueErrorOutput"
},
{
"parameters": {
"resource": "reaction",
"operation": "remove",
"channelId": { "__rl": true, "value": "={{ $('Switch').item.json.channel }}", "mode": "id" },
"timestamp": "={{ $('Switch').item.json.ts }}",
"name": "spinner"
},
"type": "n8n-nodes-base.slack",
"typeVersion": 2.4,
"position": [720, -160],
"id": "shell-remove-reaction-ok",
"name": "Remove Loading Reaction (success)",
"credentials": { "slackApi": { "id": "REPLACE_SLACK_CRED", "name": "Slack" } }
},
{
"parameters": {
"select": "user",
"user": { "__rl": true, "value": "={{ $('Slack Trigger').item.json.user }}", "mode": "id" },
"messageType": "block",
"blocksUi": "={{ { \"blocks\": $('Call Agent core').item.json.output.blocks } }}",
"otherOptions": {
"thread_ts": { "replyValues": { "thread_ts": "={{ $('Slack Trigger').item.json.thread_ts || $('Slack Trigger').item.json.ts }}" } }
}
},
"type": "n8n-nodes-base.slack",
"typeVersion": 2.4,
"position": [960, -160],
"id": "shell-send-reply",
"name": "Send Block Kit reply",
"credentials": { "slackApi": { "id": "REPLACE_SLACK_CRED", "name": "Slack" } }
},
{
"parameters": {
"select": "user",
"user": { "__rl": true, "value": "={{ $('Slack Trigger').item.json.user }}", "mode": "id" },
"text": "=There was a workflow error. https://<your-n8n-host>/workflow/<this-workflow-id>/executions/{{ $execution.id }}",
"otherOptions": {
"thread_ts": { "replyValues": { "thread_ts": "={{ $('Slack Trigger').item.json.thread_ts || $('Slack Trigger').item.json.ts }}" } }
}
},
"type": "n8n-nodes-base.slack",
"typeVersion": 2.4,
"position": [720, 64],
"id": "shell-send-error",
"name": "Send error message with execution link",
"credentials": { "slackApi": { "id": "REPLACE_SLACK_CRED", "name": "Slack" } }
}
],
"connections": {
"Slack Trigger": { "main": [[{ "node": "Switch", "type": "main", "index": 0 }]] },
"Switch": { "main": [[{ "node": "Add Loading Reaction", "type": "main", "index": 0 }], []] },
"Add Loading Reaction": { "main": [[{ "node": "Call Agent core", "type": "main", "index": 0 }]] },
"Call Agent core": {
"main": [
[{ "node": "Remove Loading Reaction (success)", "type": "main", "index": 0 }],
[{ "node": "Send error message with execution link", "type": "main", "index": 0 }]
]
},
"Remove Loading Reaction (success)": { "main": [[{ "node": "Send Block Kit reply", "type": "main", "index": 0 }]] }
}
}
```
What to notice:
- **Anti-loop at the trigger**: `options.userIds: ["U00000000BOT"]` is an exclusion list — the bot's own posts never enter the workflow. No separate filter node needed.
- **`Call Agent core`** has `onError: 'continueErrorOutput'`, so `main[1]` carries the error branch (→ **n8n-error-handling**). The loading reaction is removed on the success path; the error branch surfaces a link instead of hanging forever.
- **`threadId`** = `thread_ts || ts`, plumbed straight to the core (which keys memory on it).
- **`blocksUi`** is the `{ "blocks": [...] }` envelope, not the bare array — the bare array fails silently.
---
## 3. Domain sub-agent (Notion ideas)
A specialist sub-agent called via `.toolWorkflow` from the core. It fetches its DB schema fresh on every call and runs on a cheaper model than the router.
```json
{
"name": "Notion ideas sub-agent",
"nodes": [
{
"parameters": { "workflowInputs": { "values": [{ "name": "chatInput" }] } },
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [-240, 0],
"id": "sub-trigger",
"name": "When Executed by Another Workflow"
},
{
"parameters": {
"resource": "database",
"databaseId": { "__rl": true, "value": "REPLACE_NOTION_DB_ID", "mode": "id" },
"simple": false
},
"type": "n8n-nodes-base.notion",
"typeVersion": 2.2,
"position": [-32, 0],
"id": "sub-get-db",
"name": "Get a database",
"credentials": { "notionApi": { "id": "REPLACE_NOTION_CRED", "name": "Notion" } }
},
{
"parameters": {
"promptType": "define",
"text": "={{ $('When Executed by Another Workflow').item.json.chatInput }}",
"options": {
"systemMessage": "=You manage a Notion ideas database. Query and create idea entries.\n\n## Database schema (fetched fresh this call)\n{{ $('Get a database').first().json.properties.toJsonString() }}\n\n## Rules\n1. Always respond in chat with the result.\n2. Always return the Notion URL for any page created or referenced.\n3. Select/multi-select values must EXACTLY match an existing schema option.\n4. IMPORTANT: you are stateless. If information is missing, list exactly what's needed and remind the caller to resend the complete request with all details.",
"maxIterations": 15
}
},
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3.1,
"position": [208, 0],
"id": "sub-agent",
"name": "AI Agent"
},
{
"parameters": { "model": "anthropic/claude-haiku-4.6", "options": { "temperature": 0.1 } },
"type": "@n8n/n8n-nodes-langchain.lmChatOpenRouter",
"typeVersion": 1,
"position": [112, 256],
"id": "sub-llm",
"name": "Sub-agent LLM (cheaper than router)",
"credentials": { "openRouterApi": { "id": "REPLACE_OPENROUTER_CRED", "name": "OpenRouter" } }
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "Returns all ideas that are still active (not rejected, cancelled, or started).",
"resource": "databasePage",
"operation": "getAll",
"databaseId": { "__rl": true, "value": "REPLACE_NOTION_DB_ID", "mode": "id" },
"returnAll": true,
"filterType": "manual",
"filters": { "conditions": [{ "key": "Status|status", "condition": "does_not_equal", "statusValue": "Rejected" }] }
},
"type": "n8n-nodes-base.notionTool",
"typeVersion": 2.2,
"position": [304, 256],
"id": "sub-get-active",
"name": "Get active ideas",
"credentials": { "notionApi": { "id": "REPLACE_NOTION_CRED", "name": "Notion" } }
},
{
"parameters": {
"descriptionType": "manual",
"toolDescription": "Creates an idea entry. Always enters as status 'Idea'. Select fields must match schema options exactly.",
"resource": "databasePage",
"databaseId": { "__rl": true, "value": "REPLACE_NOTION_DB_ID", "mode": "id" },
"title": "={{ $fromAI('Title', 'Short title of the idea', 'string') }}",
"propertiesUi": {
"propertyValues": [
{ "key": "Status|status", "statusValue": "Idea" },
{ "key": "Type|select", "selectValue": "={{ $fromAI('type', 'Type column; must EXACTLY match a schema option', 'string') }}" }
]
}
},
"type": "n8n-nodes-base.notionTool",
"typeVersion": 2.2,
"position": [480, 256],
"id": "sub-create",
"name": "Create idea",
"credentials": { "notionApi": { "id": "REPLACE_NOTION_CRED", "name": "Notion" } }
}
],
"connections": {
"When Executed by Another Workflow": { "main": [[{ "node": "Get a database", "type": "main", "index": 0 }]] },
"Get a database": { "main": [[{ "node": "AI Agent", "type": "main", "index": 0 }]] },
"Sub-agent LLM (cheaper than router)": { "ai_languageModel": [[{ "node": "AI Agent", "type": "ai_languageModel", "index": 0 }]] },
"Get active ideas": { "ai_tool": [[{ "node": "AI Agent", "type": "ai_tool", "index": 0 }]] },
"Create idea": { "ai_tool": [[{ "node": "AI Agent", "type": "ai_tool", "index": 0 }]] }
}
}
```
What to notice:
- **Fresh schema injection**: `Get a database` runs **before** the agent (on `main`), and its `properties` are templated into the system prompt with `.toJsonString()`. The sub-agent never operates on a stale schema, so it can't pick a select option that was renamed last week.
- **Cheaper model** (`claude-haiku-4.6`) than the router — a focused single-domain agent doesn't need the orchestrator's model.
- **Stateless contract** restated in the system prompt — matching the tool description on the core side.
- **`maxIterations: 15`** — fine for a focused sub-agent (vs 50 on the broad router).
- The `Status|status` / `Type|select` key shape is Notion's `Name|type` convention; match the live schema.
---
## Cross-references
- The topology these fit into → **CHAT_AGENT_PATTERNS.md**
- The `.toolWorkflow` mapping → **SUBWORKFLOW_AS_TOOL.md**
- Block Kit schema and autoFix → **STRUCTURED_OUTPUT.md**
- Error branch on the core call → **n8n-error-handling**
@@ -0,0 +1,180 @@
# Human review for agent tools
Human review gates a tool behind explicit human approval. Until a human approves, the wrapped tool does not run — no matter how confident the agent is. This is the default safety pattern for any agent tool with user-visible side effects.
n8n names this **HITL** / human-in-the-loop in the node IDs (`slackHitlTool`, `discordHitlTool`, …) and "Human Review" in the UI. Same concept.
**Before adding or skipping review, ask the user.** Whether sign-off is needed is a product/policy call (blast radius, audit requirements, how much they trust the model). Surface the question, recommend based on the criteria below, and let them decide.
---
## Topology
The review node sits **between** the wrapped tool and the agent on the `ai_tool` connection:
```
[wrapped tool] --ai_tool--> [review node] --ai_tool--> [Agent]
```
- **The agent doesn't know the review node is there.** It sees the wrapped tool by the wrapped tool's name, description, and parameter schema. The review node is a transparent intercept on the execution path.
- When the agent calls the wrapped tool, the review node intercepts: collects the parameters the agent built, pauses, sends an approval prompt to a human, and only on approval does the wrapped tool run with those parameters.
In workflow JSON, the wrapped tool's `ai_tool` output points at the **review node**, and the review node's `ai_tool` output points at the **agent**:
```json
"Refund customer": {
"ai_tool": [[{ "node": "Slack approval", "type": "ai_tool", "index": 0 }]]
},
"Slack approval": {
"ai_tool": [[{ "node": "AI Agent", "type": "ai_tool", "index": 0 }]]
}
```
Do NOT wire the wrapped tool into the agent's `main` input — that flags the wrapped tool as a disconnected node in `validate_workflow`. The wrapped-tool-into-review wiring happens through `ai_tool` only.
---
## Tell the agent the review is there
Because the agent doesn't see the review node, it doesn't know its tool is gated. Models with safety priors hedge on destructive-looking tools (send, delete, refund, charge): they refuse, ask the user for confirmation first, or pick a less-direct option. With review wrapping the tool, that caution doubles up — the model self-censors AND a human reviews, and sometimes the model never even reaches the review step.
If you see the agent over-hedging on a wrapped tool, add a note to the **wrapped tool's description** (per the modular-prompt principle in **SYSTEM_PROMPT.md**):
> This tool is gated by a human review step. Use it freely when relevant. A human will see the exact parameters and approve before anything is sent. Don't ask the user for confirmation first.
Don't pre-emptively add this to every wrapped tool — many agents use the tool freely without it. Deploy when the symptom (hedging, refusing, talking itself out of trying) actually shows up.
---
## When to default to / recommend human review
- **Sends, pays, refunds, account changes** — anything user-visible and hard to roll back.
- **The approver differs from the chatter** — a customer triggers a workflow; support staff approves the refund. The customer never sees the approval.
- **Non-chat triggers** — order received, form submitted, schedule fired. The action is taken on someone's behalf, and a person approves before it runs.
- **Production agent tools** where the cost of a wrong call (money, trust, reputation) outweighs a one-step delay.
Skip review when the tool is read-only, idempotent and cheap to undo, or the deployment is internal/exploratory with mocked services.
---
## Available review tool nodes
| Node | When to use |
|---|---|
| `n8n-nodes-base.slackHitlTool` | Approver is on Slack (the common multi-channel case) |
| `n8n-nodes-base.discordHitlTool` | Approver is on Discord |
| `n8n-nodes-base.telegramHitlTool` | Approver is on Telegram |
| `n8n-nodes-base.gmailHitlTool` | Approval via Gmail |
| `n8n-nodes-base.emailSendHitlTool` | Approval via generic SMTP email |
| `n8n-nodes-base.googleChatHitlTool` | Approval in Google Chat |
| `n8n-nodes-base.microsoftOutlookHitlTool` | Approval via Outlook |
More platforms are added over time — verify with `search_nodes({ query: 'hitl' })`.
---
## Response types
`responseType` chooses the response shape the human sees:
- **`approval`** — button-based, sub-configured via `approvalOptions.values.approvalType`:
- `'single'` (default): one Approve button. The approver acts or ignores.
- `'double'`: Approve / Disapprove. For actions where disapproval should be a loud, recordable choice.
- **`freeText`** — the human types a free-form response. For when the agent is genuinely asking a question and any answer is valid.
- **`customForm`** — a multi-field form (text, dropdown, radio, checkbox, file). **This is the practical answer to "editable parameters"**: define a form whose fields match the wrapped tool's parameters and the human can override what the agent picked.
A two-button "semantic choice" ("Schedule today" / "Schedule tomorrow") is NOT a separate type — use `approval` with `approvalType: 'double'` and custom `approveLabel` / `disapproveLabel`.
---
## Wait timeout
`options.limitWaitTime` (seconds) bounds how long the workflow pauses before erroring out. Default is 45 minutes. **Set it explicitly on production workflows** — without it, paused executions sit indefinitely if approvers don't act, and the queue piles up.
---
## Approval message content — show the ACTUAL parameters
The model picked the parameters; the human approves the literal call. Reference the real values via `{{ $tool.parameters.<name> }}`:
```
The agent wants to refund {{ $tool.parameters.amount }} to {{ $tool.parameters.customerId }}.
Reason: {{ $tool.parameters.reason }}.
```
`$tool.name` is the wrapped tool's display name; `$tool.parameters` is the full object the agent built. To avoid silently leaving a new parameter out of the message, iterate over all of them:
```
The agent wants to call {{ $tool.name }}:
{{
$tool.parameters.keys()
.map(param => `${param}: ${$tool.parameters[param]}\n`)
.join('')
}}
```
### Never fill the approval message via `$fromAI()`
`$fromAI()` asks the *model* to produce a value — including, if you let it, the approval text itself. The human would then approve a model-paraphrased description instead of the literal parameters about to be sent. That defeats the entire point of review.
```
// ❌ WRONG — the model paraphrases what it's about to do
message: ={{ $fromAI('approvalText', 'describe the action for approval') }}
// ✅ RIGHT — the literal call is visible
message: =Refund {{ $tool.parameters.amount }} to {{ $tool.parameters.customerId }}?
```
### Put values in the button labels
```json
"approvalOptions": {
"values": {
"approvalType": "double",
"approveLabel": "=Approve {{ $tool.parameters.amount }} refund",
"disapproveLabel": "Cancel"
}
}
```
A button that says "Approve $50 refund" is unambiguous; "Approve" alone is not. `slackHitlTool` also exposes `buttonApprovalStyle` / `buttonDisapprovalStyle` (`'primary' | 'secondary'`) for visual emphasis.
---
## Multi-channel pattern: the approver isn't the chatter
A common production shape: a customer chats with an agent on a website (or via email/order/form), and support staff approves sensitive actions in Slack.
```
[customer chat / order trigger]
→ [Agent]
→ [Slack review tool] → [refund / cancel / escalate tool]
```
The customer never sees the Slack channel. The Slack review message routes via `slackHitlTool.parameters.user` (a resource locator). On approval, the wrapped tool fires and the agent's response goes back to the customer via the original path. This works without any chat at all — the trigger can be a webhook, schedule, form, or queue; the review tool is the only human-facing surface.
---
## Editable parameters: use customForm
For "approve, but at $40 instead of $50" workflows, use `responseType: 'customForm'`. The human fills a multi-field form whose values feed the wrapped tool. Don't try to build editable approvals on top of the `approval` type — the form mode is the supported path.
> Note: the form mode UX is reported to feel like a workaround. Sometimes it's better UX to have the user decline and respond with the change in chat.
---
## UI quirk: test-data autofill
When building a review tool, click "Approve" once on the canvas test execution. n8n autofills the test data so subsequent runs work without manual input. New builders often think the tool is broken because `$tool.parameters.<name>` shows red — that's just missing test data.
---
## Anti-patterns
| Anti-pattern | What goes wrong | Fix |
|---|---|---|
| Tool that mutates user-visible state without review | Agent fires irreversible action on a wrong inference | Wrap with the right review tool node |
| Approval message via `$fromAI()` | You approve a paraphrase, not the literal call | Use `$tool.parameters.<name>` |
| "Approve" button with no context | Approver clicks without seeing what they approve | Embed actual values in the label |
| Review on a channel the approver doesn't watch | Tool sits indefinitely, executions pile up | Pick a watched channel; set `limitWaitTime` + a fallback |
| Wrapped tool wired into the agent's `main` input | Flags as a disconnected node in validation | Wire wrapped-tool → review → agent via `ai_tool` only |
@@ -0,0 +1,139 @@
# Agent memory
Memory is a sub-node on the agent, wired via `ai_memory`. Without it, every invocation is stateless. With it, the agent holds a conversation across turns — and across executions, depending on type — keyed by whatever expression you bind to `sessionKey`.
Memory node availability shifts between n8n versions, so confirm what's installed with `search_nodes({ query: 'memory' })`.
---
## The two non-negotiables
1. **Plumb a stable key through.** Memory buckets by whatever you bind to `sessionKey`. The Chat Trigger fills `sessionId` automatically. For other triggers, derive a stable identifier (Slack `thread_ts`, a webhook conversation ID, a generated UUID, a multi-tenant composite) and forward it to memory and any session-keyed tools. Without consistency across the same conversation, memory never matches.
2. **Default to `memoryBufferWindow`.** It persists across executions via n8n's internal store, keyed on `sessionKey`, and is the right choice for nearly every chat agent. Reach for Postgres/Redis only when memory must be read **outside** the agent.
---
## The memory types
### `memoryBufferWindow` (the default)
In-context memory of the last N exchanges, persisted across executions via n8n's store.
```json
{
"parameters": {
"sessionIdType": "customKey",
"sessionKey": "={{ $json.sessionId }}",
"contextWindowLength": 50
},
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1.3,
"name": "Simple Memory"
}
```
`contextWindowLength` is the number of exchanges retained. **The default is 5 — very low** for modern chat expectations, where users assume a conversation feels close to endless. **50 is a reasonable starting point.** Higher = more context but more tokens per turn.
**Messages past the window are removed entirely.** Once the buffer fills, the oldest exchanges are dropped and the agent can't recall, search, or even know they existed. If a user said something 60 turns ago and the window is 50, that's gone from the agent's perspective. For recall beyond the window, raise `contextWindowLength`, or persist key facts in a Data Table that's read and injected into the system prompt.
The "window" is a sliding cap on how many messages stay in context — **not** a scope on persistence. With `sessionIdType: 'customKey'` you bind the key to any expression (`{{ $json.sessionId }}`, a Slack `thread_ts`, a multi-tenant composite). Each user/thread/context gets its own bucket.
### `memoryPostgresChat` / `memoryRedisChat`
Reach for these only when memory must be queried or read **outside** the agent: displaying conversation history in your own UI, analytics on past chats, sharing memory across systems, or migrating instances cleanly.
```json
{
"parameters": {
"sessionIdType": "customKey",
"sessionKey": "={{ $json.sessionId }}"
},
"type": "@n8n/n8n-nodes-langchain.memoryPostgresChat",
"typeVersion": 1.3,
"name": "Postgres Memory"
}
```
**Wrong for** the default chat case — `memoryBufferWindow` already survives across executions and is the cleaner pick.
---
## Custom patterns (Chat Memory Manager)
Most agents don't need this. But when a fixed window isn't enough, the `@n8n/n8n-nodes-langchain.memoryManager` node operates against any wired memory backend and exposes three modes:
- **`load`** (default) — read current memory into the workflow (for inspection, branching on size, feeding a summarizer).
- **`insert`** — append a message. An optional `hideFromUI` flag covers messages that should affect the agent but not show in the chat UI.
- **`delete`** — remove some or all messages.
### Pattern: rolling summarization
When a conversation runs long and you want the gist of older turns instead of dropping them:
1. After each turn, `load` the buffer.
2. If it's approaching the cap, route to a summarizer (otherwise no-op).
3. Summarize the older turns with an LLM.
4. `delete` the buffer.
5. `insert` the summary as one message, plus the most recent few turns for continuity.
The agent now sees `[summary of turns 1-40] + [recent 5 turns]`, paying far fewer input tokens while keeping long-history context.
Other patterns built the same way: **prune by relevance** (`load` → filter → `delete``insert` the keepers), **inject runtime facts** (`insert` with `hideFromUI: true`), **reset on command** (`delete` all on `/clear`).
The Memory Manager node is more recent than the rest of n8n's memory tooling — verify the modes against your installed version before relying on them in production.
---
## Session ID handling by trigger
### Chat Trigger
Sets `sessionId` automatically. Wire it everywhere consistently:
- Memory: `sessionKey: ={{ $('Chat Trigger').first().json.sessionId }}`
- Tools: `sessionId: ={{ $('Chat Trigger').first().json.sessionId }}` (**NOT** through `$fromAI`)
- Storage keying: derive bucket keys / filenames from `sessionId` for trivial per-session cleanup.
### Webhook trigger
You manage it: the caller passes a header or body field (`body.sessionId`) and you forward it, or you issue one on first call and expect it back. Either way, it must be consistent across the whole conversation, including reconnections.
### Manual / scheduled
Usually no session. Use a stable identifier per "conversation" if one exists (ticket ID, thread ID); otherwise memory adds nothing — omit it.
---
## Memory and tools
When a tool is invoked, the tool's sub-workflow does **NOT** see conversation memory — memory is the agent's context, not the tool's input. Pass needed context through `$fromAI` parameters explicitly. For session-keyed state, plumb `sessionId` and have the tool look up state from a Data Table or storage keyed by session.
---
## Memory and binary
Memory stores **text turns**. Binary uploaded mid-conversation is NOT in memory — it's in the Chat Trigger's `files[]` for that turn only. The text memory captures that "the user mentioned uploading a file," but to actually use the file in a later tool call it must still be in storage and its key must be in **that** turn's system prompt. In practice, inject the session's file inventory into the system prompt every turn (loaded by `sessionId`). → **n8n-binary-and-data**.
---
## Common mistakes
- **Hardcoding `sessionId: 'default'`** — all conversations share one bucket; memory becomes meaningless.
- **Different `sessionId` on memory vs tools** — memory looks right but tools can't find related state.
- **Unbounded `memoryBuffer` for chat** — token cost grows until timeout. Use BufferWindow with a sane limit.
- **Adding memory where there's no session** — a "summarize this article" workflow doesn't need it.
- **Expecting tools to see memory** — they see only their `$fromAI` parameters and plumbed context.
- **Drift between the surface and memory** — if anything posts to the conversation outside the agent (a scheduled reply, a human writing directly), the agent operates on an incomplete view and will contradict messages it can't see. Whatever shows on the user-facing surface must also be `insert`ed into memory.
---
## Operational notes
- **Memory size drives token cost.** A 15-turn buffer of 200-token messages is 3000 tokens of input every turn before the user even speaks. Plan for it.
- **Rate limits.** A model that hits a limit fails mid-conversation; memory holds everything until then, and the next turn resumes (assuming session-id continuity).
- **Concurrent sessions.** Persistent backends key on `sessionId`, so concurrent conversations don't interfere. Verify with two simultaneous tests.
---
## Cross-references
- Where the agent fits → parent **SKILL.md**
- Passing session-keyed state into tools → **SUBWORKFLOW_AS_TOOL.md**
- Threading-as-session on chat surfaces → **CHAT_AGENT_PATTERNS.md**
- Session-keyed file storage → **n8n-binary-and-data**
@@ -0,0 +1,102 @@
# RAG (retrieval augmented generation)
RAG in n8n is built on the LangChain primitives — document loaders, text splitters, embeddings, vector stores, retrievers, rerankers. They wire onto agents and chains the same way models and memory do (via `ai_*` connections).
This reference is intentionally **thin**. The pieces work, but opinionated end-to-end recipes ("which vector store, which chunking, when to rerank") depend heavily on data shape and scale. Verify defaults against current n8n docs and your team's choices.
---
## Before you go vector: rule out cheaper lookups
Not every retrieval problem needs a vector store. Three cheaper alternatives to eliminate first:
- **Database or Data Table for exact lookups.** "Look up customer X's record", "fetch issue #1234", "get rows where status = 'open'" are NOT RAG problems — use a query directly. → **n8n-node-configuration** for DB nodes.
- **Live search for freshness.** Information not in anything you've indexed (current news, live API state, anything time-sensitive) wants a search tool (Tavily, etc.), not RAG.
- **Grep/file-browse tools for small or structured doc sets.** When the documents are few enough to list (a repo, a docs site, a few hundred markdown files), give the agent list/fetch/search tools and let it navigate. As an example, an agent browsing a GitHub repo can use `githubTool` (list files) plus an HTTP Request Tool against the repo contents endpoint to fetch raw text — no ingest, no embeddings, full source paths in citations.
Reach for vector RAG when there are too many documents to list, queries are semantic rather than navigational, and you need similarity-based retrieval at low latency.
---
## Quickest start: in-memory vector store
The fastest path to a working RAG flow uses `@n8n/n8n-nodes-langchain.vectorStoreInMemory` — no external service, no provisioning, no extra credential beyond whichever embedding / chat-model provider you already use. Data is lost on workflow restart, so it's right for prototypes, learning, and tests, not production.
- **Ingest**: any trigger producing documents → Default Data Loader → Vector Store In-Memory (`mode: 'insert'`) with an Embeddings node wired into `ai_embedding`. A Form Trigger with a file-upload field is a quick way to drop in PDFs/CSVs without scripting.
- **Query**: Chat Trigger → Agent → Vector Store In-Memory (`mode: 'retrieve-as-tool'`), same `memoryKey` and the same embedding model as ingest.
When the data must survive restarts or scale beyond one instance, swap the in-memory node for a persistent store — the rest of the wiring stays the same.
---
## Vector RAG: the pieces
n8n exposes the LangChain primitives as sub-nodes:
- **Document loaders** (`documentDefaultDataLoader`) — pull from sources, optionally with metadata. Wires into a vector store's `ai_document`.
- **Text splitters** (`textSplitter*`) — chunk into retrievable pieces. The default loader can do this inline for simple cases.
- **Embeddings** (`embeddingsOpenAi`, `embeddingsCohere`, …) — turn chunks into vectors. Wires into `ai_embedding` on **both** ingest and query.
- **Vector stores** — `vectorStoreInMemory`, `vectorStoreQdrant`, `vectorStoreSupabase` (Postgres pgvector), `vectorStorePinecone`. Each has modes: `insert` (ingest), `retrieve-as-tool` (the agent's `ai_tool` slot), and others for direct querying.
The Default Data Loader's `metadata` field is **load-bearing**: anything you want to filter or display alongside results (source URL, document type, tenant ID) goes there. Without it, results are just chunks with no provenance.
---
## Vector RAG: two workflows
### Ingest
```
[Trigger]
→ [Vector Store, mode: 'insert']
ai_document <- [Default Data Loader (with metadata)]
ai_embedding <- [Embeddings]
```
**Ingest does not have to be a tool.** Most often it's a separate scheduled workflow pre-populating the store on a cadence (e.g. nightly), or a webhook-triggered workflow. Wire it as an agent tool only when the documents change dynamically based on conversation (the agent learns something it should remember). For static or system-managed sets, a standalone workflow is simpler.
### Query
```
[Chat / webhook trigger]
→ [Agent]
ai_tool <- [Vector Store, mode: 'retrieve-as-tool']
ai_embedding <- [Embeddings (SAME model as ingest)]
ai_languageModel <- [Chat Model]
ai_memory <- [Memory]
```
Wired as `ai_tool`, the vector store becomes a tool the agent calls when it judges retrieval relevant. Wire retrieval directly into the main flow (pre-agent) only when **every** turn requires retrieval — rare in practice.
**The embedding model must match.** Whatever embedded the documents on ingest must embed the query. Mismatched models produce garbage retrieval. Change models → re-ingest.
---
## Open decisions (verify per context)
### Vector store selection
- **In-memory** — zero ops, lost on restart. Prototypes and tests.
- **Qdrant** — open-source, self-hostable, fast, mature in n8n.
- **Postgres pgvector / Supabase** — ideal if you already run Postgres; SQL-side metadata filters and relational joins compose nicely.
- **Pinecone** — fully managed, per-request pricing.
### Embedding model
OpenAI `text-embedding-3-large`, Cohere `embed-v3`, and open-source models are common. Cost, dimension count, and quality differ — choose carefully upfront to avoid re-embedding.
### Retrieval-as-tool vs retrieval-before-agent
- **Retrieve-as-tool**: the agent decides when retrieval is relevant AND phrases the query itself (reformulate, decompose, expand vague wording). One extra round trip per retrieval, but fewer wasted retrievals and a better hit rate.
- **Retrieve-before-agent**: simpler and predictable, but pays the cost every turn AND uses the user's raw input as the query, so vague phrasing ("remind me how that thing works again?") goes straight into the search.
Tool-based composes better in multi-capability agents (retrieval is one tool among several). Always-retrieve is fine for narrow Q&A bots where every question is a knowledge-base question.
---
## Cross-references
- Agent fundamentals → parent **SKILL.md**
- Wiring sub-workflows (and agentic retrieval tools) → **SUBWORKFLOW_AS_TOOL.md**
- Tool naming/descriptions on retrieval tools → **TOOLS.md**
- Data Tables as an alternative to a vector store for small structured data → **n8n-node-configuration**
@@ -0,0 +1,163 @@
# Structured output
Non-negotiable: the output parser must **parse AND retry on failure**. Without retry, one malformed model response halts the entire workflow.
The parser is the `@n8n/n8n-nodes-langchain.outputParserStructured` node, wired into the agent (or Basic LLM Chain) via the `ai_outputParser` connection.
---
## The pattern (node objects)
The parser, with `autoFix` and its own fixer model:
```json
{
"parameters": {
"schemaType": "manual",
"inputSchema": "{ \"type\": \"object\", \"properties\": { \"score\": { \"type\": \"integer\", \"minimum\": 1, \"maximum\": 5 }, \"reason\": { \"type\": \"string\" } }, \"required\": [\"score\", \"reason\"] }",
"autoFix": true
},
"type": "@n8n/n8n-nodes-langchain.outputParserStructured",
"typeVersion": 1.3,
"name": "Structured Output Parser"
}
```
Wire the parser to the agent, and a **coding-capable fixer model** to the parser:
```json
"Structured Output Parser": {
"ai_outputParser": [[{ "node": "AI Agent", "type": "ai_outputParser", "index": 0 }]]
},
"Fixer LLM": {
"ai_languageModel": [[{ "node": "Structured Output Parser", "type": "ai_languageModel", "index": 0 }]]
}
```
On the agent, set `hasOutputParser: true` so the slot is active.
---
## Why a schema, not an example
`schemaType: 'manual'` with a real JSON Schema is the default. `jsonSchemaExample` (`schemaType: 'fromJson'`) looks easier, but an example **cannot** express:
- **Required vs optional fields** — an example is one snapshot; the parser can't tell which keys are mandatory.
- **Enums** — `"category": "compliance"` doesn't constrain the model to `compliance | history | risk`; it will invent new categories.
- **Numeric ranges** — `"score": 3` doesn't say `1-5`; the model returns `7` or `0.85` and passes.
- **Array constraints** — min/max items, item-type uniformity.
- **String formats** — email, UUID, ISO date, regex.
A schema gives the model clearer rules and the parser real validation:
```json
{
"type": "object",
"properties": {
"decision": { "type": "string", "enum": ["approve", "reject", "escalate"] },
"confidence": { "type": "number", "minimum": 0, "maximum": 1 },
"reasons": {
"type": "array",
"items": {
"type": "object",
"properties": {
"category": { "type": "string", "enum": ["compliance", "history", "risk"] },
"weight": { "type": "number", "minimum": 0, "maximum": 1 },
"note": { "type": "string" }
},
"required": ["category", "weight"]
}
},
"follow_up_required": { "type": "boolean" }
},
"required": ["decision", "confidence", "reasons", "follow_up_required"]
}
```
Reach for `fromJson` + `jsonSchemaExample` only for one-off shapes you're certain will never grow constraints. Once a field needs to be optional, enum-ed, or range-bounded, you're rewriting the parser anyway — start with the schema.
---
## `autoFix: true` and the fixer model
The model can produce almost-but-not-quite-valid JSON: trailing comma, missing field, wrong type, or JSON wrapped in a markdown code block. Without `autoFix`, the workflow halts. With it, the parser sends the bad output to a model with a "fix this" prompt, retries, and continues.
The fixer is wired as a **separate** sub-node into the parser's `ai_languageModel` slot. **Use a coding-capable model** (Sonnet-class or better). Reconciling broken JSON against a schema with enums, ranges, and required fields is a structured-output / coding task — a weak or generic model routinely produces another malformed retry, defeating the point and burning tokens.
When you want to customize the retry prompt, set `customizeRetryPrompt: true` and provide `prompt`. The placeholders `{instructions}`, `{completion}`, `{error}` are filled at retry time:
```
Instructions:
--------------
{instructions}
--------------
Completion:
--------------
{completion}
--------------
Above, the Completion did not satisfy the constraints in the Instructions.
Error:
--------------
{error}
--------------
Please try again with an answer that satisfies the constraints.
This is a structured output parser tool in n8n. Ensure the output format is correct to pass parsing.
DO NOT wrap the output in a markdown code block.
```
Generally, leave the retry prompt as default unless you have a specific reason to override it.
---
## "DO NOT wrap the output in a markdown code block"
This line is **load-bearing**. Models default to wrapping JSON in triple-backtick `json` fences, which breaks the parser. If you see parse failures on output that's clearly valid JSON inside a code block, this instruction is the fix — in both the retry prompt and, if the main model wraps aggressively, the **main** system prompt:
> When responding with structured output, return raw JSON only. DO NOT wrap in markdown code blocks. DO NOT include any prose before or after the JSON.
---
## System prompt + parser: belt and suspenders
The parser tells the model the schema; the system prompt should ALSO state the shape:
```
## Output Format
Respond with a JSON object matching this exact shape:
{ "score": 1-5 integer, "reason": "brief explanation" }
ONLY output the JSON. No prose, no markdown wrapping.
```
It's repetition, but the model takes the system prompt seriously and reinforcement helps. The parser catches what slips through.
---
## Common parse failures and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| "Failed to parse output" but the text looks like JSON | Wrapped in a markdown code block | Add "DO NOT wrap in markdown" to retry prompt and system prompt |
| Empty fields where the schema expects values | Model thinks it can omit unknowns | "Use empty string '' or null for unknown fields, never omit" |
| Wrong types (number as string) | Schema/example wasn't typed clearly | Use a real number in the schema, not a string |
| Truncated JSON (unclosed brace) | Hit max tokens mid-response | Increase max tokens, tighten the prompt to produce shorter output |
| Field names paraphrased ("Score" vs "score") | Schema didn't pin the name | "Field names are exactly as shown" in the system prompt |
| `autoFix` retries forever | Fixer model too weak for the schema | Swap in a coding-capable (Sonnet-class) fixer; tighten the retry prompt |
---
## When NOT to use a parser
- **Free-form chat replies to the user** — conversational text doesn't need parsing.
- **Tool calls only, no final structured output** — if the user-visible output is text, skip it.
- **Trivial key-value extraction** — a Set node with `JSON.parse($json.output)` covers it.
The parser is for when downstream nodes must consume strict JSON.
---
## Cross-references
- Why and where to use agents at all → parent **SKILL.md**
- The system-prompt half of structured output → **SYSTEM_PROMPT.md**
- Block Kit / adaptive cards need the manual schema even more (union types) → **CHAT_AGENT_PATTERNS.md**
@@ -0,0 +1,199 @@
# Sub-workflow as agent tool
The default agent-tool shape for anything beyond one node is the Tool Workflow node (`@n8n/n8n-nodes-langchain.toolWorkflow`). Any sub-workflow becomes a tool the agent calls, with typed inputs filled by `$fromAI()`. It composes with everything good about n8n: branching, error handling, sub-workflow reuse, native nodes, custom logic.
For the sub-workflow primitive itself (Execute Workflow Trigger inputs/outputs, stateless design, naming, search-before-build), see **n8n-subworkflows** — this reference only covers the *agent-tool* angle.
---
## Why this is the default in n8n
In raw LangChain a tool is a function. In n8n a tool can be a whole workflow, so it can:
- Branch on input (IF / Switch).
- Call multiple APIs and aggregate.
- Have its own retries, fallbacks, error handling.
- Call other sub-workflows.
- Read/write Data Tables.
- Be tested independently with `n8n_test_workflow` and pinned data.
- Be reused across agents AND non-agent workflows.
A function-as-tool can't do most of that without growing into a workflow anyway. n8n gives you the workflow primitive directly.
---
## The shape: two halves
### 1. The sub-workflow side — an Execute Workflow Trigger with typed inputs
```json
{
"parameters": {
"workflowInputs": {
"values": [
{ "name": "imagePrompt", "type": "string" },
{ "name": "imageName", "type": "string" },
{ "name": "sessionId", "type": "string" }
]
}
},
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"name": "When Executed by Another Workflow"
}
```
Each declared input becomes a parameter the caller can fill. **The trigger must be in "Define Below" mode (typed fields), not passthrough** — passthrough has no schema, so the agent has nothing to fill via `$fromAI`. Two exceptions: (a) the sub-workflow needs binary (it can't be an agent tool directly — pre-stage to storage and pass storage keys as typed string fields, see **n8n-binary-and-data**), or (b) the tool takes no inputs at all (passthrough is the only option, and the tool's only decision is whether to invoke).
Type enforcement happens on the **agent side** via the `type` argument of `$fromAI`, not at the trigger. Allowed types: `string`, `number`, `boolean`, `json`. Match them.
### 2. The Tool Workflow side — points at the sub-workflow, binds params
```json
{
"parameters": {
"description": "Use to create a new image from a prompt OR edit an existing image. Pass imageName as the storage key (e.g. \"abc123.png\") to edit; leave empty to generate from scratch. Returns { imageUrl, imageKey }.",
"workflowId": { "__rl": true, "value": "<sub-workflow-id>", "mode": "list" },
"workflowInputs": {
"mappingMode": "defineBelow",
"value": {
"imagePrompt": "={{ $fromAI('imagePrompt', 'Detailed prompt describing the desired image', 'string') }}",
"imageName": "={{ $fromAI('imageName', 'Storage key of an existing image to edit, or empty for new generation', 'string') }}",
"sessionId": "={{ $('Chat Trigger').first().json.sessionId }}"
},
"schema": [
{ "id": "imagePrompt", "displayName": "imagePrompt", "type": "string", "display": true },
{ "id": "imageName", "displayName": "imageName", "type": "string", "display": true },
{ "id": "sessionId", "displayName": "sessionId", "type": "string", "display": true }
]
}
},
"type": "@n8n/n8n-nodes-langchain.toolWorkflow",
"typeVersion": 2.2,
"name": "Generate or edit image"
}
```
Wire it into the agent with `ai_tool`:
```json
"Generate or edit image": {
"ai_tool": [[{ "node": "AI Agent", "type": "ai_tool", "index": 0 }]]
}
```
The mapping is per-input:
- **Agent-filled**: `={{ $fromAI('paramName', 'description', 'string') }}` — the agent decides.
- **Plumbed**: `={{ $('SourceNode').first().json.field }}` — your workflow fills it.
The `sessionId` line is critical: it is **NOT** an agent decision. Plumb it from the trigger so memory and session-keyed work stay consistent. **Never put `sessionId` behind `$fromAI`** — the agent will fabricate a UUID.
---
## What the agent sees (and doesn't)
The agent sees the tool's **name** (the Tool Workflow node's name) and **description** (a parameter on the node) — both follow the **TOOLS.md** rules: specific, API-doc style, treated as prompt.
It does **not** see: the sub-workflow internals, the sub-workflow's own name, or plumbed values like `sessionId`. Only `$fromAI` parameters appear in the tool schema. So you can refactor the sub-workflow heavily without changing what the agent sees.
---
## Worked example: one tool, two modes
Goal: an agent that can generate or edit images. Both share most logic; they differ only in whether they download an existing image first.
```
[Execute Workflow Trigger: { imagePrompt, imageName, sessionId }]
[Crypto: hash for new filename]
[IF: imageName empty?]
├── empty (generate) → [Gemini: generate] ──┐
└── not empty (edit): │
[S3: Download by imageName] │
↓ │
[Gemini: edit with downloaded binary] ───────┤
[S3: Upload result]
[Set: { imageUrl, imageKey }]
```
The agent picks the mode by what it puts in `imageName`. Two near-identical tools would have made selection harder — collapse them.
---
## Patterns inside the sub-workflow
### Return a stable shape (it's a contract)
The caller receives whatever the last node outputs. Pick a shape and keep it across modes:
```json
{ "imageUrl": "https://...", "imageKey": "abc123.png" }
```
Don't sometimes return `{ url, key }` and other times `{ result: { url, key } }`. The output shape is a contract every caller depends on — agents read it as part of the prompt, deterministic callers wire downstream nodes to specific paths. Drift breaks callers silently.
For calls that fail "expectedly" (search with no results), return a branchable shape:
```json
{ "ok": false, "error": "no_results", "message": "No matches found for query" }
```
### When to throw instead: Stop and Error
For unexpected-but-handled errors (auth failure, upstream down, unrecoverable input), use a `Stop and Error` node with a detailed message. It propagates as a thrown error: agents see a tool error and can retry/switch/report; deterministic callers catch it via `onError: 'continueErrorOutput'`. Pick this over `{ ok: false }` when the outcome is a true error, not a normal branch. For the full error story (4xx/5xx mapping, retries, error workflows) → **n8n-error-handling**.
### Wire `onError: 'continueErrorOutput'` on fallible nodes
Inside the sub-workflow, fallible nodes (HTTP, S3, DB) should set `onError: 'continueErrorOutput'` and route to a clean error response, so both agent and deterministic callers receive a structured error instead of a silent halt.
### Treat the input contract as an API and document it
The Execute Workflow Trigger's declared inputs ARE this tool's API. Document them in the sub-workflow's `description`:
```
Generates or edits an image.
Inputs:
imagePrompt (string, required): detailed image description.
imageName (string, optional): storage key of existing image to edit. Empty = new generation.
sessionId (string, required): chat session ID, used for storage keying.
Returns:
{ imageUrl, imageKey }
```
### Keep tool sub-workflows discoverable
Name them with a standard prefix (`Subworkflow:` or domain-specific). The Tool Workflow node references them by ID (stable), but humans browse the UI by name.
---
## Testing the sub-workflow independently
A sub-workflow tool can be tested without the agent:
1. Pin representative input on the Execute Workflow Trigger.
2. `n8n_test_workflow` runs it with that pinned data.
3. Verify the output shape matches what the agent will receive.
---
## When NOT to use sub-workflow as tool
- **Simple one-node wrappers** — "call this endpoint and return" is shorter as an HTTP Request Tool.
- **One-off code-only logic specific to this agent** — a few lines of pure JS/Python that exist nowhere else work fine as a Custom Code Tool (`.toolCode`, see **n8n-code-tool**). Decision rule: reusable business logic → sub-workflow; one-off agent-specific transform → Code Tool.
- **Capabilities that already exist as native tool nodes** — don't wrap `slackTool` in a sub-workflow.
For everything else, sub-workflow as tool is the default.
---
## Cross-references
- The four tool types overview → **TOOLS.md**
- How `$fromAI` descriptions affect behavior → **TOOLS.md** "`$fromAI()`"
- The sub-workflow primitive (stateless design, naming, I/O) → **n8n-subworkflows**
- Passing binary into tools → **n8n-binary-and-data**
- The Custom Code Tool exception → **n8n-code-tool**
@@ -0,0 +1,151 @@
# System prompts
The system prompt is the load-bearing config of an agent. Most "the agent isn't doing what I want" problems trace back to a system prompt that's too long, too vague, or mixing concerns.
This file is opinionated: keep system prompts on **persona and global behavior**, push tool-specific instructions into tool descriptions, and iterate. The system prompt goes in `options.systemMessage` on the agent node.
---
## What the system prompt is for
1. **Persona / role.** Who, scope, tone.
2. **Global output rules.** Format conventions, display protocols (e.g. "show images via `![]()` markdown"), language.
3. **Refusal and safety behavior.** What the agent should NOT do — prefer specific bounds over generic boilerplate.
4. **Universal context.** Current date, user's name/role, company/product context.
5. **Inter-tool flow rules.** "After generating, always show via the display protocol", "confirm before destructive operations" — things that touch multiple tools.
6. **File-handling injection.** When chat includes uploaded files, inject the storage keys so the agent can reference them in tool calls (mechanics → **n8n-binary-and-data**).
What it is NOT for: per-tool usage instructions. Those go in the tool's description.
---
## Always include the current date
A hardcoded date is stale immediately. Inject it at runtime:
```
Current date: {{ $now }}
```
or formatted:
```
The current time is {{ $now.format('DDDD TTTT') }}
```
---
## The modular split
```
System prompt → Persona, global behavior, format rules, file handling
Tool description → How to use THIS tool, its parameters, when to pick it over others
$fromAI desc. → What value to put in this specific parameter
```
Why this split:
- **Reuse.** A well-described tool works in any agent; the system prompt doesn't re-teach it.
- **Token efficiency.** Tool details only "load" when the model considers that tool. Per-tool text in the system prompt burns tokens every turn.
- **Maintainability.** Update one tool description, not a paragraph buried in a 5000-token prompt.
### What to move where
| Was in the system prompt | Better location |
|---|---|
| "When using Generate Image, prefer realistic photography over `8k cinematic`" | `Generate Image` tool description |
| "When the user uploads an image and asks for background changes, edit it, don't generate new" | `Edit Image` tool description (and a "do not use" boundary on `Generate Image`) |
| "Use 9:16 aspect ratio for video tools" | `Generate Video` tool description |
| "Respond with markdown image embeds: `![alt](url)`" | **System prompt** (global display rule) |
| "Refuse to generate images of real people without consent" | **System prompt** (global safety) |
| "Today is 2026-04-25" | **System prompt** as `{{ $now }}` (universal context, computed) |
The first three move out; the last three stay in.
---
## Storing the prompt
Inline (typed directly into `systemMessage`) is fine for a first agent or any prompt that lives in one place. A 1500-token inline prompt is a normal shape — don't push first-time builders toward externalization.
The real reason to externalize is **piecing**, not length. Reusable chunks of context — `COMPANY_DESCRIPTION`, `BRAND_VOICE`, `CURRENT_PROMOTION` — each get one canonical home, and every prompt that needs them references that home. Suggest this when you see one of:
- Multiple agents share the same context (same product description, same compliance language).
- Pieces drift on their own cadence (`COMPANY_DESCRIPTION` quarterly, `CURRENT_PROMOTION` weekly).
- A non-engineer owns part of the prompt (marketing owns brand voice, legal owns disclosures).
- You want to A/B test one chunk without touching the rest.
If none apply, stay inline. Mid-prompt restructures cost more than they save with no second consumer to pay them back.
### How piecing works
Load each chunk at workflow start (one node per chunk — a Data Table `Get Row`, an HTTP fetch, a Set node), then reference them inline in `systemMessage` where they should appear:
```
=You are the assistant for {{ $('Company Description').first().json.value }}.
## Market positioning
{{ $('Market Fit').first().json.value }}
## Brand voice
{{ $('Brand Voice').first().json.value }}
Current date: {{ $now }}
User: {{ $('Lookup').first().json.name }}
```
Mix sources: a **Data Table** (default for shared chunks, editable in UI), **n8n Variables** (`$vars.X`, paid plans — short shared values like a brand name), or **computed at run time** (`$now`, current user, available files).
---
## Common patterns
### Include
- **Display protocols** for output needing specific formatting (markdown image syntax, link format, code-block conventions).
- **Conversational style cues** for user-facing agents ("ask one clarifying question before destructive actions").
- **Boundaries** unique to this agent ("only answer questions about domain X, otherwise redirect").
- **Universal context** that changes per execution (date, user identity, files).
### Exclude
- **Per-tool usage docs** — move to tool descriptions.
- **Generic safety language** — built in; reinforcing adds tokens without changing behavior. Reserve for specific risks.
- **"You are a helpful assistant" preamble** — replace with a specific role.
- **Lengthy examples that aren't earning their tokens** — one sharp example beats five mediocre ones.
---
## Iteration loop
Treat the system prompt like code:
1. Run the agent on representative inputs.
2. Note where it does the wrong thing.
3. Decide: system-prompt fix, tool-description fix, or downstream-validation fix?
4. Make the smallest change that addresses it.
5. Re-test on the same inputs PLUS one or two new ones.
6. Watch for regressions on previously-working inputs.
Most "the agent doesn't follow my instructions" issues are conflicts between the system prompt, tool descriptions, and model defaults. Resolve those conflicts first.
---
## Anti-patterns
| Anti-pattern | Symptom | Fix |
|---|---|---|
| "You are a helpful assistant" + no specifics | Generic responses, no identity | Replace with a specific role and scope |
| 5000-token prompt with a section per tool | Token cost, slow responses, hard to edit | Move tool sections to tool descriptions |
| Hardcoded date / "current year" | Stale immediately | Inject `{{ $now }}` at runtime |
| A stack of `DON'T` rules | Model gets defensive, refuses too eagerly | Frame as positive instructions where possible |
| Multiple pasted "examples" | Cargo-cult, rarely earns its tokens | One sharp example, or none |
| Per-execution context hardcoded | Hard to update | Build the prompt from a template + variables |
---
## Cross-references
- Tool descriptions as the other half of the split → **TOOLS.md**
- The system-prompt half of structured output → **STRUCTURED_OUTPUT.md**
- File-handling injection mechanics → **n8n-binary-and-data**
@@ -0,0 +1,199 @@
# Agent tools
The agent picks tools by reading their **name** and **description** — nothing else. Both are part of the prompt. Treat tool design like API design: what it does, when to use it, what each parameter means, and how it fails.
---
## The four tool types
### 1. Native tool node
Pre-built tool versions of regular nodes: `slackTool`, `gmailTool`, `googleSheetsTool`, `toolCalculator`, `notionTool`, `httpRequestTool`, and so on. Identical to their non-tool counterparts except parameters can be agent-filled via `$fromAI()`.
- **Pros**: minimal config, well-tested, native feel.
- **Cons**: one node = one operation. Multi-step logic doesn't fit.
- **Use when**: the capability maps cleanly to one node and one operation.
When a native node is missing an operation or needs a non-standard param shape, point an **HTTP Request Tool** at the service's API with the service's *predefined credential type* — you reuse the existing OAuth/API-key credential and get the full API.
### 2. Sub-workflow as tool (`@n8n/n8n-nodes-langchain.toolWorkflow`)
The default for anything beyond one node. Any workflow becomes a tool with typed `$fromAI()` inputs.
- **Pros**: full power of n8n inside the tool — branching, error handling, sub-sub-workflows, native nodes, custom logic. Reusable across agents. Independently testable.
- **Cons**: one extra workflow boundary, slight latency.
- **Use when**: more than one node, logic that might be reused, or you want testability.
The canonical n8n way to build agent capabilities. → **SUBWORKFLOW_AS_TOOL.md**
### 3. HTTP Request Tool (`@n8n/n8n-nodes-langchain.toolHttpRequest`)
A wrapper around the HTTP Request node exposing its parameters to the agent.
- **Pros**: any HTTP API becomes a tool with one node.
- **Cons**: HTTP only. Auth/retry/error handling are yours to wire.
- **Use when**: calling a single external API the agent should orchestrate directly.
One thing to know: HTTP Request has its own HTTP-level timeout (default 5 minutes) — bump `options.timeout` for slow endpoints. The agent tool itself has no timeout; the agent waits as long as the tool takes. Pointing it at, say, the Notion API (with the Notion predefined credential) lets the agent compose path, method, and body itself — covering operations the native node doesn't expose. Trade-off: the agent is now writing API requests, which is more error-prone and needs a capable model plus clear endpoint guidance in the description. That widens the blast radius — make sure the user understands.
### 4. MCP Client Tool (`@n8n/n8n-nodes-langchain.mcpClientTool`)
Connects the agent to any MCP server. Two flavors:
- **External MCP servers** — any third-party or self-hosted MCP (GitHub, Linear, Notion, custom internal). One node exposes every tool that server offers.
- **n8n-hosted MCP** — a workflow on the same instance published with MCP access enabled. Same client node, pointed at an n8n MCP trigger URL. Lets one workflow serve many agents.
- **Cons**: tool descriptions and shapes come from the server, so quality varies and you can't easily tune them. Auth and reachability are yours.
- **Use when**: a maintained MCP server already covers the capability, or you want one published workflow to serve many agents.
### Plus: Custom Code Tool (`@n8n/n8n-nodes-langchain.toolCode`)
Pure inline computation (math, parsing, formatting). Its runtime contract is **string in / string out, no `$fromAI`, no `$helpers`** and is owned by the **n8n-code-tool** skill — read it before writing one. Rule of thumb: if you want `$fromAI()` in the code, you want `.toolWorkflow` instead.
---
## Decision: which tool type?
```
Capability the agent needs?
├── One native node + one operation does it
│ → native tool node
├── Native node missing an op / needs custom params for ONE API
│ → HTTP Request Tool (with the service's predefined credential)
├── More than one node, or logic that might be reused
│ → Sub-workflow as tool (.toolWorkflow) ← default when in doubt
├── Pure deterministic computation, one-off, inline
│ → Custom Code Tool (.toolCode) ← see n8n-code-tool
└── A maintained MCP server covers it / publish n8n logic to many agents
→ MCP Client Tool
```
---
## `$fromAI()`: how the agent fills tool parameters
`$fromAI()` is a **real n8n expression helper**, written inside a tool node's parameter expressions. Parameters the agent should decide get wrapped in it:
```
sendTo: ={{ $fromAI('recipient', 'Email address of the recipient', 'string') }}
subject: ={{ $fromAI('subject', 'Email subject line, concise and informative', 'string') }}
body: ={{ $fromAI('body', 'Email body in plain text, professional tone', 'string') }}
```
Shape: `$fromAI(paramName, description, type?, defaultValue?)`
- **paramName** — the name the model uses internally. snake_case or camelCase, be consistent.
- **description** — what value to produce. **Part of the prompt.** Be specific: format, range, example.
- **type** — `'string'` (default), `'number'`, `'boolean'`, `'json'`. Enforced — a wrong-typed value fails the call.
- **defaultValue** — used when the model omits the parameter.
It carries **JSON only** — it cannot carry binary (no base64, no file bytes), even through a non-AI binding. For binary, pass a storage key as a string and have the tool re-fetch (→ **n8n-binary-and-data**).
A good description vs a useless one:
```
✅ ={{ $fromAI('imageName', 'Storage key for an existing image to edit, or empty for a new generation. Use the exact key shown in the system prompt; do not reconstruct or guess.', 'string') }}
❌ ={{ $fromAI('imageName', 'image name', 'string') }} // useless to the model
```
Treat `$fromAI` descriptions like JSDoc — the model reads them to figure out what to pass.
---
## Plumbed params: hide what the agent shouldn't decide
Not every parameter has to be `$fromAI`. Any parameter can be filled deterministically from workflow context, and **plumbed values are invisible to the agent** — not in the tool schema, not influenceable by anything the model produces:
```
reason: ={{ $fromAI('reason', 'Why the user is requesting a refund', 'string') }} // agent-filled
customerId: ={{ $('Chat Trigger').first().json.user.id }} // hidden
maxRefund: ={{ $('Get user tier').first().json.refundLimit }} // hidden
idempotencyKey:={{ $('Chat Trigger').first().json.sessionId }} // hidden
```
Plumb anything the agent shouldn't get wrong or see:
- **Identity** — `userId`, `customerId`, authenticated actor, tenant scope.
- **Authority limits** — refund caps, tier flags, allowed regions.
- **Correlation IDs** — `sessionId`, idempotency keys, trace IDs.
**Give the agent a button to push, not a steering wheel.** The strongest version is a sensitive tool with **zero `$fromAI` parameters**: a "Refund order" tool takes `orderId` from the trigger, `amount` from the fetched order record, `actor` from the session — all plumbed. The agent literally cannot refund the wrong order; it only chooses whether to fire. Pair with **HUMAN_REVIEW.md** for actions needing both deterministic params and sign-off.
---
## Tool name and description as prompt
Selection process the model runs every turn:
1. It gets the system prompt, conversation, and the list of tools.
2. For each tool it reads name + description + parameter schema (with `$fromAI` descriptions).
3. It picks the tool whose description best matches what it needs to do.
**Bad names and descriptions cause bad selection — usually silently.** The model just doesn't call your tool, or calls a different one with garbage parameters. No error.
### Names: verb-first and specific
| Good | Bad | Why |
|---|---|---|
| `Search customer database` | `query` / `tool1` | Generic names say nothing |
| `Generate image with Veo` | `imageGen` | Which generator? |
| `Edit existing image` | `edit` | Edit what? |
| `Send Slack message to channel` | `slack` | Name the action, not just the surface |
| `Lookup user by email` | `getUser` | Lookup how? |
### Descriptions: three parts
1. **What it does** (one sentence).
2. **When to use it** (one or two sentences, with boundaries / examples).
3. **Parameter notes** (only if not already covered in `$fromAI` descriptions).
```
Edit existing image: Modifies an image the user already uploaded, based on a prompt.
Use when the user uploaded an image and asks for changes (color, style, composition, content).
Do NOT use for generating new images from scratch — use Generate Image for that.
The imageName parameter must be the storage key of the existing image as listed in your
available files; do not pass the original filename or a URL.
```
That description does work that would otherwise bloat the system prompt — which is exactly the point.
---
## Tool descriptions as modular prompts
Anything specific to *how to call this tool* belongs in the tool's description, not the system prompt:
| In the system prompt (move out) | Better in the tool description |
|---|---|
| "When generating images, prefer realistic photography over `8k cinematic`" | `Generate Image`: "Default to realistic photography aesthetics…" |
| "If the search tool returns nothing, summarize politely" | `Search`: "Returns up to 10 results; if empty, report 'no matches' rather than retrying broader" |
| "Use 9:16 for video tools" | `Generate Video`: "Defaults to 9:16; pass `aspectRatio: '16:9'` for landscape" |
Three reasons: **reusability** (the tool teaches each new agent how to use it), **token efficiency** (per-tool guidance only loads when the model considers that tool, not every turn), **maintainability** (one description, not a buried paragraph).
---
## Granularity: one tool with branching, not two near-identical tools
The model gets confused choosing between near-identical tools. If two are ~80% the same internally:
- **One tool with a branching parameter.** `Generate Image` vs `Edit Image` share most logic → collapse to one with an `imageName` parameter (empty = generate, populated = edit).
- **Two tools only when genuinely distinct AND the descriptions clearly differentiate.** `Send DM` vs `Send Channel Message` are distinct.
---
## Operational notes
- **maxIterations.** Agents have a configurable tool-call cap (`options.maxIterations`), and the default is **low**. A multi-tool agent that chains calls hits it and surfaces "max iterations reached" or empty output. Raise it. Build a fallback — don't trust graceful recovery.
- **Tool-call cost.** Each call is at minimum one extra model round-trip. Frequently-called tools should return **concise** results — bloated returns burn input tokens fast.
- **Tool failure handling.** Set `onError: 'continueErrorOutput'` on tool sub-workflows where you want the agent to receive an error string instead of halting; the agent can retry, switch tools, or report. → **n8n-error-handling**.
---
## Cross-references
- The sub-workflow tool pattern in detail → **SUBWORKFLOW_AS_TOOL.md**
- System-prompt-vs-tool-description split → **SYSTEM_PROMPT.md**
- Passing binary into tools → **n8n-binary-and-data**
- The Custom Code Tool contract → **n8n-code-tool**