This is the comprehensive guide to writing TypeScript custom nodes for NodeTool — the in-process counterpart to the Python node guides (Node Examples, Node Patterns, Node Reference).

A custom node lives in a standalone npm package — a pack — that the server discovers and loads at startup. There is nothing to register by hand: drop a nodetool field in package.json, install the package, and the loader does the rest.

Related: Node Packs (user-facing intro), Package Registry Guide (first-party package conventions), TypeScript DSL Guide (using nodes in code-defined workflows).

Contents

  1. Quick start
  2. Package layout
  3. package.json and the pack manifest
  4. Trust model and governance
  5. tsconfig.json
  6. Anatomy of a node
  7. The @prop decorator reference
  8. The type system
  9. Declaring outputs
  10. ProcessingContext — the runtime surface
  11. Streaming nodes (genProcess)
  12. Lifecycle hooks
  13. Registering nodes
  14. Building the pack
  15. Testing nodes
  16. Installing and running the pack
  17. Versioning the pack API
  18. Common pitfalls

1. Quick start

Scaffold a pack, write one node, install it, run the server:

mkdir nodetool-mypack && cd nodetool-mypack
npm init -y
npm install --save @nodetool-ai/node-sdk
npm install --save-dev typescript @types/node vitest

src/nodes/reverse.ts:

import { BaseNode, prop } from "@nodetool-ai/node-sdk";

export class ReverseTextNode extends BaseNode {
  static readonly nodeType = "mypack.text.Reverse";
  static readonly title = "Reverse Text";
  static readonly description = "Reverse a string character by character.";
  static readonly metadataOutputTypes = { output: "str" };

  @prop({ type: "str", default: "", title: "Text" })
  declare text: string;

  async process(): Promise<Record<string, unknown>> {
    return { output: [...(this.text ?? "")].reverse().join("") };
  }
}

src/index.ts:

import type { NodeRegistry } from "@nodetool-ai/node-sdk";
import { ReverseTextNode } from "./nodes/reverse.js";

export function register(registry: NodeRegistry): void {
  registry.register(ReverseTextNode);
}

Add a nodetool field to package.json (see §3), build with tsc, then npm link (or npm install) into your NodeTool workspace and restart the server. The node appears in the menu as Reverse Text under mypack.text.


2. Package layout

A pack is a normal npm package. Keep one node (or a small group) per file:

nodetool-mypack/
├── package.json
├── tsconfig.json
├── README.md
├── src/
│   ├── index.ts                  # entry — exports `register(registry)`
│   └── nodes/
│       ├── reverse.ts
│       └── math.ts
└── tests/
    └── reverse.test.ts

Keep the public surface in src/index.ts minimal: re-export node classes and the register function. Everything else stays internal.


3. package.json and the pack manifest

{
  "name": "@acme/cool-nodes",
  "version": "0.1.0",
  "description": "Cool custom nodes for NodeTool",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types": "./dist/index.d.ts"
    }
  },
  "scripts": {
    "build": "tsc",
    "test": "vitest run",
    "lint": "tsc --noEmit"
  },
  "dependencies": {
    "@nodetool-ai/node-sdk": "latest"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "typescript": "^5.4.0",
    "vitest": "^1.6.1"
  },
  "nodetool": {
    "apiVersion": 1,
    "register": "register"
  }
}

The nodetool field is what makes the package a pack. At startup the loader scans installed packages, picks up any with this field, imports the resolved entry, and calls the named export with the registry.

Field Default Meaning
apiVersion 1 Pack API version you built against. Packs declaring a version newer than the host supports are skipped with a warning.
register "register" Named export the loader calls with the registry. Can be async.

The loader uses the "." entry of exports (the import condition, falling back to default), or main, or index.js — in that order.


4. Trust model and governance

Custom nodes run in the server process as the server user. They have full filesystem, network, secret-store, and process.env access, with no sandbox. A pack is exactly as trusted as any dependency you npm install. Only install packs you trust.

