| name | flow-creator |
| description | Create and edit OpenCode flow YAML files that define deterministic, multi-step agent workflows. Use when the user asks to create a new flow, build an automation pipeline, design a multi-step agent workflow, convert a process description into a flow, or edit an existing flow definition. Also use when the user asks about flow syntax, step routing, structured output schemas, session management, parallel branching, fallback strategies, or postponed steps in the context of OpenCode flows.
|
Flow Creator
Workflow
- Clarify the user's desired workflow: what steps, what agents, what decisions, what inputs.
- Read
references/flow-spec.md for the complete flow YAML specification.
- Design the flow structure: identify steps, routing logic, output schemas, and fallback needs.
- Write the flow YAML file to
.agents/flows/<flow-id>.yaml (or .opencode/flows/ if the user prefers).
- Validate the generated YAML mentally against the spec constraints before delivering.
Design Guidelines
Prefer structured output when a step's result drives routing rules or feeds data to downstream steps. Use free-form output only for terminal steps or when the output is purely informational.
Keep step prompts focused on a single responsibility. If a prompt grows beyond 30 lines, consider splitting into multiple steps connected by sequential flow or rules.
Use sizeof prefix operator for array/object emptiness checks in rules rather than string comparison.
Compose multiple atomic predicates with && (AND) and || (OR), grouped with parentheses, instead of emitting overlapping rules. && binds tighter than ||; evaluation short-circuits. Example: sizeof ${args.blockers} == 0 && ${args.deploy} == true.
Use session.fork: true when consecutive steps with the same agent benefit from shared conversation history. Do not set fork when agents differ because the engine ignores it and creates a fresh session anyway.
Always define a fallback step for flows where reliability matters. A common pattern is a terminal failed step that summarizes the error.
When a step may encounter blockers requiring human intervention, use postpone: true to pause the flow. On re-invocation with the same session prefix, the postponed step resumes. In this case, recommended to instruct step with explicit Blockers section, so agent can check if blockers resolved before doing any further work. The same strategy works well for both struct output and without it.
When a step needs to iterate inside a single invocation (build levels of a graph, process pagination, retry a polling operation), route the step back to itself without postpone — this is an in-process self-loop. Always cap such a loop with maxIterations as a safety net. ${step.iteration} (1-based) is available in both prompts and rules for iteration-aware behaviour and termination predicates. Mark fields that must be recomputed each iteration as required in the output schema (args accumulate across iterations, so omitted fields persist from the prior pass).
maxTurns (per-step) overrides the agent's maxTurns for a single step. Useful when one step in a flow needs more (or fewer) tool-use turns than the rest of the flow — e.g. a long-running build coordinator vs. a short summary step. maxIterations is a different axis (counts whole agent runs of the step, not tool-use turns within one run).
Per-step compaction (compact.threshold). OpenCode's tool-use loop checks context usage before each model call and, when token_count / context_window exceeds AutoCompactionThreshold (0.95 by default), synchronously summarises the session before continuing. That default is right for most flows but too late for context-heavy steps — a composer-developer implement step with many bash / read results can burn a third of the window on a single tool call, missing the 0.95 gate on the way up and then blowing past it on the next turn. Set compact.threshold on such a step to trigger earlier:
- id: implement-with-openspec
agent: composer-developer
maxTurns: 300
compact:
threshold: 0.7
Guardrails: values MUST be in (0, 1]; the runtime clamps out-of-range inputs (< 0 → default, > 1 → 1) and logs a warn. Omitting the block or leaving threshold: 0 inherits the global default, so this is strictly opt-in — no regression risk for flows that don't need it. Only the tool-use-loop compaction check honours the override; final-turn cleanup and provider-side hard limits remain on their built-in thresholds.
Numeric predicates in rules (<, <=, >, >=). Rule predicates support numeric comparison in addition to ==, !=, =~. Use them with ${step.iteration} or numeric args to cap loops explicitly:
rules:
- if: ${args.verified} != true && ${step.iteration} < 3
then: implement
cycle: true
- if: ${args.verified} != true && ${step.iteration} >= 3
then: end
- if: sizeof ${args.blockers} > 0
then: resolve
Both sides are parsed as float64. Numeric coercion covers int / int64 / float / bool (true=1). A non-numeric LHS returns an error and the rule is skipped — mirrors the fail-loud semantics of the string operators so authoring bugs surface instead of no-matching silently.
Use flow.session.prefix with an ${args.*} reference when the flow should be re-triggerable by a user-provided identifier (e.g. Jira ticket key, PR number, Slack thread ID). On a re-trigger of a cleanly-completed prior run, the flow re-executes from step 0 against the current world — picking up new comments, status changes, or other external events — while every step's session keeps its prior message history so the agent has cumulative context. Mid-state re-triggers (a step is postponed, waiting_for_input, or stuck running) resume the paused step instead. Omit prefix to get independent invocations (each run gets a fresh timestamp prefix and shares no state).
Set flow.session.resume_on_failure: true only when the flow is a long, idempotency-safe pipeline whose earlier completed steps must NOT be redone after a transient failure (e.g. paid API calls, build artifacts, anything with non-repeatable side-effects). Default false is correct for event-driven flows where a re-trigger after failure means "re-evaluate from step 0 against current state," not "retry the same step." A typo in the session block (e.g. resume_on_fail missing the ure) fails flow load with ErrInvalidYAML rather than silently defaulting.
Use flow.args with JSON Schema to validate required inputs upfront. The prompt key is always allowed without declaration.
Args schema constraints when args render as a form input. Trigger surfaces that present flows interactively (chat-platform modals, web forms, slash-command wizards) render each declared arg as a form field. Renderers validate per field against the platform's view-spec rules, and a single non-conforming field can reject the whole form — leaving the user with an unactionable error and no job dispatched. Author args defensively:
- Keep
description short — a one-sentence summary on string, array, and object args. Renderers commonly reuse description verbatim as the input's placeholder, and platforms cap placeholder text per field (e.g. Slack: 150 characters). Booleans typically only feed the input label (much higher cap — Slack: 2000) so their descriptions can be longer. Put multi-paragraph rationale in the file-level description: instead, where there is no length limit.
- Avoid empty defaults on
string, array, and object args (default: "", default: [], default: {}). Renderers tend to apply these as the field's initial_value, which validators commonly reject as a malformed empty input on a typed field. Omit default entirely (absent args resolve to empty when the prompt substitutes ${args.foo} for a missing key) or set a non-empty placeholder default. Boolean defaults render as checkbox state, which is always valid.
When multiple rules on a step can match simultaneously, the engine forks and runs all matching steps in parallel. Design rules to be mutually exclusive when parallel execution is not desired.
Step IDs and flow filenames must be kebab-case, max 64 characters.
Keep each flow YAML under 300 KB. OpenCode's flow registry hard-caps a single file at OPENCODE_MAX_FLOW_FILE_SIZE (default 300 KB / 307200 bytes). Files above the ceiling are logged at WARN msg="Failed to parse flow file" error="invalid flow YAML: file exceeds NNNN bytes" and silently dropped from the registry — the flow won't resolve when referenced by --flow or POST /flow/{id}/start, producing auto-flow start failed err="flow not found". Under --flow-exit this now exits the process non-zero, but pre-fix flows fail silently. If a flow is approaching the ceiling, split status-routed lanes into sibling flow files or factor duplicated prelude blocks into fewer, more compact prompts before reaching for the env override — the readability cost of a giant single YAML is real.
Default agent is coder when agent is omitted from a step.
Available Agents
The following built-in agents are available for use in flow steps:
| Agent ID | Tools | Best for |
|---|
coder | All tools | Default. General-purpose development work — coding, searching, running commands. |
hivemind | view, glob, grep, fetch, sourcegraph, websearch, task, skill (no bash, edit, multiedit, write, delete, patch, lsp) | Supervisory coordination across subagents. Use for orchestration steps that delegate work rather than do it directly. |
explorer | Read-only tools only (no bash, edit, multiedit, write, delete, patch, task) | Fast codebase exploration — finding files, searching code, answering questions about the repo. |
workhorse | All tools except task and websearch | Autonomous coding tasks that run to completion. Use when a step must write/modify code but doesn't need to spawn subagents. |
summarizer | No tools | Summarizes conversation history. Has zero tool access — can only produce text output. |
descriptor | No tools | Generates short session titles. Internal/hidden, rarely needed in flows. |
You can also reference custom agents defined in:
.agents/types/<agent-name>.md (project)
.opencode/agents/<agent-name>.md (alternative project path)
Custom agent files define the agent's system prompt and tool restrictions. Use custom agents when a step requires specialized instructions or a curated tool set not covered by the built-in agents.
Step Output Guidelines
For terminal steps (e.g., failed, summary, done) that don't drive routing or feed data to downstream steps, omit the output schema. Without structured output, the agent's free-form text response is shown directly to the user, making it readable as a human-friendly summary.
For such terminal steps, prefer the summarizer agent when the step only needs to produce a textual summary from accumulated context. Since summarizer has no tool access, it is lightweight and focused purely on text generation.
- id: summary
agent: summarizer
prompt: |
Summarize what was accomplished in this flow.
Previous context: ${args}
- id: failed
agent: summarizer
prompt: |
The flow failed. Summarize what went wrong and suggest next steps.
Error context: ${args}
Reserve structured output for steps whose results are consumed by rules or subsequent step prompts via ${args.*}.
Patterns
Sequential Pipeline
Omit rules from all steps. Steps execute in array order, each receiving the previous step's output.
Conditional Branching
Use a classifier step with structured output and rules routing to different branches:
- id: classify
output:
schema:
type: object
properties:
category:
type: string
enum: [bug, feature, docs]
required: [category]
rules:
- if: ${args.category} == bug
then: fix-bug
- if: ${args.category} == feature
then: implement-feature
- if: ${args.category} == docs
then: write-docs
Parallel Fan-out
Multiple rules matching simultaneously triggers parallel execution:
rules:
- if: ${args.needs_tests} == true
then: write-tests
- if: ${args.needs_docs} == true
then: write-docs
Interactive Step (Human-in-the-Loop via Chat Bridge)
When a step needs the user (a reviewer, operator, or stakeholder) to provide
information mid-flow, mark it interactive: true and supply an interaction
block. The flow engine auto-binds the step's session to the resolved peer(s)
via the in-process chat bridge BEFORE agent.Run starts, and auto-unbinds
on struct_output. The agent's first turn fans out to the bound peer(s);
reviewer replies route back to the agent through the bridge's inbound
dispatcher. The conversation lives entirely inside one agent.Run
invocation — no postpone/resume needed for the in-conversation back-and-forth.
- id: ask-reviewer
agent: coder
interactive: true
interaction:
target: ${args.reviewer}
mention: ${args.reviewer.mention}
prompt: |
Ask the reviewer for clarification on ${args.topic}. Use the `question`
tool to present numbered options — on Slack/Telegram the bridge renders
them as interactive buttons; on Mattermost it falls back to numbered text.
Capture the reviewer's choice via struct_output.
output:
schema:
type: object
properties:
decision: { type: string }
required: [decision]
maxTurns: 50
rules:
- then: next-step
Target shapes. interaction.target accepts:
The target value MUST come from a top-level ${args.NAME} expression. The
interaction.target resolver is deliberately stricter than the general
prompt/predicate resolver — it does not accept literal PeerRefs in YAML,
and it does NOT walk dot-paths (even though prompt/predicate templates
do — see the Template Substitution section of the flow-spec reference).
So the caller (orchestrator, /flow POST body, or --flow-args JSON)
must supply the PeerRef as the whole top-level arg, not a nested field.
This forces dynamic peer selection to live in the orchestrator layer
where access control is enforced, not in YAML.
PeerRef shape (each entry, whether single or array):
{
"channel": "slack" | "telegram" | "mattermost",
"identity": "<configured identity id, e.g. 'default'>",
"peerId": "<platform-specific peer id, see below>",
"mention": "<optional first-message ping handle>"
}
Supported channels and peerId formats:
| Channel | peerId form | Notes |
|---|
slack | D<id> (DM), C<id> (channel), C<id>|<thread_ts> (thread), U<id> (user) | U<id> is auto-resolved to a DM channel via conversations.open before persistence. Channel-only C<id> is auto-mutated to C<id>|<ts> on first outbound — subsequent replies thread correctly. |
telegram | numeric chat_id (e.g. 344281281) | Private bots require the chat to have paired with /pair <code> first (allowlist gate is inbound-only — outbound delivery works regardless). |
mattermost | <channelId> (DM/channel), <channelId>|<rootPostId> (thread), 26-char user-id | User-id is auto-resolved to a DM via channels/direct. Channel peers mutate to channel|rootPostId on first outbound. |
interaction.mention (optional) is a platform-native ping handle (e.g.
@username on Slack, @FirstName on Telegram) prepended to the FIRST
outbound message for the binding only — then cleared. Useful for paging the
reviewer on a busy channel.
Bridge-disabled fail-fast. If cfg.Router == nil at server boot
(no router section in .opencode.json), the no-op InteractiveHook
returns flow.ErrInteractiveBridgeDisabled on OnInteractiveStepStart,
so the interactive step transitions to failed immediately with a clear
error rather than silently hanging. Don't author interactive flows for
deployments where the bridge isn't configured.
Tuning maxTurns for interactive steps. Each user reply consumes one
tool-use turn (the inbound→agent.Run cycle counts as one). For a planning
conversation spanning 15–30 reviewer exchanges plus tool calls per turn,
maxTurns: 100–150 is the right ballpark. The agent's default is much
lower and will cut off the conversation mid-way.
Question UI rendering. When cfg.Router.QuestionMode == "interactive"
(set in .opencode.json), the agent's question tool renders choices
using platform-native UI:
- Slack: actions block with one button per option.
- Telegram: inline keyboard with one row per option.
- Mattermost: numbered-text fallback (interactive attachments need a
webhook URL the bridge doesn't host).
Reviewer button clicks are normalized into the same bridge.Inbound
shape as text replies, so the agent's question-reply parsing works
identically across all three channels.
SSE for orchestrators. When an interactive step enters its
conversation phase, the flow runner emits flow.waiting_for_input on
the /event SSE stream with {runID, stepID, sessionID, target}. External
orchestrators (c2-agent, k8s Jobs) MAY use this to display a "waiting on
reviewer" indicator without polling /flow/status.
Self-loop with Postpone (Blocker Pattern)
A step routes back to itself with postpone: true when blockers exist. The flow pauses until the next invocation:
- id: work
output:
schema:
type: object
properties:
blockers:
type: array
default: []
description: list of blockers preventing from further progress and requiring user's feedback, empty if none
items:
type: string
required: [blockers]
rules:
- if: sizeof ${args.blockers} != 0
then: work
postpone: true
- if: sizeof ${args.blockers} == 0
then: next-step
Self-loop In-Process (Iteration Pattern)
A step routes back to itself without postpone to iterate within the same flow invocation. Use when iteration progress is determined inside the flow (no external blocker). Always cap with maxIterations:
- id: build-level
agent: coder
prompt: |
Build libraries at level ${args.current_level} (iteration ${step.iteration}).
Accumulated state: ${args.snapshot_versions}
output:
schema:
type: object
properties:
current_level:
type: integer
has_more_levels:
type: boolean
snapshot_versions:
type: object
required: [current_level, has_more_levels, snapshot_versions]
maxIterations: 20
rules:
- if: ${args.has_more_levels} == true
then: build-level
- if: ${args.has_more_levels} != true
then: publish
Each iteration shares the same step session (the agent remembers prior iterations). The iteration counter persists across postpone → resume cycles via flow_states.iteration. The cap fires the step's fallback (if any) on exhaustion.
Multi-Step Cycles (cycle: true)
A multi-step cycle is a sequential loop between two or more different steps — the flow author wants step B to run again after step A routed to it and A ran once more from B's output. Classic case: verify ⇄ implement drift-fix, where verify emits verified=false with drift notes and the flow bounces back to implement to close the gaps, then re-runs verify.
These loops need an explicit cycle: true marker on the back-edge rule — otherwise the runtime's diamond-convergence guard (see internal/flow/service.go startedSteps) treats the second scheduling of the target step as accidental parallel fan-out and silently drops it. The guard exempts self-loops and postpone routes automatically because those arrive sequentially; multi-step cycles look identical in wire behaviour but the guard can't distinguish them from a true diamond without the author's declaration.
Both edges of the cycle must be marked (verify → implement AND implement → verify) — the guard blocks re-entry from either direction, so marking only one side degrades the loop to a single pass.
${step.iteration} on cycle re-entries. When a step is re-entered via cycle: true, its iteration counter is bumped to prior + 1 instead of the default cross-step reset to 1. That means a rule like ${step.iteration} < 3 on the back-edge caps the cycle at the target step's OWN total run count in this session — three verify passes total, not three passes within one implement→verify segment. Same semantic as maxIterations on a self-loop, so ${step.iteration} in the target's rules and prompt reflects true cycle depth. First-ever entry of a cycle-target starts at 1 (no prior row) — the cap fires cleanly on the 3rd, 4th, … re-entry as expected.
- id: implement
rules:
- if: sizeof ${args.blockers} == 0
then: verify
cycle: true
- id: verify
maxIterations: 6
output:
schema:
type: object
properties:
verified: { type: boolean }
drift_notes: { type: array, items: { type: string } }
required: [verified, drift_notes]
rules:
- if: ${args.verified} == true
then: end
- if: ${args.verified} != true && ${step.iteration} < 3
then: implement
cycle: true
- if: ${args.verified} != true && ${step.iteration} >= 3
then: end
When NOT to use cycle: true:
- Self-loops (step routes to itself) — already exempt from the guard.
- Postpone routes (
postpone: true) — bypass by design.
- Real diamond convergence (fork → left + right → join) — the guard is correct to fire; leave those rules unmarked.
Symptoms of a missing cycle: true (matches the pre-fix failure mode this flag was introduced to address):
- Flow ends as
completed after the second-to-last step in the cycle.
- No
flow_states row for the re-entry target after the cycle-back rule fires.
- Log line:
Step already started, skipping (diamond convergence) (source internal/flow/service.go).
Test coverage: TestMultiStepCycle_VerifyImplementLoop (regression) and TestSelfLoop_DiamondGuardPreserved (protects the unmarked-diamond case) in internal/flow/service_loop_test.go.
Error Recovery
Use fallback to retry and then route to an error-handler step:
fallback:
retry: 2
delay: 10
to: error-handler