Script Node
The Script node executes an inline Python or Node.js script on a minion. Unlike Skill / Transform nodes, no LLM is involved: the minion spawns python3 or node directly via child_process. Use it for deterministic data processing where LLM tokens are wasteful or where the output shape must be exact.
Available from minion package version 3.54.0.
When to use
Section titled “When to use”- Reshaping data with rules an LLM might get wrong (filtering, sorting, math, regex extraction, JSON munging).
- The output format must be byte-for-byte deterministic — for example, downstream code parses it as JSON.
- The transform is short and obvious enough that prompting an LLM would be more work than writing the function.
Stick with Skill or Transform when the work needs fuzzy understanding (semantic dedup, summarization, classification, or natural language).
Configuration
Section titled “Configuration”| Field | Description |
|---|---|
assigned_role | "pm" or "engineer" — which role runs the script. The pending-nodes claim filter routes on this. |
script_runtime | "python" or "node". Selects the interpreter the minion will spawn. |
script_source | Inline source code (non-empty). |
script_timeout_seconds (optional) | Wall-clock timeout. Defaults to 60. Clamped to [1, 600]. Exceeding it SIGKILLs the process and fails the node. |
The output contract on the outgoing edge (if present) is enforced by HQ on node-complete — same path as Skill / Transform.
I/O protocol
Section titled “I/O protocol”A Script node communicates with the rest of the graph through stdin and stdout:
- Input:
input_data(the JSON object propagated from incoming edges) is piped into the script’s stdin as a single JSON value. Read it once and parse it as JSON. - Output: the script writes a single JSON object to stdout. That object becomes
output_dataand — once HQ records the node as completed — flows to the next node as itsinput_data.
Python — passing data to the next node
Section titled “Python — passing data to the next node”import sys, json
# 1. Receive input_data from the upstream nodedata = json.load(sys.stdin)
# 2. Do the workitems = [it for it in data["items"] if it["score"] >= 0.8]
# 3. Build the object to hand off downstreamoutput = { "items": items, "total": len(items), "filtered_at": "2026-05-10",}
# 4. Emit it as JSON on stdout (json.dump is safer than print —# print appends a newline which is fine, but never emit anything# other than this single JSON value)json.dump(output, sys.stdout)Node.js — passing data to the next node
Section titled “Node.js — passing data to the next node”// 1. Buffer stdin to the end (data arrives in chunks)let raw = ''process.stdin.on('data', c => raw += c)process.stdin.on('end', () => { const data = JSON.parse(raw)
// 2. Do the work const items = data.items.filter(it => it.score >= 0.8)
// 3. Build the object to hand off downstream const output = { items, total: items.length, filtered_at: '2026-05-10', }
// 4. Emit it as JSON on stdout — and only this process.stdout.write(JSON.stringify(output))})How the next node sees the output
Section titled “How the next node sees the output”Each top-level key on the object you wrote to stdout shows up as a field on the next node’s input_data.
- Next node is a Script node — same protocol in reverse.
json.load(sys.stdin)/JSON.parse(raw)returns the object, anddata["items"]/data.totalare the fields you set above. - Next node is a Skill or Transform node — the object is rendered into the minion’s prompt under an
## Input Datasection, so the LLM seesitems/totalas named fields it can reason over. - Next node is a fan-out node — set
fan_out_source: ".items"on the fan-out, and each element of theitemsarray becomes theinput_dataof one child instance.
Fan-out hand-off — minimal example
Section titled “Fan-out hand-off — minimal example”# stdin: { "raw_text": "apple\nbanana\ncherry" }import sys, json
data = json.load(sys.stdin)lines = [s.strip() for s in data["raw_text"].split("\n") if s.strip()]
# Fan-out expects an array on the field named in `fan_out_source`.# Each element becomes one downstream instance's input_data, so wrap# each value in an object with the fields the children expect.json.dump({"items": [{"name": x} for x in lines]}, sys.stdout)With fan_out_source: ".items" on the downstream fan-out, each child receives { "name": "apple" }, { "name": "banana" }, { "name": "cherry" } as its input_data.
Pitfalls
Section titled “Pitfalls”- Do not write anything other than JSON to stdout. A stray
print("debug")will corrupt the output and fail the node. Send debug logs to stderr (print("...", file=sys.stderr)in Python,console.error(...)in Node.js) — stderr is captured intooutput_summaryand visible from the execution detail page. - The top-level value must be a JSON object. Returning a bare array (
[1, 2, 3]) or a primitive (42,"ok",null) fails the node. Wrap arrays in an object:{"items": [1, 2, 3]}. - Trailing newlines are fine. Both
json.dumpandJSON.stringifyproduce no trailing newline; an extra one (e.g. viaprint(...)) is trimmed during parsing. - If the outgoing edge declares a contract, HQ enforces it. The contract you set on the edge runs the same runtime validator used for Skill / Transform nodes. Missing required fields or wrong field types fail the node — even if your script otherwise succeeded. When no contract is set, the output object is passed downstream verbatim with no validation.
Failure modes
Section titled “Failure modes”All failures result in status: failed. stderr is captured into output_summary so you can debug from the execution detail page.
| Cause | Result |
|---|---|
| Non-zero exit code | failed; stderr in summary |
| stdout is not valid JSON | failed; first 500 chars of stdout in summary |
| stdout is JSON but not an object (array / number / string / null) | failed |
| Wall-clock timeout exceeded | failed; process is SIGKILL’d |
Unsupported script_runtime | failed |
| Output contract validation fails on outgoing edge | failed by HQ runtime validator |
Validation rules
Section titled “Validation rules”assigned_roleis required.script_runtimemust be"python"or"node".script_sourcemust be non-empty.script_timeout_seconds(if set) must be a number in[1, 600].
Constraints
Section titled “Constraints”- No package installs in the initial release. Only the standard library / built-in modules of the runtimes shipped with the minion are available.
pip installandnpm installare not supported (yet) — keeping the security and supply-chain surface small. - The script runs as the minion’s agent process under a plain
child_process.spawn. There is no separate user, sandbox, or filesystem isolation. The trust boundary is identical to Skill execution: only project members can edit the graph, so anything they can write into a Skill they can write into a Script. - Concurrency is shared with Skill / Transform nodes via the minion’s
concurrency-manager(defaultMAX_CONCURRENT=2per minion). - No tmux session is created — Script nodes do not appear in the dashboard’s terminal list.
Example
Section titled “Example”flowchart LR Search["Skill: search"] -- SearchResults --> Script["Script: filter & rank"] Script -- TopItems --> Report["Skill: write-report"]The upstream Skill emits SearchResults with extra fields. A short Python script filters by score and keeps the top N — deterministic, no tokens, no LLM ambiguity. The downstream Skill then takes the cleaned TopItems and writes a human report.