Project n8n-kit

Expand description

@vahor/n8n-kit

Build n8n workflows using TypeScript code with full type safety and IDE support.

npm install @vahor/n8n-kit
  • 400+ Nodes: Support for all base n8n nodes plus AI nodes
  • Type Safety: Full TypeScript support with intelligent autocomplete
  • Expression Builder: Type-safe n8n expressions with IDE support
  • Chain API: Fluent API for building workflow chains
  • Bundle JS: Can bundle dependencies inside your Code nodes

The package provides several export paths for different use cases:

Main export containing core functionality:

  • App - Application container
  • Chain - Workflow chain builder
  • Workflow - Workflow definition
  • Credentials - Credential management
  • NodejsFunction - Bundle TypeScript/JavaScript code for Code nodes

Manually implemented nodes with full type definitions:

  • If - Conditional logic node
  • ScheduleTrigger - Schedule-based triggers
  • And more...

Auto-generated nodes from n8n-nodes-base (without output types):

  • All 400+ n8n base nodes and ai nodes

Auto-generated credentials from n8n-nodes-base:

  • All n8n credential types with proper typing

Your entrypoint file should export an app instance:

// src/index.ts
import { App, Workflow, Chain } from "@vahor/n8n-kit";

const app = new App();

new Workflow(app, "my-workflow", {
active: true,
name: "My Workflow",
definition: [
Chain.start(
// ...
),
],
});

export { app };

The App class serves as the main container for your n8n workspace. Passing an instance of App in the constructor of a Workflow will automatically add the workflow to the list of workflows to deploy.

You know what a workflow is, right?

new Workflow(app, "my-workflow", {
active: true,
name: "My Workflow",
tags: ["tag1", "tag2"],
settings: {
executionTimeout: 1200,
},
inputSchema: type({
field: "string",
}),
outputSchema: type({
someField: "string",
otherField: "number",
}),
definition: (workflow) => [
new StickyNote("note", {
position: [0, 0],
content: "Hello World",
height: 120,
width: 600,
}),

anotherChain,

chainWithWorkflowParameter(workflow),
]
});

Parameters:

  • name - Name of the workflow
  • active - Whether the workflow is active or not
  • tags - Tags of the workflow
  • settings - Settings of the workflow
  • inputSchema - Input schema of the workflow. Only used for type validation when using ExecuteWorkflow and ExecuteWorkflowTrigger.
  • outputSchema - Output schema of the workflow. Only used for type validation when using ExecuteWorkflow and ExecuteWorkflowTrigger.
  • definition - Definition of the workflow. Can be a function that takes the workflow as parameter (required for Group nodes)

For all type parameters, you'll have to use the type function from arktype to define the schema. The type method is re-exported in @vahor/n8n-kit.

Maybe not all your workflows are defined in your current application. If that's the case, you can still reference them by their ID:

const importedWorkflow = Workflow.import(app, {
// Needs either hashId or n8nWorkflowId
hashId: "my-workflow-hash-id", // Id generated by n8n-kit, visible in the CLI output or in the n8n tags
n8nWorkflowId: "my-workflow-n8n-id", // Id of the workflow in n8n
inputSchema: type({
// ...
}),
outputSchema: type({
// ...
}),
});

Then you'll be able to use that workflow in execution nodes:

import { ExecuteWorkflow } from "@vahor/n8n-kit/nodes";

new ExecuteWorkflow("call-to-external-workflow", {
workflow: importedWorkflow,
workflowInputs: {
// type-safe based on the input schema
},
});

Credentials must be created in n8n first, then referenced by ID:

const googleDriveApiCredentials = Credentials.byId({
name: "googleDriveOAuth2Api", // Credential type (auto-completed - type derives from it)
id: "abc123", // n8n credential ID
});

To get the id, go on your n8n instance, click on a credential, and copy the id from the URL. e.g. https://n8n.instance.com/home/credentials/yTwI5ccVwfGll1Kf => yTwI5ccVwfGll1Kf

Note: As these credentials are not deployed using the CLI, you don't need to add them to the app.

Chains represent connected sequences of nodes:

Chain.start(triggerNode)
.next(processNode)
.next(({ $ }) => conditionalNode) // Access to expression builder
.multiple([Chain.start(node1).next(node11), node2]) // Multiple branches
.connect(["node-1", "node-2"], node3) // Connect to multiple nodes by their ids

Note: You can't use next() after multiple(), you'll have to use connect() to join the branches in one chain.

Each node has a label and properties:

new NodeType("unique-id", {
label: "Display Name", // Optional, defaults to node id
position: [0, 0], // Optional, when missing the node is automatically positioned
disabled: true, // Optional, defaults to false
parameters: { // Node-specific parameters
// ... type-safe parameters
},
})
Warning

