Runs a snippet of JavaScript or TypeScript on the Bun runtime and routes the workflow based on the value the code returns. This is the node that touches the real world: run tests, call CLIs, hit HTTP endpoints, inspect files, do a bit of math, or produce structured data for the steps that follow.
Purpose#
Reach for script whenever a step needs logic rather than a prompt. Because you
hand back a real return value instead of parsing text out of stdout, later nodes
can read the result field by field with no line-splitting, trimming, or CRLF
fiddling to get wrong. And because it's JS/TS on Bun rather than a shell
one-liner, the same step behaves identically on macOS, Linux, and Windows.
You still get full shell access when you want it (see
Running shell commands with Bun.$ below),
so switching off raw shell scripts doesn't cost you shell access.
It runs in its own process, so a crash or a runaway loop can't take down the Roscoe server, and it's killed cleanly (along with anything it spawns) on cancel or timeout.
YAML schema#
states:
compute-total:
type: script # discriminator
label: Compute the order total # required, shown in UI / logs
script: | # required — JS/TS; export the value you want as the result
const items = [12, 30, 0.5];
const total = items.reduce((a, b) => a + b, 0);
export default total <= 100; // small orders auto-charge; larger ones get a look
validator: # required — controls how the result routes
kind: boolean # boolean | enum | confidence
on: # transition map; keys depend on validator
'true': charge
'false': reviewYour code runs as a module, so top-level await is fine:
export default the value you want as the result.
import { $ } from 'bun';
const branch = (await $`git rev-parse --abbrev-ref HEAD`.text()).trim();
export default { branch };A function export works too, and is handy when you want early returns or heavier logic: if the default export is callable, it's called (and awaited).
export default async () => {
return { ok: true };
};A function that returns nothing yields null; a module with no default
export fails with a clear message.
Configuration#
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
type |
'script' |
yes | — | Discriminator. |
label |
string | yes | — | Human-readable name for the step. |
script |
string | yes | — | JS/TS source. export default the result value (top-level await allowed), or a function that returns it. Multi-line via YAML block scalar (script: |). |
validator |
boolean | enum | confidence |
yes | — | Determines how the returned value is routed. See below. |
timeoutSeconds |
integer (seconds) | no | 300 |
Hard wall-clock timeout for the step. Defaults to 5 minutes; raise it for long-running work. |
maxIterations |
integer (1-50) | no | — | Per-run iteration cap when this node sits in a feedback loop. See the closed-loop feedback recipe. |
detectStall |
boolean | no | false |
Fail the run early if the node produces an identical result three times in a row. |
on |
map of transition → nodeId | depends | — | Required for enum; recommended for boolean/confidence. |
Validators#
Routing is decided from the returned value (call it result), not from
stdout.
kind: boolean emits 'true' when result is truthy, 'false' otherwise.
An object or array is truthy, so returning any object routes 'true';
return an explicit boolean (or 0/""/null) when you want to branch.
kind: enum requires String(result) to exactly match one of the keys of
on. Anything else is a runtime failure that lists the valid keys.
kind: confidence requires result to be a number (or an object with a
numeric score field). It emits 'true' at or above the validator's
threshold, 'false' below it.
validator:
kind: enum
on:
ready: deploy
blocked: notify
skip: doneIf your code throws, the node fails (no transition) and the error message
is surfaced on the run. A thrown error is a hard failure, not a routed
'false'. This matters when you're porting a command that used to branch on a
failure: run it with .nothrow() and route on the exit code yourself, rather
than letting the throw kill the node.
import { $ } from 'bun';
// A failing command routes 'false' instead of failing the whole node.
const res = await $`bun run test`.nothrow();
export default res.exitCode === 0;See the full validators reference.
Running shell commands with Bun.$#
You don't lose shell access. Bun's built-in shell works from inside a script and runs the same way on every OS:
script: |
import { $ } from 'bun';
const branch = (await $`git rev-parse --abbrev-ref HEAD`.text()).trim();
export default { branch };A few things worth knowing:
- Values you interpolate with
${...}are escaped automatically, so interpolating abranchname that came from upstream output is safe with no manual quoting. .nothrow()stops a non-zero exit from throwing and hands youexitCode,stdout, andstderrto branch on. Without it, a failed command throws and fails the node..cwd(dir)sets the working directory for that command;.text()returns captured stdout;.quiet()buffers output instead of streaming it live.
Use this for the occasional command; keep heavier logic in plain JS/TS.
Runtime#
- Runtime: Bun (the same runtime Roscoe itself runs on). TypeScript is transpiled for you, no build step.
- Standard library + Bun built-ins only. The script runs with no
node_modules, soimports of third-party packages fail. You're limited to the JS/TS standard library and Bun's built-ins (bun,bun:sqlite,node:*, etc.). For third-party logic, shell out to a project script withBun.$. - You can import your own local files. A relative
importresolves against the working directory (see below), soimport { helper } from './lib/util.ts'picks up./lib/util.tsin your project. Absolute paths work too. Imported files resolve their own relative imports normally (relative to themselves). - Timeout: 5 minutes (300s) by default; override per node with
timeoutSeconds. - Max stdout/stderr buffer: 128 MiB (the node is killed if it emits more).
Working directory & bundled scripts#
The script inherits the Roscoe server process's working directory, which is
not guaranteed to be your repo root, so relative paths to project files
(apps/site/..., docs/) and repo-relative git commands aren't reliable on
their own. Anchor explicitly first, using one of these env vars.
$ROSCOE_WORKFLOW_DIR is the absolute path to the workflow's asset
directory: the folder named after the workflow that sits next to its
.workflow.yaml (<workflows-dir>/<id>/). It's set only when that directory
exists. Put helper scripts and fixtures there and reference them as
`${process.env.ROSCOE_WORKFLOW_DIR}/release.ts`, so paths stay stable
even if the workflow is moved or renamed. This directory is shared across
all runs of the workflow, so don't write per-run files there (concurrent
runs would clobber each other); use $ROSCOE_RUN_DIR for that instead.
$ROSCOE_RUN_DIR is a temporary folder unique to this run
(<roscoe-home>/runs/<run-id>/), created on first use. It's the safe place to
save intermediate files and pass them to a later step: temporary working
space that's kept for a while and cleaned up automatically, not the
permanent record. Anything durable should be a node output, and secrets
don't belong there.
$ROSCOE_RUN_ID is this run's unique id, useful as an idempotency or
correlation key (e.g. an API request header). It's also available
everywhere as {{ run.id }} / {{ run.dir }}.
To pin a repo-bound script to the repo root, resolve it from the workflow dir:
script: |
import { $ } from 'bun';
const root = (
await $`git -C ${process.env.ROSCOE_WORKFLOW_DIR} rev-parse --show-toplevel`.text()
).trim();
const res = await $`bun run test`.cwd(root).nothrow();
export default res.exitCode === 0;Upstream node outputs and workflow inputs are also flattened into the
environment as $ROSCOE_OUT_<node>_<field> (a node collect with field
stdout shows up as $ROSCOE_OUT_collect_stdout), so you can read a prior
result without templating it into the source. Prefer this over {{ ... }} for
anything large or untrusted. Env vars can't break your syntax the way an
interpolated string can.
- Project-local CLIs may not be on
PATH. Tools installed as project dependencies (not globally) won't resolve as bare commands, so invoke them through the package manager:bun <tool>,npx <tool>,uv run <tool>, etc.
Outputs#
script writes the following into variables.<nodeId> for downstream
interpolation:
| Key | Type | Notes |
|---|---|---|
result |
any | The structured value your script produced (object, array, number, etc). |
stdout |
string | Anything the script printed. Trimmed of trailing whitespace. |
stderr |
string | Trimmed of trailing whitespace. |
exitCode |
number | 0 on success; 1 on signal/timeout/overflow. |
Read the structured result from later nodes as {{nodeId.result}}, or drill
into it: {{nodeId.result.total}}. The result travels through a file, not
stdout, so console.log in your script is captured as stdout for display
and never corrupts result.
While the script is still running, its stdout/stderr stream live into the
run viewer (with ANSI colors preserved), so you can
watch long steps (test suites, builds, deploys) progress instead of waiting for
the node to finish.
Transitions#
| Validator | Emitted transition keys |
|---|---|
boolean |
'true' (returned value is truthy) or 'false' (falsy). |
enum |
String(result), which must match a key in on. |
confidence |
'true' (value ≥ threshold) or 'false' (below it). |
A thrown error, an enum value that matches no key, or a confidence result
that isn't a number fails the run with a descriptive error.
Worked example#
name: score-and-route
version: 1
description: Compute a risk score in TS and branch on it.
initial: score
states:
score:
type: script
label: Score the request
script: |
const amount = 4200;
const risk = amount > 1000 ? 82 : 10;
export default { score: risk };
validator:
kind: confidence
threshold: 70
on:
'true': manual-review
'false': auto-approve
manual-review:
type: end
label: Needs a human
outcome: success
auto-approve:
type: end
label: Approved
outcome: successCommon pitfalls#
- Forgetting the default export. The node reads the module's
export default(a value, or a function that returns one). A script with no default export fails with a clear message. - A failing command failing the whole node. By default a non-zero command
throws, which is a hard node failure. If you meant to branch on the exit code,
add
.nothrow()and returnres.exitCode === 0. - Boolean routing on an object. Any object/array is truthy, so a
booleanvalidator always routes'true'unless you return an explicit falsy value. Return a real boolean when you mean to branch. - Reaching for a package. There's no
node_modulesin the runtime. Animportof a third-party library will fail. Shell out withBun.$to a script in your project instead. - Assuming the repo root is the working directory. It isn't guaranteed, so
anchor with
$ROSCOE_WORKFLOW_DIR(see above) before touching project files. - Hanging scripts. The node is killed at the timeout (5 minutes by default;
raise
timeoutSecondsfor longer work). Servers and interactive prompts will be killed. - YAML quoting
'true'/'false'. Without quotes, YAML parses these as booleans and theonmap keys won't match the string transitions the executor emits.
Where to next#
- ai_agent — hand the result to a model instead of branching on it yourself.
- ai_judge — let a model make the routing decision instead of your code.
- Script, then AI summarise — a copy-pasteable script + summarize recipe.
- Output chaining — read one node's result from the next.