# ai_agent node

Sends a prompt to a Claude model and records the raw text response into the
workflow's variables. Unlike [ai_judge](/docs/nodes/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](/docs/nodes/consensus) (N agents) instead.

## YAML schema

```yaml
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: report
```

## Configuration

| 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](#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](#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](/docs/recipes/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](/docs/authoring/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

```yaml
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: success
```

## Tool-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.

```yaml
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: verify
```

The 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 `claude` CLI is required.** Tool-enabled nodes always run on the CLI
  backend (the API/SDK backend has no built-in file/bash tools), regardless of
  whether `ANTHROPIC_API_KEY` is set. If the CLI isn't installed the node fails
  with a clear message: install [Claude Code](https://claude.ai/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.
- **`response` still 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-only `Read`/`Grep` and routes the node through the CLI so
  the model can query the spilled data. See [Oversized inputs](#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 `claude` CLI, 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 can `Grep` it line-wise; anything else is written as
  plain text. The node is auto-granted read-only `Read` and `Grep` (never
  `Bash` or `Write`) 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](/docs/nodes/map) 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](/docs/configuration/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_agent` cannot route. Adding
  multiple keys to `on` is a schema violation. Use
  [ai_judge](/docs/nodes/ai-judge) when the _content_ of the answer needs to
  determine the next step.
- **Forgetting to allow the model.** `model:` must appear in the
  `roscoe.yaml` model allow-list. Pre-flight rejects unknown models with a
  clear error before any node runs.
- **Empty responses.** If the model returns no text, `ai_agent` retries up
  to `maxRetries` times 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](#oversized-inputs)), but summarizing
  upstream still gives the model a cleaner, cheaper prompt.
- **Accidentally relying on the response shape.** `ai_agent` returns
  free-form text. If you need structured output, prompt for JSON in
  `ai_judge` and let its validator parse the envelope.

## Where to next

- [consensus](/docs/nodes/consensus) — vote across N agents instead of
  trusting one response.
- [map](/docs/nodes/map) — fan the same prompt across branches, or map over
  a list, and reduce the results.
- [Output chaining](/docs/authoring/output-chaining) — read
  `{{node.response}}` from a later prompt or condition.
- [LLM backends](/docs/configuration/llm-backends) — how Roscoe picks between
  the SDK and the `claude` CLI.
