Params & named ports
Custom nodes start as one text lane in, one out. Two optional descriptor arrays take them further — params puts editable widgets on the node card, inputs/outputs give it named typed handles. Both are declared in node.json; neither requires any threadle import in your code.
params: widgets on the node card
Section titled “params: widgets on the node card”"params": [ { "name": "mode", "type": "choice", "options": ["fold", "strip"], "default": "fold" }, { "name": "width", "type": "int", "label": "max width", "default": 72, "min": 10, "max": 200 }, { "name": "strict", "type": "bool", "default": true }]Each entry renders as a widget directly on the node, ComfyUI-style, and its value is stored per node in the graph — two copies of the same node can carry different settings.
| Field | Meaning |
|---|---|
name | Required. Letter first, then letters/digits/_/-. Doubles as a JSON key and env-var suffix. |
type | text (default), int, float, bool, json, or choice. |
default | Pre-filled value. May be written as a native JSON number or boolean; it is stored as a string. Validated against the declaration at load time. choice params default to options[0] when omitted. |
options | choice only, and required for it: a non-empty array of strings. |
min / max | int / float only. Numeric bounds, enforced on the stored value. |
multiline | text only, and only true: render a textarea. |
label | Widget caption. Falls back to name. |
description | Longer hint on the widget. |
Widget by type: dropdown for choice, checkbox for bool, number input for int/float, textarea for json or "multiline": true, one-line input otherwise. An out-of-range or malformed value is flagged on the card before you run.
How values reach the process
Section titled “How values reach the process”Values are always strings, even for int and bool. Before each run threadle merges stored values over the declared defaults and validates every one; a bad value aborts the node with <node>: param "<name>" ... rather than running it.
Resolved values arrive as environment variables:
| Variable | Contents |
|---|---|
THREADLE_PARAMS | One JSON object of every param, e.g. {"mode":"fold","width":"72","strict":"true"} |
THREADLE_PARAM_<NAME> | One per param. Name uppercased, dashes → underscores: keep-blank → THREADLE_PARAM_KEEP_BLANK |
THREADLE_NODE | The node’s id (always set, params or not) |
Class nodes additionally get them as the second argument to run:
async run(input: string, ctx: { params: Record<string, string> }) { const width = parseInt(ctx.params.width ?? "72", 10);}Command-flavor nodes read the env vars — $THREADLE_PARAM_WIDTH in a shell script, os.environ["THREADLE_PARAM_WIDTH"] in Python, jq over $THREADLE_PARAMS.
Named ports: more than one lane
Section titled “Named ports: more than one lane”"inputs": [ { "name": "text" }, { "name": "pattern", "required": false } ],"outputs": [ { "name": "head" }, { "name": "tail" } ]| Field | Meaning |
|---|---|
name | Required, same character rules as params, unique within its side. |
type | text (default), int, float, bool, json. |
required | Inputs only. Defaults to true; set false to allow the port to stay unwired. |
maxConnections | Max wires on this handle (integer 1–64). Default 1 — a single-wire slot. Raise it for fan-in / fan-out; the card shows ×N when N > 1. |
Declaring either array replaces the single lane on that side with one labeled handle per port. Wire different upstream nodes into different inputs and route each output to a different consumer. Declaring input and inputs (or output and outputs) together is rejected at scan time.
Port capacity
Section titled “Port capacity”Every handle has a max connection count. Extra wires are refused while you drag (same as a type mismatch).
| Kind | Default |
|---|---|
| Named custom port | 1 (override with maxConnections) |
| Legacy custom single lane | 1 in · 1 out |
| Built-ins like Delay, Text → Prompt, approval, iterator, output | 1 in · 1 out |
| Knot | unlimited in · 1 out |
| Prompt / skill / rules | no in · unlimited out |
| Agent | 1 in · unlimited out |
"inputs": [ { "name": "text" }, { "name": "parts", "maxConnections": 8 }]Here text stays a single-wire slot; parts accepts up to eight inbound wires (joined with blank lines before stdin, per port).
Validation happens at three moments:
- Wire time — each handle carries its port’s value type, so incompatible drags are refused (
textaccepts anything,intwidens tofloat). A full slot (maxConnectionsreached) is refused the same way. - Pre-run — a required input with no wire blocks the run.
- Run time — inbound text is checked against the port type before stdin is built; stdout ports are checked after.
The protocol switch
Section titled “The protocol switch”Named ports change the stdin/stdout format from plain text to JSON.
stdin becomes a JSON object keyed by input port name:
{ "text": "alpha\nbeta\ngamma", "pattern": "^a" }Optional unwired ports are simply absent. Multiple wires into one port (when maxConnections allows) are joined with a blank line first. The environment variable THREADLE_INPUTS_JSON=1 marks the switch, so a command node can branch on it. Class nodes also get the parsed object as ctx.inputs.
stdout must be a JSON object keyed by output port name:
{ "head": "alpha", "tail": "beta\ngamma" }Every declared output port must be present and type-conformant, or the node fails with output is missing port "<name>". Non-string values are carried onward as their JSON encoding. Class nodes may simply return { head, tail } — the runner serializes it.
The first declared output is the node’s primary lane value: what the run log shows and what an untyped consumer receives.
Worked example: head-tail
Section titled “Worked example: head-tail”Both features in one node — an int and a bool param, one input port, two output ports.
{ "id": "head-tail", "label": "Head · tail", "glyph": "⑂", "description": "splits input lines into the first N (head) and the rest (tail)", "entry": "node.ts", "inputs": [{ "name": "text" }], "outputs": [{ "name": "head" }, { "name": "tail" }], "params": [ { "name": "n", "type": "int", "label": "head lines", "default": 1, "min": 1 }, { "name": "keep-blank", "type": "bool", "label": "keep blanks", "default": false } ]}export default class HeadTail { async run( _raw: string, ctx: { params: Record<string, string>; inputs?: Record<string, string> }, ): Promise<{ head: string; tail: string }> { const n = Math.max(1, parseInt(ctx.params.n ?? "1", 10) || 1); let lines = (ctx.inputs?.text ?? "").split("\n"); if (!/^(true|1)$/i.test(ctx.params["keep-blank"] ?? "")) { lines = lines.filter((l) => l.trim() !== ""); } return { head: lines.slice(0, n).join("\n"), tail: lines.slice(n).join("\n") }; }}Note the shape: the raw stdin string is ignored because ctx.inputs already holds the parsed ports, params are read as strings and coerced by the node itself, and the return object fills both declared outputs. head is declared first, so it is the primary lane value.
The full node ships in the repo under examples/nodes/head-tail/, alongside shout, char-stats, extract-urls, fibonacci, sort-unique, and sum-numbers.
Related
Section titled “Related”- Custom nodes — descriptor fields, flavors, execution
- Sharing & importing nodes — install someone else’s
- Graph & wires — typed lanes on the canvas