Script Nodes
DAG workflow nodes support a script field that runs a TypeScript, JavaScript,
or Python snippet as part of the workflow. No AI agent is invoked — the script
runs via the bun or uv runtime, stdout is captured as the node’s output,
and the result is available downstream as $nodeId.output.
Use script nodes for deterministic work that needs a real programming language:
parsing JSON, transforming data between upstream AI nodes, calling HTTP APIs
with typed clients, or computing values that a shell one-liner would mangle.
If a plain shell command is enough, use a bash: node
instead.
Quick Start
Section titled “Quick Start”Inline TypeScript (bun)
Section titled “Inline TypeScript (bun)”nodes: - id: parse script: | const data = { count: 42, label: "ok" }; console.log(JSON.stringify(data)); runtime: bunInline Python (uv)
Section titled “Inline Python (uv)”nodes: - id: compute script: | import json, statistics values = [1, 2, 3, 4, 5] print(json.dumps({ "mean": statistics.mean(values) })) runtime: uvNamed script from .archon/scripts/
Section titled “Named script from .archon/scripts/”nodes: - id: fetch-pages script: fetch-github-pages # resolves .archon/scripts/fetch-github-pages.ts runtime: bun timeout: 60000The file .archon/scripts/fetch-github-pages.ts is loaded and executed with
bun --no-env-file run <path>.
How It Works
Section titled “How It Works”- Substitute variables.
$ARGUMENTS,$WORKFLOW_ID,$ARTIFACTS_DIR,$BASE_BRANCH,$DOCS_DIR, and upstream$nodeId.outputreferences are substituted into thescripttext before execution. - Detect inline vs named. If the
scriptvalue contains a newline or any shell metacharacter (see Inline vs Named Scripts below), it’s treated as inline code. Otherwise it’s treated as a named-script reference. - Dispatch.
runtime: bun+ inline →bun --no-env-file -e '<code>'runtime: bun+ named →bun --no-env-file run <path>runtime: uv+ inline →uv run [--with dep ...] python -c '<code>'runtime: uv+ named →uv run [--with dep ...] <path>
- Capture.
stdout(with the trailing newline stripped) becomes$nodeId.output. On a successful run,stderris logged as a warning and posted to the conversation but does not fail the node. A non-zero exit code fails the node; on failure, tails of both streams are surfaced in the error message (Script node 'X' failed [exit N]: [stderr] ... [stdout] ...), sharing a ~2 KB diagnostic budget — stderr keeps priority, stdout gets the remainder, and a label prefixes each stream only when both are populated. With stderr empty, the stdout tail becomes the diagnostic. The script body is never echoed back to users. A timeout also fails by default. Seton_timeout: skipwhen the result is optional and downstream nodes handle its absence with anif_skippedbinding. - Retain. Regardless of outcome, capped and credential-redacted tails of
both streams are written to the run transcript as an
exec_outputrow — see Retained subprocess evidence. That retention is evidence only; it never caps$nodeId.output.
YAML Schema
Section titled “YAML Schema”- id: node-name script: <inline code OR named identifier> # required, non-empty runtime: bun | uv # required deps: ["httpx", "pydantic>=2"] # optional, uv-only (see below) timeout: 60000 # optional ms, default 120000 on_timeout: skip # optional; default is to fail depends_on: [upstream] # optional when: "$upstream.output != '[]'" # optional (upstream is a bash/script node; # an AI producer needs output_format + a field) output_format: # optional JSON Schema; makes stdout a contract type: object properties: severity: { type: string } required: [severity] trigger_rule: all_success # optional (default) retry: # optional; same shape as bash/AI nodes max_attempts: 3 on_error: transientFields
Section titled “Fields”| Field | Type | Required | Description |
|---|---|---|---|
script | string | Yes | Inline code, or a named script in the owning workflow’s scripts/ directory (packaged workflows) or shared script directories (legacy workflows) |
runtime | 'bun' | 'uv' | Yes | Which runtime executes the script. Must match the file extension for named scripts |
deps | string[] | No | Python dependencies to install for this run. uv only — ignored with a warning for bun |
timeout | number (ms) | No | Hard kill after this many milliseconds. Default: 120000 (2 min) |
on_timeout | 'skip' | No | Complete the node as skipped after a timeout. The default is failed. The persisted skip cause is timeout |
output_format | object | No | JSON Schema the node’s stdout must satisfy. See Declaring a result contract |
Standard DAG fields (id, depends_on, when, trigger_rule, retry) all
work, and so does output_format — see
Declaring a result contract. AI-specific fields
(model, provider, context, allowed_tools, denied_tools, hooks,
mcp, skills, agents, effort, maxBudgetUsd, systemPrompt,
fallbackModel, betas, sandbox) are accepted by the parser but emit a
loader warning and are ignored at runtime — no AI is invoked. idle_timeout is also accepted but ignored: script nodes
run as one-shot subprocesses, so use timeout (hard kill after N ms) instead.
Inline vs Named Scripts
Section titled “Inline vs Named Scripts”The executor decides mode from the script string itself. A value is treated
as inline code if it contains a newline or any shell metacharacter; otherwise
it’s a named script lookup.
- Metacharacters that trigger inline mode: space,
;(){}&|<>$`"' - Inline examples:
"const x = 1; console.log(x)", multi-line blocks, any snippet with a space - Named examples:
fetch-pages,analyze_metrics,triage-fmt— bare identifiers with no whitespace or shell syntax
If you want an inline snippet that happens to be syntactically a single identifier, add a trailing comment or newline to force inline mode.
Named Script Resolution
Section titled “Named Script Resolution”Named scripts use one of two resolution modes:
- Packaged workflow: resolve only from the owning workflow’s
scripts/directory. Bundled package scripts use the same ownership rule and are embedded for binary distribution. - Legacy workflow: resolve shared scripts from
<repoRoot>/.archon/scripts/, then~/.archon/scripts/.
Workflow-local lookup is scoped to the workflow that declared the node, including through include: expansion. Authors still write only the bare name (script: publish); the ownership key is internal.
Packaged scripts can import modules from their pack’s .shared/ directory. Keep package dependencies out of that tree: packaged scripts must not depend on the target project’s package.json, node_modules, or tsconfig.json. Python dependencies can still be declared with deps:. Imports across packs and npm dependencies are outside the packaged-module contract.
Share code within a pack
Section titled “Share code within a pack”Put reusable .ts, .js, or .py modules under <pack>/.shared/. Module subdirectories are supported. Use regular files; the binary generator rejects symlinks under .shared. .shared is reserved for modules: its files are neither workflows nor named script targets. A packaged workflow that names an unavailable script, including a shared module, fails at load time.
my-pack/├── .shared/│ ├── result.ts│ └── result.py├── release/│ ├── release.yaml│ └── scripts/│ └── publish.ts└── inspect/ ├── inspect.yaml └── scripts/ └── report.pyFor Bun, import from the script’s location:
import { summary } from '../../.shared/result.ts';console.log(summary);Python scripts run as files, so package-relative syntax such as from ...shared does not apply. Add the pack’s shared directory using Python’s standard library:
from pathlib import Pathimport sys
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / ".shared"))from result import summary
print(summary)Archon preserves these relative paths in project and global source trees, bundled binaries, and frozen captures. A binary caches each pack’s scripts and modules as one unit; changing a shared module creates a new unit. Authored scripts remain the only entry points, so workflow nodes still use names such as script: publish.
Keep output under the supplied ARTIFACTS_DIR or STATE_DIR, never beside a script. Bun module loading does not add files beside these sources, and Archon disables Python bytecode caching in both workflow execution and executable fixtures. This prevents import caches from changing the frozen source; it does not prevent your script from writing there explicitly.
Frozen source integrity
Section titled “Frozen source integrity”When a run uses captured source, Archon rechecks the full capture against the run’s pinned digest and source-resolution settings before each named-script attempt, including retries, and before lookup or subprocess dispatch. Any change to the capture refuses the node before it starts. Inline scripts are already held in the workflow definition and do not read the capture at execution time.
This is checkpoint detection, not sealing or sandboxing. A process running as the Archon user can change source after the check, and a parallel node that already started is not cancelled.
Each shared scripts directory is walked one subfolder deep (e.g. .archon/scripts/triage/foo.ts
resolves as foo). Deeper nesting is ignored. On a same-name collision the
repo-local entry wins silently — see Global Workflows
for the shared precedence rules.
Extension ↔ Runtime Mapping
Section titled “Extension ↔ Runtime Mapping”Named scripts derive their runtime from the file extension:
| Extension | Runtime |
|---|---|
.ts, .js | bun |
.py | uv |
The runtime: declared on the node must match the file’s extension — the
validator rejects runtime: uv pointing at a .ts file, and vice versa. For
inline scripts, you can use any language that the chosen runtime supports.
Dependencies (uv only)
Section titled “Dependencies (uv only)”deps is a pass-through to uv run --with <dep>, which installs packages into
a per-run ephemeral environment:
- id: scrape script: | import httpx r = httpx.get("https://api.github.com/repos/anthropics/anthropic-cookbook") print(r.text) runtime: uv deps: ["httpx>=0.27"]- Version pinning — any PEP 508 specifier works (
pkg==1.2.3,pkg>=2,<3). - Bun ignores
deps— Bun auto-installs imported packages on first run, so the validator emits a warning if you setdepswithruntime: bun. Remove the field, or switch touvif you need explicit dependency management. - No persistent environment — each run is isolated; there is no
requirements.txtor lockfile to maintain.
Output and Data Flow
Section titled “Output and Data Flow”stdout (trimmed of its trailing newline) becomes $nodeId.output. Print JSON
if you want downstream nodes to access structured fields with
$nodeId.output.field — the workflow engine tries to parse the output as JSON
for field access in when: conditions and prompt substitution.
Declare output_format when you want that JSON to be a contract rather than a
convention — see Declaring a result contract.
- id: classify script: | const input = process.argv.slice(2).join(' '); const severity = input.includes('crash') ? 'high' : 'low'; console.log(JSON.stringify({ severity, length: input.length })); runtime: bun
- id: investigate command: investigate-bug depends_on: [classify] when: "$classify.output.severity == 'high'"Declaring a result contract
Section titled “Declaring a result contract”Without output_format, $classify.output.severity above works by convention:
the engine parses the text and hopes the key is there. Declare output_format
and the same result becomes a contract the node owns.
- id: classify script: | const input = process.env.ARGUMENTS ?? ''; console.log(JSON.stringify({ severity: input.includes('crash') ? 'high' : 'low', units: [], })); runtime: bun output_format: type: object properties: severity: { type: string, enum: [low, high] } units: { type: array, items: { type: object } } required: [severity, units]With a schema declared, the node:
- parses stdout as one strict JSON document — no code fences, no prose preamble, no repair pass, and no second attempt;
- validates it against the schema, and fails the node when it does not match, naming the offending JSON path and quoting the start of stdout;
- publishes the canonical JSON document as
$classify.output, the logical value to downstream bindings andfan_out.items, and the declared property names to$classify.output.<field>; - makes a reference to an undeclared field fail the consuming node instead
of silently resolving to
''.
That is the same contract an AI node’s output_format carries, so a script and
an agent are interchangeable as the producer behind a returns: node. What that
buys a caller — through an include: alias, a workflow: sub-run, a fan-out, or
an artifact pointer — is described once in
Authoring workflows → Result contracts.
A schemaless script is unchanged: stdout stays raw text, trimmed only of its trailing newline as before.
Variable Substitution in Scripts
Section titled “Variable Substitution in Scripts”Variables are substituted into the script text as raw strings, without
shell quoting — unlike bash: nodes, where $nodeId.output values are
auto-quoted. Treat substituted values as untrusted input and parse them with
language features, not by interpolating into shell syntax.
For named scripts, variables are not passed automatically. Read them from
the environment (process.env.USER_MESSAGE, os.environ['USER_MESSAGE'])
or accept them via stdin. For inline scripts, substituted variables are
literally embedded into the code string at execution time.
Environment and Isolation
Section titled “Environment and Isolation”Script subprocesses receive process.env merged with any codebase-scoped env
vars you’ve configured by opening Environment variables from the project row
in the console project rail, or through the env: block in
.archon/config.yaml. This is the same injection surface used by Claude, Codex,
and bash nodes.
Target repo .env isolation: the Bun subprocess is invoked with
--no-env-file, so variables in the target repo’s .env do not leak into
the script. Archon-managed env (from ~/.archon/.env and <repo>/.archon/.env)
passes through normally. uv-launched Python subprocesses do not auto-load
.env at all. See Security Model
for the full story.
Validation
Section titled “Validation”archon validate workflows <name> checks script nodes for:
- Script file exists — for named scripts, the basename must exist in the
owning workflow’s
scripts/directory or the legacy shared search path, with a matching extension for the declared runtime. Missing files fail validation with a hint showing the expected path. - Runtime available on PATH —
bunoruvmust be installed. Missing runtimes emit a warning with the official install command:curl -fsSL https://bun.sh/install | bashcurl -LsSf https://astral.sh/uv/install.sh | sh
depswithruntime: bun— warns thatdepsis a no-op under Bun.
Runtime availability is cached per-process — the check spawns which bun /
which uv once and memoizes the result.
Patterns
Section titled “Patterns”Transform AI output before the next node
Section titled “Transform AI output before the next node”Use a script node as a deterministic adapter between two AI nodes. The script parses the upstream classifier’s JSON, filters, and forwards a clean payload:
- id: classify prompt: "Classify: $ARGUMENTS" allowed_tools: [] output_format: type: object properties: items: type: array items: { type: object }
- id: filter script: | const upstream = JSON.parse(process.env.UPSTREAM ?? '{}'); const high = (upstream.items ?? []).filter(i => i.severity === 'high'); console.log(JSON.stringify(high)); runtime: bun depends_on: [classify]
- id: triage command: triage-high-severity depends_on: [filter] when: "$filter.output != '[]'"(Note: to actually populate UPSTREAM you’d inline-substitute
$classify.output into the script body. The example above illustrates the
shape.)
Reusable helper in ~/.archon/scripts/
Section titled “Reusable helper in ~/.archon/scripts/”A helper you want available in every repo — say, a triage summary formatter —
lives at ~/.archon/scripts/triage-fmt.ts:
const raw = process.argv.slice(2).join(' ') || '{}';const data = JSON.parse(raw);const lines = data.issues?.map((i: { id: string; title: string }) => `- [${i.id}] ${i.title}`).join('\n') ?? '';console.log(lines || 'no issues');Then reference it by name from any repo’s workflow:
- id: format script: triage-fmt runtime: bun depends_on: [gather]Python with scientific dependencies
Section titled “Python with scientific dependencies”- id: analyze script: | import json, sys import pandas as pd data = json.loads(sys.argv[1]) if len(sys.argv) > 1 else [] df = pd.DataFrame(data) print(df.describe().to_json()) runtime: uv deps: ["pandas>=2.0"] depends_on: [collect]What Does NOT Work
Section titled “What Does NOT Work”- AI-only features —
hooks,mcp,skills,allowed_tools,denied_tools,agents,model,provider,effort,maxBudgetUsd,systemPrompt,fallbackModel,betas, andsandboxare all ignored at runtime. The loader emits a warning listing the ignored fields. (output_formatis not in that set — a script owns it, see Declaring a result contract.) - JSON repair and reasks — a certified script’s stdout must be exactly right the first time. There is no fence stripping and no second attempt: stdout that does not match the declared schema is a bug in the script.
- Interactive prompts — the script runs headlessly; any
stdinread will see EOF immediately. - Runtimes other than
bunanduv— rejected at parse time. - Cancelling mid-execution — script subprocesses are killed on workflow cancel, but there’s no cooperative cancellation signal. Design scripts to complete quickly or fail fast.
See Also
Section titled “See Also”- Authoring Workflows — full workflow reference
- Global Workflows, Commands, and Scripts — home-scoped
~/.archon/scripts/ - Security Model — env isolation details
- Variables Reference — substitution rules