Per-step CLI and model routing¶
How do I run one step with one CLI agent and the next step with a different one?
Bernstein supports per-step routing on plan YAML files and on tasks posted directly to the task server. A step can pin its own adapter (cli:) and its own model (model: / effort:), and those hints win over the top-level plan-wide defaults. Use this when one stage of the work needs a different runtime than the rest of the plan, for example running red/green/refactor on one adapter and the review pass on another.
This page covers what the per-step fields do, where they are honoured, where they are silently dropped, and how to verify the route the orchestrator actually took.
TL;DR¶
| Field | Type | Scope | Wins over |
|---|---|---|---|
cli | string | One step | Top-level plan cli: and role policy. |
model | enum | One step | Cascade router initial pick. |
effort | enum | One step | Cascade router effort default. |
- Set them inside
steps:in a plan YAML. - Leave them off when the step should follow the plan-wide default.
bernstein.yamlitself takes a top-levelcli:only; the per-step override lives on each plan step.- Workflow manifests under
templates/workflows/*.yamldo not carry these fields today; see Where it works below.
What the fields mean¶
cli¶
Pins the adapter for one step. Accepts any name the adapter registry knows: claude, codex, gemini, qwen, opencode, cursor, copilot, and so on (see the full list with bernstein adapters list). A per-step cli: overrides:
- The top-level
cli:in the plan or inbernstein.yaml. - The role default supplied by the model-routing policy.
- The
auto-detection result.
If the named adapter is not installed, the run fails fast with a clear error rather than silently substituting a different one.
model¶
Pins the model variant for one step: auto, opus, sonnet, haiku. Only meaningful when the resolved adapter is Claude-compatible (other adapters bind their own models and ignore this hint). When set, the cascade router skips its initial selection logic and uses the pinned model as attempt 0.
effort¶
Pins the effort tier for one step: low, normal, high, max. This is a Claude-adapter knob that maps onto the inline reasoning budget. Like model:, it is honoured when the adapter is Claude-compatible.
Worked example¶
Discussion #962 asks the canonical question: red/green/refactor on one adapter, review on another. Express that in a plan:
name: rgr-with-review
description: >
Red/green/refactor on opencode; bring claude in for the review pass.
cli: opencode # plan-wide default
stages:
- name: rgr
steps:
- title: "Red: write the failing test"
role: qa
# cli omitted on purpose: this step inherits the plan-wide
# opencode adapter so the test is authored by the same runtime
# that will implement the fix below.
- title: "Green: make it pass"
role: backend
# cli also inherited from the plan-wide default.
- title: "Refactor: tighten the implementation"
role: backend
# still on opencode.
- name: review
depends_on: [rgr]
steps:
- title: "Independent review pass"
role: reviewer
cli: claude # override: review uses a different runtime
model: opus # high-stakes review pinned to opus
effort: high
Three steps inherit cli: opencode. The fourth pins cli: claude plus model: opus and effort: high so the review pass runs on a different adapter and a heavier model than the rest of the plan. No manager agent needed; the plan is the decomposition.
Where it works¶
| Surface | Per-step cli: / model: / effort: | Notes |
|---|---|---|
Plan YAML (bernstein run --from-plan) | Yes | Read in plan_loader._parse_step. Stored on the resulting Task row. |
POST /tasks on the task server | Yes | The HTTP payload accepts the same keys; the planner forwards them when it creates child tasks (planner.py:86). |
| Manager-emitted plans | Yes | When the manager agent decomposes a goal it can stamp cli and model on individual steps. |
Workflow manifests (templates/workflows/*.yaml) | No | The Archon-style manifest schema validates with extra="forbid" (workflow_spec.py). Per-node routing is tracked separately; use a plan YAML when you need it today. |
bernstein.yaml | Top-level only | The seed file has one global cli:. Per-step routing belongs on the plan or task. |
If you write a workflow manifest and need per-step routing, the recommended path is to author it as a plan YAML instead. Plans and workflows are sibling primitives that target different shapes; the plan loader is the one with full routing-hint support today.
When the hint is forwarded vs. dropped¶
The orchestrator forwards cli / model / effort in these places:
- Plan ingest.
plan_loader._parse_stepreads them off each step dict and writes them onto theTask(plan_loader.py:255-294). - Planner-emitted child tasks. When the planner posts derived tasks to the task server, it includes
cli,model, andeffortin the JSON body (planner.py:91-96). A regression in this exact path was fixed in PR #1259 and pinned bytests/unit/test_per_step_routing.py. - Spawner dispatch. The agent spawner reads
task.cliand resolves the adapter from the registry before launching the process; iftask.cliis unset it falls back to the role policy plus the orchestrator-wide default.
The hint is dropped (silently or with a warning) in these cases:
- The adapter named in
cli:is not installed. The run aborts with a registry-miss error; it does not substituteauto. model:/effort:are set on a step whose resolved adapter is not Claude-compatible. The fields are accepted by the schema but the non-Claude adapter ignores them. No warning today; track the actual pick in the trace (next section).- Workflow manifest nodes carry
cli:ormodel:. The manifest loader rejects the file at validation time (workflow_spec.pyenforcesextra="forbid").
How to verify the route in the trace¶
Every task spawn writes one JSONL event per attempt under .sdd/traces/<task_id>.jsonl. The relevant fields:
| Field | Meaning |
|---|---|
task.cli | The adapter override on the task row. Empty when unset. |
task.model | The model override on the task row. Empty when unset. |
task.effort | The effort override on the task row. Empty when unset. |
spawn.adapter | The adapter actually launched. Compare to task.cli. |
spawn.model | The model the adapter reported it loaded. |
Two quick checks:
# Show the override on every task in the run.
jq -r '[.task_id, .task.cli, .task.model, .task.effort] | @tsv' \
.sdd/traces/*.jsonl | sort -u
# Confirm each spawn used the requested adapter.
jq -r 'select(.event=="spawn") | [.task_id, .task.cli, .spawn.adapter] | @tsv' \
.sdd/traces/*.jsonl
If task.cli is claude but spawn.adapter is codex, the run hit an adapter-resolution fallback (almost always: claude is not installed or not on PATH). The bernstein doctor output names the exact registry miss.
See also¶
- Plans (YAML schema) - full schema for the plan file, including the rest of the step fields.
- Model routing and escalation - what the cascade router does when
model:is not pinned. templates/plan.yaml- the in-repo seed file with the commentedcli:override example.