To avoid silently running whatever happens to be in node_modules in production, the loader is gated:

  • Allowlist — a list of trusted pack names; "*" allows everything. Set via:
    • The env var NODETOOL_PACKS_ALLOWLIST (comma-separated names), or
    • The allow field of ~/.config/nodetool/packs.json (path overridable with the NODETOOL_PACKS_CONFIG env var).
  • allowUnlisted — whether packs not on the allowlist load anyway. Defaults to true in development (so installing a pack just works) and false in production (NODETOOL_ENV=production). Override via the config file.

Two further guards protect the registry regardless of trust:

  • Reserved namespaces — packs cannot register node types under first-party namespaces (nodetool., lib., provider names like openai., replicate., …). Such nodes are skipped with a warning.
  • Collision protection — a pack cannot shadow an already-registered node type (a built-in, or a node registered earlier by another pack). The conflicting node is skipped with a warning; the original wins.

Example allowlist file:

{
  "allow": ["@acme/cool-nodes", "@other-org/audio-pack"],
  "allowUnlisted": false
}

5. tsconfig.json

The SDK uses legacy (experimental) decorators without runtime metadata emission. Match those flags or builds will produce unusable output:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "sourceMap": true,
    "outDir": "dist",
    "rootDir": "src",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": false
  },
  "include": ["src"]
}

Do not enable "useDefineForClassFields" or "emitDecoratorMetadata" — they conflict with the SDK’s decorator protocol. Property declarations must use declare (see §6).


6. Anatomy of a node

Every node extends BaseNode and declares its inputs with @prop. The runtime assigns properties on the instance before calling process() — your method reads inputs from this.<field>, not from a parameter.

import {
  BaseNode,
  prop,
  type ProcessingContext
} from "@nodetool-ai/node-sdk";

export class AddOffsetNode extends BaseNode {
  // ── Identity ────────────────────────────────────────────────
  static readonly nodeType = "mypack.math.AddOffset";
  static readonly title = "Add Offset";
  static readonly description = "Add a constant offset to a number.";

  // ── Output type ────────────────────────────────────────────
  static readonly metadataOutputTypes = { output: "float" };

  // ── Inputs ─────────────────────────────────────────────────
  @prop({ type: "float", default: 0.0, title: "Value" })
  declare value: number;

  @prop({ type: "float", default: 1.0, title: "Offset" })
  declare offset: number;

  // ── Execution ──────────────────────────────────────────────
  async process(_context?: ProcessingContext): Promise<Record<string, unknown>> {
    return { output: (this.value ?? 0) + (this.offset ?? 1) };
  }
}

Required static members

Member Type Purpose
nodeType string Unique dotted identifier. By convention: <namespace>.<category>.<Name>.
title string Display name in the node menu.
description string One-line tooltip.

Common optional static members

Member Type Purpose
metadataOutputTypes { [name]: typeString } Maps output handle name → NodeTool type string. Required if outputs are anything other than the default output: any.
isStreamingOutput boolean Marks the node as producing a stream — see §11.
isStreamingInput boolean Marks the node as consuming a stream via the run(...) hook.
isDynamic boolean Allows users to add/remove handles in the UI; pair with setDynamic / getDynamic.
exposeAsTool boolean Surface the node to agents as a callable tool.
outputCorrelation { [output]: OutputCorrelation } Controls per-iteration / per-chunk fan-out semantics.

The process method

abstract process(context?: ProcessingContext): Promise<Record<string, unknown>>
  • Returns an object whose keys are output handle names declared in metadataOutputTypes.
  • Reads inputs from this.<field>. The runtime has already populated them via assign().
  • The context parameter is optional. Many pure-compute nodes ignore it. Anything that touches secrets, storage, HTTP, or providers will use it — see §10.
  • Throwing an Error fails the node and surfaces the message to the UI. Throw Error objects, not strings.

Do not add an inputs parameter — that pattern is from an older draft of this guide and does not match the runtime contract. Property assignment happens before process() runs.


7. The @prop decorator reference

@prop is the only decorator pack authors call directly. Outputs are declared via static class members, not decorators.

