Skip to content

Custom nodes

A custom node is a directory under ~/.config/threadle/nodes/:

my-node/
├── node.json ← identity + metadata (the descriptor)
└── node.ts ← the logic (a default-exported class)

The contract is text in on stdin, text out on stdout. That is the whole API until you opt into params and named ports.

Nothing is compiled into threadle. Nodes are discovered by scanning the directory, so adding one is creating a folder.


Settings → threadle internals → custom nodes → + Create node. Type a name, press the button. threadle writes node.ts and node.json and opens the file in your editor.

// node.json — the descriptor
{
"id": "shout",
"label": "Shout",
"glyph": "!",
"description": "uppercases and adds enthusiasm",
"entry": "node.ts",
"input": "text",
"output": "text",
"timeoutMs": 60000
}
// node.ts — the file "entry" points at
export default class Shout {
async run(input: string): Promise<string> {
return input.toUpperCase() + "!!!";
}
}

Reload threadle. The node shows up in the palette’s nodes tab under custom, in the canvas right-click menu, and in the wire-drop menu. Wire prompt → Shout → output and press ▶ Run.

No imports, no build step, no dependency on threadle. Node’s native type stripping loads the .ts directly (threadle passes --experimental-strip-types when running on Node < 23).


FieldWhenMeaning
idrecommendedStable identity graphs reference. Survives folder renames and imports. Lowercased and cleaned to a-z, 0-9, dashes. Defaults to the directory name.
entryclass flavorFile whose default export implements run(input, ctx). Must resolve inside the node’s own directory.
commandcommand flavorargv array — never a shell string. A relative argv[0] (./script) resolves against the node’s directory.
labeloptionalCard title. Defaults to the id.
glyphoptionalUp to 2 characters. Defaults to .
descriptionoptionalShown in the palette and Settings list.
input / outputoptionalSingle-lane value type: text (default), int, float, bool, json.
inputs / outputsoptionalNamed ports — arrays of { name, type?, required?, maxConnections? }. Supersede the scalar input/output; declaring both forms on the same side is an error. Each named port defaults to one wire (maxConnections: 1).
paramsoptionalWidgets on the node card — array of { name, type, ... }.
timeoutMsoptionalPer-run timeout. Default 60000, clamped to 300000.
envoptional"inherit" opts the process into the full parent environment. See Trust.

Exactly one of entry or command picks the flavor. With neither, threadle looks for a bare node.ts, node.mts, node.js, or node.mjs in the directory.

A directory with no node.json at all still works: the class file is found by name, and metadata is read off the instance’s fields (label, glyph, description, input, output, timeoutMs, inputs, outputs, params) by a sandboxed child process. The directory name becomes the id.


entry points at a module whose default export is a class with a run method (a plain object with run also works).

export default class Slugify {
label = "Slugify"; // only read when there is no node.json
async run(input: string): Promise<string> {
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
}

Returning a non-string serializes to JSON; returning null/undefined yields empty output. Throwing fails the node with the stack’s first line as the error.

Need npm packages? Run npm install inside the node’s folder — the working directory is pinned there, so imports resolve against its own node_modules.

{
"id": "uppercase",
"label": "UPPERCASE",
"glyph": "⇧",
"description": "shouts the incoming text",
"command": ["tr", "a-z", "A-Z"]
}

Input arrives on stdin, output leaves on stdout. jq, a Python script, a compiled binary — anything that reads a pipe. command is always an argv array, so there is no shell and no interpolation.


Ports carry a value type. Compatibility is checked twice:

TypeAccepted text
textanything
int/^[+-]?\d+$/
floatany finite number
booltrue, false, 1, 0 (case-insensitive)
jsonparses with JSON.parse

At wire time — a connection is refused while you drag unless the types are compatible. text accepts everything; int widens to float; otherwise the types must match.

At run time — inbound text is re-validated against the declared input, and stdout against the declared output. A node that claims int output but prints prose fails loudly instead of poisoning downstream nodes.


Each run is a fresh child process. Crash away — the threadle server survives.

GuardrailValue
Working directorythe node’s own folder
TimeouttimeoutMs, default 60 000 ms, max 300 000 ms
Output cap4 MB of process output
Concurrent custom-node runs8 (further runs error rather than queue forever)
EnvironmentPATH, HOME, LANG, LC_ALL, TMPDIR, TERM, SHELL, plus THREADLE_NODE — nothing else unless "env": "inherit"

stderr is surfaced in the run log even on success. A non-zero exit fails the node with the stderr excerpt as the error; a timeout reports timed out after <n>ms.


  • Custom nodes live on the text lane: they take input from prompts, converters, agents, outputs, gates and iterators, and feed all of those — including other custom nodes chained back to back.
  • Multiple wires into one input are joined with a blank line before hitting stdin.
  • They respect mute (skipped entirely), bypass (inbound text passed through unexecuted), pre-run validation, and partial execution (“run from this node”).
  • Broken descriptors do not disappear silently — Settings lists them with the parse error (duplicate id, missing entry file, bad port type, …) next to a button that opens the folder in your editor.