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.
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': analyseToken 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
TemplateInterpolationErrorwhen a token resolves toundefinedornullwith 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 areJSON.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.
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:
┌─ 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:
- Restructure so the producing node sits on every path to the consumer (a pre-split node, or a join node both branches feed through).
- Provide a fallback:
{{ B.response || "" }}, soDruns on either branch and the fallback fires whenCwas 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.
// 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. |
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. |
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).
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 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:
roscoe run summarise-build --var env=production --var artifact=app.tar.gzThe variables are typed as strings. If you need a number or boolean inside a condition expression, parse it explicitly (see Condition 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:
{
"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:
console.log(process.env.ROSCOE_OUT_summarise_response);
// → Build OKRules 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
execto fail on some platforms.
This means you have two ways to use prior output in a script:
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': failedThe 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:
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 returnedresult, 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/…). runis a reserved name: a node, input, or output can't be calledrun.
Worked example#
An ai_agent produces a summary, an ai_judge evaluates it, and a script
writes the verdict to disk.
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': doneRun it with:
roscoe run summary-pipeline --var source="Long input text here..."Where to next#
- Condition expressions — branch on
variableswith a tiny expression language. - AI agent node — full field reference.
- Script node — return-value semantics and env injection details.