@prop(options: PropOptions)
Option Type Purpose
type string (required) NodeTool type string — see §8.
default unknown Default value when no upstream input is connected.
title string Display name in the UI. Defaults to the field name.
description string Tooltip text.
min / max number Bounds for numeric types — used by sliders and validation.
required boolean Whether the input must be connected or set.
values (string \| number)[] Allowed values; renders as a dropdown / enum selector.
json_schema_extra Record<string, unknown> Custom UI metadata (renderer hints, layout, etc.).

Always pair @prop with a declare field — the runtime owns assignment, so emitted initializers would just be overwritten.

@prop({ type: "str", default: "", title: "Prompt", description: "What to generate" })
declare prompt: string;

@prop({ type: "float", default: 0.7, min: 0, max: 2, title: "Temperature" })
declare temperature: number;

@prop({ type: "str", default: "auto", values: ["auto", "fast", "best"], title: "Mode" })
declare mode: string;

8. The type system

The type string in @prop and the values in metadataOutputTypes come from the same vocabulary — the one shared with Python nodes.

Scalars

String TS type
"str" string
"int" number
"float" number
"bool" boolean
"json" unknown
"any" unknown

Collections

String TS type
"list[T]" T[] (e.g. list[str], list[any])
"dict[str, any]" Record<string, unknown>
"dict[str, str]" Record<string, string>

Media references

Media flows through the graph as small reference objects, not as raw bytes. Import the types from @nodetool-ai/node-sdk:

import type {
  ImageRef,
  AudioRef,
  VideoRef,
  TextRef,
  DocumentRef,
  DataframeRef
} from "@nodetool-ai/node-sdk";
String TS type Use for
"image" ImageRef Images (URI or inline bytes)
"audio" AudioRef Audio clips
"video" VideoRef Video files
"document" DocumentRef PDFs, Word, plain-text files
"text" TextRef Large text blobs by reference
"dataframe" DataframeRef Tabular data
"model_3d" Model3DRef 3D meshes / glTF

Model selectors

These render as model pickers in the UI:

String Meaning
"language_model" An LLM (provider + model id pair).
"image_model" An image-generation model.
"video_model" A video-generation model.
"tts_model" A text-to-speech model.
"asr_model" A speech-recognition model.
"embedding_model" An embedding model.

Domain types

"date", "datetime", "image_size", "enum" — render with specialised inputs.


9. Declaring outputs

Single output, default name output:

static readonly metadataOutputTypes = { output: "str" };

async process(): Promise<Record<string, unknown>> {
  return { output: "hello" };
}

Multiple outputs:

static readonly metadataOutputTypes = {
  text: "str",
  tokenCount: "int"
};

async process(): Promise<Record<string, unknown>> {
  const text = this.input ?? "";
  return { text, tokenCount: text.split(/\s+/).length };
}

Output keys returned by process() must match keys in metadataOutputTypes — extra keys are dropped, missing keys produce undefined on the wire.


10. ProcessingContext — the runtime surface

The context passed to process() and genProcess() is your gateway to everything the runtime owns: secrets, storage, cache, HTTP, providers, messages. You only need it when a node has side effects.

import type { ProcessingContext } from "@nodetool-ai/node-sdk";

Identity

context.jobId;        // string — unique per workflow run
context.workflowId;   // string | null
context.userId;       // string
context.workspaceDir; // string | null — per-job scratch dir

Secrets

const apiKey = await context.getSecret("OPENAI_API_KEY");        // string | null
const required = await context.getSecretRequired("STRIPE_KEY");  // throws if missing

Always prefer getSecret over process.env — secrets are user-scoped and may come from an encrypted store, not the process environment.

HTTP helpers

const resp = await context.httpGet("https://api.example.com/data", {
  headers: { Authorization: `Bearer ${apiKey}` }
});
await context.httpPost(url, { json: { foo: 1 } });
// Also: httpPut, httpPatch, httpDelete, httpHead

Cache

const cached = await context.getCachedResult(this.nodeType, this.serialize());
if (cached) return cached;
const result = await expensive();
await context.cacheResult(this.nodeType, this.serialize(), result, 3600);
return result;

