Sends a prompt to a Claude model and records the raw text response into the
workflow's variables. Unlike ai_judge, ai_agent
does not parse, validate, or branch on the response. It always proceeds
along its single outgoing transition.
Purpose#
Use ai_agent when you want the model to produce content that a later
step will consume: a summary, a draft, a translation, a chunk of generated
text. If you need the model to make a routing decision, use ai_judge (one
agent) or consensus (N agents) instead.
YAML schema#
states:
summarize:
type: ai_agent # discriminator
label: Summarize logs # required, shown in UI / logs
model: claude-haiku-4-5 # required, must be in roscoe.yaml allow-list
prompt: | # required, supports {{node.key}} interpolation
Summarize the following in one sentence:
{{collect.stdout}}
maxRetries: 1 # optional, 0-5, default 1
timeoutSeconds: 600 # optional; overrides the global default for this node
allowedTools: [Read, Edit, Bash] # optional; turns this into a tool-enabled agent
on: # exactly one outgoing key — name is yours
next: reportConfiguration#
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
type |
'ai_agent' |
yes | — | Discriminator. |
label |
string | yes | — | Human-readable name for the step. |
model |
string | yes | — | Model id; the runner enforces it against roscoe.yaml. |
prompt |
string | yes | — | Sent to the model verbatim after {{...}} interpolation. |
maxRetries |
integer (0-5) | no | 1 (or defaults.maxRetries from roscoe.yaml) |
Retries on empty response or transient backend errors. Timeouts are not retried. |
timeoutSeconds |
integer (1-2147483) | no | global default (defaults.timeoutMs, else 5 min) |
Hard wall-clock timeout for this node. Overrides the global default; raise it for long agentic edits. See Timeouts. |
allowedTools |
string[] | no | none (plain text completion) | When set, runs this node as a tool-enabled agent via the claude CLI with these tools allowed. See Tool-enabled agents. |
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 output three times in a row. Most useful on reviewer-shaped nodes. |
on |
transition map | no | — | At most one key; the executor takes the first. |
Outputs#
| Key | Type | Notes |
|---|---|---|
response |
string | The model's full text reply. |
Reference downstream as {{nodeId.response}}. See
Output chaining for how to
chain it into a later node's prompt or condition.
Transitions#
ai_agent is a single-transition node. The executor takes the first key
of the on map regardless of what the model said. The schema integrity
check forbids more than one outgoing edge from an ai_agent for this reason.
If you need branching, switch to ai_judge.
The transition key name is arbitrary; common choices are next or done.
Worked example#
name: summarize-system
version: 1
description: Collect uname/date and ask Claude for a one-line summary.
initial: collect
states:
collect:
type: script
label: Collect system info
script: |
import { $ } from 'bun';
const res = await $`uname -a && date`.nothrow();
export default res.exitCode === 0;
validator:
kind: boolean
on:
'true': summarize
'false': done
summarize:
type: ai_agent
label: Summarize
model: claude-haiku-4-5
prompt: |
Briefly summarize the following system output in one sentence:
{{collect.stdout}}
on:
next: report
report:
type: script
label: Print summary
script: |
// Read the upstream response from the environment — safer than
// interpolating it into source, since it can't break the syntax.
console.log(process.env.ROSCOE_OUT_summarize_response ?? '');
export default true;
validator:
kind: boolean
on:
'true': done
'false': done
done:
type: end
label: Done
outcome: successTool-enabled agents#
By default ai_agent is a one-shot text completion: prompt in, text out, no
side effects. Set allowedTools to turn it into an agentic step that can
read and modify files and run commands in the run's working directory, useful
for "apply this refactor", "fix the failing test", or "update the shared
onboarding doc" steps.
edit_readme:
type: ai_agent
label: Update the README
model: claude-sonnet-5
allowedTools: [Read, Edit, Write]
prompt: |
Update README.md to document the new --json flag.
on:
next: verifyThe allowed tool names are a curated set of Claude Code tools:
| Tool | Grants |
|---|---|
Read |
Read files. |
Edit |
Modify existing files in place. |
Write |
Create or overwrite files. |
Glob / Grep |
Search the working tree. |
Bash |
Run shell commands. |
WebFetch / WebSearch |
Fetch URLs / search the web. |
How it works and what to know:
- The
claudeCLI is required. Tool-enabled nodes always run on the CLI backend (the API/SDK backend has no built-in file/bash tools), regardless of whetherANTHROPIC_API_KEYis set. If the CLI isn't installed the node fails with a clear message: install Claude Code. - The agent operates in the run's working directory (
cwd). For an MCP-launched run that's the project root the call was scoped to; tool grants are pre-approved so the agent runs without interactive permission prompts. - Only the listed tools are allowed. Grant the narrowest set the step
needs, e.g.
[Read, Grep]for an analysis step that must not write. responsestill holds the agent's final message. It's a summary of what it did, not a transcript of every tool call.- Omitted or empty
allowedTools= plain text completion on the default backend, with one exception: a single oversized interpolated value auto-grants read-onlyRead/Grepand routes the node through the CLI so the model can query the spilled data. See Oversized inputs.
Oversized inputs#
Interpolating a big value into a prompt (say {{ extract.stdout }} from a
verbose script step, or a large JSON array of records) can blow past the
model's context window. Roscoe handles that for you instead of silently
dropping data:
- Under 16 KiB, an interpolated value is inlined unchanged.
- Over 16 KiB, on a node that can run on the
claudeCLI, the full value is spilled to a file in the run directory and replaced in the prompt by a short manifest: the spill file's absolute path, its size, and (for a JSON array) the record count and field names. The surrounding instruction text is left intact. A JSON array is written as JSONL (one record per line) so the model canGrepit line-wise; anything else is written as plain text. The node is auto-granted read-onlyReadandGrep(neverBashorWrite) so it can query the file instead of guessing from a truncated excerpt. - When the CLI isn't available (e.g. an API/SDK node with no tools), the value is head/tail truncated instead, with a marker pointing at the full spilled file (the older, deterministic fallback).
Two things worth knowing. The auto-grant is a real (read-only) privilege
increase: a no-tools node gains filesystem read access once its input crosses
the threshold, and the CLI's Read/Grep aren't sandboxed to the run
directory. That's why it's limited to those two read-only tools. And this
only kicks in for CLI-backed nodes, which already run non-deterministic tool
loops; below-threshold values and API/SDK nodes are untouched.
Retrieval, not synthesis. This path is built for finding things: a value, a matching record, the lines about a topic. The model greps the spill file and reads around the hits, which is reliable at any size.
It's much weaker at questions that need the whole input at once, like
"summarize everything", "how many of these rows are over the threshold", or
"total up every amount". Grep can't shortcut those, and the model won't
reliably read the entire file. For whole-document synthesis or aggregation,
use a map node instead: it splits the input into chunks,
gives each chunk to its own agent that reads it in full, then reduces the
per-chunk results. In a head-to-head on a ~49 KB document, a single
queryable agent's "count the rows over N" answer was wildly off while a
map + reduce landed within one of the true count.
Timeouts#
ai_agent has a 5-minute default wall-clock timeout (or defaults.timeoutMs
from roscoe.yaml if set). Set
timeoutSeconds on the node to override it for that step, and raise it for
tool-enabled agents that do real work (multi-file edits, builds), since those
take far longer than a one-shot completion. A timed-out node fails; timeouts
are not retried.
Common pitfalls#
- Trying to branch on the response.
ai_agentcannot route. Adding multiple keys toonis a schema violation. Use ai_judge when the content of the answer needs to determine the next step. - Forgetting to allow the model.
model:must appear in theroscoe.yamlmodel allow-list. Pre-flight rejects unknown models with a clear error before any node runs. - Empty responses. If the model returns no text,
ai_agentretries up tomaxRetriestimes before failing. Timeouts (5 min default) are not retried; they indicate a systemic hang, not a flaky completion. - Long prompts that overflow. There is no per-node prompt-length cap, but
interpolating
{{somenode.stdout}}from a verbose script step can blow past the model's context window. Roscoe spills or truncates oversized values automatically (see Oversized inputs), but summarizing upstream still gives the model a cleaner, cheaper prompt. - Accidentally relying on the response shape.
ai_agentreturns free-form text. If you need structured output, prompt for JSON inai_judgeand let its validator parse the envelope.
Where to next#
- consensus — vote across N agents instead of trusting one response.
- map — fan the same prompt across branches, or map over a list, and reduce the results.
- Output chaining — read
{{node.response}}from a later prompt or condition. - LLM backends — how Roscoe picks between
the SDK and the
claudeCLI.