Topology Reference
Topology defines the role structure and routing within an autoloop loop. It is declared in topology.toml and controls which roles exist, what events they can emit, and how events route to the next role.
Topology is advisory — it is not a hard workflow engine. The model receives routing suggestions and allowed events as context, and backpressure enforces the protocol at the event-emit boundary.
File format
topology.toml lives at the root of a loop's project directory.
name = "autocode"
completion = "task.complete"
[[role]]
id = "planner"
emits = ["tasks.ready", "task.complete"]
prompt_file = "roles/planner.md"
[[role]]
id = "builder"
emits = ["review.ready", "build.blocked"]
prompt_file = "roles/build.md"
[[role]]
id = "critic"
emits = ["review.passed", "review.rejected"]
prompt_file = "roles/critic.md"
[[role]]
id = "finalizer"
emits = ["queue.advance", "finalization.failed", "task.complete"]
prompt_file = "roles/finalizer.md"
[handoff]
"loop.start" = ["planner"]
"queue.advance" = ["planner"]
"build.blocked" = ["planner"]
"tasks.ready" = ["builder"]
"review.ready" = ["critic"]
"review.rejected" = ["builder"]
"review.passed" = ["finalizer"]
"finalization.failed" = ["builder"]Top-level keys
| Key | Type | Required | Description |
|---|---|---|---|
name | string | No | Human-readable name for the topology. |
completion | string | No | The event that signals loop completion. Falls back to event_loop.completion_event in autoloops.toml, then to the completion_promise text fallback. |
[[role]] — role definitions
Each [[role]] table defines one role in the loop. Roles are processed in declaration order.
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Unique identifier for the role. Used in handoff maps and prompt rendering. |
emits | array of strings | Yes | Events this role is allowed to emit. Determines the allowed-event set for backpressure. |
prompt | string | No | Inline prompt text for this role. |
prompt_file | string | No | Path to a markdown file containing the role's prompt, relative to the project directory. |
backend_kind | string | No | Override the loop's backend.kind for iterations routed to this role. See Per-role backend overrides. |
backend_provider | string | No | Override the ACP backend.provider preset for this role (kiro, claude-agent-acp, generic, or a custom label). |
backend_command | string | No | Override backend.command for this role's iterations. |
backend_args | array of strings | No | Override backend.args entirely (replace, not merge). |
backend_prompt_mode | string | No | Override backend.prompt_mode ("arg", "stdin", or "acp"). |
backend_timeout_ms | int | No | Override backend.timeout_ms for this role's iterations. |
backend_agent | string | No | ACP setSessionMode agent/mode for this role. Subordinate to agents.toml when the agent map resolves a non-empty value for the same role. |
backend_model | string | No | ACP unstable_setSessionModel model id for this role. |
If both prompt and prompt_file are set, prompt takes precedence. If neither is set, the role has no prompt text.
All eight backend_* fields are optional. When unset, the role inherits the global backend.* value from autoloops.toml. See Per-role backend overrides for resolution order, ACP session lifecycle, and a worked example.
[handoff] — event routing map
The handoff section maps events to suggested next roles. Each key is an event name and each value is an array of role IDs.
[handoff]
"loop.start" = ["planner"]
"tasks.ready" = ["builder"]
"review.passed" = ["finalizer"]When an event is emitted, the handoff map is consulted to determine which roles should run next. If the event is not in the handoff map, all roles are suggested — the model picks from the full deck.
The special event "loop.start" is the initial routing event at the beginning of the loop.
How routing works
The routing model has three layers:
- Suggested roles — looked up from the handoff map using the most recent event. If the event has no entry, all roles are suggested.
- Allowed events — the union of
emitsarrays from all suggested roles. This is what the model may emit next. - Backpressure — if the model emits an event not in the allowed set,
autoloop emitfails immediately and the event is logged asevent.invalidin the journal. The model is re-prompted with routing context.
This is soft routing: the model sees suggestions and constraints but is not forced into a fixed state machine. The backpressure layer prevents protocol violations without requiring hard-coded transitions.
Per-role backend overrides
By default every iteration uses the global backend.* settings from autoloops.toml. The optional backend_* fields on a [[role]] let you route one role to a different backend provider, command, model, or timeout without changing the rest of the loop. A heavyweight critic can run on a slow, premium model while the planner and builder use a cheaper one; a single role can target an ACP provider such as Kiro or Claude Agent ACP while the rest of the loop runs through Pi.
Resolution order per iteration
For each iteration, the harness resolves the backend spec by overlaying these layers in order — later wins:
- Global
backend.*config fromautoloops.toml(the baseline). - Role
backend_*fields fromtopology.tomlfor the first suggested role of the current routing event. Each defined field overrides the corresponding global value; unset fields fall through. agents.tomloverlay onbackend_agentonly — and only whenresolveRoleAgent(...)returns a non-empty string. This wins over a role'sbackend_agentfor ACP agent/mode routing; it does not affect any other backend field.
Two other rules:
- Multiple suggested roles → first in
allowedRoleswins. If the handoff map routes one event to several roles, the first role in the list contributes the per-iterationbackend_*overrides. - Zero suggested roles → full global fallback, no agent overlay. When no role is suggested for the current event, the iteration runs against the global
backend.*exactly as if no overrides existed.
Field-by-field overlay semantics
Each backend_* field overrides the corresponding global field independently. Unspecified role fields fall through to the global value. Notably, backend_args is replace, not merge — when a role sets backend_args, the global backend.args is discarded for that role's iterations.
ACP session lifecycle
When the loop uses an ACP backend, the harness starts a fresh stdio ACP session for each iteration. Per iteration:
- Iteration is non-ACP and a session is live → terminate the session, then run via the command/pi backend.
- Iteration is ACP → initialize the provider command, create a new session, apply supported
agent/modelsettings, send the prompt, then terminate the session after the turn. - Role/provider settings differ between iterations → no special reuse logic is needed; each iteration already gets an isolated provider session.
Minimal example
A single role pinned to a different ACP model than the rest of the loop:
[[role]]
id = "critic"
emits = ["review.passed", "review.rejected"]
prompt_file = "roles/critic.md"
backend_kind = "acp"
backend_provider = "kiro"
backend_command = "kiro-cli"
backend_args = ["acp"]
backend_model = "anthropic/claude-opus-4"End-to-end worked example: per-role backends and models
A three-role loop where the planner runs on the global Pi backend, the builder uses Kiro ACP with one model, and the critic uses Claude Agent ACP with a different model:
# autoloops.toml
backend.kind = "pi"
backend.command = "pi"# topology.toml
[[role]]
id = "planner"
emits = ["tasks.ready"]
prompt_file = "roles/planner.md"
# no backend_* — uses the global pi backend
[[role]]
id = "builder"
emits = ["review.ready"]
prompt_file = "roles/builder.md"
backend_kind = "acp"
backend_provider = "kiro"
backend_command = "kiro-cli"
backend_args = ["acp"]
backend_model = "anthropic/claude-sonnet-4"
[[role]]
id = "critic"
emits = ["review.passed", "review.rejected"]
prompt_file = "roles/critic.md"
backend_kind = "acp"
backend_provider = "claude-agent-acp"
backend_command = "npx"
backend_args = ["-y", "@agentclientprotocol/claude-agent-acp"]
backend_model = "anthropic/claude-opus-4"Walkthrough of one full cycle:
loop.start→ planner. Nobackend_*fields, soiter.backendis the globalpispec. No ACP session exists; the iteration runs through the Pi adapter.tasks.ready→ builder.iter.backendresolves toacp:kiro+kiro-cli acp+claude-sonnet-4. The harness creates a fresh Kiro ACP session for the builder turn.review.ready→ critic.iter.backendresolves toacp:claude-agent-acp+npx -y @agentclientprotocol/claude-agent-acp+claude-opus-4. The harness terminates the builder session and creates a fresh Claude Agent ACP session for the critic.review.rejected→ builder. Routes back to the builder. The harness creates a new Kiro ACP session for the builder, so critic context cannot bleed into the retry. (If routing went to a non-ACP role instead, the harness would simply run the next iteration via Pi.)
See Backend configuration for global defaults; see agents.toml — per-role ACP agent routing for the ACP agent overlay that wins over backend_agent.
Structured parallel routing
When parallel.enabled = true, topology still owns the normal routing model, but the harness recognizes two bounded fan-out forms:
explore.parallel— globally available exploratory fan-out<allowed-event>.parallel— dispatch fan-out for a normal event that is already in the current allowed set
Examples:
- if
tasks.readyis allowed, the parent may emittasks.ready.parallel - if
review.readyis not allowed,review.ready.parallelis rejected - completion events and coordination events do not gain
.parallelvariants in v1
Joined events are harness-owned:
- the model must not emit
*.parallel.joined explore.parallel.joinedresumes the same routing context that opened the wave<base-event>.parallel.joinedcan be routed explicitly inhandoff
Example explicit join routing:
[handoff]
"tasks.ready" = ["builder"]
"tasks.ready.parallel.joined" = ["builder"]Parallelism is still structured, not free-form:
- only normal parent turns get the global
Structured parallelismprompt block - branch child prompts do not get that global metaprompt
- only one active wave may exist at a time
- the parent launches all branches in that wave before joining them
- branch state is isolated under
.autoloop/waves/<wave-id>/...
Prompt injection
Each iteration, the topology is rendered into the prompt as advisory context:
Topology (advisory):
Recent routing event: tasks.ready
Suggested next roles: builder
Allowed next events: review.ready, build.blocked
Role deck:
- role `planner`
emits: tasks.ready, task.complete
prompt: You are the planner.
- role `builder`
emits: review.ready, build.blocked
prompt: You are the builder.
...The prompt summary for each role shows the first non-empty line of its prompt text.
Default topology
If no topology.toml exists, the loop runs with an empty topology: no roles, no handoff map, no completion event from topology. The loop still functions — it relies on autoloops.toml for the completion event and the model operates without role routing.
Completion
The loop completes when the completion event is emitted. The completion event is resolved in this order:
completionfield intopology.tomlevent_loop.completion_eventinautoloops.toml- The
completion_promisetext fallback (a string the model can output directly)
Additionally, autoloops.toml can declare event_loop.required_events — events that must appear in the journal before the completion event is accepted.
Design patterns
Linear pipeline
Roles hand off in sequence. Each role emits one "success" event that routes to the next role.
planner → builder → critic → finalizerRejection loops
A reviewing role can reject and route back to the producing role, creating iterative refinement cycles.
"review.rejected" = ["builder"] # builder tries again
"fix.failed" = ["fixer"] # fixer tries againFan-back to start
After a cycle completes a unit of work, route back to the first role to pick up the next unit.
"queue.advance" = ["planner"] # planner picks next task
"report.updated" = ["scanner"] # scanner looks for moreBlocked escalation
A role that cannot proceed emits a .blocked event, routing to a role that can re-plan or provide context.
"build.blocked" = ["planner"]
"fix.blocked" = ["diagnoser"]Examples
Every auto* preset in presets/ includes a topology.toml. See:
presets/autocode/topology.toml— 4-role build loop with rejectionpresets/autospec/topology.toml— clarify → research → design → task → critique spec looppresets/autodoc/topology.toml— audit → write → check → publish cyclepresets/autoresearch/topology.toml— hypothesis → implement → measure → evaluatepresets/autosec/topology.toml— scan → analyze → harden → reportpresets/autofix/topology.toml— diagnose → fix → verify → close with re-open support