Dynamic Workflows
Deterministic multi-agent Workflows for OpenCode: TypeScript scripts that fan work out to subagents, with typed results, a live TUI panel, a web app and a versioned protocol.
3
近 30 天 +2
828
46.1
生态多维模型
9 天前
2026-09-26
快速安装与配置
opencode.json写入当前项目的 opencode.json,只对这个仓库生效。
opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@malhashemi/opencode-dynamic-workflows@0.4.0"]
}写入 ~/.config/opencode/opencode.json,对所有项目生效。
~/.config/opencode/opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@malhashemi/opencode-dynamic-workflows@0.4.0"]
}若你要在本地改造这个插件,先装到项目里再从本地路径引用。
shell
pnpm add -D @malhashemi/opencode-dynamic-workflowsOpenCode 启动时会通过内嵌运行时自动加载 npm 依赖并缓存至本地目录,无需手动在全局环境执行安装。
Deterministic multi-agent Workflows for OpenCode.
Write the plan as a TypeScript script. It fans work out to subagents, gets typed results back, and combines them
with plain code.
Install · Usage · Configuration · Writing Workflows · How it works · Contributing
A Workflow is a small TypeScript program for OpenCode V2. Each agent() call in it starts
one Unit: a fresh OpenCode session that runs one subagent on one prompt. The script decides what fans out, what
verifies and what gets combined, using pipeline, parallel, loops and if. The models do only the parts that need a
model.
When one agent improvises the orchestration, it decides turn by turn how many subagents to start, whether to check their work and when to stop, and it can decide differently on every run. A Workflow runs the same structure every time. Units run under concurrency caps, typed Units return validated values instead of prose, a person can answer questions and permission requests while it runs, and every Unit is journaled so a stopped or crashed Run can be resumed.

A real Run in the web app: engine-audit, 9 typed Units on two models across 4 phases.
A Workflow
// .opencode/workflows/review-changes.ts
import { defineWorkflow, z } from "@malhashemi/opencode-dynamic-workflows/workflow"
const Finding = z.object({ line: z.number().nullable(), claim: z.string() })
const Findings = z.object({ findings: z.array(Finding) })
const Verdict = z.object({ holds: z.boolean(), evidence: z.string() })
export default defineWorkflow({
meta: {
name: "review-changes",
description: "Review each changed file, then verify every finding against the code",
args: z.object({ base: z.string().default("main") }),
},
async run({ agent, pipeline, parallel, collect, $, args }) {
const diff = await $`git diff --name-only ${args.base}`
const files = diff.stdout.split("\n").filter(Boolean)
const checked = await pipeline(
files,
(file) => agent(`Review ${file} for bugs.`, { label: file, schema: Findings }),
(review, file) =>
review &&
parallel(review.findings.map((f) => async () => {
const verdict = await agent(`Does this hold in ${file}? ${f.claim}`, {
label: `verify:${file}`,
schema: Verdict,
})
return { file, ...f, verdict }
})),
)
return collect(checked).flat().filter((f) => f?.verdict?.holds)
},
})
Each changed file gets a reviewer Unit, and each finding gets its own verifier as soon as that file's review is done.
Only the findings that hold come back. Save the file, then type /review-changes in OpenCode, or ask the model to "run
review-changes against main".
You can also let the model write the script. The tool descriptions point it to the bundled dynamic-workflows skill;
it writes a Workflow for the task at hand and runs it with workflow_inline once you approve the source.
Requirements
- OpenCode 2.0.16 or later. The plugin uses the V2 plugin API and does not work with OpenCode V1.
- Any model OpenCode can use. Typed results work best with models that call tools reliably; for the others the engine falls back to JSON in the reply, repair turns and an extraction call.
- macOS, Linux or Windows. Development and live testing happen on macOS; if something breaks on Linux or Windows, please open an issue.
Install
opencode plugin add @malhashemi/opencode-dynamic-workflows
opencode service restart
That adds the package to your global OpenCode config. The TUI part loads with the server part; there is nothing else
to install. opencode plugin update brings later releases.
Or add it to plugins yourself, in a project's opencode.json or the global one. The object form takes
options; a project entry for the same package overrides the global one's options:
{
"$schema": "https://opencode.ai/config.json",
"plugins": [{ "package": "@malhashemi/opencode-dynamic-workflows", "options": { "inline": "ask" } }]
}
To run from a checkout of this repository, see the development guide: a checkout and the installed package are two plugins to OpenCode, so do not load both in one place.
Usage
Tools the model gets
| Tool | What it does |
|---|---|
workflow |
list the saved Workflows; run one by name (with args, optionally background: true); status, result, stop or resume (alias resumeFromRunId) a Run by id; save_run to keep an inline Run's script. |
workflow_inline |
Run model-written source (or a project file via scriptPath) after approval, or save it as a durable Workflow instead of running it. |
workflow_result, question |
Inside Units only: the typed-result tool, and a question tool the engine answers. |
The plugin also registers the dynamic-workflows skill, the full authoring guide: API, pipeline vs parallel, typed
Units, questions, resume, quality patterns and worked examples. The tool descriptions tell the model to load it before
writing a Workflow. Source: packages/plugin/skill/dynamic-workflows/SKILL.md.
A Run started with background: true returns its id at once. When it ends, a notification with a summary and a result
preview arrives in the session that started it (plugin option notify).
Commands
/workflow <key> <request>runs a durable Workflow by key.- Every durable Workflow also gets its own command,
/<key>, with namespaces written with/(team:reviewbecomes/team/review). The commands follow your Workflow files as they change. Both kinds hand the request to the model, which callsworkflowwith the matchingargs.
In the TUI
/workflows (or leader f) opens the Run library. From there you open a Run (phases, Units,
activity, result) and a Unit (prompt, output, transcript). The session you are in also shows its Runs in a strip above
the prompt, in the sidebar and in a run panel (/workflows panel). The header links to the same page in the web app.
Below the Runs, Saved lists the project's durable Workflows with the args each one needs, so you do not have to
remember their keys. ↵ asks what you want and sends it to your session as /<key> <request>: the agent
builds the args and runs it. s starts a Workflow that needs no args at once.

