Skip to content

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": [
{ "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.

FieldMeaning
nameRequired. Letter first, then letters/digits/_/-. Doubles as a JSON key and env-var suffix.
typetext (default), int, float, bool, json, or choice.
defaultPre-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.
optionschoice only, and required for it: a non-empty array of strings.
min / maxint / float only. Numeric bounds, enforced on the stored value.
multilinetext only, and only true: render a textarea.
labelWidget caption. Falls back to name.
descriptionLonger 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.

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:

VariableContents
THREADLE_PARAMSOne JSON object of every param, e.g. {"mode":"fold","width":"72","strict":"true"}
THREADLE_PARAM_<NAME>One per param. Name uppercased, dashes → underscores: keep-blankTHREADLE_PARAM_KEEP_BLANK
THREADLE_NODEThe 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.


"inputs": [ { "name": "text" }, { "name": "pattern", "required": false } ],
"outputs": [ { "name": "head" }, { "name": "tail" } ]
FieldMeaning
nameRequired, same character rules as params, unique within its side.
typetext (default), int, float, bool, json.
requiredInputs only. Defaults to true; set false to allow the port to stay unwired.
maxConnectionsMax 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.

Every handle has a max connection count. Extra wires are refused while you drag (same as a type mismatch).

KindDefault
Named custom port1 (override with maxConnections)
Legacy custom single lane1 in · 1 out
Built-ins like Delay, Text → Prompt, approval, iterator, output1 in · 1 out
Knotunlimited in · 1 out
Prompt / skill / rulesno in · unlimited out
Agent1 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:

  1. Wire time — each handle carries its port’s value type, so incompatible drags are refused (text accepts anything, int widens to float). A full slot (maxConnections reached) is refused the same way.
  2. Pre-run — a required input with no wire blocks the run.
  3. Run time — inbound text is checked against the port type before stdin is built; stdout ports are checked after.

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.


Both features in one node — an int and a bool param, one input port, two output ports.

node.json
{
"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 }
]
}
node.ts
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.