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 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
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(...).