Skip to content
Back to skills

Nodetool Custom Node Developer

ASecurity

Create or update TypeScript NodeTool nodes, decorated properties, process methods, and node packages.

  • 552 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 1, 2026
ai-agentstypescriptrustgobashnodetestingapi

Works with

  • api

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned September 12, 2026

npx -y skills add nodetool-ai/nodetool --skill nodetool-custom-node-developer --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Nodetool Custom Node Developer?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Nodetool Custom Node Developer
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/nodetool-ai-nodetool-custom-node-developer/badge)](https://www.skillsdirectory.com/skills/nodetool-ai-nodetool-custom-node-developer)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: nodetool-custom-node-developer
description: "Create or update TypeScript NodeTool nodes, decorated properties, process methods, and node packages."
---

You are a NodeTool node developer. You create TypeScript nodes that extend `BaseNode` from `@nodetool-ai/node-sdk`.

# Package Layout

```
my-nodes/
├── package.json          # depends on @nodetool-ai/node-sdk
├── tsconfig.json         # extends ../../tsconfig.base.json
├── src/
│   ├── index.ts          # exports registration function + ALL_NODES array
│   └── nodes/
│       ├── category-a.ts # node classes
│       └── category-b.ts
└── tests/
    └── nodes.test.ts
```

# Node Template

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

export class MyNode extends BaseNode {
  // REQUIRED static fields
  static readonly nodeType = "mypack.category.NodeName"; // namespace.category.Name
  static readonly title = "Human-Readable Title";
  static readonly description = "Brief description.\n    search, keywords, here";
  static readonly metadataOutputTypes = { output: "str" }; // declare all outputs

  // Inputs via @prop decorator
  @prop({ type: "str", default: "", title: "Input Text" })
  declare input_text: any;

  @prop({ type: "int", default: 10, min: 1, max: 100, title: "Count" })
  declare count: any;

  // Process method — MUST return all keys from metadataOutputTypes
  async process(): Promise<Record<string, unknown>> {
    const text = String(this.input_text ?? "");
    const count = Number(this.count ?? 10);
    return { output: text.repeat(count) };
  }
}
```

# Input Resolution Pattern

Property values **and** connected input values are assigned to instance fields
*before* `process()` is called, so always read inputs from `this`:
```typescript
const value = this.field ?? defaultValue;
```
- `this.field` — resolved value (connection value if connected, else the
  user-set property, else the `@prop` default)
- `defaultValue` — extra in-code fallback for `null`/`undefined`

Do **not** declare a `process(inputs: Record<string, unknown>)` parameter — the
runtime calls `process(context?: ProcessingContext)`, so an `inputs` parameter
typed that way fails strict type-checking.

# @prop Decorator Options

| Option | Type | Notes |
|--------|------|-------|
| `type` | string | **Required**: `"int"`, `"float"`, `"str"`, `"bool"`, `"list[any]"`, `"image"`, `"audio"`, `"video"`, `"document"`, `"dataframe"`, `"enum"`, `"any"` |
| `default` | unknown | Default value |
| `title` | string | Display name in UI |
| `description` | string | Tooltip text |
| `min` / `max` | number | Numeric bounds |
| `required` | boolean | Validation flag |
| `values` | (string\|number)[] | Enum options |

# Patterns

## Multi-Output Node
```typescript
static readonly metadataOutputTypes = { text: "str", confidence: "float" };

async process(): Promise<Record<string, unknown>> {
  return { text: "result", confidence: 0.95 }; // ALL keys must be returned
}
```

## Streaming Node (genProcess)
```typescript
static readonly isStreamingOutput = true;
static readonly metadataOutputTypes = { output: "str" };

async *genProcess(): AsyncGenerator<Record<string, unknown>> {
  for (const item of items) {
    yield { output: item };
  }
}
```

## Stateful Collector
```typescript
static readonly syncMode = "on_any"; // fires on each incoming value

@prop({ type: "any", title: "Item" })
declare item: unknown;

private collected: unknown[] = [];

async initialize(): Promise<void> {
  this.collected = []; // reset per run
}

async process(): Promise<Record<string, unknown>> {
  this.collected.push(this.item);
  return { output: this.collected };
}
```

## Enum Input
```typescript
@prop({ type: "enum", values: ["low", "medium", "high"], default: "medium", title: "Quality" })
declare quality: any;
```

## Secrets / API Keys
```typescript
static readonly requiredSettings = ["MY_API_KEY"];

async process(): Promise<Record<string, unknown>> {
  // Resolved secrets are available via the `_secrets` getter.
  const apiKey = this._secrets.MY_API_KEY || process.env.MY_API_KEY;
  if (!apiKey) throw new Error("MY_API_KEY not configured");
  // use apiKey...
}
```

## Media Refs (Image/Audio/Video)
```typescript
@prop({ type: "image", title: "Input Image" })
declare image: { uri?: string; data?: string } | undefined;

async process(): Promise<Record<string, unknown>> {
  const img = this.image;
  if (!img) throw new Error("No image provided");
  // img.data is base64, img.uri is a URL
  const resultData = await processImage(img);
  return { output: { type: "image", data: resultData } }; // return base64
}
```

## Lifecycle Hooks
```typescript
async initialize(): Promise<void> { }   // once at start of run
async preProcess(): Promise<void> { }   // before each process() call
async finalize(): Promise<void> { }     // after all processing
```

# Optional Static Properties

```typescript
static readonly isDynamic = true;            // dynamic schema
static readonly supportsDynamicOutputs = true;
static readonly isStreamingInput = true;
static readonly basicFields = ["prompt", "model"]; // shown first in UI
```

# Scaffold a Pack

The fastest start is the scaffolder, which generates a self-contained pack
(package.json with the `nodetool` manifest field, tsconfig, an example node, a
test, and a README):

```bash
npm run create:pack -- @myorg/my-nodes        # in the nodetool repo
# or directly: node scripts/create-pack.mjs @myorg/my-nodes ./my-nodes
```

# Registration

```typescript
// src/index.ts
import type { NodeClass, NodeRegistry } from "@nodetool-ai/node-sdk";
import { MyNode, OtherNode } from "./nodes/my-nodes.js";

export const ALL_NODES: readonly NodeClass[] = [MyNode, OtherNode];

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

The server auto-loads packs — no need to edit its source. Mark the package as a
pack with a `nodetool` field in `package.json`, naming the export above:

```jsonc
{
  "name": "@myorg/my-nodes",
  "main": "dist/index.js",
  "nodetool": { "apiVersion": 1, "register": "register" }
}
```

On startup the server scans installed dependencies, imports any package with a
`nodetool` field, and calls the named export with the registry. Install the
built pack where the server can resolve it (`npm install <pack>`, or `npm link`
for local dev) and restart. The export may be sync or `async`.

**Trust model.** Custom nodes run in-process as the server user (full
filesystem/network/secret access, no sandbox), so loading is gated. In
development unlisted packs load automatically; in production
(`NODETOOL_ENV=production`) only packs on the allowlist
(`NODETOOL_PACKS_ALLOWLIST` or `~/.config/nodetool/packs.json`) load. Packs also
cannot register under reserved namespaces (`nodetool.`, `lib.`, provider names)
or shadow an existing node type. Only install packs you trust.

# Testing

```typescript
import { describe, it, expect } from "vitest";
import { MyNode } from "../src/nodes/my-nodes.js";

describe("MyNode", () => {
  it("processes input", async () => {
    // Constructor assigns properties to instance fields.
    const node = new MyNode({ input_text: "hello", count: 3 });
    const result = await node.process();
    expect(result.output).toBe("hellohellohello");
  });
});
```

# Common Pitfalls

- **Forgetting output keys**: Every key in `metadataOutputTypes` must appear in the return object
- **Wrong input access**: Read inputs from `this.field` (assigned before `process()`); do not add an `inputs` parameter to `process()`
- **nodeType format**: Must be `namespace.category.Name` with dots as separators
- **Mutable state without initialize()**: If using instance fields, reset them in `initialize()`
- **Missing secrets declaration**: Add to `requiredSettings` array for proper UI prompting

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.