Skip to content

Workflows and subagents

In this guide you'll build a procedure-following agent from WorkflowStepDefinition steps, then compose an orchestrator tree where a primary agent delegates to focused subagents — including the dual-compile trick that makes one worker reachable both via the Task tool and by direct invocation, and the spawn patterns for primary-to-primary dispatch.

1. Define a workflow

Use workflow=[WorkflowStepDefinition(...)] when the agent must execute a procedure on every message (use plain system_prompt for open-ended jobs; set both and the prompt is prepended above the workflow). Each step's fields are rendered as prompt hints — criteria to evaluate, gates, conditional routes, tool notes, subagent invocations, and todo bookkeeping:

from open_agent_compiler import (
    Criterion, Gate, GateCheck, Route, ToolUse, WorkflowStepDefinition,
)

workflow = [
    WorkflowStepDefinition(
        id=1, name="ClassifySeverity",
        todo_name="classify",
        todo_description="Pick severity ∈ {low, medium, high}.",
        instructions="Read the ticket and decide its severity.",
        evaluates=(Criterion(name="severity",
                             question="How severe is the ticket?",
                             possible_values=("low", "medium", "high")),),
        marks_done=("classify",),
    ),
    WorkflowStepDefinition(
        id=2, name="EscalateIfHigh",
        gate=Gate(checks=(GateCheck(variable="severity", value="high"),)),
        instructions="Write a one-paragraph escalation note to ops.",
        routes=(Route(criteria_name="severity", value="low", goto_step=4),
                Route(criteria_name="severity", value="medium", goto_step=3)),
        tool_uses=(ToolUse(tool_name="rule-checker", note="verify policy"),),
        subagents=("critic",),          # invoked at this step
        marks_done=("escalate",),
    ),
]

Only id and name are required, so you can stub steps first and fill in detail later. preamble / postamble on the AgentDefinition wrap the rendered block. examples/60_workflow_agent/ exercises every field.

2. Understand what compiles: STEP 0 and todo_mode

The compiler renders the steps as a ## MANDATORY WORKFLOW section. With todo_mode="strict" (the default) it prepends a STEP 0: Create Task List block that instructs the agent to create one todo per step (from todo_name + todo_description), emits a per-step "mark done" line, and closes with a FINAL CHECKLIST that verifies all todos completed.

  • strict — STEP 0 plus per-step completion marks. Use when audit trails matter (production orchestrators).
  • lazy — STEP 0 once, no per-step marks. The agent focuses on work, not bookkeeping.
  • none — no todo machinery at all. For one-shot agents.

Primaries use the runtime's todowrite/todoread tools; subagents don't have them, so the compiler switches their todo blocks to the bundled subagent_todo.py file-based tracker automatically. Setting workspace=".agent_workspace/{name}" additionally emits a STEP 0a block that initializes an isolated sandbox via workspace_io.py (and the security policy then forbids file creation through bash).

3. Build an orchestrator tree

Subagents are referenced twice: as AgentHeaders on the parent's subagents field, and as their own TemplateSlots so they compile alongside the parent. The slot named "primary" compiles in primary mode; every other slot becomes a subagent.

from open_agent_compiler import (
    AgentDefinition, AgentHeader, AgentRegistry, CompilationConfig,
    ModelParameters, TemplateSlot, TemplateTree,
)

orchestrator = AgentDefinition(
    header=AgentHeader(agent_id="orchestrator", name="orchestrator",
                       description="Routes input through summarizer + critic."),
    usage_explanation_long="Summarise then critique via delegation.",
    usage_explanation_short="summarise + critique orchestrator",
    subagents=[
        AgentHeader(agent_id="summarizer", name="summarizer",
                    description="Compress text into one paragraph.",
                    mode="subagent"),
        AgentHeader(agent_id="critic", name="critic",
                    description="Argue against the user's claim.",
                    mode="subagent"),
    ],
    system_prompt="Delegate via the Task tool; never summarise inline. ...",
)

reg = AgentRegistry()
orch_id = reg.register_agent("orchestrator", orchestrator,
    ModelParameters(model_name="zai-coding-plan/glm-4.5-air", temperature=0.2))
sum_id = reg.register_agent("summarizer", summarizer_defn, ...)
crit_id = reg.register_agent("critic", critic_defn, ...)

reg.register_template(TemplateTree(name="tpl", slots=[
    TemplateSlot(name="primary", default_agent_id=orch_id),
    TemplateSlot(name="summarizer", default_agent_id=sum_id),
    TemplateSlot(name="critic", default_agent_id=crit_id),
]))
reg.create_compilation_config(CompilationConfig(name="prod", template_name="tpl"))

The compiled orchestrator gains an "## Available Subagents" section with the exact Task-tool call for each child (subagent_type: "summarizer"), plus permission: task: allow in its frontmatter. Each agent in the tree can run on a different model — the compiler writes each subagent's model into its own frontmatter, so mixed-provider trees work unchanged (examples/40_subagents/). Subagents cannot delegate further: fan-out is one level deep. If a workflow step names a subagent, that agent must also appear in defn.subagents, or the security policy will forbid the invocation.

4. Know the permissions model

tool_permissions=AgentToolPermissions(read=..., write=..., edit=..., mcp=...) compiles into both the enforcement config (the frontmatter permission: / tool: blocks, default-deny with explicit allows for each declared bash command, skill, and Task target) and a SECURITY POLICY prompt section listing ALLOWED and FORBIDDEN actions — enforcement and agent awareness stay in sync from one source of truth. Per-server MCP allowlists come from mcp_servers=[MCPServerDefinition(name="slack", allowed_tools=[...])].

5. Dual-compile workers with also_compile_as_primary

A subagent slot compiles to mode: subagent and cannot be invoked via opencode run --agent <name> — opencode falls back to the default agent. That breaks direct dispatch and improvement-loop evaluation. Set the flag on the slot:

TemplateSlot(name="transcript-scorer", default_agent_id=scorer_id,
             also_compile_as_primary=True)

The compiler emits BOTH transcript-scorer.md (subagent, for Task delegation) AND transcript-scorer-primary.md (primary mode, callable directly via opencode run --agent transcript-scorer-primary or from another primary via opencode_manager.py). Use it on every worker you want to evaluate or dispatch to in isolation.

6. Spawn patterns: primary-to-primary dispatch

Setting mode="primary" on a subagent reference switches the composition from Task-tool delegation to bash dispatch through the bundled opencode_manager.py (auto-copied into build/scripts/, with the matching bash allowlist). The worker keeps a clean primary identity — its own subagents, its own session — and long-running work doesn't chew up the parent's context window (examples/50_primary_dispatch/).

The typed equivalent is SpawnAgentTool, a ScriptTool wrapping the same dispatcher, returning a TaskHandle:

from open_agent_compiler import SpawnAgentInput, SpawnAgentTool

out = SpawnAgentTool().execute(SpawnAgentInput(
    agent_name="number-cruncher-primary",   # the -primary twin
    prompt="What is 6 * 7?",
    context={"user_id": "alice"},           # → OAC_CTX_USER_ID env var
    spawn_async=True,
))
print(out.task.run_id, out.task.status, out.task.poll_url)

spawn_async=False blocks until done (best for short workers; timeout_s guards it). spawn_async=True fires and forgets, returning a running TaskHandle with a poll_url (/runs/{run_id}/await) that the scaffolded FastAPI service drains later. The target must be primary-mode — combine with also_compile_as_primary so a worker is reachable both ways. See examples/31_spawn_agent/.