Custom Nodes — Conventions & Patterns
This guide describes the conventions and patterns used by the custom n8n nodes in community-nodes/. It is intended for anyone adding a new node, modifying an existing one, or doing a code review. Following these conventions keeps nodes consistent, testable, and compatible with the n8n-node toolchain.
Repository Layout
All custom nodes live under a single pnpm package at community-nodes/:
community-nodes/
├── credentials/ # ICredentialType definitions, one file per credential
├── nodes/ # INodeType definitions, one folder per node
│ └── <NodeName>/
│ ├── <NodeName>.node.ts # Main node implementation
│ ├── <NodeName>.node.json # Codex/category metadata (optional)
│ └── shared/ # Internal helpers (GenericFunctions.ts, etc.)
├── icons/ # SVG icons used by nodes and credentials
├── tests/ # Vitest suites, one folder per node
├── dist/ # Build output (gitignored)
├── package.json # Package manifest with n8n node/credential entries
├── tsconfig.json
├── vitest.config.ts
└── eslint.config.mjs
The package is registered as an n8n community package via the n8n field in community-nodes/package.json. Every node and credential file must be added to the n8n.nodes and n8n.credentials arrays there, pointing at the compiled .js file under dist/.
Package Conventions
- Package name:
community-nodes(.keywordbcgov-community-nodes) - API version:
n8nNodesApiVersion: 1,strict: false - License:
Apache-2.0 - Build tooling:
@n8n/node-clipowersbuild,dev,lint, andlint:fixscripts - Test runner: Vitest (
pnpm test), configured viavitest.config.ts - Peer dependency:
n8n-workflow— the runtime types nodes import from. Avoid adding runtime dependencies unless absolutely necessary; the lint rule@n8n/community-nodes/no-runtime-dependenciesflags them. - Allowed runtime dependencies:
sanitize-htmlandzodare the only runtime dependencies. Add a new runtime dependency only after confirming it cannot be avoided.
Node File & Folder Conventions
Each node lives in its own folder named with the node's display name (PascalCase, no spaces):
nodes/<NodeName>/<NodeName>.node.ts
nodes/<NodeName>/<NodeName>.node.json (optional Codex metadata)
nodes/<NodeName>/shared/ (optional helpers)
Naming
| Convention | Example | Notes |
|---|---|---|
| Folder name | OidcToken | PascalCase, matches the node's display name with spaces removed |
| Source file | OidcToken.node.ts | <FolderName>.node.ts |
description.name | oidcToken | camelCase unique identifier; used in credentials: [{ name }] references |
description.displayName | OIDC Token | Human-readable label shown in the n8n UI |
| Class name | OidcToken | PascalCase, implements INodeType |
| Codex metadata file | OidcToken.node.json | Links the node to its docs category and declares categories |
Metadata file
Nodes optionally ship a <NodeName>.node.json describing the codex version and documentation link:
{
"node": "community-nodes.oidcToken",
"nodeVersion": "1.0",
"codexVersion": "1.0",
"categories": ["Development", "Transform"],
"resources": {
"primaryDocumentation": [
{ "url": "https://github.com/bcgov/common-hosted-workflow/tree/main/docs/community-nodes/oidc-token" }
]
}
}
The resources.primaryDocumentation[0].url should point to the matching docs subfolder under docs/community-nodes/.
Node Description Shape
Every node class implements INodeType and exposes a description: INodeTypeDescription. The fields used consistently across this repo are:
export class MyNode implements INodeType {
description: INodeTypeDescription = {
displayName: 'My Node',
name: 'myNode',
icon: { light: 'file:../../icons/<icon>.svg', dark: 'file:../../icons/<icon>.dark.svg' },
group: ['transform'], // 'transform' | 'input' | etc.
version: 1,
subtitle: '={{$parameter["operation"]}}', // optional expression
usableAsTool: true, // enable AI-tool usage unless there's a reason not to
defaults: { name: 'My Node' },
inputs: [NodeConnectionTypes.Main],
outputs: [NodeConnectionTypes.Main],
credentials: [{ name: 'myCredential', required: true }],
properties: [
/* ... */
],
};
}
Common properties patterns
- Operation selectors: an
optionstype withnoDataExpression: truenamedoperationthat drives conditional display of other properties viadisplayOptions.show.operation. - Conditional fields: gate secondary fields with
displayOptions: { show: { operation: ['wait'] } }(or any parent property). - Typed selects: use
type: 'options'withname/value/descriptionper option. - JSON inputs: use
type: 'json'with a default like'={}'and ahintshowing the expected shape; never use rawstringfor structured payloads. - Expressions: prefix defaults that are expressions with
=, e.g.default: '={{ $json.body }}'. - Required fields: set
required: trueanddescriptionthat explains what is expected. - Numeric bounds: use
typeOptions: { minValue: 0, numberPrecision: 2 }for numeric fields.
Icons
- SVG icons live in
community-nodes/icons/. - Always provide a light and dark variant. The dark variant is named
<icon>.dark.svg. - Reference icons by relative path from the source file (e.g.
file:../../icons/my-node.svgfor nodes,file:../icons/my-node.svgfor credentials). - The
eslint-plugin-n8n-nodes-baseicon-validationrule is disabled in this repo, but you should still follow this convention.
Credential Conventions
Credentials live in community-nodes/credentials/ as <Name>.credentials.ts:
credentials/OidcToken.credentials.ts
Each credential class implements ICredentialType:
import { Icon, type ICredentialType, type INodeProperties } from 'n8n-workflow';
export class MyCredential implements ICredentialType {
name = 'myCredential';
icon: Icon = { light: 'file:../icons/<icon>.svg', dark: 'file:../icons/<icon>.dark.svg' };
displayName = 'My Service';
documentationUrl = 'https://github.com/bcgov/common-hosted-workflow/tree/main/docs/community-nodes/my-node';
properties: INodeProperties[] = [
/* ... */
];
}
Patterns to follow:
nameis camelCase and must exactly match thenamereferenced by nodes in theircredentialsarray.documentationUrlpoints at the docs subfolder for the credential's associated node.- Secret fields use
typeOptions: { password: true }. - Required vs optional: mark
required: truefor fields the node cannot function without (e.g. Client ID). Document why an apparently-required field is optional (e.g. Client Secret may be omitted for public clients on the Password grant). - Placeholders: provide a realistic
placeholderfor URL/config fields so users can infer the expected shape. - Descriptions: mention alternate fields ("Provide this OR the Token Endpoint") and which node behaviour each field is used by ("Only used when Grant Type is Password").
The credential file must be added to n8n.credentials in community-nodes/package.json (pointing at the compiled dist/credentials/...js).
Convention: shared helpers
Reusable node logic lives in a shared/ subfolder next to the node. The shared helpers are not exported as part of the node module surface — they are internal to the node.
Common patterns:
shared/GenericFunctions.ts— HTTP helpers, validation, and pure logic (e.g.resolveEndpoints,fetchToken,decodeJwt).shared/types.ts— local type definitions shared across the node's helpers.shared/properties.ts— reusable property definitions when a node has many operations.shared/<topic>.ts— split by concern when the node is large (e.g. CHEFS, DevXMessageConnector), seenodes/DevXMessageConnector/sources/shared/.
Keep the .node.ts file focused on the INodeTypeDescription and the execute() / webhook() orchestration. Push logic into helpers so it can be unit tested independently.
Execution Patterns
Programmatic nodes (execute())
Most nodes implement async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>.
Required conventions:
- Read inputs:
const items = this.getInputData(); - Read credentials:
const credentials = (await this.getCredentials('<name>')) as unknown as <TypedShape>; - Validate early: throw a
NodeOperationErrorwith a clear message before any network call when configuration is invalid. - Loop with item index: iterate
itemswithfor (const [i] of items.entries())and process each item independently. - Per-item error handling:
try {/* ... */} catch (error) {if (this.continueOnFail()) {returnData.push({ json: { error: (error as Error).message }, pairedItem: { item: i } });continue;}if ((error as Error & { response?: unknown }).response) {throw new NodeApiError(this.getNode(), error as unknown as JsonObject);}throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i });}
- Enrich and emit: use
this.helpers.constructExecutionMetaData(this.helpers.returnJsonArray(payload), { itemData: { item: i } })to produceINodeExecutionData. - Return shape: return
[returnData]— an array of arrays, per n8n convention.
Webhook / waiting nodes (webhook())
Nodes that pause and resume on incoming webhooks implement an additional async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData>.
Conventions:
- Declare
webhooks: [{ name, httpMethod, responseMode, responseData, path, restartWebhook }]in the description. - Use
waitingNodeTooltipto surface the resume URL in the n8n UI while waiting. - Put the workflow to wait with
this.putExecutionToWait(waitTill). Use a far-future date for indefinite waits, or compute a timeout fromtimeoutAmount+timeoutUnit. - In the
webhook()handler:- Read query/body via
this.getQueryData()andthis.getBodyData(). - Return
{ webhookResponse }(withrestartWebhook: true) when not all callbacks have arrived yet. - Return
{ webhookResponse, workflowData: [...] }only when the wait is complete and the execution should resume.
- Read query/body via
- For multi-step waits that need crash-safe state, persist state externally via the external-hooks service (see
MultiWebhookWait) and provide a downstreamClearoperation to fetch partial status and clean up DB entries. - The
@n8n/community-nodes/webhook-lifecycle-completelint rule may be disabled with an inline// eslint-disable-next-linecomment when the lifecycle is intentionally handled acrossexecute()andwebhook().
Downstream Clear / status operations
Long-running or wait nodes should expose a secondary operation (e.g. operation: 'clear') that returns the current completion status and removes persisted state. This pairs with the wait operation's timeout branch so timeouts produce an accurate partial-execution report.
HTTP Requests
Use this.helpers.httpRequest({ ... }) for all outbound HTTP calls. Conventions:
- Always set
method,url,headers(includingAcceptandContent-Type: application/jsonwhere relevant),body, andjson: true. - For status-aware calls, set
returnFullResponse: trueandignoreHttpStatusErrors: true, then branch onresponse.statusCode(e.g. treat404as "not found" instead of throwing). - Wrap
httpRequestin atry/catchand rethrow asNodeOperationErrorwith:- A succinct message:
`Failed to <action>: ${detail}` - A
descriptionthat names the failing URL:POST ${url} failed.,
- A succinct message:
- Never
fetch()directly or use third-party HTTP clients —this.helpers.httpRequestis the n8n-blessed path that respects proxy / retry config.
Configuration From Environment
When a node needs runtime configuration (e.g. base URL, an internal auth token), read it from process.env with documented fallbacks:
const baseUrl = (process.env.N8N_BASE_URL ?? process.env.WEBHOOK_URL ?? '').replace(/\/$/, '');
const internalToken = process.env.INTERNAL_AUTH_TOKEN;
- Throw a
NodeOperationErrorwith both amessageand adescriptionwhen an env var is missing, so users can see exactly which variable to set. - Document every env var the node reads in the node's docs README.
- Avoid env-based config when a credential field can serve the same purpose. Reserve env vars for values that must be consistent across executions within a deployment (base URL, internal service tokens).
Error Handling & Validation Summary
| Situation | Error type | Notes |
|---|---|---|
| Invalid credential / missing required field | NodeOperationError | Throw before any network call. Message should name the bad field. |
| Bad user input on the node UI | NodeOperationError | Include { itemIndex: i } when processing per-item. |
| HTTP / API failure (response present) | NodeApiError | Lets n8n show status + body to the user. |
| HTTP / API failure without a response object | NodeOperationError | Fallback for transient transport errors. |
Recovery on continueOnFail() | — | Emit { json: { error }, pairedItem: { item: i } } and continue. |
TypeScript Configuration
community-nodes/tsconfig.json enforces strict mode. Conventions:
strict,strictNullChecks,noImplicitAny,noImplicitReturns,noUnusedLocalsare all on. Do not relax these.target: 'es2019',module: 'commonjs'— match the n8n runtime.useUnknownInCatchVariables: falseis set socatch (error)blocks treaterrorasunknownwithout forcing narrow casts; still prefer expliciterror as Errorcasts.- Output goes to
./dist/and is emitted with declarations and source maps. ignoreDeprecations: "6.0"is set — keep it to silence known upstream deprecation warnings.
Lint & Style
community-nodes/eslint.config.mjs is the source of truth. Highlights:
- Built on
@eslint/jsrecommended +typescript-eslintrecommended +@n8n/eslint-plugin-community-nodesrecommendedWithoutN8nCloudSupport. eslint-plugin-import-xflat/recommended is applied for import ordering and resolution.pluginN8nNodesBaseis registered, but most of its node/credential/community rules are turned off in the per-glob overrides. Before re-enabling any of them, confirm the existing nodes pass.no-console: 'error'for**/*.ts(logging in node code is forbidden — use throw orcontinueOnFailoutput).no-console: 'off'applies intests/**.- The
package.jsonoverride block downgrades several community-package rules (package-name-convention,valid-peer-dependencies,no-runtime-dependencies,require-community-node-keyword). Do not add new exceptions without justification.
Running lint
cd community-nodes
pnpm lint # check
pnpm lint:fix # auto-fix
pnpm build # tsc -> dist/
Testing Conventions
Tests live in community-nodes/tests/<NodeName>/, run with Vitest.
- One folder per node, matching the source node's folder name.
- A shared
helpers.tsfile per node provides mock construction and parameter wiring. - Tests are split by behaviour or operation, one
.test.tsper concern, e.g.:node.test.ts— general routing / happy pathtext.test.ts,html.test.ts,github.test.ts,sysdig.test.ts, … — per-source/per-mode suiteserror-handling.test.ts— failure paths andcontinueOnFailbehaviour
- Vitest configuration:
environment: 'node',include: ['tests/**/*.test.ts'],clearMocks: true,restoreMocks: true.
Mocking n8n-workflow
Because n8n-workflow provides runtime classes, helpers mock the module at the top of helpers.ts:
import { vi } from 'vitest';
vi.mock('n8n-workflow', () => ({
NodeConnectionTypes: { Main: 'main' },
NodeApiError: class NodeApiError extends Error {
/* ... */
},
NodeOperationError: class NodeOperationError extends Error {
/* ... */
},
}));
import { MyNode } from '../../nodes/MyNode/MyNode.node';
The mock must be in place before the node source is imported. Import the real node class afterwards.
Building a mock execution context
Helpers expose a factory (commonly createExecutionContext or similar) that returns an object satisfying the IExecuteFunctions shape, with stubbed:
getInputData()returning an array of itemsgetCredentials(name)returning a typed credential objectgetNodeParameter(name, itemIndex, fallback)threaded from the test'sparamshelpers.httpRequestas avi.fnreturning configured responses (and asserting on call args)helpers.constructExecutionMetaData/returnJsonArrayas pass-throughscontinueOnFail()defaulted tofalsegetNode()returning a stub node object forNodeApiError/NodeOperationErrorconstructors
Test command
cd community-nodes
pnpm test # all suites
pnpm exec vitest run tests/MyNode/foo.test.ts # single file
What to test
- Happy path per operation / mode / source
- Field-dependent display (
displayOptions) when relevant - Credential validation messages
- HTTP request shape (URL, method, headers, body) — assert on
httpRequestmock args - Error handling:
NodeApiErrorfor HTTP failures,NodeOperationErrorfor validation,continueOnFail-on error items - Edge cases: empty input arrays, missing optional fields, malformed JSON in dynamic inputs
Documentation Conventions
Each node gets a docs subfolder under docs/community-nodes/<kebab-case-name>/:
docs/community-nodes/<node>/
├── _category_.json # { "label": "<Display Name>", "position": <int> }
├── README.md # Overview, source layout, quick start, links to other docs
├── architecture.md # Design, data flow, error-handling model (optional)
├── node-operations.md # Every property and operation documented (optional)
└── credentials.md # Credential setup and field reference (optional)
Naming:
- Docs folder is kebab-case (
oidc-token,multi-webhook-wait) — matches the URL in the credential'sdocumentationUrland the codexresources.primaryDocumentationURL. - Source folder is PascalCase (
OidcToken). The translation is mechanical.
Docs style (observed in this repo):
- Short intro paragraph, then sections with
##headers. - Use tables for property/mode/option references.
- Use fenced code blocks for file trees, payloads, and ASCII diagrams.
- Use ASCII diagrams for flow/architecture (see
architecture.mdfiles); avoid external image dependencies when an ASCII diagram works. - When linking to GitHub assets, use the absolute
https://github.com/bcgov/common-hosted-workflow/tree/main/docs/community-nodes/<node>URL pattern (matches the codex metadata). - Per-node docs README links back to the Architecture / Node Operations / Credentials docs in the same folder.
The top-level docs/community-nodes/_category_.json sets the sidebar position; nested _category_.json files order each node's docs within the section.
Checklist: Adding a New Node
Use this checklist when introducing a new custom node.
- Source
-
nodes/<NodeName>/<NodeName>.node.tsimplementingINodeType -
<NodeName>.node.jsonCodex metadata with docs URL -
shared/folder for helpers; node logic pushed out of.node.ts - Light + dark SVG icons in
icons/
-
- Credentials (if applicable)
-
credentials/<Name>.credentials.tsimplementingICredentialType -
namematches the reference in the node'scredentialsarray - Secret fields use
typeOptions: { password: true }
-
- Package manifest
- Add
dist/nodes/<NodeName>/<NodeName>.node.jston8n.nodesinpackage.json - Add
dist/credentials/<Name>.credentials.jston8n.credentialsinpackage.json
- Add
- Docs
-
docs/community-nodes/<node>/folder with_category_.json,README.md - At minimum
architecture.md,node-operations.md,credentials.mdfor non-trivial nodes - Credential
documentationUrland codexprimaryDocumentationURL point at<node>docs folder
-
- Tests
-
tests/<NodeName>/folder withhelpers.tsand at least one<topic>.test.ts - Mock
n8n-workflowbefore importing the node - Cover happy paths, validation errors, HTTP request shape, and
continueOnFail
-
- Quality gates
-
pnpm lintpasses -
pnpm buildproducesdist/with no type errors -
pnpm testpasses
-
Existing Nodes Reference
| Folder | Node name | Display Name | Style | Credential(s) |
|---|---|---|---|---|
nodes/CHEFS/ | chefs | CHEFS | Programmatic | ChefsApi, ChefsFormAuth |
nodes/CHEFSResubmitWait/ | chefsResubmitWait | CHEFS Resubmit Wait | Webhook/Wait | ChefsApi, ChefsFormAuth |
nodes/CHEFSSubmissionExtractor/ | chefsSubmissionExtractor | CHEFS Submission Extractor | Programmatic | ChefsApi, ChefsFormAuth |
nodes/DevXMessageConnector/ | devXMessageConnector | DevX Message Connector | Programmatic | DevXConnector |
nodes/MultiWebhookWait/ | multiWebhookWait | Multi Webhook Wait Node | Webhook/Wait | (env-based: INTERNAL_AUTH_TOKEN) |
nodes/OidcToken/ | oidcToken | OIDC Token | Programmatic | oidcToken |
nodes/WorkflowInteractionLayer/ | workflowInteractionLayer | Workflow Interaction Layer | Programmatic | WorkflowInteractionLayerApi |
See the per-node docs in each subfolder of docs/community-nodes/ for node-specific design and usage.