A Run in the TUI: phases, Units, budget and the typed result.
![]() |
![]() |
| The library: live Runs first, each with its progress. | A Unit: its prompt, and its output as markdown. |
| Where | Keys |
|---|---|
| Library | ↵ open (on a saved Workflow: run it in your session) · s start a saved Workflow that needs no args · a answer · f filter · d clean up finished · b open in the browser · p pair a device · r refresh |
| Run, while running | ↵ Unit · o transcript · s stop Run · x stop Unit · r restart Unit · b open in the browser · p parent session |
| Run, when finished | e resume · w save as a durable Workflow · d delete its Unit sessions |
| Approval | ↑/↓, PgUp/PgDn, Home/End scroll the script · ←/→ choose · ↵ confirm |
| Anywhere | j/k or arrows to move · ⌫ back |
/workflows also takes panel, answer, cleanup, pair, refresh, or a Run id or Workflow name to open.
In the browser
The web app is served by the plugin's Gateway at http://127.0.0.1:4320 (the next free port if that one is taken).
The /workflows header shows its address and b opens the page you are on; every tool result also links to
its Run. A browser on the same machine pairs itself for control actions. For a browser on
another device (with gateway.bind set to lan or tailscale), run /workflows pair in the TUI and enter the
one-use code.

The Run library, kept live over Server-Sent Events.