This feature is experimental and may change in the future.
As a lot of n8n nodes are auto-generated they don't have output types.

The ExpressionBuilder provides type-safe n8n expressions with IDE support:

// In .next() callback, $ gives access to previous node outputs
// json will be the previous node object. You can also access properties of all past nodes in the chain.
.next(({ $ }) =>
new SomeNode("id", {
parameters: {
value: $("json.propertyName").toExpression(),
},
})
)

$ is of type [$Selector<T>], you can use it as $Selector<typeof myChain>.

$("json.user.name").toExpression()
// Results in: "={{ $json.user.name }}"

$("['Node Name'].nested.property").toExpression()
// Results in: "={{ $('Node Name').nested.property }}"

Node IDs are automatically replaced with the node label when the workflow is built:

const node = new Code("code", {
label: "Some very long label",
outputSchema: type({ type: "string" }),
parameters: {
jsCode: "...",
},
});

// and then
$("code.type").toExpression()

// Results in: "={{ $('Some very long label').item.json.type }}"

This avoids typing long names and changing references when the node label changes.

$("json.text")
.call("toUpperCase")
.call("split", " ")
.call("join", "-")
.toExpression()
// Results in: "={{ $json.text.toUpperCase().split(' ').join('-') }}"

The expression builder includes typed methods for common operations:

Array methods: find, filter, join, first, last
String methods: toLowerCase, toUpperCase, trim, split

// Example with typed array and string methods
$("data").first(d => d.element.name.toLowerCase().startsWith("a")).toExpression()
// Results in: "={{ $('data').first((d) => d.element.name.toLowerCase().startsWith("a")) }}"

These typed methods provide full IDE support and type safety, equivalent to using .call() with the same method names.

Note: Using an array method on a non-array will result in a type error (same with string methods...).

$("json.config")
.prop("['api.key']")
.toExpression()
// Results in: "={{ $json.config['api.key'] }}"

When using multiple expressions in one place, calling toExpression() can make it hard to read the code.

To avoid this, you can use the expr function:

import { expr } from "@vahor/n8n-kit";

expr`Hello ${$("json.name")}, you have ${$("json.count")} messages.`
// Results in: "=Hello {{ $json.name }}, you have {{ $json.count }} messages."

Note: You can still use string values directly instead of using the builder.

The NodejsFunction class allows you to bundle dependencies inside your Code nodes, enabling you to use npm packages and write complex logic in separate files without having to install them on your n8n instance.

A similar class exists for PythonFunction without the bundling functionality. (does not support importing modules)

import { NodejsFunction } from "@vahor/n8n-kit";
import { Code } from "@vahor/n8n-kit/nodes";

new Code("Bundle JS", {
parameters: {
language: "javaScript",
jsCode: NodejsFunction.from({
projectRoot: path.join(__dirname, "my-function"),
input: {
action: $("json.action"),
user_id: $("json.user_id"),
},
}),
},
})

Your bundled function should be in its own directory with a package.json:

my-function/
├── package.json
├── index.ts # Default entrypoint
└── package-lock.json

package.json example:

{
"name": "my-function",
"dependencies": {
"zod": "^4"
}
}

index.ts example:

import * as z from "zod/mini";

type Input = {
action: string;
user_id: string;
};

const handler = (input: Input) => {
const schema = z.object({
action: z.enum(["create", "update", "delete"]),
user_id: z.string().check(z.length(24)),
});

return schema.safeParse(input);
};
NodejsFunction.from({
projectRoot: "/path/to/function", // Required: Directory with package.json
entrypoint: "index.ts", // Optional: Default is "index.ts" or "index.js"
mainFunctionName: "handler", // Optional: Default is "handler"
installCommand: ["npm", ["ci"]], // Optional: Install command to run before bundling (default: ["npm", ["ci"]])
input: { // Optional: Parameters to pass to the function
param1: "value",
param2: $("json.field"), // Can use expression builder
},
bundlerOptions: { // Optional: tsdown bundler options
// Advanced bundling configuration
},
})

Passing input to the NodejsFunction constructor will automatically pass the input parameters to your function. Example:

NodejsFunction.from({
projectRoot: "/path/to/function",
input: {
param1: "value",
param2: $("json.field"), // Can use expression builder
},
})

will be transformed to:

return handler({
param1: "value",
param2: $json.field
});
  1. Bundle Time: Dependencies are installed with npm ci and code is bundled using tsdown
  2. Output: The returned value of your main function becomes the node's output

You need to enable strict mode in your tsconfig.json file

Modules§

credentials
credentials/generated
nodes
nodes/generated
workflow