Storage and workspace files

const uri = await context.storage.store("output.png", bytes, "image/png");
const data = await context.storage.retrieve(uri);

await context.workspaceStorage.store("notes.txt", "hello", "text/plain");

LLM providers

if (await context.isProviderConfigured("openai")) {
  const provider = await context.getProvider("openai");
  const stream = provider.streamChat({ messages, model: "gpt-5.4-mini" });
}

Variables (per-job key/value scratch)

context.set("counter", 0);
const n = context.get<number>("counter", 0);

Messages

context.emit(msg) posts a ProcessingMessage (log line, status update, tool call, …) onto the run’s event stream. The UI uses these to surface progress.


11. Streaming nodes (genProcess)

For nodes that produce results incrementally, override genProcess and set isStreamingOutput:

import { BaseNode, prop, type ProcessingContext } from "@nodetool-ai/node-sdk";

export class WordStreamNode extends BaseNode {
  static readonly nodeType = "mypack.text.WordStream";
  static readonly title = "Word Stream";
  static readonly description = "Emit each word of the input as a separate event.";
  static readonly metadataOutputTypes = { word: "str" };
  static readonly isStreamingOutput = true;

  @prop({ type: "str", default: "", title: "Text" })
  declare text: string;

  async process(): Promise<Record<string, unknown>> {
    // Fallback for non-streaming consumers.
    return { word: this.text };
  }

  async *genProcess(
    _context?: ProcessingContext
  ): AsyncGenerator<Record<string, unknown>> {
    for (const word of String(this.text ?? "").split(/\s+/)) {
      if (word) yield { word };
    }
  }
}

Output correlation

When a streaming node mixes per-item outputs with aggregate ones, declare outputCorrelation so the runtime knows how to fan downstream nodes:

static readonly outputCorrelation = {
  word:  { kind: "iteration", source: "__execution__", group: "items" },
  count: { kind: "single",    source: "__execution__" }
};

kind: "iteration" means each yielded value advances downstream consumers; kind: "single" means the value is the run’s final aggregate. See OutputCorrelation in @nodetool-ai/node-sdk for the full vocabulary.

Default behaviour

If you don’t override genProcess, the base implementation yields the single result of process(). So a non-streaming node needs nothing extra; only streaming nodes override genProcess.


12. Lifecycle hooks

Override these on your class to run setup and teardown:

async initialize(): Promise<void> { /* once when the node enters the run */ }
async preProcess(): Promise<void>  { /* before every process()/genProcess() */ }
async finalize(): Promise<void>    { /* once when the node leaves the run */ }

Use initialize for expensive one-time setup (e.g. opening a connection) and finalize to release it. preProcess is called per execution and is rarely needed.


13. Registering nodes

Your pack’s register function — the export named in the manifest — receives the registry and adds each node class:

import type { NodeRegistry } from "@nodetool-ai/node-sdk";
import { AddOffsetNode } from "./nodes/math.js";
import { ReverseTextNode } from "./nodes/reverse.js";
import { WordStreamNode } from "./nodes/stream.js";

const ALL_NODES = [AddOffsetNode, ReverseTextNode, WordStreamNode] as const;

export function register(registry: NodeRegistry): void {
  for (const cls of ALL_NODES) {
    registry.register(cls);
  }
}

Things the loader will refuse — silently dropping the offending node and warning in the log:

  • A nodeType under a reserved namespace.
  • A nodeType already registered by a built-in or an earlier pack (no shadowing).

The register function may be async — useful if you build node classes from a manifest at load time.

What NodeRegistry exposes

Pack authors only need:

Method Purpose
register(nodeClass, opts?) Add a node class.
has(nodeType) Already registered?
list() All registered node types.
getClass(nodeType) The class for a node type.
listMetadata() UI metadata for every registered node.

Everything else on the registry is for the runtime.


14. Building the pack

npm run build       # tsc → dist/
npm run lint        # tsc --noEmit
npm run test        # vitest run

