refactor(core): Move node-specific builder guidance to per-node @builderHint (no-changelog) (#29992)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mutasem Aldmour
2026-05-12 09:45:33 +02:00
committed by GitHub
parent 95cf41c37c
commit 3297536011
31 changed files with 1215 additions and 290 deletions
@@ -3,6 +3,154 @@
exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] = `
{
"builderHint": {
"extraTypeDefContent": [
{
"content": "Sits on the main flow — pipe the documents you want to embed into this node. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\` and \`documentLoader\`. If the goal is letting an LLM query the store, use \`mode: 'retrieve-as-tool'\` instead.
<patterns>
<pattern title="insert mode — upsert documents (generic, works for any vectorStore* node)">
// Substitute the type literal and provider-specific parameters (e.g. pineconeIndex,
// qdrantCollection, supabaseTableName) — see the rest of this file for the exact shape.
const store = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: {
mode: 'insert',
// ...provider-specific parameters
},
subnodes: { embedding: embeddingsOpenAi, documentLoader: defaultDataLoader }
}
});
</pattern>
</patterns>",
"displayOptions": {
"show": {
"mode": [
"insert",
],
},
},
},
{
"content": "Canonical RAG mode — declare with the \`tool({...})\` factory (NOT \`vectorStore\`) and plug into an AI Agent's \`subnodes.tools\`. Required subnodes: \`embedding\`. Set \`toolDescription\` so the agent knows when to call it.
<patterns>
<pattern title="retrieve-as-tool mode — RAG via AI Agent (generic, works for any vectorStore* node)">
// Substitute the type literal and provider-specific parameters — see the rest of this file
// for the exact shape (e.g. pineconeIndex, qdrantCollection, supabaseTableName).
const knowledgeBase = tool({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: {
mode: 'retrieve-as-tool',
toolDescription: 'Search the product knowledge base',
// ...provider-specific parameters
},
subnodes: { embedding: embeddingsOpenAi }
}
});
const agent = node({
type: '@n8n/n8n-nodes-langchain.agent',
config: {
name: 'Support Agent',
parameters: { promptType: 'define', text: expr('{{ $json.question }}') },
subnodes: { model: openAiModel, tools: [knowledgeBase] }
}
});
</pattern>
</patterns>",
"displayOptions": {
"show": {
"mode": [
"retrieve-as-tool",
],
},
},
},
{
"content": "One-shot similarity search on the main flow using the \`prompt\` parameter. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For LLM-driven querying (RAG), use \`mode: 'retrieve-as-tool'\` instead.
<patterns>
<pattern title="load mode — one-shot similarity search (generic)">
// Substitute the type literal and provider-specific parameters — see the rest of this file.
const lookup = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: {
mode: 'load',
prompt: expr('{{ $json.query }}'),
// ...provider-specific parameters
},
subnodes: { embedding: embeddingsOpenAi }
}
});
</pattern>
</patterns>",
"displayOptions": {
"show": {
"mode": [
"load",
],
},
},
},
{
"content": "Exposes the store as an \`ai_vectorStore\` subnode for another node (e.g. \`toolVectorStore\`). Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For RAG with an AI Agent directly, prefer \`mode: 'retrieve-as-tool'\`.
<patterns>
<pattern title="retrieve mode — feed another node as a subnode (generic)">
// Substitute the type literal and provider-specific parameters — see the rest of this file.
const store = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: { mode: 'retrieve' /* + provider-specific parameters */ },
subnodes: { embedding: embeddingsOpenAi }
}
});
const retrieverTool = tool({
type: '@n8n/n8n-nodes-langchain.toolVectorStore',
config: {
name: 'KB Retriever',
parameters: { description: 'Search the product knowledge base' },
subnodes: { vectorStore: store, model: openAiModel }
}
});
</pattern>
</patterns>",
"displayOptions": {
"show": {
"mode": [
"retrieve",
],
},
},
},
{
"content": "Updates a single document by \`id\`. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. Only available on stores whose \`operationModes\` enables it — most providers omit this mode.
<patterns>
<pattern title="update mode — update document by ID (generic)">
// Substitute the type literal and provider-specific parameters — see the rest of this file.
const store = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: { mode: 'update', id: expr('{{ $json.docId }}') },
subnodes: { embedding: embeddingsOpenAi }
}
});
</pattern>
</patterns>",
"displayOptions": {
"show": {
"mode": [
"update",
],
},
},
},
],
"inputs": {
"ai_document": {
"displayOptions": {
@@ -66,6 +214,7 @@ exports[`createVectorStoreNode retrieve mode supplies vector store as data 1`] =
},
},
},
"searchHint": "Pick mode by where data flows: \`insert\` upserts documents into the store on the main flow; \`load\` runs a one-shot similarity search on the main flow; \`retrieve-as-tool\` is the canonical RAG mode — plug into an AI Agent's \`subnodes.tools\`; \`retrieve\` exposes the store as a subnode for another node's \`subnodes.vectorStore\`; \`update\` updates a single document by ID.",
},
"codex": {
"categories": [
@@ -10,6 +10,10 @@ export const DEFAULT_OPERATION_MODES: NodeOperationMode[] = [
'retrieve-as-tool',
];
// `mode` is a discriminator field, so per-option `builderHint`s here would never
// surface in the generated `.d.ts` (discriminator props are dropped from narrowed
// types). Per-mode guidance lives as node-level `extraTypeDefContent` variations
// in `createVectorStoreNode.ts`, which the codegen routes per-combo.
export const OPERATION_MODE_DESCRIPTIONS: INodePropertyOptions[] = [
{
name: 'Get Many',
@@ -77,7 +77,127 @@ export const createVectorStoreNode = <T extends VectorStore = VectorStore>(
},
},
builderHint: {
searchHint:
"Pick mode by where data flows: `insert` upserts documents into the store on the main flow; `load` runs a one-shot similarity search on the main flow; `retrieve-as-tool` is the canonical RAG mode — plug into an AI Agent's `subnodes.tools`; `retrieve` exposes the store as a subnode for another node's `subnodes.vectorStore`; `update` updates a single document by ID.",
...args.meta.builderHint,
extraTypeDefContent: [
{
displayOptions: { show: { mode: ['insert'] } },
content: `Sits on the main flow — pipe the documents you want to embed into this node. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\` and \`documentLoader\`. If the goal is letting an LLM query the store, use \`mode: 'retrieve-as-tool'\` instead.
<patterns>
<pattern title="insert mode — upsert documents (generic, works for any vectorStore* node)">
// Substitute the type literal and provider-specific parameters (e.g. pineconeIndex,
// qdrantCollection, supabaseTableName) — see the rest of this file for the exact shape.
const store = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: {
mode: 'insert',
// ...provider-specific parameters
},
subnodes: { embedding: embeddingsOpenAi, documentLoader: defaultDataLoader }
}
});
</pattern>
</patterns>`,
},
{
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
content: `Canonical RAG mode — declare with the \`tool({...})\` factory (NOT \`vectorStore\`) and plug into an AI Agent's \`subnodes.tools\`. Required subnodes: \`embedding\`. Set \`toolDescription\` so the agent knows when to call it.
<patterns>
<pattern title="retrieve-as-tool mode — RAG via AI Agent (generic, works for any vectorStore* node)">
// Substitute the type literal and provider-specific parameters — see the rest of this file
// for the exact shape (e.g. pineconeIndex, qdrantCollection, supabaseTableName).
const knowledgeBase = tool({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: {
mode: 'retrieve-as-tool',
toolDescription: 'Search the product knowledge base',
// ...provider-specific parameters
},
subnodes: { embedding: embeddingsOpenAi }
}
});
const agent = node({
type: '@n8n/n8n-nodes-langchain.agent',
config: {
name: 'Support Agent',
parameters: { promptType: 'define', text: expr('{{ $json.question }}') },
subnodes: { model: openAiModel, tools: [knowledgeBase] }
}
});
</pattern>
</patterns>`,
},
{
displayOptions: { show: { mode: ['load'] } },
content: `One-shot similarity search on the main flow using the \`prompt\` parameter. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For LLM-driven querying (RAG), use \`mode: 'retrieve-as-tool'\` instead.
<patterns>
<pattern title="load mode — one-shot similarity search (generic)">
// Substitute the type literal and provider-specific parameters — see the rest of this file.
const lookup = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: {
mode: 'load',
prompt: expr('{{ $json.query }}'),
// ...provider-specific parameters
},
subnodes: { embedding: embeddingsOpenAi }
}
});
</pattern>
</patterns>`,
},
{
displayOptions: { show: { mode: ['retrieve'] } },
content: `Exposes the store as an \`ai_vectorStore\` subnode for another node (e.g. \`toolVectorStore\`). Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. For RAG with an AI Agent directly, prefer \`mode: 'retrieve-as-tool'\`.
<patterns>
<pattern title="retrieve mode — feed another node as a subnode (generic)">
// Substitute the type literal and provider-specific parameters — see the rest of this file.
const store = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: { mode: 'retrieve' /* + provider-specific parameters */ },
subnodes: { embedding: embeddingsOpenAi }
}
});
const retrieverTool = tool({
type: '@n8n/n8n-nodes-langchain.toolVectorStore',
config: {
name: 'KB Retriever',
parameters: { description: 'Search the product knowledge base' },
subnodes: { vectorStore: store, model: openAiModel }
}
});
</pattern>
</patterns>`,
},
{
displayOptions: { show: { mode: ['update'] } },
content: `Updates a single document by \`id\`. Declare with \`vectorStore({...})\`. Required subnodes: \`embedding\`. Only available on stores whose \`operationModes\` enables it — most providers omit this mode.
<patterns>
<pattern title="update mode — update document by ID (generic)">
// Substitute the type literal and provider-specific parameters — see the rest of this file.
const store = vectorStore({
type: '@n8n/n8n-nodes-langchain.vectorStoreXxx',
config: {
name: 'Knowledge Base',
parameters: { mode: 'update', id: expr('{{ $json.docId }}') },
subnodes: { embedding: embeddingsOpenAi }
}
});
</pattern>
</patterns>`,
},
],
inputs: {
ai_embedding: { required: true },
ai_document: {
@@ -75,7 +75,7 @@ function collectAgents(): AgentEntry[] {
researchMode: true,
webhookBaseUrl: 'https://your-instance.example.com',
filesystemAccess: true,
localGateway: { status: 'connected' },
localGateway: { status: 'connected', capabilities: ['filesystem', 'browser'] },
toolSearchEnabled: true,
licenseHints: ['<sample license hint — replace with real hint at runtime>'],
timeZone: 'UTC',
@@ -101,10 +101,7 @@ function collectAgents(): AgentEntry[] {
"localGateway disconnected with filesystem + browser capabilities — renders the 'install Computer Use' pitch and 'Browser Automation (Unavailable)' note",
body: getSystemPrompt({
webhookBaseUrl: 'https://your-instance.example.com',
localGateway: {
status: 'disconnected',
capabilities: ['filesystem', 'browser'],
},
localGateway: { status: 'disconnected' },
browserAvailable: false,
}),
},
@@ -115,7 +112,7 @@ function collectAgents(): AgentEntry[] {
body: getSystemPrompt({
webhookBaseUrl: 'https://your-instance.example.com',
filesystemAccess: true,
localGateway: { status: 'connected' },
localGateway: { status: 'connected', capabilities: ['filesystem'] },
browserAvailable: false,
}),
},
@@ -50,6 +50,12 @@ const agentNode = makeNode({
ai_memory: { required: false },
ai_tool: { required: false, displayOptions: { show: { hasTools: [true] } } },
},
extraTypeDefContent: [
{
content:
'<patterns>\n<pattern title="basic">\nconst agent = node({ ... })\n</pattern>\n</patterns>',
},
],
},
});
@@ -171,6 +177,21 @@ describe('NodeSearchEngine', () => {
expect(agentResult?.builderHintMessage).toBe('Use an AI Agent for autonomous task execution');
});
it('should NOT surface builderHint.extraTypeDefContent in search results', () => {
const results = engine.searchByName('AI Agent');
const agentResult = results.find((r) => r.name === '@n8n/n8n-nodes-langchain.agent');
expect(agentResult).toBeDefined();
// Result type has no extraTypeDefContent field; assert it never leaks in
// via untyped assignment either.
expect(agentResult).not.toHaveProperty('extraTypeDefContent');
expect(JSON.stringify(agentResult)).not.toContain('<patterns>');
expect(JSON.stringify(agentResult)).not.toContain('basic');
// The formatted XML the LLM actually sees must not contain the example.
const xml = engine.formatResult(agentResult!);
expect(xml).not.toContain('<patterns>');
expect(xml).not.toContain('const agent = node');
});
it('should include subnode requirements when present', () => {
const results = engine.searchByName('AI Agent');
const agentResult = results.find((r) => r.name === '@n8n/n8n-nodes-langchain.agent');
@@ -67,6 +67,17 @@ export interface SearchableNodeType {
builderHint?: {
message?: string;
inputs?: BuilderHintInputs;
/**
* Multi-line content variations emitted into generated `.d.ts` only;
* intentionally ignored by the search engine to keep results lightweight.
*/
extraTypeDefContent?: Array<{
content: string;
displayOptions?: {
show?: Record<string, unknown[]>;
hide?: Record<string, unknown[]>;
};
}>;
};
}
@@ -6,11 +6,6 @@
* - createSandboxBuilderAgentPrompt(): Sandbox-based builder with real files + tsc
*/
import {
AI_TOOL_PATTERNS,
CONNECTION_CHANGING_PARAMETERS,
BASELINE_FLOW_CONTROL,
} from '@n8n/workflow-sdk/prompts/node-selection';
import {
EXPRESSION_REFERENCE,
ADDITIONAL_FUNCTIONS,
@@ -60,219 +55,12 @@ const NODE_CONFIGURATION_SAFETY_RULES = `## Node Configuration Safety Rules
- Use live \`nodes(action="explore-resources")\` for resource locator, list, and model fields when credentials are available.
- If a configuration is unclear after reading the definition, ask for clarification or use placeholders — do not guess.`;
// The AI Agent subnode example uses `newCredential()` in both modes. In sandbox
// mode the submit runner preserves unresolved credential slots for
// `submit-workflow`, so the same outlet works there too.
function buildBuilderSpecificPatterns(): string {
const openAiCredExample = "newCredential('OpenAI')";
return `## Critical Patterns (Common Mistakes)
// Node-specific configuration examples used to live here. They have moved
// onto the nodes themselves as `@builderHint` annotations and `<patterns>...</patterns>`
// blocks in the generated `.d.ts` — fetch them on-demand via `nodes(action="type-definition")`.
const BUILDER_SPECIFIC_PATTERNS = `## Critical Patterns (Common Mistakes)
**Pay attention to @builderHint annotations in search results and type definitions** — these provide critical guidance on how to correctly configure node parameters. Write them out as notes when reviewing — they prevent common configuration mistakes.
### Self-check: conditional nodes and routing
After writing any workflow with IF, Switch, or Filter nodes, verify:
1. **Every \`conditions\` object has \`options\`, \`conditions\` array, and \`combinator\`** — missing any of these crashes the node at runtime.
2. **Switch uses \`rules.values\`** (not \`rules.rules\`) — the wrong key crashes during workflow loading.
3. **Each branch reaches the correct destination** — trace the data flow from the condition through \`.onTrue()\`/\`.onFalse()\`/\`.onCase()\` to the target node. Verify the routing matches the user's requirements.
4. **Condition expressions reference the right fields** — check that \`leftValue\` expressions use fields that actually exist in the upstream node's output.
5. **Merge nodes use the correct mode** — \`append\` to concatenate items from branches, \`combineBySql\` or \`combineByPosition\` only when matching items across inputs. Wrong mode silently drops or duplicates data.
### AI Agent with Subnodes — use factory functions in subnodes config
\`\`\`javascript
const chatTrigger = trigger({
type: '@n8n/n8n-nodes-langchain.chatTrigger',
version: 1.3,
config: {
name: 'Chat Trigger',
parameters: { public: false },
output: [{ sessionId: 'chat-session-id', chatInput: 'Hello' }]
}
});
const model = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.3,
config: {
name: 'OpenAI Chat Model',
parameters: { model: { __rl: true, mode: 'list', value: 'gpt-5.4' } },
credentials: { openAiApi: ${openAiCredExample} }
}
});
const parser = outputParser({
type: '@n8n/n8n-nodes-langchain.outputParserStructured',
version: 1.3,
config: {
name: 'Output Parser',
parameters: {
schemaType: 'fromJson',
jsonSchemaExample: '{ "score": 75, "tier": "hot" }'
}
}
});
const memoryNode = memory({
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
version: 1.3,
config: {
name: 'Conversation Memory',
parameters: {
sessionIdType: 'customKey',
sessionKey: nodeJson(chatTrigger, 'sessionId'),
contextWindowLength: 10
}
}
});
const agent = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'AI Agent',
parameters: {
promptType: 'define',
text: '={{ $json.prompt }}',
hasOutputParser: true,
options: { systemMessage: 'You are an expert...' }
},
subnodes: { model: model, memory: memoryNode, outputParser: parser }
}
});
\`\`\`
WRONG: \`.to(agent, { connectionType: 'ai_languageModel' })\` — subnodes MUST be in the config object.
For values inside AI subnodes, use explicit references such as \`nodeJson(triggerNode, 'sessionId')\` instead of \`$json.sessionId\`. For Chat Trigger memory specifically, \`sessionIdType: 'fromInput'\` is also valid.
### Code Node
\`\`\`javascript
const codeNode = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Process Data',
parameters: {
mode: 'runOnceForAllItems',
jsCode: \\\`
const items = $input.all();
return items.map(item => ({
json: { ...item.json, processed: true }
}));
\\\`.trim()
}
}
});
\`\`\`
### Data Table (built-in n8n storage)
\`\`\`javascript
const storeData = node({
type: 'n8n-nodes-base.dataTable',
version: 1.1,
config: {
name: 'Store Data',
parameters: {
resource: 'row',
operation: 'insert',
dataTableId: { __rl: true, mode: 'name', value: 'my-table' },
columns: {
mappingMode: 'defineBelow',
value: {
name: '={{ $json.name }}',
email: '={{ $json.email }}'
},
schema: [
{ id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
{ id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }
]
}
}
}
});
\`\`\`
**Data Table rules**
- Row IDs are auto-generated by Data Tables. Do NOT create a custom \`id\` column and do NOT seed an \`id\` value on insert.
- To fetch many rows, use \`operation: 'get'\` with \`returnAll: true\`. Do NOT invent \`getAll\`.
- When filtering rows for update/delete, it is valid to match on the built-in row \`id\`, but that is not part of the user-defined table schema.
### Google Sheets — Column Mapping
The \`columns\` parameter requires a schema object, never a string:
\`\`\`javascript
// autoMapInputData — maps $json fields to sheet columns automatically
columns: {
mappingMode: 'autoMapInputData',
value: {},
schema: [
{ id: 'Name', displayName: 'Name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
{ id: 'Email', displayName: 'Email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: false },
]
}
// defineBelow — explicit expression mapping
columns: {
mappingMode: 'defineBelow',
value: { name: '={{ $json.name }}', email: '={{ $json.email }}' },
schema: [
{ id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
{ id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }
]
}
\`\`\`
WRONG: \`columns: 'autoMapInputData'\` — this is a string, not a schema object. Will fail validation.
### Parallel Branches + Merge
When multiple paths must converge, include the full downstream chain in EACH branch.
There is NO fan-in primitive — shared nodes must be duplicated or use sub-workflows.
### Batch Processing — splitInBatches with loop
\`\`\`javascript
const batch = node({
type: 'n8n-nodes-base.splitInBatches',
version: 3,
config: { name: 'Batch', parameters: { batchSize: 50 } }
});
// Connect: trigger -> batch -> processNode -> batch (loop back)
// The batch node automatically outputs to "done" when all items are processed.
\`\`\`
### Multiple Triggers
Independent entry points can feed into shared downstream nodes. Each trigger starts its own branch:
\`\`\`javascript
export default workflow('id', 'name')
.add(webhookTrigger).to(processNode).to(storeNode)
.add(scheduleTrigger).to(processNode);
\`\`\`
### Google Sheets — documentId and sheetName (RLC fields)
These are Resource Locator fields that require the \`__rl\` object format:
\`\`\`typescript
// CORRECT — RLC object with discovered ID
documentId: { __rl: true, mode: 'id', value: '1abc123...' },
sheetName: { __rl: true, mode: 'name', value: 'Sheet1' },
// CORRECT — RLC with name-based lookup
documentId: { __rl: true, mode: 'name', value: 'Sales Pipeline' },
// WRONG — plain string
documentId: 'YOUR_SPREADSHEET_ID', // Not an RLC object
// WRONG — expr() wrapper
documentId: expr('{{ "spreadsheetId" }}'), // RLC fields don't use expressions
\`\`\`
Always use the IDs from \`nodes(action="explore-resources")\` results inside the RLC \`value\` field.
### AI Tool Connection Patterns
${AI_TOOL_PATTERNS}
### Connection-Changing Parameters
${CONNECTION_CHANGING_PARAMETERS}
### Baseline Flow Control Nodes
${BASELINE_FLOW_CONTROL}`;
}
const BUILDER_SPECIFIC_PATTERNS = buildBuilderSpecificPatterns();
**Pay attention to @builderHint annotations in search results and type definitions** — they contain node-specific configuration rules and code examples. Read them carefully when configuring any node — they prevent common mistakes.`;
// ── Composed SDK rules from shared + local sources ───────────────────────────
@@ -340,11 +128,12 @@ ${PLACEHOLDERS_RULE}
## Mandatory Process
1. **Research**: If the workflow fits a known category (notification, chatbot, scheduling, data_transformation, etc.), call \`nodes(action="suggested")\` first for curated recommendations. Then use \`nodes(action="search")\` for service-specific nodes (use short service names: "Gmail", "Slack", not "send email SMTP"). The results include \`discriminators\` (available resources and operations) for nodes that need them. Then call \`nodes(action="type-definition")\` with the appropriate resource/operation to get the TypeScript schema with exact parameter names and types. **Pay attention to @builderHint annotations** in search results and type definitions — they prevent common configuration mistakes.
2. **Build**: Write TypeScript SDK code and call \`build-workflow\`. Follow the SDK patterns below exactly.
3. **Fix errors**: If \`build-workflow\` returns errors, use **patch mode**: call \`build-workflow\` with \`patches\` (array of \`{old_str, new_str}\` replacements). Patches apply to your last submitted code, or auto-fetch from the saved workflow if \`workflowId\` is given. Much faster than resending full code.
4. **Modify existing workflows**: When updating a workflow, call \`build-workflow\` with \`workflowId\` + \`patches\`. The tool fetches the current code and applies your patches. Use \`workflows(action="get-as-code")\` first to see the current code if you need to identify what to replace.
5. **Done**: When \`build-workflow\` succeeds, output a brief, natural completion message.
3. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
4. **Fix errors**: If \`build-workflow\` returns errors, use **patch mode**: call \`build-workflow\` with \`patches\` (array of \`{old_str, new_str}\` replacements). Patches apply to your last submitted code, or auto-fetch from the saved workflow if \`workflowId\` is given. Much faster than resending full code.
5. **Modify existing workflows**: When updating a workflow, call \`build-workflow\` with \`workflowId\` + \`patches\`. The tool fetches the current code and applies your patches. Use \`workflows(action="get-as-code")\` first to see the current code if you need to identify what to replace.
6. **Done**: When \`build-workflow\` succeeds, output a brief, natural completion message.
Do NOT produce visible output until step 5. All reasoning happens internally.
Do NOT produce visible output until step 6. All reasoning happens internally.
## Credential Rules (tool mode)
- Use \`newCredential('Credential Name', 'credential-id')\` only when the user selected a specific existing credential or the workflow already has one.
@@ -448,8 +237,8 @@ const fetchWeather = node({
name: 'Fetch Weather',
parameters: {
locationSelection: 'cityName',
cityName: '={{ $json.city }}',
format: '={{ $json.units }}'
cityName: expr('{{ $json.city }}'),
format: expr('{{ $json.units }}')
},
credentials: { openWeatherMapApi: { id: 'credId', name: 'OpenWeatherMap account' } }
}
@@ -617,18 +406,20 @@ n8n normalizes column names to snake_case (e.g., \`dayName\` → \`day_name\`).
5. **Write workflow code** to \`${workspaceRoot}/src/workflow.ts\`.
6. **Validate with tsc**: Run the TypeScript compiler for real type checking:
6. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
7. **Validate with tsc**: Run the TypeScript compiler for real type checking:
\`\`\`
execute_command: cd ~/workspace && npx tsc --noEmit 2>&1
\`\`\`
Fix any errors using \`edit_file\` (with absolute path) to update the code, then re-run tsc. Iterate until clean.
**Important**: If tsc reports errors you cannot resolve after 2 attempts, skip tsc and proceed to submit-workflow. The submit tool has its own validation.
7. **Submit**: When tsc passes cleanly, call \`submit-workflow\` to validate the workflow graph and save it to n8n.
8. **Submit**: When tsc passes cleanly, call \`submit-workflow\` to validate the workflow graph and save it to n8n.
8. **Fix submission errors**: If \`submit-workflow\` returns errors, edit the file and submit again immediately. Skip tsc for validation-only errors. **Never end your turn on a file edit — always re-submit first.** The system compares file hashes: if the file changed since the last submit, all your work is discarded. End only on a successful re-submit or after you explicitly report the blocking error.
9. **Fix submission errors**: If \`submit-workflow\` returns errors, edit the file and submit again immediately. Skip tsc for validation-only errors. **Never end your turn on a file edit — always re-submit first.** The system compares file hashes: if the file changed since the last submit, all your work is discarded. End only on a successful re-submit or after you explicitly report the blocking error.
9. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
10. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
### For complex workflows (5+ nodes, multiple integrations):
@@ -644,8 +435,9 @@ Follow the **Compositional Workflow Pattern** above. The process becomes:
c. Submit the chunk: \`submit-workflow\` with \`filePath\` pointing to the chunk file. Test via \`executions(action="run")\`.
d. Fix if needed (max 2 submission fix attempts per chunk).
6. **Write the main workflow** in \`${workspaceRoot}/src/workflow.ts\` that composes chunks via \`executeWorkflow\` nodes, referencing each chunk's workflow ID.
7. **Submit** the main workflow.
8. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
7. **Trace wiring before declaring done**: For workflows containing IF, Switch, or Merge nodes, trace each branch from its source to its target — confirm IF outputs are wired with \`.onTrue()\`/\`.onFalse()\`, every Switch \`outputKey\` has a matching \`.onCase('<outputKey>')\`, and the Merge mode matches the data shape. Read each node's \`@builderHint\` for selection criteria.
8. **Submit** the main workflow.
9. **Done**: Output ONE sentence summarizing what was built, including the workflow ID and any known issues.
Do NOT produce visible output until the final step. All reasoning happens internally.
@@ -52,6 +52,58 @@ export class GuardrailsV2 implements INodeType {
},
searchHint:
'Classify operation has two outputs: output 0 (Pass) for items that passed all guardrail checks, output 1 (Fail) for items that failed. Use .output(index).to() to connect from a specific output. @example guardrails.output(0).to(passNode) and guardrails.output(1).to(failNode). Sanitize operation has only one output.',
extraTypeDefContent: [
{
displayOptions: {
show: {
operation: ['classify'],
},
},
content: `<patterns>
<pattern title="Guardrails classify with separate Pass and Fail outputs">
const model = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.3,
config: {
name: 'OpenAI Chat Model',
parameters: { model: { __rl: true, mode: 'list', value: 'gpt-5.4' } },
credentials: { openAiApi: { id: 'credId', name: 'OpenAI account' } }
}
});
const guardrailsCheck = node({
type: '@n8n/n8n-nodes-langchain.guardrails',
version: 2,
config: {
name: 'Guardrails',
parameters: {
operation: 'classify',
text: expr('{{ $json.input }}'),
guardrails: { jailbreak: { value: { threshold: 0.7 } } }
},
subnodes: { model }
}
});
const passHandler = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Handle Pass', parameters: {} }
});
const failHandler = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Handle Fail', parameters: {} }
});
// output 0 = Pass, output 1 = Fail
guardrailsCheck.output(0).to(passHandler);
guardrailsCheck.output(1).to(failHandler);
</pattern>
</patterns>`,
},
],
},
};
}
@@ -30,6 +30,8 @@ export class Agent extends VersionedNodeType {
},
defaultVersion: 3.1,
builderHint: {
searchHint:
"Wire model/memory/tools/outputParser via the SDK `subnodes` config object using factory functions (`languageModel()`, `memory()`, `tool()`, `outputParser()`). Inside subnodes, reference upstream data with `nodeJson(triggerNode, 'path')`, not `$json` — subnodes do not share the main predecessor's item context.",
relatedNodes: [
{
nodeType: 'n8n-nodes-base.aggregate',
@@ -50,6 +52,73 @@ export class Agent extends VersionedNodeType {
'Required for conversational workflows - connect memory to every agent that needs to recall previous messages in the conversation',
},
],
extraTypeDefContent: [
{
content: `<patterns>
<pattern title="Agent with model, memory, structured output parser">
const chatTrigger = trigger({
type: '@n8n/n8n-nodes-langchain.chatTrigger',
version: 1.3,
config: {
name: 'Chat Trigger',
parameters: { public: false },
output: [{ sessionId: 'chat-session-id', chatInput: 'Hello' }]
}
});
const model = languageModel({
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
version: 1.3,
config: {
name: 'OpenAI Chat Model',
parameters: { model: { __rl: true, mode: 'list', value: 'gpt-5.4' } },
credentials: { openAiApi: { id: 'credId', name: 'OpenAI account' } }
}
});
const parser = outputParser({
type: '@n8n/n8n-nodes-langchain.outputParserStructured',
version: 1.3,
config: {
name: 'Output Parser',
parameters: {
schemaType: 'fromJson',
jsonSchemaExample: '{ "score": 75, "tier": "hot" }'
}
}
});
const memoryNode = memory({
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
version: 1.3,
config: {
name: 'Conversation Memory',
parameters: {
sessionIdType: 'customKey',
sessionKey: nodeJson(chatTrigger, 'sessionId'),
contextWindowLength: 10
}
}
});
const agent = node({
type: '@n8n/n8n-nodes-langchain.agent',
version: 3.1,
config: {
name: 'AI Agent',
parameters: {
promptType: 'define',
text: expr('{{ $json.prompt }}'),
hasOutputParser: true,
options: { systemMessage: 'You are an expert...' }
},
subnodes: { model, memory: memoryNode, outputParser: parser }
}
});
</pattern>
</patterns>`,
},
],
},
};
@@ -57,6 +57,10 @@ export class AgentToolV3 implements INodeType {
type: 'boolean',
default: false,
noDataExpression: true,
builderHint: {
propertyHint:
'Set to `true` when you need structured JSON output. The agent then requires an `outputParser` entry in its `subnodes` config (typically an `outputParserStructured` node defined via the `outputParser({...})` SDK factory). With `hasOutputParser: false` the agent returns a plain string in `$json.output`.',
},
},
{
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,
@@ -98,6 +98,10 @@ export class AgentV3 implements INodeType {
type: 'boolean',
default: false,
noDataExpression: true,
builderHint: {
propertyHint:
'Set to `true` when you need structured JSON output. The agent then requires an `outputParser` entry in its `subnodes` config (typically an `outputParserStructured` node defined via the `outputParser({...})` SDK factory). With `hasOutputParser: false` the agent returns a plain string in `$json.output`.',
},
},
{
displayName: `Connect an <a data-action='openSelectiveNodeCreator' data-action-parameter-connectiontype='${NodeConnectionTypes.AiOutputParser}'>output parser</a> on the canvas to specify the output format you require`,
@@ -18,9 +18,18 @@ import type * as GenerateTypesModule from '../generate-types/generate-types';
// Type Definitions (Expected interfaces from the implementation)
// =============================================================================
interface BuilderHintVariation {
content: string;
displayOptions?: {
show?: Record<string, unknown[]>;
hide?: Record<string, unknown[]>;
};
}
interface ParameterBuilderHint {
propertyHint: string;
placeholderSupported?: boolean;
extraTypeDefContent?: BuilderHintVariation[];
}
interface NestedOption {
@@ -76,6 +85,7 @@ interface NodeTypeDescription {
hidden?: boolean;
schemaPath?: string;
builderHint?: {
message?: string;
inputs?: Record<
string,
{
@@ -86,6 +96,7 @@ interface NodeTypeDescription {
};
}
>;
extraTypeDefContent?: BuilderHintVariation[];
};
}
@@ -1393,6 +1404,156 @@ describe('generate-types', () => {
expect(result).not.toContain('GmailV21Params');
});
it('should route node-level extraTypeDefContent variations into matching narrowed types only', () => {
const vectorStoreLikeNode: NodeTypeDescription & { builderHint?: unknown } = {
name: 'n8n-nodes-test.vectorStoreLike',
displayName: 'Vector Store Like',
group: ['transform'],
version: 1,
inputs: ['main'],
outputs: ['main'],
builderHint: {
extraTypeDefContent: [
{
displayOptions: { show: { mode: ['insert'] } },
content: '<patterns>\n<pattern>insert example</pattern>\n</patterns>',
},
{
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
content: '<patterns>\n<pattern>RAG example</pattern>\n</patterns>',
},
],
},
properties: [
{
displayName: 'Operation Mode',
name: 'mode',
type: 'options',
options: [
{ name: 'Insert', value: 'insert' },
{ name: 'Retrieve as Tool', value: 'retrieve-as-tool' },
],
default: 'insert',
},
{
displayName: 'Insert Field',
name: 'insertField',
type: 'string',
default: '',
displayOptions: { show: { mode: ['insert'] } },
},
{
displayName: 'Tool Description',
name: 'toolDescription',
type: 'string',
default: '',
displayOptions: { show: { mode: ['retrieve-as-tool'] } },
},
],
};
const result = generateTypes.generateDiscriminatedUnion(vectorStoreLikeNode);
// Each variation must land in its own narrowed type body — no cross-bleed.
const sections = result.split(/export type /);
const insertSection = sections.find((s) => s.startsWith('VectorStoreLikeInsertParams'));
const retrieveSection = sections.find((s) =>
s.startsWith('VectorStoreLikeRetrieveAsToolParams'),
);
expect(insertSection).toBeDefined();
expect(retrieveSection).toBeDefined();
expect(insertSection!).toContain('<pattern>insert example</pattern>');
expect(insertSection!).not.toContain('RAG example');
expect(retrieveSection!).toContain('<pattern>RAG example</pattern>');
expect(retrieveSection!).not.toContain('insert example');
});
it('should not duplicate an unconditional node-level variation across narrowed types (file header only)', () => {
const node: NodeTypeDescription & { builderHint?: unknown } = {
name: 'n8n-nodes-test.unconditional',
displayName: 'Unconditional',
group: ['transform'],
version: 1,
inputs: ['main'],
outputs: ['main'],
builderHint: {
extraTypeDefContent: [{ content: 'unconditional only' }],
},
properties: [
{
displayName: 'Operation Mode',
name: 'mode',
type: 'options',
options: [
{ name: 'Insert', value: 'insert' },
{ name: 'Retrieve', value: 'retrieve' },
],
default: 'insert',
},
{
displayName: 'Insert Field',
name: 'insertField',
type: 'string',
default: '',
displayOptions: { show: { mode: ['insert'] } },
},
],
};
// Unconditional content does NOT appear inside narrowed type bodies —
// it's reserved for the file-level node header.
const result = generateTypes.generateDiscriminatedUnion(node);
expect(result).not.toContain('unconditional only');
// File header emits it exactly once.
const header = generateTypes.generateNodeJSDoc(node);
expect(header).toContain('unconditional only');
expect(header.match(/unconditional only/g)?.length).toBe(1);
});
it('should skip variations whose displayOptions do not match the combo', () => {
const node: NodeTypeDescription & { builderHint?: unknown } = {
name: 'n8n-nodes-test.partialMatch',
displayName: 'Partial Match',
group: ['transform'],
version: 1,
inputs: ['main'],
outputs: ['main'],
builderHint: {
extraTypeDefContent: [
{
displayOptions: { show: { mode: ['unknownMode'] } },
content: 'should NOT appear',
},
],
},
properties: [
{
displayName: 'Operation Mode',
name: 'mode',
type: 'options',
options: [
{ name: 'Insert', value: 'insert' },
{ name: 'Retrieve', value: 'retrieve' },
],
default: 'insert',
},
{
displayName: 'Insert Field',
name: 'insertField',
type: 'string',
default: '',
displayOptions: { show: { mode: ['insert'] } },
},
],
};
const result = generateTypes.generateDiscriminatedUnion(node);
expect(result).not.toContain('should NOT appear');
});
it('should generate simple interface for HTTP Request (no discriminators)', () => {
const result = generateTypes.generateDiscriminatedUnion(mockHttpRequestNode);
@@ -1776,6 +1937,77 @@ describe('generate-types', () => {
expect(result).toContain('@builderHint');
expect(result).toContain('&lt;a href=');
});
it('should include unconditional extraTypeDefContent variation below @builderHint, preserving line breaks and tags verbatim', () => {
const prop: NodeProperty = {
name: 'columns',
displayName: 'Columns',
type: 'resourceMapper',
description: 'Column mapping',
builderHint: {
propertyHint: 'Pass the full resourceMapper object',
extraTypeDefContent: [
{
content:
'<patterns>\n<pattern title="autoMap">\ncolumns: { mappingMode: \'autoMapInputData\' }\n</pattern>\n</patterns>',
},
],
},
default: {},
};
const result = generateTypes.generatePropertyJSDoc(prop);
expect(result).toContain('@builderHint Pass the full resourceMapper object');
expect(result).toContain(' * <patterns>');
expect(result).toContain(' * <pattern title="autoMap">');
expect(result).toContain(" * columns: { mappingMode: 'autoMapInputData' }");
expect(result).toContain(' * </pattern>');
expect(result).toContain(' * </patterns>');
// Angle brackets in variation content must NOT be HTML-escaped — the LLM
// must see the tags verbatim so it can use them as structural cues.
expect(result).not.toContain('&lt;patterns&gt;');
expect(result).not.toContain('&lt;pattern title=');
});
it('should escape closing JSDoc sequences inside variation content', () => {
const prop: NodeProperty = {
name: 'foo',
displayName: 'Foo',
type: 'string',
description: 'Foo',
builderHint: {
propertyHint: 'msg',
extraTypeDefContent: [{ content: 'block end */ inside example' }],
},
default: '',
};
const result = generateTypes.generatePropertyJSDoc(prop);
// The literal "*/" would terminate the JSDoc block early; it must be escaped.
expect(result).not.toContain('block end */ inside');
expect(result).toContain('block end *\\/ inside');
});
it('should skip param-level variations whose displayOptions cannot be evaluated at file/property scope', () => {
// Param-level emission (generatePropertyJSDoc, generateNestedPropertyJSDoc) has
// no discriminator combo, so any gated variation is dropped — those belong on the
// node-level builderHint where the codegen can route them per narrowed type.
const prop: NodeProperty = {
name: 'foo',
displayName: 'Foo',
type: 'string',
description: 'Foo',
builderHint: {
propertyHint: 'msg',
extraTypeDefContent: [
{ displayOptions: { show: { mode: ['insert'] } }, content: 'gated content' },
{ content: 'always shown' },
],
},
default: '',
};
const result = generateTypes.generatePropertyJSDoc(prop);
expect(result).toContain('always shown');
expect(result).not.toContain('gated content');
});
});
describe('generateNodeJSDoc', () => {
@@ -1789,6 +2021,37 @@ describe('generate-types', () => {
const result = generateTypes.generateNodeJSDoc(mockGmailNode);
expect(result).toContain('Node Types');
});
it('should emit node-level @builderHint searchHint at the file header', () => {
const node = {
...mockGmailNode,
builderHint: {
searchHint: 'AI Agent — wire subnodes via the config object',
},
};
const result = generateTypes.generateNodeJSDoc(node);
expect(result).toContain('@builderHint AI Agent — wire subnodes via the config object');
});
it('should emit unconditional extraTypeDefContent variations at the file header but skip gated ones', () => {
const node = {
...mockGmailNode,
builderHint: {
extraTypeDefContent: [
{ content: '<patterns>\n<pattern>always</pattern>\n</patterns>' },
{
displayOptions: { show: { mode: ['insert'] } },
content: '<patterns>\n<pattern>insert-only</pattern>\n</patterns>',
},
],
},
};
const result = generateTypes.generateNodeJSDoc(node);
// Unconditional variation lands at file header.
expect(result).toContain(' * <pattern>always</pattern>');
// Gated variation does NOT — it's emitted per-combo via emitNodeHintForCombo.
expect(result).not.toContain('insert-only');
});
});
// =========================================================================
@@ -250,6 +250,20 @@ const AI_TYPE_TO_SUBNODE_FIELD: Record<
// Type Definitions
// =============================================================================
/**
* One variation of `extraTypeDefContent`, optionally gated by `displayOptions`.
* Variations with `displayOptions` are emitted only in narrowed discriminator
* types (e.g. per-mode or per-resource/operation files) whose combo matches.
* Variations without `displayOptions` are emitted unconditionally.
*/
export interface BuilderHintVariation {
content: string;
displayOptions?: {
show?: Record<string, unknown[]>;
hide?: Record<string, unknown[]>;
};
}
export interface ParameterBuilderHint {
propertyHint: string;
placeholderSupported?: boolean;
@@ -350,6 +364,117 @@ export interface JsonSchema {
$ref?: string;
}
// =============================================================================
// JSDoc emission helpers
// =============================================================================
/**
* Emit `@builderHint` JSDoc plus optional multi-line `extraTypeDefContent` lines.
*
* `message` is HTML-escaped (`<` / `>` → entities) because it round-trips through
* the search engine's `<builder_hint>...</builder_hint>` XML envelope, where bare
* angle brackets would corrupt the wrapping tag.
*
* `extraTypeDefContent` does NOT escape angle brackets — author-written tags such
* as `<patterns>` must round-trip into the `.d.ts` verbatim so the LLM sees them
* as structural cues. Only `*\/` is escaped to keep the JSDoc block well-formed.
*/
/**
* Determines whether a variation should be emitted in the current scope.
*
* The two scopes are mutually exclusive so unconditional variations are
* NOT duplicated across narrowed types:
*
* - File-level header (no `combo`): emit ONLY unconditional variations
* (those with no `displayOptions`). They appear once at the top of the
* generated `.d.ts` and cover every narrowed type.
*
* - Narrowed config block (per `combo`): emit ONLY gated variations
* whose `displayOptions` match the combo. Unconditional variations are
* skipped here — they were already emitted at the file header.
*/
function variationApplies(
variation: BuilderHintVariation,
combo: DiscriminatorCombination | undefined,
): boolean {
const opts = variation.displayOptions;
// File-level scope: only unconditional variations.
if (!combo) return !opts;
// Narrowed scope: only gated variations whose displayOptions match.
if (!opts) return false;
if (opts.show) {
for (const [key, conditions] of Object.entries(opts.show)) {
const value = combo[key];
if (value === undefined) return false;
if (!checkConditions(conditions, [value])) return false;
}
}
if (opts.hide) {
for (const [key, conditions] of Object.entries(opts.hide)) {
const value = combo[key];
if (value === undefined) continue;
if (checkConditions(conditions, [value])) return false;
}
}
return true;
}
function emitBuilderHint(
lines: string[],
indent: string,
hint: { propertyHint?: string; extraTypeDefContent?: BuilderHintVariation[] },
combo?: DiscriminatorCombination,
): void {
if (hint.propertyHint) {
const safePropertyHint = hint.propertyHint
.replace(/\*\//g, '*\\/')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
lines.push(`${indent} * @builderHint ${safePropertyHint}`);
}
if (!hint.extraTypeDefContent) return;
for (const variation of hint.extraTypeDefContent) {
if (!variationApplies(variation, combo)) continue;
const safe = variation.content.replace(/\*\//g, '*\\/');
for (const line of safe.split('\n')) {
lines.push(`${indent} * ${line}`);
}
}
}
/**
* `builderHint` is an extended n8n property not part of the upstream
* `NodeTypeDescription`. Centralized cast keeps the rest of the file clean.
*/
function getNodeBuilderHint(node: NodeTypeDescription): NodeBuilderHint | undefined {
return (node as NodeTypeDescription & { builderHint?: NodeBuilderHint }).builderHint;
}
/**
* Emit a JSDoc block for the node-level builderHint scoped to a single
* discriminator combination — only variations whose `displayOptions` match the
* combo are rendered. The `propertyHint` is intentionally not re-emitted per combo
* (it already lands in the file-level node header).
*/
function emitNodeHintForCombo(
lines: string[],
node: NodeTypeDescription,
combo: DiscriminatorCombination,
): void {
const hint = getNodeBuilderHint(node);
if (!hint?.extraTypeDefContent?.some((v) => variationApplies(v, combo))) return;
const hintLines: string[] = [`${INDENT}/**`];
emitBuilderHint(hintLines, INDENT, { extraTypeDefContent: hint.extraTypeDefContent }, combo);
hintLines.push(`${INDENT} */`);
lines.push(...hintLines);
}
// =============================================================================
// Schema Discovery & JSON Schema to TypeScript Conversion
// =============================================================================
@@ -883,11 +1008,7 @@ function generateNestedPropertyJSDoc(
// Builder hint - guidance for AI/workflow builders
if (prop.builderHint) {
const safeBuilderHint = prop.builderHint.propertyHint
.replace(/\*\//g, '*\\/')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
lines.push(`${indent} * @builderHint ${safeBuilderHint}`);
emitBuilderHint(lines, indent, prop.builderHint);
}
// Placeholder support flag — signals to the builder agent (and the runtime
@@ -1055,14 +1176,10 @@ function generateFixedCollectionType(
groupJsDocLines.push(`${INDENT.repeat(2)}/** ${desc}`);
}
if (group.builderHint) {
const safeBuilderHint = group.builderHint.propertyHint
.replace(/\*\//g, '*\\/')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
if (groupJsDocLines.length === 0) {
groupJsDocLines.push(`${INDENT.repeat(2)}/**`);
}
groupJsDocLines.push(`${INDENT.repeat(2)} * @builderHint ${safeBuilderHint}`);
emitBuilderHint(groupJsDocLines, INDENT.repeat(2), group.builderHint);
}
if (isMultipleValues && hasMinRequired) {
if (groupJsDocLines.length === 0) {
@@ -1859,7 +1976,9 @@ export function generateDiscriminatedUnion(node: NodeTypeDescription): string {
lines.push(`export type ${configName} = {`);
// Add discriminator fields
emitNodeHintForCombo(lines, node, combo);
// Discriminator literal fields for this combo.
for (const [key, value] of Object.entries(combo)) {
if (value !== undefined) {
lines.push(`${INDENT}${key}: '${value}';`);
@@ -1914,11 +2033,7 @@ export function generatePropertyJSDoc(
// Builder hint - guidance for AI/workflow builders
if (prop.builderHint) {
const safeBuilderHint = prop.builderHint.propertyHint
.replace(/\*\//g, '*\\/')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
lines.push(` * @builderHint ${safeBuilderHint}`);
emitBuilderHint(lines, '', prop.builderHint);
}
// Placeholder support flag — signals to the builder agent (and the runtime
@@ -2025,6 +2140,18 @@ export function generateNodeJSDoc(node: NodeTypeDescription): string {
lines.push(` * @subnodeType ${subnodeType}`);
}
// Node-level builder hint — searchHint and unconditional extraTypeDefContent.
// `relatedNodes` and `inputs` are consumed elsewhere (search engine, subnode
// extraction). Variations with `displayOptions` are skipped here — they're
// emitted per-combo in narrowed config types via `emitNodeHintForCombo`.
const nodeHint = getNodeBuilderHint(node);
if (nodeHint && (nodeHint.searchHint || nodeHint.extraTypeDefContent?.length)) {
emitBuilderHint(lines, '', {
propertyHint: nodeHint.searchHint,
extraTypeDefContent: nodeHint.extraTypeDefContent,
});
}
lines.push(' */');
return lines.join('\n');
@@ -2533,7 +2660,9 @@ export function generateDiscriminatorFile(
}
lines.push(`export type ${configName} = {`);
// Add discriminator fields
emitNodeHintForCombo(lines, node, combo);
// Discriminator literal fields for this combo.
for (const [key, value] of Object.entries(combo)) {
if (value !== undefined) {
lines.push(`${INDENT}${key}: '${value}';`);
@@ -3398,7 +3527,9 @@ function generateDiscriminatedUnionForEntry(
lines.push(`export type ${configName} = {`);
// Add discriminator fields
emitNodeHintForCombo(lines, node, combo);
// Discriminator literal fields for this combo.
for (const [key, value] of Object.entries(combo)) {
if (value !== undefined) {
lines.push(`${INDENT}${key}: '${value}';`);
@@ -3568,7 +3699,18 @@ interface BuilderHintInput {
}
interface NodeBuilderHint {
searchHint?: string;
relatedNodes?: Array<{ nodeType: string; relationHint: string }>;
inputs?: Record<string, BuilderHintInput>;
/**
* Multi-line content (typically code examples wrapped in `<patterns>...</patterns>`)
* emitted into the generated `.d.ts` but NOT surfaced in
* `nodes(action="search")` results. Each variation may carry `displayOptions`
* so per-mode / per-resource / per-operation examples land only in their
* corresponding narrowed type. Variations with no `displayOptions` emit
* once at the file-level node header.
*/
extraTypeDefContent?: BuilderHintVariation[];
}
/**
@@ -10,23 +10,3 @@ Structured Output Parser: Prefer this over manually extracting/parsing AI output
Multi-agent systems:
AI Agent Tool (@n8n/n8n-nodes-langchain.agentTool) contains an embedded AI Agent — it's a complete sub-agent that the main agent can call through tool(). Each AgentTool needs its own Chat Model. Node selection: 1 AI Agent + N AgentTools + (N+1) Chat Models.`;
export const AI_TOOL_PATTERNS = `AI Agent tool connection patterns:
When AI Agent needs external capabilities, use TOOL nodes (not regular nodes):
- Research: SerpAPI Tool, Perplexity Tool -> AI Agent [tool()]
- Calendar: Google Calendar Tool -> AI Agent [tool()]
- Messaging: Slack Tool, Gmail Tool -> AI Agent [tool()]
- HTTP calls: HTTP Request Tool -> AI Agent [tool()]
- Calculations: Calculator Tool -> AI Agent [tool()]
- Sub-agents: AI Agent Tool -> AI Agent [tool()] (for multi-agent systems)
Tool nodes: AI Agent decides when/if to use them based on reasoning.
Regular nodes: Execute at that workflow step regardless of context.
Vector Store patterns:
- Insert documents: Document Loader -> Vector Store (mode='insert') [documentLoader()]
- RAG with AI Agent: Vector Store (mode='retrieve-as-tool') -> AI Agent [tool()]
The retrieve-as-tool mode makes the Vector Store act as a tool the Agent can call.
Structured Output Parser: Connect to AI Agent when structured JSON output is required.`;
@@ -1,5 +1,4 @@
export { AI_NODE_SELECTION, AI_TOOL_PATTERNS } from './ai-nodes';
export { NODE_SELECTION_PATTERNS, BASELINE_FLOW_CONTROL } from './use-case-patterns';
export { AI_NODE_SELECTION } from './ai-nodes';
export { NODE_SELECTION_PATTERNS } from './use-case-patterns';
export { TRIGGER_SELECTION } from './trigger-selection';
export { NATIVE_NODE_PREFERENCE } from './native-preference';
export { CONNECTION_CHANGING_PARAMETERS } from './connection-parameters';
@@ -44,12 +44,3 @@ CHATBOTS:
MEDIA:
- OpenAI: DALL-E image generation, Sora video, Whisper transcription
- Google Gemini: Imagen image generation`;
export const BASELINE_FLOW_CONTROL = `Baseline flow control nodes (used in most workflows):
- n8n-nodes-base.aggregate: Combines multiple items into one item
- n8n-nodes-base.if: Routes items based on true/false condition
- n8n-nodes-base.switch: Routes items to different paths based on rules or expressions
- n8n-nodes-base.splitOut: Expands a single item containing an array into multiple individual items
- n8n-nodes-base.merge: Combines data from multiple parallel branches (for 3+ inputs: mode="append" + numberInputs)
- n8n-nodes-base.set: Transforms and restructures data fields`;
@@ -97,6 +97,30 @@ export class Code implements INodeType {
relationHint: 'Use this instead for creating html pages',
},
],
extraTypeDefContent: [
{
content: `<patterns>
<pattern title="runOnceForAllItems with $input.all()">
const codeNode = node({
type: 'n8n-nodes-base.code',
version: 2,
config: {
name: 'Process Data',
parameters: {
mode: 'runOnceForAllItems',
jsCode: \`
const items = $input.all();
return items.map(item => ({
json: { ...item.json, processed: true }
}));
\`.trim()
}
}
});
</pattern>
</patterns>`,
},
],
},
parameterPane: 'wide',
properties: [
@@ -27,6 +27,40 @@ export class DataTable implements INodeType {
usableAsTool: true,
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
builderHint: {
extraTypeDefContent: [
{
displayOptions: { show: { resource: ['row'], operation: ['insert'] } },
content: `<patterns>
<pattern title="Insert with explicit schema">
const storeData = node({
type: 'n8n-nodes-base.dataTable',
version: 1.1,
config: {
name: 'Store Data',
parameters: {
resource: 'row',
operation: 'insert',
dataTableId: { __rl: true, mode: 'name', value: 'my-table' },
columns: {
mappingMode: 'defineBelow',
value: {
name: expr('{{ $json.name }}'),
email: expr('{{ $json.email }}')
},
schema: [
{ id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
{ id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }
]
}
}
}
});
</pattern>
</patterns>`,
},
],
},
hints: [
{
message: 'The selected data table has no columns.',
@@ -34,6 +34,10 @@ export const description: INodeProperties[] = [
value: get.FIELD,
description: 'Get row(s)',
action: 'Get row(s)',
builderHint: {
propertyHint:
"There is no `getAll` operation. To fetch many rows, use `operation: 'get'` with `returnAll: true`.",
},
},
{
name: 'If Row Exists',
@@ -52,6 +56,10 @@ export const description: INodeProperties[] = [
value: insert.FIELD,
description: 'Insert a new row',
action: 'Insert row',
builderHint: {
propertyHint:
'Row IDs are auto-generated. Do NOT define a custom `id` column or seed `id` on insert. The built-in row `id` is valid for filtering update/delete but is not part of the user-defined table schema.',
},
},
{
name: 'Update',
@@ -29,6 +29,10 @@ export class FilterV2 implements INodeType {
outputs: [NodeConnectionTypes.Main],
outputNames: ['Kept', 'Discarded'],
parameterPane: 'wide',
builderHint: {
searchHint:
'Filter emits 0 items when nothing matches and the chain stops cleanly — no IF gate needed before downstream loops.',
},
properties: [
{
displayName: 'Conditions',
@@ -43,6 +47,13 @@ export class FilterV2 implements INodeType {
version: '={{ $nodeVersion >= 2.3 ? 3 : $nodeVersion >= 2.2 ? 2 : 1 }}',
},
},
builderHint: {
propertyHint: `Must always contain these three sibling keys:
- combinator: 'and' or 'or', default to 'and'
- conditions: [ a list of condition objects ]
- options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 1 }
e.g.: { combinator: 'and', options: { caseSensitive: true, leftValue: '', typeValidation: 'strict', version: 2 }, conditions: [{ leftValue: expr('{{ $json.field }}'), rightValue: 'value', operator: { type: 'string', operation: 'equals' } }] }`,
},
},
{
...looseTypeValidationProperty,
@@ -23,6 +23,38 @@ export class GoogleSheets extends VersionedNodeType {
relationHint: 'Prefer for workflow data storage with upsert',
},
],
extraTypeDefContent: [
{
displayOptions: {
show: {
resource: ['sheet'],
operation: ['append', 'appendOrUpdate', 'update'],
},
},
content: `<patterns>
<pattern title="autoMapInputData — maps $json fields to sheet columns automatically">
columns: {
mappingMode: 'autoMapInputData',
value: {},
schema: [
{ id: 'Name', displayName: 'Name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
{ id: 'Email', displayName: 'Email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: false }
]
}
</pattern>
<pattern title="defineBelow — explicit expression mapping">
columns: {
mappingMode: 'defineBelow',
value: { name: expr('{{ $json.name }}'), email: expr('{{ $json.email }}') },
schema: [
{ id: 'name', displayName: 'name', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true },
{ id: 'email', displayName: 'email', required: false, defaultMatch: false, display: true, type: 'string', canBeUsedToMatch: true }
]
}
</pattern>
</patterns>`,
},
],
},
};
@@ -81,7 +81,10 @@ export const descriptions: INodeProperties[] = [
type: 'resourceLocator',
default: { mode: 'list', value: '' },
required: true,
builderHint: { propertyHint: "Default to mode: 'list' which is easier for users to set up" },
builderHint: {
propertyHint:
"Default to mode: 'list' which is easier for users to set up. Resource locator value must be `{ __rl: true, mode, value }` — never a plain string or `expr()` wrapper.",
},
modes: [
{
displayName: 'From List',
@@ -139,7 +142,10 @@ export const descriptions: INodeProperties[] = [
default: { mode: 'list', value: '' },
// default: '', //empty string set to progresivly reveal fields
required: true,
builderHint: { propertyHint: "Default to mode: 'list' which is easier for users to set up" },
builderHint: {
propertyHint:
"Default to mode: 'list' which is easier for users to set up. Resource locator value must be `{ __rl: true, mode, value }` — never a plain string or `expr()` wrapper.",
},
typeOptions: {
loadOptionsDependsOn: ['documentId.value'],
},
@@ -6,7 +6,12 @@ import {
type ResourceMapperField,
} from 'n8n-workflow';
import { cellFormat, handlingExtraData, useAppendOption } from './commonDescription';
import {
cellFormat,
columnsResourceMapperBuilderHint,
handlingExtraData,
useAppendOption,
} from './commonDescription';
import type { GoogleSheet } from '../../helpers/GoogleSheet';
import type { SheetProperties, ValueInputOption } from '../../helpers/GoogleSheets.types';
import {
@@ -127,6 +132,7 @@ export const description: SheetProperties = [
value: null,
},
required: true,
builderHint: columnsResourceMapperBuilderHint,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {
@@ -8,6 +8,7 @@ import { NodeOperationError } from 'n8n-workflow';
import {
cellFormat,
columnsResourceMapperBuilderHint,
handlingExtraData,
locationDefine,
useAppendOption,
@@ -171,6 +172,7 @@ export const description: SheetProperties = [
value: null,
},
required: true,
builderHint: columnsResourceMapperBuilderHint,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {
@@ -206,6 +208,7 @@ export const description: SheetProperties = [
value: null,
},
required: true,
builderHint: columnsResourceMapperBuilderHint,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {
@@ -1,4 +1,17 @@
import type { INodeProperties } from 'n8n-workflow';
import type { INodeProperties, IParameterBuilderHint } from 'n8n-workflow';
/**
* Builder hint shared by every Google Sheets `columns` resourceMapper parameter
* (append, appendOrUpdate, update). The full resourceMapper object shape is
* non-obvious, and a bare string like `'autoMapInputData'` silently fails
* validation. The matching `<patterns>` example lives on the node-level
* `builderHint.extraTypeDefContent` in `Google/Sheet/GoogleSheets.node.ts`,
* gated by `displayOptions: { show: { resource: ['sheet'], operation: [...] } }`.
*/
export const columnsResourceMapperBuilderHint: IParameterBuilderHint = {
propertyHint:
"Pass the full resourceMapper object: { mappingMode, value, schema }. A bare string like 'autoMapInputData' fails validation.",
};
export const dataLocationOnSheet: INodeProperties = {
displayName: 'Data Location on Sheet',
@@ -1,7 +1,12 @@
import type { IExecuteFunctions, IDataObject, INodeExecutionData } from 'n8n-workflow';
import { NodeOperationError, UserError } from 'n8n-workflow';
import { cellFormat, handlingExtraData, locationDefine } from './commonDescription';
import {
cellFormat,
columnsResourceMapperBuilderHint,
handlingExtraData,
locationDefine,
} from './commonDescription';
import type { GoogleSheet } from '../../helpers/GoogleSheet';
import {
ROW_NUMBER,
@@ -157,6 +162,7 @@ export const description: SheetProperties = [
value: null,
},
required: true,
builderHint: columnsResourceMapperBuilderHint,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {
@@ -192,6 +198,7 @@ export const description: SheetProperties = [
value: null,
},
required: true,
builderHint: columnsResourceMapperBuilderHint,
typeOptions: {
loadOptionsDependsOn: ['sheetName.value'],
resourceMapper: {
@@ -29,6 +29,10 @@ export class IfV2 implements INodeType {
outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
outputNames: ['true', 'false'],
parameterPane: 'wide',
builderHint: {
searchHint:
'After configuring, confirm the workflow wires both `.onTrue()` and `.onFalse()` (or only the relevant one) to the correct downstream node — IF has two named outputs and silently drops items routed to an unwired branch.',
},
properties: [
{
displayName: 'Conditions',
@@ -13,6 +13,93 @@ export const versionDescription: INodeTypeDescription = {
defaults: {
name: 'Merge',
},
builderHint: {
searchHint:
'Mode selection is the single most consequential decision on this node — the wrong mode silently drops or duplicates items rather than erroring. Pick by data shape: `append` to concatenate items from parallel branches; `combineByPosition` only when both branches emit the same number of items in the same order; `combineByFields` to join by a matching key (default; usually correct for "merge by ID"); `combineBySql` for >2 inputs or aggregation; `chooseBranch` to discard all but one input. Read each mode\'s @builderHint before picking.',
extraTypeDefContent: [
{
content: `<patterns>
<pattern title="Combine two branches by matching key (combineByFields, default)">
const usersApi = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'Fetch Users', parameters: { url: 'https://api.example.com/users' } }
});
const ordersApi = node({
type: 'n8n-nodes-base.httpRequest',
version: 4.2,
config: { name: 'Fetch Orders', parameters: { url: 'https://api.example.com/orders' } }
});
const mergeNode = merge({
type: 'n8n-nodes-base.merge',
version: 3.2,
config: {
name: 'Merge by ID',
parameters: {
mode: 'combine',
combineBy: 'combineByFields',
fieldsToMatchString: 'id',
joinMode: 'keepMatches',
outputDataFrom: 'both'
}
}
});
// Wire each upstream branch to a specific merge input slot.
usersApi.to(mergeNode.input(0));
ordersApi.to(mergeNode.input(1));
</pattern>
<pattern title="Append items from parallel branches (append)">
const branchA = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Branch A', parameters: {} }
});
const branchB = node({
type: 'n8n-nodes-base.set',
version: 3.4,
config: { name: 'Branch B', parameters: {} }
});
const mergeNode = merge({
type: 'n8n-nodes-base.merge',
version: 3.2,
config: {
name: 'Append',
parameters: { mode: 'append', numberInputs: 2 }
}
});
branchA.to(mergeNode.input(0));
branchB.to(mergeNode.input(1));
</pattern>
<pattern title="Three or more inputs with SQL (combineBySql)">
const mergeNode = merge({
type: 'n8n-nodes-base.merge',
version: 3.2,
config: {
name: 'SQL Merge',
parameters: {
mode: 'combineBySql',
numberInputs: 3,
query: 'SELECT * FROM input1 LEFT JOIN input2 ON input1.id = input2.userId LEFT JOIN input3 ON input1.id = input3.userId'
}
}
});
input1Node.to(mergeNode.input(0));
input2Node.to(mergeNode.input(1));
input3Node.to(mergeNode.input(2));
</pattern>
</patterns>`,
},
],
},
inputs: `={{(${configuredInputs})($parameter)}}`,
outputs: [NodeConnectionTypes.Main],
// If mode is chooseBranch data from both branches is required
@@ -23,6 +23,30 @@ export class SplitInBatchesV3 implements INodeType {
outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
outputNames: ['done', 'loop'],
builderHint: {
searchHint:
"Loop pattern: connect splitInBatches → per-item work → back to splitInBatches via `nextBatch(splitInBatches)`. The `done` output fires automatically after all items are processed. Already no-ops on empty input — do NOT add an IF gate before it to check 'has items?'.",
extraTypeDefContent: [
{
content: `<patterns>
<pattern title="Per-item loop using splitInBatches with nextBatch">
const sibNode = splitInBatches({
version: 3,
config: { name: 'Batch Process', parameters: { batchSize: 1 } }
});
export default workflow('id', 'name')
.add(startTrigger)
.to(fetchRecords)
.to(sibNode
.onDone(finalizeResults)
.onEachBatch(processRecord.to(nextBatch(sibNode)))
);
</pattern>
</patterns>`,
},
],
},
properties: [
{
displayName:
@@ -59,6 +59,52 @@ export class SwitchV3 implements INodeType {
},
inputs: [NodeConnectionTypes.Main],
outputs: `={{(${configuredOutputs})($parameter)}}`,
builderHint: {
extraTypeDefContent: [
{
displayOptions: { show: { mode: ['rules'] } },
content: `<patterns>
<pattern title="Switch with two cases plus a default branch">
const routeByPriority = switchCase({
version: 3.2,
config: {
name: 'Route by Priority',
parameters: {
rules: {
values: [
{
outputKey: 'urgent',
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'urgent' }],
combinator: 'and'
}
},
{
outputKey: 'normal',
conditions: {
options: { caseSensitive: true, leftValue: '', typeValidation: 'strict' },
conditions: [{ leftValue: expr('{{ $json.priority }}'), operator: { type: 'string', operation: 'equals' }, rightValue: 'normal' }],
combinator: 'and'
}
}
]
}
}
}
});
export default workflow('id', 'name')
.add(startTrigger)
.to(routeByPriority
.onCase('urgent', processUrgent.to(notifyTeam))
.onCase('normal', processNormal)
.onDefault(archive));
</pattern>
</patterns>`,
},
],
},
properties: [
{
displayName: 'Mode',
@@ -128,6 +174,10 @@ export class SwitchV3 implements INodeType {
name: 'rules',
placeholder: 'Add Routing Rule',
type: 'fixedCollection',
builderHint: {
propertyHint:
"Use `rules.values` (NOT `rules.rules`). Each rule needs `outputKey` and a complete `conditions` object with these three sibling keys: `combinator` ('and' | 'or'), `conditions` (array of condition objects), `options` (`{ caseSensitive, leftValue, typeValidation }`). Same shape as IF. Each `outputKey` you define must be wired via `.onCase('<outputKey>')` to the intended downstream node — unwired cases silently drop their items.",
},
typeOptions: {
multipleValues: true,
sortable: true,
+18
View File
@@ -1908,6 +1908,16 @@ export interface INodePropertyCollection {
builderHint?: IParameterBuilderHint;
}
/**
* One variation of `extraTypeDefContent`, optionally gated by `displayOptions`
* so per-mode / per-resource / per-operation examples land only in their
* corresponding narrowed type without cross-bleed.
*/
export interface IBuilderHintVariation {
content: string;
displayOptions?: IDisplayOptions;
}
export interface IParameterBuilderHint {
propertyHint: string;
placeholderSupported?: boolean;
@@ -2555,6 +2565,14 @@ export interface IBuilderHint {
searchHint?: string;
/** Related nodes that work together with this node */
relatedNodes?: IRelatedNode[];
/**
* Multi-line content (typically code examples wrapped in `<patterns>...</patterns>`)
* emitted into the generated workflow-sdk `.d.ts` but NOT surfaced in
* `nodes(action="search")` results. Each variation may carry `displayOptions`
* so per-mode / per-resource / per-operation examples land only in their
* corresponding narrowed type.
*/
extraTypeDefContent?: IBuilderHintVariation[];
}
export interface INodeTypeDescription extends INodeTypeBaseDescription {