# Output chaining

Workflows rarely consist of independent steps: most useful pipelines feed one
node's output into the next node's prompt, script, or branching condition.
Roscoe tracks every node's output in a shared `variables` map and lets you
reference any prior output with mustache-style template tokens. This page
covers how that works, what fields each node type writes, and how to seed the
initial context from the CLI or the API.

## How it works

When a node finishes, its output object is merged into the run's `variables`
map under the node's id. So if your `summarise` node writes
`{ response: "..." }`, then any later node sees `variables.summarise.response`.

You reference these values with `{{ nodeId.field }}` tokens inside any string
field: prompts, scripts, condition expressions, even labels.

```yaml
states:
  analyse:
    type: ai_agent
    label: Analyse dataset
    model: claude-haiku-4-5
    prompt: Analyse this dataset and provide a one-paragraph summary.
    on:
      done: review

  review:
    type: ai_judge
    label: Is the summary accurate?
    model: claude-sonnet-4-5
    prompt: |
      Is this summary an accurate reflection of the dataset?

      Summary: {{ analyse.response }}
    validator:
      kind: boolean
    on:
      'true': done
      'false': analyse
```

### Token rules

- Tokens use double braces with optional whitespace: `{{ analyse.response }}`
  and `{{analyse.response}}` both work.
- The path is dot-separated and traverses object fields:
  `{{ vote.votes.0.reasoning }}` would index the first vote.
- Unresolved tokens fail loudly at runtime: the interpolator throws
  `TemplateInterpolationError` when a token resolves to `undefined` or
  `null` with no fallback, which fails the node and surfaces in the run
  view's error column. Provide a fallback (see below) when a referenced
  field might genuinely be absent.
- Primitives are coerced via `String(value)`; objects and arrays are
  `JSON.stringify`'d.
- Escape a literal `{{` by writing `\{{`.

### Fallback syntax

When a referenced node might not have produced output on the current path
(e.g. a branch-only node), use `||` followed by a literal default. The
fallback fires only when the path resolves to `undefined` or `null`. Real
values, including `""`, `0`, and `false`, pass through unchanged.

```yaml
prompt: |
  Severity: {{ classify.result || "unknown" }}
  Score:    {{ score-node.score || 0 }}
  Notes:    {{ optional-step.response || "" }}
```

Supported fallback literals:

- Double- or single-quoted strings: `"no data"`, `'fallback'`
- Integers and decimals: `0`, `-1`, `2.5`
- Empty string: `""` (use when no default makes sense but you want the
  workflow to still run)

Fallbacks bypass the dominance check (below) but **not** the typo check:
unknown nodes or unknown fields still fail at save time whether or not
you provide a fallback.

### Dominance: refs must run on every path

The editor and the integrity validator reject a `{{ B.field }}` in node
`D` unless `B` is guaranteed to run on every path that reaches `D`
(dominator analysis). Consider:

```text
        ┌─ B ─┐
A ──────┤     ├─→ D
        └─ C ─┘
```

`D` can reach `B` through `A → B → D`, but it can also reach `D` through
`A → C → D` where `B` never ran. Referencing `{{ B.response }}` from `D`
is rejected at save time with:

> node 'B' is not guaranteed to run before 'D' — 'D' is reachable via
> paths that bypass 'B'. Add a fallback like `{{ B.response || "" }}` or
> use a node that runs on every path.

Two ways to fix it:

1. **Restructure** so the producing node sits on every path to the
   consumer (a pre-split node, or a join node both branches feed
   through).
2. **Provide a fallback**: `{{ B.response || "" }}`, so `D` runs on
   either branch and the fallback fires when `C` was the chosen path.

The dominance check applies to references inside `prompt` fields (all AI
and human nodes) and `script` fields (script nodes). It does NOT apply
to `condition.expression`, which uses JS bracket syntax
(`variables["B"].response`) and is not parsed as a template token.

### Audit trail: `interpolated` on the output

Every executor that runs a template-bearing field captures the
substitutions it made and surfaces them on the node's output under
`interpolated`. This lets you audit what the model actually saw without
re-running the workflow.

```jsonc
// classify-severity output, viewed in the run inspector:
{
  "response": "high",
  "model": "claude-sonnet-5-20260101",
  "interpolated": {
    "read-alert.response": "Database pool exhausted on prod-rds-01",
    "gather-context.response": "Last seen 4 minutes ago, 47 affected users",
  },
}
```

`interpolated` is metadata. You can read it in the run UI, but you cannot
reference it from another node's prompt (there is no
`{{ x.interpolated.y }}`).

## Output fields by node type

These are the fields each executor writes back into `variables` under its
node id.

