Named-agent dispatch — how an isolated step receives its instructions
A route's step can allocate its work to a named agent instead of the in-loop
session. When a step carries agent: <name> (any name other than executor or
reviewer), satelle dispatches that step: it spawns the agent CLI configured
in .satelle/workflows/agents.toml under [<name>], hands it the work, and folds the
result back in. The agent runs with a fresh context — it never sees the
conversation — so the contract below is how it learns what to do and how it gets
the rest of the story.
The repo agents file is committed substrate by product default (role set +
bindings a clone needs to run gated steps); execution detail and secrets live
in the machine catalog and satelle.local.toml. See
satelle help global-agents for that posture and the fresh-clone checklist.
The dispatch contract
Dispatch fires on entry to the step, after that step's entry gate accepts, and ONLY because the route allocated that step to a named agent.
Flat dispatch. The orchestrator is the sole scheduler: orch → step → orch.
Steps never call steps. A reviewer returns a verdict and dispatches nothing.
An agent-less, agent: executor or agent: reviewer step dispatches nothing —
entering a step never fires an agent of its own.
An advisor is a named agent the route says the orchestrator MAY consult —
park triage, a post-close retrospective. It is a declaration, never a dispatch:
satelle story route <id> names it, the orchestrator decides when to consult it,
and the orchestrator records the advice on the story. (The earlier
on-enter entry dispatch is retired: a step that fires an agent at itself hides
work from the one place accountable for the route.)
The agent receives:
- System prompt, assembled in this order:
- the session-resident principles (unless the binding sets
inject_principles = false; default on), - an executor charter — you are performing this step of this workflow; do the step's work, but never change the item's status (the workflow's gates govern every advance),
- the pull-context call-to-action (see below),
- the step's
skills:rubric — the instructions for the step.
- the session-resident principles (unless the binding sets
- Payload (dual delivery): the work item as JSON —
{story, from, to, review_skill}.storycarries the id, title, body, and acceptance criteria;from/toare the transition being performed. Delivery is always on stdin, and the same bytes substitute into the command placeholder{payload}when the template includes it (one argv token). Stdin-first CLIs (e.g. Claude) leave{payload}out of the template so the prompt is not double-fed; argv-first CLIs (e.g.grok -p {payload} …) opt in. Empty{model}/{settings}drop their flag; empty{payload}does not. - Capabilities: the binding's
toolsgrant, and itsmodelunless the
Multi-reviewer steps (parallel:)
A step with several reviewers: runs them concurrently by default (cap 4):
all verdicts are collected with no short-circuit, ledger order stays list order,
and any reject still refuses the transition (the error names every rejecting
reviewer). Trade-off: a rejected parallel round spends tokens on every reviewer.
Set parallel: 0 on the step for byte-for-byte sequential execution with
first-reject short-circuit, or parallel: N to bound the fan-out. See
satelle help workflows.
Gate binding by agent name
A step's reviewer_agent: (or a ## gate section's agent:) may name any
role = "reviewer" binding in .satelle/workflows/agents.toml. Omitted, the gate uses
[reviewer]. The agents layer owns harness, tools, and model — the route names
who. See the satelle-route-standard principle.
What each role needs
The two roles get their context by opposite routes, so they need opposite grants.
- Performers — a spine step with
agent: <name>— are dispatched as a child process with no conversation history. They reconstruct context by pulling the story, its documents, and the ledger, so the grant must carry a context channel: eitherBash(satelle:*)(a broadBash,Bash(*)or*also qualifies) for the read-only satelle CLI, orread_filefor disk reads under~/.satelle/<repo-key>/stories/<id>/. Claude-onlyReaddoes not qualify — the Claude pull path is the CLI, not a disk-first rubric. - Reviewers (
role = "reviewer", named by a step'sreviewers:or by a## gatesection) need no channel: satelle pushes the attachments into the transition payload'sdocsarray, and reviewer bindings never reach the dispatch path that consults a grant. A shell grant on a reviewer is capability that is never exercised — it only widens the ceiling.
satelle agent validate judges both before you engage anything: a performer
with no channel is an error (non-zero exit — dispatch would refuse it later
anyway), and an unused reviewer shell grant is a warning (exit 0 — keeping
it is the repo's call). One predicate decides the channel question for both the
runtime refusal and validate, so they cannot disagree.
Refusals (fail loud, never silent):
- A step names
agent: <name>but.satelle/workflows/agents.tomldefines no[<name>]binding → the transition is refused (there is no silent in-loop fallback). - A dispatched binding's
toolsgrant carries no context channel (see What each role needs above) → the dispatch is refused, because the agent could not pull its context. - A binding whose
commandisin-loopkeeps the step with the orchestrating session — not dispatched, and so exempt from the channel requirement.
Control plane in vs agent I/O out
- In (orchestrator / any agent → satelle): satelle CLI verbs only
(
satelle story set,story get, …). Small local surface — not an MCP tool dump of every verb. Status and engagement change only through satelle. - Out (satelle → isolated worker): subprocess configured in
.satelle/workflows/agents.toml. Two transports, one binding shape (epic:agent-dispatch-transport):
| interface | Meaning |
|-------------|---------|
| command (default; omit = command) | Full multi-token argv template; any CLI (Claude Code, grok -p, wrappers, custom). |
| acp | Agent Client Protocol over stdio; command is the spawn line only (e.g. grok agent stdio). System/payload ride the session, not {placeholders}. |
Shared fields on both: role, tools, model, effort, secondary,
principles, env, timeout, settings. Claude Code does not support ACP
— keep Claude on interface = command. An ACP-capable CLI is usable when it
implements ACP agent stdio and the binding sets interface = "acp". Workers
never advance story status; they return text/verdicts that satelle enacts after
gates.
Progressive execution diagnostics
Command JSONL and ACP session/update traffic are normalized into the same
provider-neutral execution events: start, heartbeat, safe message, tool start
and completion, artifact candidate, usage, completion, and failure. Interactive
CLI progress is written to stderr, while the command's structured result remains
on stdout and the final agent response remains authoritative.
Named dispatches write a sanitized normalized event log under the repository's
Satelle runtime logs/dispatch/ directory. Set
SATELLE_AGENT_TRACE_RAW=1 only for short-lived transport debugging to create a
sibling -raw.log; raw traces are opt-in because provider traffic may be
sensitive. Satelle filters hidden reasoning and redacts obvious credential
shapes from both surfaces, but operators should still protect and remove raw
traces after diagnosis.
Structured step artifacts
A skill can ask Satelle to own a dispatched step's final artifact by declaring a generic contract in its frontmatter:
output_name: plan
output_type: plan
output_required: true
output_schema: body
output_ac_coverage: true
The isolated agent returns one canonical final object:
{"artifact":{"name":"plan","type":"plan","body":"# Plan\n\n## AC1\n..."}}
Satelle decodes command and ACP results through the same seam, validates the declared fields and optional acceptance-criterion coverage, and attaches the typed document before committing the workflow transition. A decode, validation, or attachment failure refuses the transition and releases its in-flight lease. Because Satelle owns the write, a contracted planner can use only read-only repository tools.
An output-contract skill can opt into a bounded validate–repair–escalate policy:
attempt_repair_max: 1
attempt_escalate_max: 1
attempt_max_total: 3
attempt_token_budget: 12000
attempt_time_budget: 8m
attempt_on_exhaust: fail
attempt_initial_effort: low
attempt_repair_effort: medium
attempt_escalate_effort: high
attempt_escalate_binding: stronger-planner
All keys are optional. With no attempt_* keys, dispatch remains a single
validate-or-fail call. Binding names refer to ordinary provider-neutral
agents.toml sections; effort overrides use the same command/ACP effort seam as
the binding itself. Quality escalation is deliberately separate from
secondary=, which remains reserved for rate-limit or service-unavailable
failover.
After each candidate, Satelle runs the declared deterministic output validators. A valid initial result attaches immediately. An invalid result can receive a targeted repair containing the prior draft and all validator findings; only after configured repair attempts fail can the stronger binding or effort run. Attempt count, token (when reported), and wall-time budgets bound the loop. Cancellation and invocation timeouts stop immediately. Exhaustion always fails the transition: no policy can attach an invalid artifact or bypass a workflow gate.
Each policy attempt records an agent-attempt telemetry event with phase,
binding, model, effort, elapsed time, validator findings, escalation reason, and
usage_available. Token fields are omitted when the transport did not report
usage, so unavailable cost is never represented as measured zero.
Legacy self-attaching steps remain supported. To migrate one:
- Add the
output_*contract to its skill. - Change its rubric to return the JSON artifact instead of running
satelle story attach. - Remove
Bash(satelle:*)from its binding when no other Satelle verb is needed. - Keep the workflow's exit review responsible for semantic artifact quality.
Planner transport evidence
For this repository the default [planner] remains Claude's non-interactive
command transport. The comparison is reproducible with make planner-bench,
which runs the same planning fixtures through that binding shape and Grok ACP
and writes versioned per-run records plus redacted raw and attached-artifact
sidecars under tests/plannerbench/out/. The schema and interpretation guide is
tests/plannerbench/EVIDENCE.md.
Artifact quality failures remain inspectable benchmark outcomes with explicit
per-criterion reasons. Infrastructure failures or an under-sampled selected
cell fail the target. Usage that a transport does not report is n/a with
provenance, never numeric zero.
Changing the binding requires ACP to preserve 100% artifact correctness and
policy fidelity, introduce no reliability regression, and win at least two of:
20% lower median wall time, lower median tokens, or strictly better failure
diagnostics. An ambiguous or under-powered result retains Claude command.
Operators can use Grok ACP as a temporary planner fallback by copying its
benchmark binding into [planner]; the documented default is not rewritten
automatically.
Reasoning effort (effort=)
Optional per-binding thinking/reasoning level (e.g. low | medium | high).
Empty means the peer default.
| Transport | How effort is applied |
| --- | --- |
| command | Substitutes into {effort} (flag dropped when empty, like {model}). Also supports fused forms such as model_reasoning_effort="{effort}" (empty drops the whole token and a preceding -flag). Default Claude/Grok templates include the flag; DefaultCodexExecCommand uses -c model_reasoning_effort="{effort}" (TOML-quoted string for Codex -c). |
| ACP | Session path: session/set_config_option for reasoning_effort / effort (failure-tolerant). Grok-shaped ACP spawns also receive argv --reasoning-effort (Grok CLI flag). Codex ACP and other non-Grok peers never get that argv flag — it is not ACP (sty_aa726901). |
Rate-limit secondary (secondary= / [defaults])
When an isolated dispatch fails with a classified rate-limit or unavailability error (429, overloaded, quota, 503, …), satelle retries once on a secondary binding — without rewriting agents.toml mid-incident:
[defaults]
secondary = "fallback-grok" # used when a binding omits secondary=
[planner]
# secondary = "fallback-grok" # per-binding override of [defaults]
effort = "high"
[fallback-grok]
interface = "acp"
command = "grok agent stdio"
tools = "read_file,grep,list_dir"
model = "grok-4.5"
Non-rate-limit failures still refuse the transition (no silent swallow). Unconfigured secondary preserves single-binding behaviour. Failover is per-dispatch only (not a sticky rewrite of the primary binding).
Example ACP binding (optional; defaults stay command):
[reviewer-acp]
role = "reviewer"
interface = "acp"
command = "grok agent stdio"
tools = "read_file,grep,list_dir"
model = "grok-4.5"
principles = "session"
Codex — preferred ACP, secondary command (sty_3b4909bb)
Codex is a first-class agent on the same two transports as everyone else. There is no third satelle interface. App Server is an implementation detail of the ACP adapter, not a satelle protocol.
| Preference | Transport | Binding shape |
| --- | --- | --- |
| 1. Preferred | ACP | interface = "acp" + command = "npx -y @agentclientprotocol/codex-acp" (DefaultCodexACPCommand) |
| 2. Secondary | command | interface = "command" (default) + full codex exec -s read-only -m {model} -c model_reasoning_effort="{effort}" {system} (DefaultCodexExecCommand) |
Bare command = "codex" is rejected by validate (like bare claude/grok);
satelle init / migrate expands it to DefaultCodexExecCommand. Global
NewRunner("codex") resolves to that command template — for ACP, set
interface = "acp" explicitly.
satelle agents vs satelle agent: satelle agents install|remove provisions
two satelle-owned surfaces per target (claude / grok / codex / all):
- Launchers under
$SATELLE_HOME/agents/bin/(e.g.satelle-codex→npx -y @agentclientprotocol/codex-acp— nostdiosubcommand; that is not part of the adapter contract). Generated ACP bindings usecommand = "sh <launcher>"(multi-token) sointerface=acpaccepts them. - Harness compliance scaffolds in the repo:
.claude/settings.json,.grok/hooks/satelle.json,.codex/hooks.json— blocking PreToolUse hooks that deny governed code-changing actions unless a satelle story is engaged.
Ownership: only marker-bearing launchers and satelle hook entries are written or
removed; user harness keys/hooks are preserved. Install/remove are idempotent.
Neither path changes the default reviewer or [agent] cli.
satelle agent (singular) selects and validates the headless CLI / agents.toml.
PreToolUse deny channels
The installed satelle-hook.sh uses one coherent structured-deny contract:
| Harness | Structured deny emitted by Satelle | Handler exit |
| --- | --- | --- |
| Claude | hookSpecificOutput.permissionDecision=deny with a non-empty permissionDecisionReason | 0 |
| Grok | top-level decision=deny with a non-empty reason | 0 |
| Codex | hookSpecificOutput.permissionDecision=deny with a non-empty permissionDecisionReason | 0 |
Exit 0 means the hook handler ran successfully; the JSON decision still blocks
the tool. Claude and Codex use a separate fallback contract for exit 2: the
blocking reason must be non-empty on stderr and structured stdout is not the
authoritative channel. Do not mix exit 2 with JSON-only stdout and empty
stderr. Satelle's wrapper prefers the structured path for policy and
infrastructure denials, emits a static safe infrastructure reason when the
binary is absent or unusable, and keeps irrelevant/read-only Bash fail-open so
the operator can diagnose the installation.
Install compliance + ACP adapter (dogfood)
# Launchers + .claude/.grok/.codex blocking-hook scaffolds (repo cwd)
satelle agents install all
# Or a single harness:
satelle agents install codex
# Manual ACP fallback (or use npx each run):
# npm install -g @agentclientprotocol/codex-acp
# Authenticate through the Codex CLI (for example, `codex login`). Satelle does
# not require CODEX_API_KEY, OPENAI_API_KEY, or a Satelle-specific environment flag.
# Optional: point at a specific codex binary
export CODEX_PATH=$(which codex)
# Reviewer dogfood: keep the adapter in read-only agent mode when possible
export INITIAL_AGENT_MODE=read-only
# Codex will prompt to trust .codex/hooks.json on first run (/hooks).
# Automation: codex exec --dangerously-bypass-hook-trust …
Local hook smoke (never CI): run go test -tags codexlive ./tests/codexlive/
(or make codex-smoke when present). It uses existing Codex CLI login/configuration
and reports a clear prerequisite when Codex is absent or unauthenticated. Hermetic
unit tests cover the install path without Codex, npm, or API keys.
Sample agents.toml — Codex ACP for low-cost / park roles
# Preferred: reuse satelle's ACP client (same as Grok).
[reviewer-summary]
role = "reviewer"
effort = "low"
interface = "acp"
command = "npx -y @agentclientprotocol/codex-acp"
tools = "read_file,grep,list_dir"
model = "o4-mini"
principles = "session"
[retrospective]
role = "agent"
effort = "high"
interface = "acp"
command = "npx -y @agentclientprotocol/codex-acp"
tools = "read_file,grep,list_dir,Bash(satelle:*)"
principles = "session"
[blocked-triage]
role = "agent"
effort = "high"
interface = "acp"
command = "npx -y @agentclientprotocol/codex-acp"
tools = "read_file,grep,list_dir,Bash(satelle:*)"
principles = "session"
Sample — Codex exec command transport
[reviewer-codex-exec]
role = "reviewer"
command = "codex exec -s read-only -m {model} -c model_reasoning_effort=\"{effort}\" {system}"
# payload is always on stdin; do not add {payload} to argv
effort = "high"
model = "o4-mini"
principles = "session"
satelle agent validate treats -s read-only as reviewer ceiling evidence and
hard-rejects role=reviewer Codex command templates whose effective
sandbox is not read-only — including workspace-write, an omitted sandbox, and
danger-full-access / --dangerously-bypass-approvals-and-sandbox. Codex ACP
reviewers are not required to carry -s (ceiling = tools grant + permission
policy).
Live dogfood vs CI
- Hermetic tests (in
make integration/ unit suites) use fake ACP peers and validate/buildArgs only — they never requirecodex, npm, or API keys. - Live dogfood is optional and uses the same model as Claude and Grok: the
operator authenticates the agent CLI itself (
codex login, Claude login, Grok session). Satelle never stores or injects agent API keys. Install the adapter when using ACP, point a named binding at Codex ACP or exec, runsatelle agent validate, then drive a cheap gate (e.g. step-summary). Optional live probes must never be required by CI.
Claude remains the default init [reviewer] preset until an operator opts in.
The command template — full templates and placeholders
Each binding's command says how the agent runs. With interface = "command" (the default), an isolated binding requires a full multi-token command template — the real argv is literal in the file so the operator can
read exactly what will run. Bare single-token CLI names (claude / grok /
codex) are rejected by satelle agent validate (and refuse engage); run
satelle init to expand a legacy bare preset, or write the full template
yourself. The only bare single-token value that remains valid is:
command = "in-loop"— no subprocess; the driving session performs the step.
An omitted [reviewer] command resolves to the default full claude template
(read-only denylist on Write/Edit/NotebookEdit/Bash). Example full templates
seeded by init and the canonical consts:
- Claude (stdin-first):
claude -p --output-format json --disallowedTools Write,Edit,NotebookEdit,Bash --append-system-prompt {system} --allowedTools {tools} --model {model} - Grok (argv-first):
grok -p {payload} --system-prompt-override {system} --tools read_file,grep,list_dir -m {model} --deny Write --deny Edit …
The first token is the binary; the rest are argv tokens carrying the placeholders
(each one argv token): {system} (the rubric), {tools} (the grant),
{model}, {settings}, {payload}. Empty {model}/{settings} drop
that flag; empty {payload} does not. The work item is always also on stdin
(dual delivery), so stdin-first CLIs (claude) omit {payload} and argv-first
CLIs (grok) include it.
With interface = "acp", command must not contain those placeholders —
satelle sends system/payload over the ACP session instead.
The field is
command; the older keyharnessis a deprecated alias — a pre-renameagents.tomlstill parses (commandwins when both are set).
The agent's output is captured to the executor log (and, for a task execution, a run-output document), so an isolated step's work stays reviewable.
The pull contract — reconstruct context by id, don't wait to be told
Because a dispatched agent starts fresh, satelle does not cram documents or history into the payload. The payload is a handle — the item and its id — and the agent pulls everything else itself, by id, with the read-only satelle CLI:
satelle story get <id>— the full current record.satelle story docs <id>, thensatelle story doc <id> <name>— the attached documents: the implementation plan and every prior step summary (each gated transition deposits one), which narrate the work so far.satelle ledger list --story <id>— the evidence ledger (transitions, review verdicts, summaries).
A read-only reviewer whose grant excludes Bash judges attachments from the
transition payload's docs array (injected by the engine, sty_58fa970e) — no
disk path required. Shell-granted agents may also pull more via the satelle CLI.
Do not use in-repo .satelle/stories/ — that path is obsolete
post-relocation. Fetch before concluding a document or a prior step is
missing (payload first, then CLI when available).
What makes a step safe to dispatch (sufficiency)
- Give the step a rubric. A dispatched step needs
skills: <name>. A rubric-less dispatched step (agent: <name>with noskills:) receives only the charter and the item — rarely enough to perform a real step. - Make the item self-sufficient. An isolated agent never sees the conversation, so the story's body, acceptance criteria, and attached docs must stand alone. Anything the step needs that lives only in the chat is lost. The plan and step-summary documents (pulled by id) are the sanctioned channel for carrying context forward — not the conversation.
Gate/dispatch sequencing — judge the EXIT edge
Dispatch fires on entry to the target state, after the entry gate accepts — so a dispatched step's work must be judged by its exit edge's gates. An entry-gated state followed by an ungated commit/push ships the dispatched agent's mutations unjudged. When you allocate a step to a named agent, make sure the edge out of that state carries the review that vets what the agent did.
Custom agents — a worked example
To add a custom agent (say an architect that runs on a stronger model), you
define it as a binding and allocate a step to it — both in satelle's own
substrate, never in a harness's agent directory.
-
Define the binding in
.satelle/workflows/agents.toml:[architect] command = "claude -p --append-system-prompt {system} --allowedTools {tools} --model {model}" tools = "Read,Grep,Glob,Bash(satelle:*)" # read-only + the pull-context CLI model = "opus" # per-step model selection is the model keyArgv-first CLI example (payload on
-pand still on stdin). Grok's single-turn flag is-p/--single— put{payload}there (not only on stdin). Prefer--output-format plainso the model's decision JSON is on stdout for gate parsing;--output-format jsonalso works because satelle unwraps Grok's{ "text": "…" }envelope the same way it unwraps Claude's{ "result": "…" }. Use enough--max-turnsfor tool-using gates (1 is often too low).[architect] command = "grok -p {payload} --system-prompt-override {system} --tools {tools} --always-approve --output-format plain --max-turns 8 --no-subagents" tools = "read_file,grep,list_dir,run_terminal_command" # Grok-native tool ids # `read_file` IS a context channel (disk reads under ~/.satelle/<repo-key>/ # stories/<id>/), so a Grok-native grant needs no Bash(satelle:*) to dispatch. -
Allocate a route step to it in
step.md:## design agent: architect skills: architect -
satelle dispatches it on entry to
design: the item on stdin, thearchitectrubric (+ charter + pull-context call-to-action) as the system prompt, the binding's tools/model as the grant — on whatever CLI the harness names.
Anti-pattern: defining that agent in a harness-specific agent directory (e.g.
.claude/agents/architect.md) works for that one harness, but hides the process
configuration from satelle — it cannot see, validate, dispatch, or carry it
repo-agnostically, and it silently pins the repo to one CLI vendor. Keep process
agents in .satelle/workflows/agents.toml + the route's two halves.
Mixing model backends — per-binding env + ${VAR} (sty_001558ce)
model selects the model within the harness's CLI; to point one step at a
different backend (a non-default API), a binding may also set env —
environment variables layered onto that dispatched agent's process, binding keys
winning. Each value may reference the [vars] KV via ${NAME}, resolved at
load; an unknown ${VAR} refuses the command (naming the binding + var) rather
than dispatching with a blank credential. ${...} appears only in env values, so
it never collides with the {system}/{tools}/{model} argv placeholders.
Put the KV in [vars]: NON-secret values may sit in the committed satelle.toml;
secrets go in the gitignored satelle.local.toml, whose keys win per-key. The
KV is file-only (no DB) and satelle.local.toml is excluded from the substrate
push, so a key never leaves the machine.
Worked example — run one step on GLM through z.ai's Anthropic-compatible
endpoint (the same claude CLI, no wrapper binary), while the in-loop session
stays on its own model:
# .satelle/workflows/agents.toml
[planner]
command = "claude -p --append-system-prompt {system} --allowedTools {tools} --model {model}"
tools = "Read,Grep,Glob,Bash(satelle:*)"
model = "glm-4.6" # the model id the endpoint expects (glm-5.2 is the newest)
env = { ANTHROPIC_BASE_URL = "https://api.z.ai/api/anthropic", ANTHROPIC_AUTH_TOKEN = "${GLM_API_KEY}" }
# satelle.local.toml (gitignored — never commit the key)
[vars]
GLM_API_KEY = "sk-…"
Keep the committed default on your always-available backend so a clone with no
key still runs; make the alternate backend an opt-in the operator switches on.
An exit gate that re-judges the step (e.g. plan → in_progress) on the default
backend keeps a weak alternate-model output from reaching the build.
See also: satelle help workflows (choosing a lifecycle) and
satelle help reviewer-checks (gate skills).
A step names the binding that gates it with reviewer_agent: (default [reviewer]). Models live in agents.toml only.
Mirrored from satelle’s built-in help. Read it in the binary with
satelle help agent-dispatch, or see the canonical source in the
satelle repo.