A Unit and its transcript, down to the workflow_result call that delivered its typed value (Output and Prompt panels omitted).
Where Workflows live
.opencode/workflows/**/*.ts (or workflow/) in the project and every parent directory, the global config directory,
and $OPENCODE_CONFIG_DIR. A subfolder becomes a namespace: workflows/team/review.ts with meta.name: "review" has
the key team:review. The nearest scope wins a key collision.
Configuration
Pass options with the object form of the plugin entry. Every option is optional, and an invalid value falls back to its default.
{
"plugins": [
{
"package": "@malhashemi/opencode-dynamic-workflows",
"options": {
"inline": "ask",
"maxConcurrentUnits": 5,
"providerConcurrency": { "github-copilot": 4 },
"gateway": { "port": 4320 },
},
},
],
}
| Option | Default | Description |
|---|---|---|
inline |
"ask" |
Inline (model-written) Workflows: ask a person each time (or "always for this project"), allow, or deny. With ask and nobody attached, the Run is refused. |
inlineCapabilities |
true |
Give inline Runs ctx.$, ctx.file and ctx.fetch. Durable Runs always have them. |
notify |
true |
When a background Run started by the model ends, post a notification (summary and result preview) into that session. |
maxConcurrentUnits |
5 |
Units in flight per Run. A Workflow's meta.concurrency can lower it, not raise it. |
maxConcurrentRuns |
5 |
Runs executing at once in the OpenCode process. Further Runs wait, queued, until one ends. |
providerConcurrency |
{} |
Caps per provider id across all Runs, e.g. { "github-copilot": 4 } for a rate-limited subscription. |
retention |
"keep" |
delete-on-success marks Runs that succeed for cleanup; /workflows cleanup in the TUI then deletes their Unit sessions. |
limits |
{ maxUnits: 1000, maxItemsPerCall: 4096, maxUnitSteps: 250 } |
Hard limits per Run, per parallel/pipeline call and per Unit (model requests). |
gateway |
see below | The HTTP + SSE Gateway that serves the protocol and the web app. |
gateway.* |
Default | Description |
|---|---|---|
enabled |
true |
false turns the Gateway off; the TUI keeps working over plugin RPC. |
bind |
"loopback" |
loopback (127.0.0.1), lan (0.0.0.0), tailscale (your 100.64.0.0/10 address), or an IP. |
port |
4320 |
The next free port is used if it is taken; the TUI and tool results show the real URL. |
auth |
"token" |
Control actions need a token. none removes auth for loopback clients on a loopback bind only. |
allowedOrigins |
[] |
Extra browser origins (CORS and CSRF allow-list). |
web |
true |
Serve the web app. |
Writing Workflows
The dynamic-workflows skill is the complete guide, and
packages/plugin/docs/examples/ has runnable Workflows:
review-files.ts (typed findings per file),
research.ts (a question to the person, a hard token budget) and
repo-report.ts (ctx.$ and ctx.file). Import everything from
@malhashemi/opencode-dynamic-workflows/workflow.
run(ctx) receives:
| Member | What it does |
|---|---|
agent(prompt, opts?) |
One Unit. Returns its final text, or with schema a validated value. A failed Unit returns null and is added to errors; it never throws. |
pipeline(items, ...stages) |
Each item runs through its stages on its own, with no barrier between items. Stages get (previous, item, index). |
parallel(thunks) |
Runs thunks concurrently and waits for all of them (a barrier). Failures become null. |
collect(xs) |
Drops the nulls, with the narrowed type. |
errors |
Every dropped Unit: { unit, prompt, subagent, error }. |
args |
The Run's args, validated against meta.args before anything starts. |
log(msg), phase(title) |
Progress, shown in the TUI and the web app. |
ask(questions, { fallback, graceMs? }) |
Ask a person. The fallback answers at once when nobody is attached, so a Run never hangs. |
budget |
{ total, spent(), remaining() } in output tokens. |
signal |
The Run's AbortSignal. |
$, file, fetch |
Shell, files and HTTP, confined to the project and recorded in the Run's activity. |
workflow(name, args?) |
Run a saved Workflow as one step of this Run (one level deep). |
worktrees() |
The git worktrees kept by isolation: "worktree" Units that changed files. |
agent() options: schema, label, phase, subagent (alias agentType; default general), model
("provider/model#variant" or { providerID, modelID }), effort (the variant), retries (repair turns, default 2),
timeoutMs, permissions, isolation: "worktree" (a fresh git worktree, removed if unchanged) and location (another
directory).
meta: name, description, whenToUse, phases, args (zod), concurrency, unitTimeout, budget (a number,
or { tokens, hard: true } to stop at the limit), permissions (rules for every Unit), limits, and
interaction: { permissions: "ask" | "auto" | "deny", graceMs }.
Default to pipeline. Use parallel as a barrier only when a stage needs every result of the previous one, such as
deduplicating findings across all files before verifying them.
Typed results
A Unit with a schema gets a workflow_result tool whose input is your schema. The tool validates each call, so the
model can correct a bad call in the same turn. If the model answers in text instead, the engine tries the JSON in that
text, then sends up to retries repair turns in the same session, then extracts the value with one plain generation
call. Every Unit records which path produced its value (resultPath: tool, text-json, extract, replay or
text) and every attempt.
Resume
Every Run is journaled under .opencode/workflows/runs/<runId>/. A plugin reload does not stop running Runs. If the
OpenCode service dies, the Run reads back as interrupted. resume starts a new Run that matches Units by start order
and prompt: each Unit whose prompt is unchanged returns its recorded result at once, answers to ask are replayed, and
from the first changed prompt on everything runs live. Units that failed run again.
How it works
flowchart LR
you((You)) -->|"/key, or ask"| model["Your session's model"]
model -->|"workflow, workflow_inline"| engine["Workflow engine<br/>(server plugin)"]
engine -->|"agent()"| units["Units<br/>one OpenCode session each"]
units -->|"workflow_result"| engine
engine --> journal[("Run journal<br/>.opencode/workflows/runs")]
engine <-->|plugin RPC| tui["TUI plugin<br/>/workflows, run panel"]
engine <-->|"Gateway: HTTP + SSE"| web["Web app, scripts"]
- The engine runs inside the OpenCode server as a plugin, with no changes to OpenCode. It loads the Workflow
module, validates
args, and runsrun(ctx). Eachagent()call waits for a slot under the Run's concurrency cap (and any per-provider cap), then creates a Unit session, prompts it, waits for it to finish and reads its result. Runs beyondmaxConcurrentRunswait, queued. - Units are ordinary OpenCode sessions, so they use your providers, subagents and permission rules. They are not linked into your conversation; the TUI and web app show them.
- The journal records the Run, each Unit, the script source and the result, which is what resume replays.
- Clients talk to the engine through one versioned protocol: the TUI over plugin RPC, the web app and scripts over the Gateway.
Security
Inline Workflows are model-written code that runs with your privileges, and they are not sandboxed: the approval is
the control. By default a person approves each one (Run once, Always for this project or Reject) after
reading the whole source, with its size and SHA-256, in the TUI (/workflows, a scrolling view) or the web app. The
script is not loaded, so none of its code runs, until then. Saving inline source as a durable Workflow asks the same
way. The request waits until someone answers; there is no time limit. Headless, an inline Run is refused unless the
project was approved before. The plugin option inline changes this: "allow" runs inline Workflows without asking,
"deny" never runs them.

