# script node

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.$`](#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

```yaml
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.

```js
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).

```js
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](/docs/recipes/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.

```yaml
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.

```js
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](/docs/authoring/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:

```yaml
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 `import`s 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:

```yaml
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](/docs/running/web-ui) (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

```yaml
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](/docs/nodes/ai-agent) — hand the result to a model instead of
  branching on it yourself.
- [ai_judge](/docs/nodes/ai-judge) — let a model make the routing decision
  instead of your code.
- [Script, then AI summarise](/docs/recipes/script-then-ai) — a
  copy-pasteable script + summarize recipe.
- [Output chaining](/docs/authoring/output-chaining) — read one node's
  result from the next.