Ship the dist/ directory plus package.json and a README.md. Don’t ship src/ — it won’t be loaded.

"files": ["dist", "README.md", "LICENSE"]

15. Testing nodes

Use Vitest (same harness the SDK uses internally). The pattern for an end-to-end test:

import { describe, it, expect } from "vitest";
import { NodeRegistry } from "@nodetool-ai/node-sdk";
import { register } from "../src/index.js";
import { ReverseTextNode } from "../src/nodes/reverse.js";

describe("ReverseTextNode", () => {
  it("registers under its nodeType", () => {
    const registry = new NodeRegistry();
    register(registry);
    expect(registry.has(ReverseTextNode.nodeType)).toBe(true);
  });

  it("reverses a string", async () => {
    const node = new ReverseTextNode({ text: "hello" });
    const result = await node.process();
    expect(result).toEqual({ output: "olleh" });
  });
});

For nodes that need a ProcessingContext, build a minimal one with the constructor options shown in §10 or use a test double that stubs only the methods your node calls.

Testing streaming nodes

it("yields one event per word", async () => {
  const node = new WordStreamNode({ text: "foo bar baz" });
  const chunks: unknown[] = [];
  for await (const chunk of node.genProcess()) chunks.push(chunk);
  expect(chunks).toEqual([{ word: "foo" }, { word: "bar" }, { word: "baz" }]);
});

16. Installing and running the pack

For a published pack:

cd /path/to/nodetool
npm install @acme/cool-nodes
# in production, allowlist it:
echo '{ "allow": ["@acme/cool-nodes"] }' > ~/.config/nodetool/packs.json
npm run dev:server

For local development, npm link the pack so edits land without re-publishing:

cd nodetool-mypack && npm run build && npm link
cd /path/to/nodetool && npm link @acme/cool-nodes
npm run dev:server

The server logs which packs were discovered:

Loaded node pack @acme/cool-nodes@0.1.0 (3 node(s))
Skipped node pack @other/blocked@1.0.0: not on pack allowlist
Pack @evil/shadowy: skipped node nodetool.text.Override (reserved-namespace)

17. Versioning the pack API

The pack API is versioned with a single integer (apiVersion, currently 1). Forward compatibility:

  • If your pack declares apiVersion: 2 and runs on a host that only knows 1, the host skips it cleanly rather than crashing.
  • If the host knows 2 and your pack still declares 1, everything keeps working — the host is responsible for backward compatibility.

When the host bumps the version, the changelog will name exactly what changed and what (if anything) authors need to migrate.


18. Common pitfalls

Property declarations without declare. TypeScript will emit initializers that overwrite the values the runtime just assigned. Always:

@prop({ type: "str", default: "" })
declare text: string;     // good

@prop({ type: "str", default: "" })
text: string = "";        // bad — runs after assign()

ESM .js extensions in imports. With module: "Node16", internal imports must use the .js extension (matching the compiled output), even from .ts source:

import { ReverseTextNode } from "./nodes/reverse.js";   // good
import { ReverseTextNode } from "./nodes/reverse";      // bad

emitDecoratorMetadata. Leave this false. The SDK does not consume runtime metadata and enabling it can produce conflicting decorator output.

Loading from src/ instead of dist/. The loader resolves the package’s exports/main, which points at dist/. Forgetting to build before installing means the pack loads stale code (or nothing).

Reserved-namespace nodeTypes. A nodeType of nodetool.foo.MyNode will be silently rejected. Use your own namespace (mypack.foo.MyNode, @acme.foo.MyNode, …).

Collision with built-ins. If a built-in already owns the nodeType you picked, your node is dropped. Pick a unique name or run nodetool workflows list --json and grep to be sure.

Throwing strings instead of Error. The runtime wraps errors and surfaces .message; a thrown string becomes undefined in the log.

Long-running process blocking the event loop. If your work is CPU-bound, do it in a Worker or via code-runners. The server is single-threaded.

Reading from process.env for user-scoped secrets. Use context.getSecret(key) — it consults the user’s secret store, which may be encrypted and is not the process environment.