| Node type     | Available fields                                                                                                                                                                                                                                                                                                                                                                                                 |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ai_agent`    | `<id>.response`: raw text from the model. `<id>.model`: id the API actually resolved the call to.                                                                                                                                                                                                                                                                                                                |
| `ai_judge`    | `<id>.result` (boolean / string / number depending on validator), `<id>.reasoning`, `<id>.score` (confidence validator only, 0–100), `<id>.model`.                                                                                                                                                                                                                                                               |
| `consensus`   | `<id>.outcome` (`approved`/`rejected` for a boolean or confidence validator; `decided`/`undecided` for `answer` or `most_consistent`), `<id>.votes` (per-agent verdict + reasoning), `<id>.approvals`, `<id>.agentCount`, `<id>.quorum`, `<id>.model`. Answer mode also writes `<id>.winningValue` and `<id>.distribution`. See [Consensus and validators](/docs/authoring/validators#consensus-and-validators). |
| `round_robin` | `<id>.outcome` (`decided` or `tie`), `<id>.winnerIdx` (the winning candidate's index, `null` on a tie, so pair it with a `\|\|` fallback), `<id>.standings`, `<id>.matches`, `<id>.model`.                                                                                                                                                                                                                       |
| `map`         | `<id>.outcome` (`success` if any branch completed, else `failure`), `<id>.outputs` (array of branch responses), `<id>.reduced` (the synthesis text, if `reduce` ran), `<id>.successes`, `<id>.failures`, `<id>.model`.                                                                                                                                                                                           |
| `script`      | `<id>.result` (the value the script returned), `<id>.stdout`, `<id>.stderr`, `<id>.exitCode`.                                                                                                                                                                                                                                                                                                                    |
| `condition`   | `<id>.result`: `true` or `false`.                                                                                                                                                                                                                                                                                                                                                                                |
| `human`       | `<id>.decision`: the transition the human picked. `<id>.response`: any free-text reply (when prompted).                                                                                                                                                                                                                                                                                                          |
| `subworkflow` | Author-declared: whichever keys `outputMapping` names on the node, plus `<id>.__outcome`. No fixed field list. See [Subworkflow node](/docs/nodes/subworkflow).                                                                                                                                                                                                                                                  |
| `end`         | Terminal. Does not write to `variables`.                                                                                                                                                                                                                                                                                                                                                                         |

Every AI-calling node (`ai_agent`, `ai_judge`, `consensus`, `round_robin`)
also writes `<id>.cost` whenever Roscoe can price the call.

Every executor that interpolated at least one token also writes an
`interpolated` map to its output (see [Audit trail](#audit-trail-interpolated-on-the-output)).
That field is metadata. It does not participate in template references.

For `consensus`, each entry in `<id>.votes` has a `result` (the per-agent
boolean or score) and a `reasoning` string. See
[Consensus node](/docs/nodes/consensus) for the full schema.

A `script` node's `<id>.result` is whatever value the script returned with
`export default`. When it returns an object, downstream nodes can read the
fields directly (e.g. `{{ record.result.score }}`) instead of parsing text
out of `stdout`.

## Seeding the initial context

You can supply variables before the workflow even starts. They land directly
in `variables` and can be referenced as `{{ key }}`.

### From the CLI

Use one or more `--var key=value` flags:

```bash
roscoe run summarise-build --var env=production --var artifact=app.tar.gz
```

The variables are typed as strings. If you need a number or boolean inside a
condition expression, parse it explicitly (see
[Condition expressions](/docs/authoring/expressions)).

### From the REST API or MCP

Pass a `context` object to the `start_workflow` call, whether you drive it
over REST or through [MCP](/docs/running/mcp):

```json
{
  "workflow": "summarise-build",
  "context": {
    "env": "production",
    "artifact": "app.tar.gz"
  }
}
```

A workflow that references `{{ env }}` and `{{ artifact }}` in prompts or
scripts will see the values you passed.

## Scripts: env-var injection

Scripts get the same `variables` map injected as **environment
variables**, in addition to the `{{ ... }}` substitution that happens before
the script runs. Each field is flattened with the prefix `ROSCOE_OUT_` and
joined with underscores. Read them off `process.env`.

For example, after an `ai_agent` node with id `summarise` produces
`{ response: "Build OK" }`, a downstream script sees:

```js
console.log(process.env.ROSCOE_OUT_summarise_response);
// → Build OK
```

Rules for env-var flattening:

- Keys are sanitised: anything outside `[A-Za-z0-9]` becomes `_`.
- Nesting is joined with `_` up to a depth of 5; deeper subtrees are
  serialised as JSON.
- Values are truncated at 32 KiB.
- Keys that start with a digit after the prefix are skipped (POSIX rule).
- A warning is printed if the total env size crosses 256 KiB. Large outputs
  can cause `exec` to fail on some platforms.

This means you have two ways to use prior output in a script:

```yaml
report:
  type: script
  label: Write report
  script: |
    // Option 1: template substitution (Roscoe fills this in before the script runs).
    await Bun.write('report.txt', `Summary: {{ summarise.response }}\n`);

    // Option 2: env var (read the value at runtime off process.env).
    await Bun.write('report.txt', `${process.env.ROSCOE_OUT_summarise_response}\n`);

    export default true;
  validator:
    kind: boolean
  on:
    'true': done
    'false': failed
