Use this when you have a deterministic command that produces text (build logs, test reports, a nightly export, system info) and you want a model to turn that output into a useful summary or classification. It's the simplest bridge between command-line tooling and a model that can explain the output in plain language.
The workflow#
name: demo-2-week-of-dinners
version: 1
initial: setup
states:
setup:
type: script
label: Set up the week
script: |
const nights = ['Monday', 'Tuesday', 'Wednesday'];
console.log(`Planning ${nights.length} dinners: ${nights.join(', ')}.`);
export default nights;
validator:
kind: boolean
on:
'true': plan
'false': couldnt-plan
plan:
type: map
label: Plan each night
model: claude-haiku-4-5
over: '{{ setup.result }}'
itemVar: night
prompt: |
Plan one dinner for {{ night }}. Give the dish, a one-line why it's
a good weeknight pick, and a short ingredient list.
reduce:
prompt: |
Here are the dinners you just planned:
{{ outputs }}
Turn them into one shopping list, grouped by aisle.
on:
success: done
failure: couldnt-plan
done:
type: end
label: Menu and list ready
outcome: success
couldnt-plan:
type: end
label: Couldn't finish the plan
outcome: failureThis is the shape of demo-2-week-of-dinners, one of the starter templates
seeded by roscoe init (trimmed here to a few nights; the real template plans
a configurable week and layers in diet and pantry inputs).
Running it#
roscoe run demo-2-week-of-dinnersThe script step runs immediately, building the list of nights to plan. The
map step then plans each night in parallel and folds the results into one
shopping list through its reduce step, and the run finishes at done. The
web UI shows every node's output side by side; the merged list is also
stored on the run record under variables.plan.reduced.
Why these node types#
script with validator: kind: boolean runs the setup logic and returns
true, which routes 'true' into the plan step. If you'd rather skip the AI
step when the script fails, return false from the script; that routes
'false' straight to couldnt-plan instead of burning model calls on empty
input. map suits the planning step because it's one task (plan a dinner)
applied across a whole list, not a single forward edge; anything more
decision-shaped (classify into one of N categories, decide whether to
escalate) wants ai_judge instead, so a validator can parse the response.
The template token {{ setup.result }} works because every script node
writes its structured result (whatever it export defaults), along with
stdout, stderr, and exitCode, to the variables bag, and any of them is
available to reference in a downstream prompt or script. The reduce step
sees the collected branch outputs as {{ outputs }}, a JSON array, so its
prompt is worded to expect a list rather than a single block of text.
Real-world shapes#
For a daily expense digest, the script exports the day's submitted expenses
to a CSV and the AI step turns the rows into a one-paragraph, plain-English
summary for a manager to skim. For CI summarisation, the script runs
bun run test and the AI step summarises failures into a Slack-ready
message; pair it with a downstream script node that posts to a webhook.
For a build narrator, the script runs git log --since='1 week ago' --oneline and the AI step writes a release-notes draft; wrap it with a
human approval node to make it a publish workflow (see
/docs/recipes/approval-flow). The same shape covers incident triage too:
run kubectl get events, let the AI step extract the likely root cause and
rank severity, then route to page-on-call vs. log-only with a condition
or ai_judge node.
Passing output to a later step#
A script node can hand its result to a later script two ways. The tidiest
is a template reference to the structured result:
{{ setup.result.0 }}.
Upstream outputs are also flattened into the environment as
ROSCOE_OUT_<node>_<field>, which is handy when a later step wants an upstream
value without templating it into the source:
post:
type: script
label: Post summary to Slack
script: |
import { $ } from 'bun';
const summary = process.env.ROSCOE_OUT_plan_reduced ?? '';
await $`curl -X POST ${process.env.SLACK_WEBHOOK} \
-H "Content-Type: application/json" \
-d ${JSON.stringify({ text: summary })}`;
export default true;
validator:
kind: boolean
on:
'true': done
'false': doneReading from the environment is the safer choice for anything large or
untrusted: a ${...} interpolation in Bun.$ is escaped for you, so an
upstream value full of quotes or shell metacharacters can't break the command.
Scripts also get two per-run values: $ROSCOE_RUN_DIR (a temporary folder
unique to this run) and $ROSCOE_RUN_ID (the run's id). Write a file to
$ROSCOE_RUN_DIR in one step and read it in a later one; it's safe even when
several runs execute at once, unlike the workflow-wide $ROSCOE_WORKFLOW_DIR.
See /docs/nodes/script and
/docs/authoring/output-chaining for the full
run namespace ({{ run.id }} / {{ run.dir }} work in any node).
Variations#
To add a verifier, chain the AI step into an ai_judge that checks whether
the result is faithful to the source (see
/docs/recipes/multi-step-analysis). To branch on the script's output, return a
string instead of a boolean and switch the validator to kind: enum to
route on it, e.g. classify by severity. For structured output, use
ai_judge with kind: enum so the model returns a known category that
drives the next transition instead of free text.
See also#
/docs/nodes/script— return values, validators, env passthrough/docs/nodes/map— fan-out modes, reduce, and per-branch outputs/docs/recipes/multi-step-analysis— verify the summary before publishing