Skip to content

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.yaml itself takes a top-level cli: only; the per-step override lives on each plan step.
  • Workflow manifests under templates/workflows/*.yaml do 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:

  1. The top-level cli: in the plan or in bernstein.yaml.
  2. The role default supplied by the model-routing policy.
  3. 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:

  1. Plan ingest. plan_loader._parse_step reads them off each step dict and writes them onto the Task (plan_loader.py:255-294).
  2. Planner-emitted child tasks. When the planner posts derived tasks to the task server, it includes cli, model, and effort in the JSON body (planner.py:91-96). A regression in this exact path was fixed in PR #1259 and pinned by tests/unit/test_per_step_routing.py.
  3. Spawner dispatch. The agent spawner reads task.cli and resolves the adapter from the registry before launching the process; if task.cli is 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 substitute auto.
  • 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: or model:. The manifest loader rejects the file at validation time (workflow_spec.py enforces extra="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 commented cli: override example.