This file provides guidance on how to work with the n8n repository.
n8n is a workflow automation platform written in TypeScript, using a monorepo structure managed by pnpm workspaces. It consists of a Node.js backend, Vue.js frontend, and extensible node-based workflow engine.
- Always use pnpm
- Write all technical text (code comments, PR descriptions, issue and ticket descriptions, docs) in ASD-STE100 Simplified Technical English: use short sentences, the active voice, and one instruction for each sentence
- JSDoc on
@Env(...)config properties is public documentation. The docs team exports these comments into the environment variable reference on docs.n8n.io. Write them for a self-hosting operator and follow then8n:env-var-docsskill, which applies the n8n docs style guide and overrides the ASD-STE100 rule above for these comments - Secrets on the command line: if a developer opted into anonymous dev
metrics (
scripts/dev-metrics), pnpm command arguments are recorded. Arguments of secret-carrying words (config,login,publish,token) — whether a subcommand or baked into a flag — are dropped, and the home dir is stripped from paths, but other args are sent as-is — so never put secrets in a command. Pass sensitive values via environment variables, which are never captured. - When adding comments, keep them concise and to the point - explain the "why" in a line or two; don't be overly verbose. Comments should be scoped and relevant to the surrounding code, not just to the current task
- We use Linear as a ticket tracking system
- We use Posthog for feature flags
- To find registered telemetry events (names, descriptions, properties), run
pnpm --filter @n8n/telemetry catalog(--jsonfor structured output). The registry is being adopted incrementally, so search call sites if the catalog has no match. Then8n:telemetryskill covers adding or changing events - When starting to work on a new ticket – create a new branch from fresh master with the name specified in Linear ticket
- When creating a new branch for a ticket in Linear - use the branch name suggested by Linear, unless it is a security fix (see Security Fix Hygiene below)
- Use mermaid diagrams in MD files when you need to visualise something
- Write an ADR only when asked. When a change makes a decision that an engineer could challenge in 12 months, say so in one line and let the human decide. Read docs/adr/README.md before you write one. CI enforces its conventions
- Developing v3 features: land normal feature work on
masterbehind an opt-in flag; introduce breaking changes only on the3.xbranch. See .github/DEVELOPING_V3.md. - The AI gateway feature is "Gateway credits" in user-facing text (UI copy,
error messages, prompts). Only internal identifiers, i18n keys, telemetry, and
comments keep the historical
n8nConnect/n8n credits/ AI Gateway names - Shared utilities: before you hand-roll a utility (
isRecord, secret or PII redaction, JSON extraction from LLM output, Zod to JSON Schema, model-id parsing, AI client HTTP transport and response-size limiting, …), you MUST check the shared packages for an existing implementation and use it:@n8n/utils(generic helpers, redaction),@n8n/ai-utilities(AI- and LLM-specific helpers) andn8n-workflow(workflow graph and traversal). A new shared helper usually belongs in one of these packages too; domain logic stays in the package that owns the domain.
n8n shared skills live in .agents/skills/. Claude Code consumes them through
symlinks in .claude/plugins/n8n/skills/; OpenCode reads .agents/skills/
directly. Harness-specific overrides remain real directories in the harness
path, such as .opencode/skills/setup-mcps/. See
skills README for editing and sync guidance.
n8n-specific Claude Code commands and agents live in .claude/plugins/n8n/ and
are namespaced under n8n:. Use n8n: prefix when invoking them (e.g.
/n8n:create-pr, /n8n:plan, n8n:developer agent). See
plugin README for structure and details.
For routine pre-commit checks, follow Verify changes.
For focused tests, use pnpm agent:test or pnpm agent:playwright.
Pass test files and worker limits after --. Run each command with --help
for its options.
pnpm agent:lint and pnpm agent:typecheck run full-repo checks by default.
With --filter <package>, they still check the whole package. These wrappers
save logs and reduce console output. They do not reduce CPU or memory use.
Do not use them as routine pre-commit checks.
For a fresh checkout (cat-bot, a new hire, any agent verifying the repo
builds), prefer pnpm agent:setup over running install + build + tests by
hand. It chains them in one process, caps per-process memory and turbo
concurrency so a 6GB box doesn't OOM, streams all output to
.agent-setup/<step>.log (gitignored), and surfaces only a one-line summary
per step plus the tail of the failing log. A machine-readable
.agent-setup/summary.json is always written so a backgrounded run is
readable in a single shot — no polling, no scrolling logs.
pnpm agent:setup # install → build → test (full suite)
pnpm agent:setup install # one step at a time
pnpm agent:setup --json # JSON summary on stdout (for scripts/agents)Use pnpm build to build all packages. ALWAYS redirect the output of the
build command to a file:
pnpm build > build.log 2>&1You can inspect the last few lines of the build log file to check for errors:
tail -n 20 build.logIf build outputs or the turbo cache are stale (e.g. after switching branches
or worktrees) but dependencies haven't changed, use pnpm reset (lightweight
by default) for a fast recovery: it cleans build outputs and force-rebuilds
(keeping node_modules and untracked files). If that doesn't fix your issue,
use pnpm reset --full, which also wipes untracked files and reinstalls
dependencies.
An empty instance is a bad place to test anything that reads a user's work. These commands fill one. They are dev tooling on the private root package, so they never reach a user.
N8N_API_KEY=<jwt> pnpm seed:preference # 10 workflows in one house style, plus history
N8N_API_KEY=<jwt> pnpm seed:account # ~500 varied workflows across 30 projects
pnpm inspect:activity # read-only activity_event viewer on 127.0.0.1Both seed profiles delete their own prior output, so a re-run replaces it.
That clear step deletes anything named [seed] and any empty team project,
whoever made them, so do not point either at a shared instance. The viewer is
unauthenticated and serves the whole table: keep it on loopback.
See scripts/instance-seeding/AGENTS.md for profiles, tokens, determinism, and the other commands.
Monorepo Structure: pnpm workspaces with Turbo build orchestration
The monorepo is organized into these key packages:
packages/@n8n/api-types: Shared TypeScript interfaces between frontend and backendpackages/workflow: Core workflow interfaces and typespackages/core: Workflow execution enginepackages/cli: Express server, REST API, and CLI commandspackages/frontend/editor-ui: Vue 3 frontend applicationpackages/frontend/@n8n/i18n: Internationalization for UI textpackages/nodes-base: Built-in nodes for integrationspackages/@n8n/nodes-langchain: AI/LangChain nodespackages/@n8n/instance-ai: "n8n Assistant" in the UI, "Instance AI" in code — n8n Assistant backend. See itsCLAUDE.mdfor architecture docs.@n8n/design-system: Vue component library for UI consistency@n8n/config: Centralized configuration management
- Frontend: Vue 3 + TypeScript + Vite + Pinia + Storybook UI Library
- Backend: Node.js + TypeScript + Express + TypeORM
- Testing: Vitest (unit) + Playwright (UI, API, infrastructure, lifecycle, performance, and E2E orchestration)
- Database: TypeORM with SQLite/PostgreSQL support
- Code Quality: Biome (for formatting) + ESLint + lefthook git hooks
- Dependency Injection: Uses
@n8n/difor IoC container - Controller-Service-Repository: Backend follows MVC-like pattern
- Event-Driven: Internal event bus for decoupled communication
- Context-Based Execution: Different contexts for different node types
- State Management: Frontend uses Pinia stores
- Design System: Reusable components and design tokens are centralized in
@n8n/design-system, where all pure Vue components should be placed to ensure consistency and reusability
- Each package has isolated build configuration and can be developed independently
- Hot reload works across the full stack during development
- Node development uses dedicated
node-devCLI tool - Workflow tests are JSON-based for integration testing
- AI features have dedicated development workflow (
pnpm dev:ai)
The n8n-workflow package exports graph traversal utilities from
packages/workflow/src/common/. Use these instead of custom traversal logic.
Key concept: workflow.connections is indexed by source node.
To find parent nodes, use mapConnectionsByDestination() to invert it first.
import { getParentNodes, getChildNodes, mapConnectionsByDestination } from 'n8n-workflow';
// Finding parent nodes (predecessors) - requires inverted connections
const connectionsByDestination = mapConnectionsByDestination(workflow.connections);
const parents = getParentNodes(connectionsByDestination, 'NodeName', 'main', 1);
// Finding child nodes (successors) - uses connections directly
const children = getChildNodes(workflow.connections, 'NodeName', 'main', 1);- NEVER use
anytype - use proper types orunknown - Avoid type casting with
as- use type guards or type predicates instead (except in test code whereasis acceptable) - Define shared interfaces in
@n8n/api-typespackage for FE/BE communication - Lazy-load heavy modules — if a module is only used in a specific code
path (not every request), use
await import()at point of use instead of top-levelimport. Applies especially to native modules and large parsers.
- Don't use the deprecated
ApplicationErrorclass anywhere — it's a compatibility shim kept only so community nodes keep resolving. Use one of these instead, picking by cause:UserError— the user caused it (invalid input, unauthorized action, business-rule violation).OperationalError— a transient, expected issue (network request failing, DB query timing out) that should be handled gracefully.UnexpectedError— a bug in the code (logic mistake, unhandled case, failed assertion) that developers need to fix.
- Import from appropriate error classes in each package
TypeORM (@n8n/typeorm) must stay in the persistence layer. The shared
@n8n/db package and backend persistence adapters can import it. A module can
colocate or group its entities and repositories according to its domain.
Business logic — services, controllers, handlers, commands, factories — must not
import from @n8n/typeorm (including @n8n/typeorm/... subpaths). The
typeorm-persistence-boundary Code Health rule recognizes entity and repository
declarations. It scans backend packages that depend on @n8n/db or
@n8n/typeorm. Tests, migrations, and explicit helper-only adapters are
exceptions. Existing business-logic imports stay in the shrink-only Code Health
baseline. A new business-logic import fails CI.
- Pattern: when a query needs operators (
In,IsNull,LessThan,FindOptionsWhere, …), put it behind a use-case-named repository method that takes plain parameters and returns domain-shaped values — not a genericfind(options)passthrough. - Transactions: transaction orchestration belongs in the persistence layer.
Don't reach for
.manager/.manager.transaction(...)orcreateQueryBuilder(...)in business logic. Use the sanctioned primitive in@n8n/db: inject the abstractTransactionRunnerand wrap the unit of work intxRunner.run(ctx, async (ctx) => …). The callback receives anOperationContextcarrying the active transaction; thread thatctxinto the repository methods you call.runrequires a context — pass an empty{}at the operation entry point, and reuse the one you were handed everywhere below it (a context that already carries a transaction is joined, not nested). Repositories extendBaseRepositoryand resolve the rightEntityManagerwiththis.managerFor(ctx); theTransactionhandle is opaque and never exposes a driver type to business logic. Seeoauth-token.service.ts+oauth-*-token.repository.tsfor a worked example. - Anti-patterns reviewers reject — they hide the dependency instead of
removing it:
- String-matching TypeORM errors, e.g.
error.name === 'QueryFailedError'. - Relabeling the import from
@n8n/typeormto@n8n/dbto silence the rule (@n8n/dbre-exports several operators/types, but this relabels the dependency rather than removing it). - Pushing
.manager/createQueryBuilderinto business logic to avoid an operator import — trades a visible leak for an invisible one.
- String-matching TypeORM errors, e.g.
Rule policy lives in four shared configs in @n8n/eslint-config, and a package
config picks exactly one:
| layer | subpath | for |
|---|---|---|
baseConfig |
@n8n/eslint-config/base |
runtime-agnostic libraries |
backendConfig |
@n8n/eslint-config/backend |
anything that runs on Node; adds the network and encryption boundaries |
frontendConfig |
@n8n/eslint-config/frontend |
Vue packages |
nodesConfig |
@n8n/eslint-config/nodes |
n8n-nodes-base and @n8n/nodes-langchain; adds the node and credential file rules |
A package config may add ignores, an additive plugin config, a block that
raises rules to error, and blocks scoped to files. It must not turn a rule
down for the whole package: every lint script runs with --quiet, so a warn
enforces nothing and reads as if it did. The code-health rule
lint-config-layering enforces this, with existing debt in
.code-health-baseline.json, which only shrinks.
To stop enforcing a rule everywhere, retire it in base.ts with the count
behind the decision. To enforce one again in a package that is ready, set it to
error there. node scripts/lint-parity/majority.mjs prints how many packages
downgrade each rule, and scripts/lint-parity/snapshot.mjs plus diff.mjs
prove a config change only altered what you meant it to.
New code encrypts and decrypts only through cipher.encryptV2() /
cipher.decryptV2() — the key-manager module decides which key is used and in
which output format. Enforced in CI by the rules in
packages/@n8n/eslint-config/src/configs/encryption-boundary.ts (part of
backendConfig, and so of nodesConfig; every package that runs on Node
extends one of those layers):
Cipherdoes not expose the legacy or explicit-key methods.- The raw AES classes stay inside
packages/core/src/encryption/. - Deployment keys are never deleted — data encrypted with a key becomes unreadable without it. Deactivate keys instead; the repository does not expose deletion and database triggers reject direct deletion.
- Inline disables that name these rules, and bare line-form disables, are
themselves lint errors. The code-health rule
encryption-boundary(CI "Static Analysis") is the enforcement layer: it checks that every package that depends onn8n-coreor@n8n/dbextendsbackendConfig(ornodesConfig) aterrorseverity, and rejects every directive form that would silence the rules in non-test code (eslint-disable*and inlineeslintconfiguration comments). Widening the boundary happens inencryption-boundary.tsonly; that file and the rule files require security (IAM) approval via OWNERS.
- Refer to
packages/frontend/AGENTS.md - All UI text must use i18n - add translations to
@n8n/i18npackage - Use CSS variables directly - never hardcode spacing as px values
- data-testid must be a single value (no spaces or multiple values)
- Always use the
design-systemskill in reviews
- Before committing, run formatting and lint checks on changed files. Use
the package's configured tools through
pnpm execwith explicit file paths. Do not append file paths topnpm lint; package scripts can still check the whole package. - Run tests that cover the changed behavior from the owning package:
pnpm test <test-file> --maxWorkers=1. Or use the root wrapper:pnpm agent:test --filter <package> -- <test-file> --maxWorkers=1. For Playwright, usepnpm agent:playwright -- <spec-file> --workers=1. - For routine commits, run checks one at a time. Use one test worker by default. Do not run full frontend typechecks locally. Use CI for comprehensive lint, typechecks, and test suites.
- For documentation-only changes, review the diff and run
git diff --check. Skip code tests when no behavior changed. - Report which checks ran and which checks were left to CI.
- Use Vitest for unit tests. Use Playwright when a test needs its browser, fixtures, or managed containers.
- For Vitest packages with
@n8n/didecorators, usecreateVitestConfigWithDecoratorsfrom@n8n/vitest-config/node-decorators. - Check import and mock side effects before running tests. Keep tests out of
user-owned directories. Set
N8N_USER_FOLDERto a test-owned directory before importing n8n settings. Clean up only paths that the test created. - CI runs
@n8n/code-healthstatic analysis on PRs. It checks monorepo rules, including dependency hygiene and encryption-boundary coverage.
| Goal | Command |
|---|---|
| Run product E2E against a local instance | pnpm --filter=n8n-playwright test:local |
| Run backend with PostgreSQL, Redis, email, and proxy services | pnpm --filter n8n-containers services --services postgres,redis,mailpit,proxy, then pnpm dev:be |
| Add editor hot reload | pnpm dev:fe:editor |
| Start a Codespace backend and share its port | pnpm dev:up |
The root pnpm dev command does not start a server. See the
Playwright guide and the
Codespaces guide for details.
When implementing features:
- Define API types in
packages/@n8n/api-types - Implement backend logic in
packages/climodule, followscripts/backend-module/backend-module-guide.md - Add API endpoints via controllers
- Update frontend in
packages/frontend/editor-uiwith i18n support. For a frontend feature module, obeypackages/@n8n/module-cli/frontend-module-guide.md - Write tests with proper mocks
Security improvements, whether driven by enterprise requirements or internal standards, must NEVER add friction to the common-case building experience. When designing security-related features (defaults, behaviors, flows, error handling), apply these checks:
- No friction for the common case: A community builder's workflow should remain intuitive. Security should be invisible when it can be.
- Migration and upgrade paths: Existing users must have a clear, non-disruptive path forward when defaults or behaviors change.
- Security layers on top, not in competition: Great UX and strong security are not trade-offs. They're both required. If a design forces a choice between them, the design needs more work.
This is a public repository. When working on security fixes, never expose the attack vector or vulnerability type in any public-facing artifact. Attackers monitor open-source repos for signals like branch names, commit messages, PR titles, test descriptions, and Linear URLs.
Rules for security fixes:
- Branch names: Do NOT use the Linear-suggested branch name if it reveals
the vulnerability. Rename to describe the fix neutrally
(e.g.
node-1234-improve-request-handling, notnode-1234-fix-ddos-vulnerability). - Commit messages: Describe what the code now does, not the threat it
prevents (e.g.
fix: add payload size validation, notfix: prevent denial of service). - Test descriptions: Use neutral, functional language
(e.g.
'should sanitize query parameters', not'should prevent SQL injection'). - Code comments: Do not describe the attack scenario in comments.
- Linear references: Never include the URL slug
(e.g.
.../N8N-1234/fix-ssrf-vulnerability).
This is a public repository. Never mention customer names in any public-facing artifact — not all customers have agreed to be named publicly, and naming them can reveal security-relevant details about their setup.
This applies to PR titles and descriptions, branch names, commit messages,
code, code comments, test names and test data, and fixtures. When implementing
a customer request, describe the use case neutrally (e.g. "a customer with a
large multi-main setup", not the company name) and use generic placeholder
names (e.g. Acme Corp) in tests and examples.
- When creating a PR, use the conventions in
.github/pull_request_template.mdand.github/pull_request_title_conventions.md. - Use
gh pr create --draftto create draft PRs. - If there is a corresponding Linear ticket, reference it in the PR
description using
https://linear.app/n8n/issue/[TICKET-ID]. Do not create a Linear ticket on your own — ask first. - always link to the github issue if mentioned in the linear ticket.