Approving an inline Workflow: the whole script, highlighted, before any of it runs.
Units get OpenCode's permission rules plus the engine's own: workflow_result and question allowed, workflow and
workflow_inline denied (no recursion). A Unit's permission request goes to a person when one is attached; headless,
it is denied with a message the Unit can act on.
The Gateway binds to loopback by default, checks Host and Origin headers, and needs a bearer token for every control action;
remote browsers pair with a one-use code. Read docs/security.md for the trust
model, capabilities, limits and data on disk, and SECURITY.md to report a vulnerability.
Build on it
Your app can run Workflows too: an ADE, an editor extension, a dashboard, a bot. Everything the TUI and the web app do goes through one versioned protocol (v1, which changes only by addition), with two transports:
- OpenCode plugin RPC on the OpenCode server (
POST /api/rpc/workflow/<method>), for apps that already talk to OpenCode. It uses your existing OpenCode credentials; the TypeScript contract is@malhashemi/opencode-dynamic-workflows/rpc, the types@malhashemi/opencode-dynamic-workflows/protocol. - The Gateway (HTTP + Server-Sent Events), described by an OpenAPI 3.1 document
that a running Gateway also serves at
/v1/openapi.json.
curl -s http://127.0.0.1:4320/v1/openapi.json | jq '.paths | keys'
curl -N "http://127.0.0.1:4320/v1/events?location=/my/project"
The integration guide covers both, including how to be "attached" so that questions and approvals reach your users. The protocol reference and JSON Schemas have the details. If you add support to your app, tell us in an issue: we will list it here and help with what the protocol is missing.
Troubleshooting
| Symptom | Fix |
|---|---|
| The plugin does not load from a local directory. | OpenCode resolves a directory plugin's entry by path (<dir>/server or <dir>/index), not through package.json exports. Point plugins at packages/plugin, which ships server.ts, rpc.ts and a tui entry at its root, and run bun run build first. |
Inline Workflows are refused in opencode run. |
Nobody can approve them headless. Approve "always for this project" once from the TUI or web app, save the script as a durable Workflow, or set inline: "allow". |
| A Unit fails with "exceeded its step limit". | The Unit made more model requests than limits.maxUnitSteps (a loop guard, 250 by default). Raise the limit for Workflows whose Units do long work. |
| The web app is not at port 4320. | Another process holds the port, so the Gateway took the next free one. Tool results and the TUI show the real URL. |
Still stuck? Open an issue with your OS, OpenCode
version and, if you can, the Run's journal (.opencode/workflows/runs/<runId>/).
Development
git clone https://github.com/malhashemi/opencode-dynamic-workflows
cd opencode-dynamic-workflows
bun install
bun run check # formatting, lint, types, tests (no OpenCode needed)
bun run build # dist/tui.js, dist/web, protocol schema check
bun run pack # build, then the publishable tarball
bun run verify:live # real OpenCode on a private server
The live tests start opencode serve with its own database under $TMPDIR/opencode; it never touches your
configuration. WF_LIVE_MODEL picks the model (default claude-work/claude-opus-5-5).
The repository has two packages: packages/plugin (the published package: server plugin, TUI
plugin, authoring API, protocol and Gateway) and packages/web (the web app, built into
packages/plugin/dist/web). packages/plugin/README.md is generated from this file for npm; after editing this
README, run bun run packages/plugin/script/sync-readme.ts.
Contributing
Bug reports, platform reports from Linux and Windows, example Workflows and code are all welcome. Start with CONTRIBUTING.md, and follow the code of conduct. Changes are listed in the changelog.
License
MIT © M. Adel Alhashemi
This is an independent project, not affiliated with or endorsed by the OpenCode team.
同类生态推荐
Workflows
@rphang/opencode-workflows
Claude Code-style dynamic workflows for opencode v2: the model writes a JS orchestration script that fans work out to parallel subagents (agent, parallel, pipeline, budget, resume)
Agents Sidebar
opencode-agents-sidebar
Universal OpenCode TUI sidebar for browsing provider agents and subagents
Threads
@op1/threads
Opinionated background agents for OpenCode V2: visible worker sessions that report back with a verdict, and durable parallel workflows that survive restarts.


