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.
60 seconds to a node
Section titled “60 seconds to a node”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 atexport 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).
node.json fields
Section titled “node.json fields”| Field | When | Meaning |
|---|---|---|
id | recommended | Stable identity graphs reference. Survives folder renames and imports. Lowercased and cleaned to a-z, 0-9, dashes. Defaults to the directory name. |
entry | class flavor | File whose default export implements run(input, ctx). Must resolve inside the node’s own directory. |
command | command flavor | argv array — never a shell string. A relative argv[0] (./script) resolves against the node’s directory. |
label | optional | Card title. Defaults to the id. |
glyph | optional | Up to 2 characters. Defaults to ⌁. |
description | optional | Shown in the palette and Settings list. |
input / output | optional | Single-lane value type: text (default), int, float, bool, json. |
inputs / outputs | optional | Named 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). |
params | optional | Widgets on the node card — array of { name, type, ... }. |
timeoutMs | optional | Per-run timeout. Default 60000, clamped to 300000. |
env | optional | "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.
Flavor: TypeScript class
Section titled “Flavor: TypeScript class”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.
Flavor: any executable
Section titled “Flavor: any executable”{ "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.
Typed values
Section titled “Typed values”Ports carry a value type. Compatibility is checked twice:
| Type | Accepted text |
|---|---|
text | anything |
int | /^[+-]?\d+$/ |
float | any finite number |
bool | true, false, 1, 0 (case-insensitive) |
json | parses 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.
Execution
Section titled “Execution”Each run is a fresh child process. Crash away — the threadle server survives.
| Guardrail | Value |
|---|---|
| Working directory | the node’s own folder |
| Timeout | timeoutMs, default 60 000 ms, max 300 000 ms |
| Output cap | 4 MB of process output |
| Concurrent custom-node runs | 8 (further runs error rather than queue forever) |
| Environment | PATH, 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.
On the canvas
Section titled “On the canvas”- 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.
Related
Section titled “Related”- Params & named ports — widgets and multi-lane nodes
- Sharing & importing nodes — publish and install
- Nodes overview — the built-in palette
- Trust model — what a custom node can and cannot reach