script node

Runs JavaScript or TypeScript on Bun and routes on the value it returns.

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': review

Your 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: done

If 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 a branch name that came from upstream output is safe with no manual quoting.
  • .nothrow() stops a non-zero exit from throwing and hands you exitCode, stdout, and stderr to 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, so imports 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 with Bun.$.
  • You can import your own local files. A relative import resolves against the working directory (see below), so import { helper } from './lib/util.ts' picks up ./lib/util.ts in 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: success

Common 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 return res.exitCode === 0.
  • Boolean routing on an object. Any object/array is truthy, so a boolean validator 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_modules in the runtime. An import of a third-party library will fail. Shell out with Bun.$ 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 timeoutSeconds for longer work). Servers and interactive prompts will be killed.
  • YAML quoting 'true' / 'false'. Without quotes, YAML parses these as booleans and the on map 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.

View this page as Markdown

Predictable workflows from unpredictable AI