Skip to content

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.

  • 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).

FieldDescription
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_sourceInline 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.

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_data and — once HQ records the node as completed — flows to the next node as its input_data.
import sys, json
# 1. Receive input_data from the upstream node
data = json.load(sys.stdin)
# 2. Do the work
items = [it for it in data["items"] if it["score"] >= 0.8]
# 3. Build the object to hand off downstream
output = {
"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)
// 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))
})

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, and data["items"] / data.total are 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 Data section, so the LLM sees items / total as 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 the items array becomes the input_data of one child instance.
# 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.

  • 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 into output_summary and 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.dump and JSON.stringify produce no trailing newline; an extra one (e.g. via print(...)) 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.

All failures result in status: failed. stderr is captured into output_summary so you can debug from the execution detail page.

CauseResult
Non-zero exit codefailed; stderr in summary
stdout is not valid JSONfailed; first 500 chars of stdout in summary
stdout is JSON but not an object (array / number / string / null)failed
Wall-clock timeout exceededfailed; process is SIGKILL’d
Unsupported script_runtimefailed
Output contract validation fails on outgoing edgefailed by HQ runtime validator
  • assigned_role is required.
  • script_runtime must be "python" or "node".
  • script_source must be non-empty.
  • script_timeout_seconds (if set) must be a number in [1, 600].
  • No package installs in the initial release. Only the standard library / built-in modules of the runtimes shipped with the minion are available. pip install and npm install are 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 (default MAX_CONCURRENT=2 per minion).
  • No tmux session is created — Script nodes do not appear in the dashboard’s terminal list.
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.