Custom Nodes
Extend Codemation with your own nodes by pairing a config class with a node implementation class.
When the built-in nodes are not enough, define your own node.
The built-in Callback node is fine for quick, inline logic when you want to move fast. The intended way to build durable workflow behavior is still a custom node (config + implementation): that is what makes automated testing straightforward and reuse natural. If you are past a one-off experiment, prefer this page over piling logic into Callback.
The pattern is simple:
- a config class that describes how the node is used in workflow definitions
- a node implementation class that performs the runtime work
Where custom nodes can live
You do not need a separate package for every custom node.
There are two good options:
- inside your app repo when the node is specific to a single app
- inside a reusable package when you want to share or publish it
If a node only exists for one app, keeping it in the app repo is often the best choice.
For example, the sample app in this repository has app-local custom nodes under:
src/nodes/
kitchenSinkExample.ts
kitchenSinkExampleNode.tsUse a separate package when:
- the node should be reused across multiple apps
- you want a cleaner package boundary
- you plan to publish or version the node independently
Config class example
The @codemation/node-example package contains a compact example:
export class ExampleUppercase<
TInputJson extends Record<string, unknown> = Record<string, unknown>,
TField extends keyof TInputJson & string = keyof TInputJson & string,
> implements RunnableNodeConfig<TInputJson, TInputJson> {
readonly kind = "node" as const;
readonly type: TypeToken<unknown> = ExampleUppercaseNode;
constructor(
public readonly name: string,
public readonly cfg: { field: TField },
public readonly id?: string,
) {}
}The important parts are:
kind = "node"typepointing at the implementation class- constructor arguments that become workflow configuration
Node implementation example
The runtime class processes items in batches:
@node({ packageName: "@codemation/node-example" })
export class ExampleUppercaseNode implements Node<ExampleUppercase<Record<string, unknown>, string>> {
kind = "node" as const;
outputPorts = ["main"] as const;
async execute(items: Items, ctx: NodeExecutionContext<ExampleUppercase<Record<string, unknown>, string>>) {
const out: Item[] = [];
for (const item of items) {
const json = typeof item.json === "object" && item.json !== null ? (item.json as Record<string, unknown>) : {};
const value = String(json[ctx.config.cfg.field] ?? "");
out.push({ ...item, json: { ...json, [ctx.config.cfg.field]: value.toUpperCase() } });
}
return { main: out };
}
}The same pattern also works inside an app repo. A custom node does not need a package name unless you are treating it as a reusable package.
What execute should return
Downstream nodes read item.json on each output port. That value should be what this step produces, shaped like your node’s output contract—not the input payload with the real result nested inside.
- Prefer setting
jsonto the actual output DTO for that step (same idea as built-inHttpRequest: its output type is response metadata only; it does not pass arbitrary input fields through). - Avoid the recurring anti-pattern of returning
{ ...item, json: { ...inputJson, result: <actual> } }when downstream only needs<actual>. Usejson: <actual>(and only merge or spread input when the node is intentionally an enrichment that keeps the same top-level schema). - Use
keepBinaries: trueon transform helpers such asdefineNode(...)when you want plain JSON returns to inherit inbound binaries. - Return an explicit item-shaped object when you need precise control over
binary,meta, orpaired. - Pass-through (
return { main: items }) is valid for merge / route / no-op nodes, not as a default for every transform.
defineNode(...) with binary preservation
const normalizeInvoiceNode = defineNode({
key: "invoice.normalize",
title: "Normalize invoice",
keepBinaries: true,
input: {
source: "string",
},
execute({ input }, { config }) {
return {
source: config.source,
invoiceId: String(input["id"] ?? ""),
};
},
});That node returns plain JSON, but inbound binaries are still preserved because keepBinaries is enabled. If you need to replace or clear binaries, return an explicit item-shaped result instead.
Emit new files: use ctx.binary, not base64 in json
Run state persists item.json as JSON. If you put base64 or other large strings in json, that payload is stored inline in the database and grows with the file size. Binary attachments are written to binary storage; only small reference metadata stays on the item, so runs stay lean and scalable.
Inside execute, use the ctx.binary service from NodeExecutionContext (class-based nodes) or args.ctx.binary / context.execution.binary with defineNode:
await ctx.binary.attach({ name, body, mimeType, filename? })— uploads bytes.bodymay beUint8Array,ArrayBuffer, aReadableStream, or an async iterable ofUint8Arraychunks (same idea asHttpRequestNodestreaming a download).ctx.binary.withAttachment(baseItem, name, attachment)— returns an item whosebinary[name]points at storage.
Minimal sketch:
async execute(items, ctx) {
const out: Item[] = [];
for (const item of items) {
const bytes = ...; // from API, disk, decode only if unavoidable
const attachment = await ctx.binary.attach({
name: "report",
body: bytes,
mimeType: "application/pdf",
filename: "report.pdf",
});
const next: Item = {
json: { ok: true, reportBinaryName: "report" },
};
out.push(ctx.binary.withAttachment(next, "report", attachment));
}
return { main: out };
}Put identifiers, content types, and filenames in json if downstream needs them; put raw bytes through attach. Only reserve base64 in json for tiny payloads (for example a short data-URL preview you accept as a product tradeoff—not bulk files).
Params belong to the node, mapping belongs to the workflow
For reusable helper-defined nodes, treat config as the node's parameter surface:
- the node should read resolved config values plus the current
item - the workflow should map upstream data into those params with literals or
itemExpr(...)
That keeps the node reusable across workflows because it does not need to know the exact item.json shape produced by surrounding steps.
Reach for inputSchema only when the node intentionally depends on a specific wire payload shape and should fail fast when the current item does not match that contract.
Trigger item shape
Trigger nodes should follow the same contract, with one extra rule:
- Emit one workflow item per external event or record
- one email should become one
Item - one webhook request should become one
Item - one queue message should become one
Item
The batch is already the array. Because of that, avoid wrapper payloads such as:
json: { results: [...] }json: { foundItems: [...] }json: { items: [...] }
when those arrays really mean “multiple emitted events”.
Instead, map each source record to its own workflow item so downstream nodes always consume a consistent shape:
return {
main: sourceMessages.map((message) => ({
json: {
messageId: message.id,
subject: message.subject,
},
})),
};That keeps the engine contract predictable:
Itemsis the collection- each
item.jsonis one emitted domain record - downstream nodes do not need trigger-specific unwrapping logic
What to remember
- Nodes receive batches of items, not single records
- Keep constructor setup cheap and side-effect free
- Put external calls inside
execute(...) - Prefer explicit configuration over hidden globals
- Return outputs by named ports such as
main - On each port,
jsonis the emitted payload for the next step—produce it deliberately, not as “input plus wrapper”
App-local node vs shared package
If you keep the node in the app repo, a structure like this works well:
src/
nodes/
MyCustomNode.ts
MyCustomNodeConfig.tsFor anything reusable, prefer a separate package such as:
packages/node-my-feature/
src/
MyNodeConfig.ts
MyNode.tsThat gives you a clean path to share nodes across projects.
Register app-local nodes
If the node lives inside your app repo, register the runtime class in codemation.config.ts.
import type { CodemationAppContext, CodemationConfig } from "@codemation/host";
export default {
register(app: CodemationAppContext) {
app.registerNode(MyCustomNode);
app.discoverWorkflows("src/workflows");
},
} satisfies CodemationConfig;Without that registration step, the host knows about the config class in your workflow code but does not know how to construct the runtime implementation.
If the node lives in a plugin package, do the same registration from definePlugin({ register(context) { ... } }).
Use a custom node in a workflow
Once the package is installed and available, use it like any other node:
new ExampleUppercase("Uppercase subject", { field: "subject" });For an app-local node, the usage looks the same from the workflow author’s point of view:
new MyCustomNodeConfig("Enrich invoice", { customerIdField: "customerId" });Good first custom node ideas
- domain-specific data normalization
- API wrappers with opinionated defaults
- CRM or ERP helpers
- prompt-preparation steps
- validation or enrichment nodes
Custom nodes as AI tools
If you already have a good runnable node, you can also expose it to AIAgent as a node-backed tool instead of writing a second custom Tool adapter.
That is usually the right choice when:
- the node already has the correct DI wiring
- the node already declares credential requirements
- the agent should reuse the same business capability a normal workflow can call directly
See Plugin developers for the node-backed tool pattern and AgentToolFactory.asTool(...).