```

The env-var form is safer for multi-line content or anything with quotes,
because the value is read at runtime instead of being substituted into the
script text; the template form is more readable for short strings.

## The `run` namespace: per-run id and temporary folder

Every run exposes a built-in `run` namespace, separate from node outputs:

| Reference       | Env var           | What it is                                      |
| --------------- | ----------------- | ----------------------------------------------- |
| `{{ run.id }}`  | `$ROSCOE_RUN_ID`  | This run's unique id (stable for the whole run) |
| `{{ run.dir }}` | `$ROSCOE_RUN_DIR` | A temporary folder scoped to this run           |

`{{ run.id }}` and `{{ run.dir }}` resolve in any node: prompts, judge
criteria, conditions, and scripts. Roscoe fills them in before the node runs.
The env vars (`$ROSCOE_RUN_ID` / `$ROSCOE_RUN_DIR`) are injected into
**script** nodes (and anything they spawn, e.g. `bun run foo.js`, which inherits
the environment).

### Why the temporary folder exists

`$ROSCOE_WORKFLOW_DIR` is shared by **every run of the workflow**, so two runs
going at once will overwrite each other if they write to the same file. The
per-run folder is unique per run, so it's the safe place to save intermediate
files and hand them from one script step to the next:

```yaml
fetch:
  type: script
  label: Fetch data
  script: |
    import { $ } from 'bun';
    const res = await $`curl -s https://example.com/data.json > "$ROSCOE_RUN_DIR/data.json"`.nothrow();
    export default res.exitCode === 0;
  validator: { kind: boolean }
  on: { 'true': process, 'false': failed }

process:
  type: script
  label: Process data
  script: |
    import { $ } from 'bun';
    const res = await $`jq '.items | length' "$ROSCOE_RUN_DIR/data.json"`.nothrow();
    export default res.exitCode === 0;
  validator: { kind: boolean }
  on: { 'true': done, 'false': failed }
```

`$ROSCOE_WORKFLOW_DIR` is still there when you _want_ cross-run sharing (e.g.
bundled helper scripts), but it's no longer the default place for per-run work.

### Things to know

- Working space, not the record: the durable, auditable record of a run is
  its node outputs and run history (in the database, shown in the run
  view). The folder is temporary. It's kept for a while (default 7 days,
  `ROSCOE_RUN_DIR_TTL_HOURS`) and then cleaned up automatically. Anything
  that must survive pause/resume or be auditable should be a node's output
  (its returned `result`, or stdout) rather than a file in the folder.
- Created on demand: the folder is created the first time a script node
  uses it, so AI-only workflows never create one.
- No secrets: the folder is plaintext on disk until it's cleaned up, so
  don't write secrets there.
- Shared within one run: if a single node fans out internally and writes
  files in parallel, give each writer a distinct subpath (e.g.
  `$ROSCOE_RUN_DIR/$nodeId/…`).
- `run` is a reserved name: a node, input, or output can't be called `run`.

## Worked example

An `ai_agent` produces a summary, an `ai_judge` evaluates it, and a script
writes the verdict to disk.

```yaml
name: summary-pipeline
version: 1
initial: summarise
states:
  summarise:
    type: ai_agent
    label: Summarise input
    model: claude-haiku-4-5
    prompt: |
      Summarise the following text in one paragraph:

      {{ source }}
    on:
      done: judge

  judge:
    type: ai_judge
    label: Is the summary faithful?
    model: claude-sonnet-4-5
    prompt: |
      Original text:
      {{ source }}

      Proposed summary:
      {{ summarise.response }}

      Is the summary an accurate reflection of the original?
    validator:
      kind: confidence
      threshold: 70
    on:
      'true': record
      'false': record

  record:
    type: script
    label: Record verdict
    script: |
      const verdict = {
        summary: process.env.ROSCOE_OUT_summarise_response,
        score: Number(process.env.ROSCOE_OUT_judge_score),
        reasoning: process.env.ROSCOE_OUT_judge_reasoning,
      };
      await Bun.write('verdict.json', JSON.stringify(verdict, null, 2));
      export default true;
    validator:
      kind: boolean
    on:
      'true': done
      'false': done
```

Run it with:

```bash
roscoe run summary-pipeline --var source="Long input text here..."
```

## Where to next

- [Condition expressions](/docs/authoring/expressions) — branch on
  `variables` with a tiny expression language.
- [AI agent node](/docs/nodes/ai-agent) — full field reference.
- [Script node](/docs/nodes/script) — return-value semantics and env
  injection details.
