This guide is for people who build on OpenCode: an ADE, an editor extension, a dashboard, a bot. Everything the TUI and the web app do is available to you through a versioned protocol, and you do not need the TUI. A Workflow runs in the OpenCode server, so a user who installs the plugin gets Workflows in your app as well.
If you add support, open an issue or a pull request that adds your app to the README. We are glad to help, and to add what the protocol is missing.
- Workflows: list the saved Workflows of a project, with each one's
argsJSON Schema, so you can build a form for it. - Runs: start a saved Workflow or inline source; follow its phases, Units, tokens and cost live; stop, resume, restart a Unit; read the result and every Unit's transcript.
- People in the loop: show a Workflow's questions, a Unit's permission requests and inline-Workflow approvals, and send the answers.
- History: every Run is journaled, including Runs from before a restart.
| OpenCode plugin RPC | The Gateway (HTTP + SSE) | |
|---|---|---|
| Address | the OpenCode server: POST /api/rpc/workflow/<method> |
http://127.0.0.1:4320/v1/… (next free port if taken) |
| Auth | whatever your app already uses for OpenCode | a device token for control actions (pairing) |
| Events | the OpenCode event stream, as rpc.workflow.event |
Server-Sent Events at GET /v1/events |
| Contract | @malhashemi/opencode-dynamic-workflows/rpc (typed) |
OpenAPI 3.1, also served at GET /v1/openapi.json |
| Best for | apps that already talk to an OpenCode server | scripts, other machines, apps that do not use the OpenCode client |
Both carry the same resources, commands and events (protocol reference,
JSON Schemas). Protocol v1 changes only by addition: new optional
fields, event types and error codes. Ignore what you do not know, and check protocol in info.
Detect the plugin with info: it answers with the protocol version, the plugin and OpenCode versions, the
capabilities (runs, interactions, inline-approval, …), the limits and the Gateway's address.
A Run asks a person three kinds of thing: a Workflow's ask() questions, a Unit's permission requests, and the
approval of inline (model-written) Workflows. They wait only while some client is attached to the project.
With nobody attached, a question takes its fallback answer, a permission request is denied, and an inline
Workflow is refused, so a Run never hangs.
- Plugin RPC: call
attach({ surface, ttlMs: 45000, sessionID })with an id unique to this window of your app (for examplemy-app-<uuid>), and repeat it every 20 seconds or so while your app shows Workflows.sessionIDis the session in view, if any. Calldetachon exit. - Gateway: an open
GET /v1/eventsstream counts as attached.
Show pending interactions from run.interactions (and the interaction.pending / interaction.resolved
events), and answer with replyInteraction: one list of chosen labels per question. For an inline approval,
interaction.approval carries the whole source, its sha256 and bytes, the action (run or save) and,
for a save, the target file. Show the source to the person before they choose; that approval is the only thing
between model-written code and their machine. The choices are Run once, Always for this project and Reject
(Save and Reject for a save).
import { OpenCode } from "@opencode/client"
import type { PendingInteraction, ProtocolEvent } from "@malhashemi/opencode-dynamic-workflows/protocol"
import { WorkflowRpc } from "@malhashemi/opencode-dynamic-workflows/rpc"
const directory = "/path/to/project"
const client = OpenCode.make({ baseUrl: "http://127.0.0.1:4096", headers: { "x-opencode-directory": directory } })
const workflow = client.rpc(WorkflowRpc)
const at = { location: { directory } }
// Your UI: show the interaction (its questions and options; for an approval, the whole `approval.source`) and
// resolve with the labels the person chose, one list per question.
declare function askPerson(interaction: PendingInteraction): Promise<string[][]>
// Be attached while your UI is open, so questions and approvals wait for the person. One surface id per window:
// two windows sharing an id would replace each other's attachment, and one's detach would end both.
const surface = `my-app-${crypto.randomUUID()}`
await workflow.attach({ surface, ttlMs: 45_000 }, at)
// The heartbeat also re-reads the Run, so a dropped event (a question, the Run's end) is caught within 20 s.
// For exact recovery, track each event's `seq` and `epoch` and use `eventsSince` (see "Events worth handling").
const heartbeat = setInterval(() => {
void workflow.attach({ surface, ttlMs: 45_000 }, at)
void reconcile()
}, 20_000)
let runId: string | null = null
const early: ProtocolEvent[] = [] // events that arrive before startRun returns the Run's id
// An event and a re-read can both see one interaction: ask about each once at a time. Something that failed is
// left undone, so the next re-read tries again.
const asking = new Set<string>()
const answered = new Set<string>()
let finishing = false
let finished = false
async function answer(interaction: PendingInteraction) {
const id = interaction.interactionId
// A native OpenCode form (`interaction.form`) is answered through OpenCode's own form API, not replyInteraction.
if (!runId || interaction.form || answered.has(id) || asking.has(id)) return
asking.add(id)
try {
const answers = await askPerson(interaction)
await workflow.replyInteraction({ runId, interactionId: id, answers }, at)
answered.add(id)
} finally {
asking.delete(id)
}
}
async function finish() {
if (!runId || finished || finishing) return
finishing = true
try {
const { status, result } = await workflow.getResult({ runId }, at)
console.log(status, result)
clearInterval(heartbeat)
await workflow.detach({ surface }, at)
finished = true
} finally {
finishing = false
}
}
// Answer what is pending and finish a Run that has ended, from the Run itself rather than from events.
async function reconcile() {
if (!runId || finished) return
const { run } = await workflow.getRun({ runId }, at)
for (const interaction of run.interactions) await answer(interaction)
if (run.status !== "queued" && run.status !== "running") await finish()
}
async function handle(event: ProtocolEvent) {
if (event.runId !== runId) return
if (event.type === "interaction.pending") await answer(event.data as PendingInteraction)
if (event.type === "run.ended") await finish()
}
// Listen before starting, and hold what arrives before the Run's id is known.
workflow.events.on("event", ({ data }) => void (runId ? handle(data) : early.push(data)))
const { workflows } = await workflow.listWorkflows({}, at) // each with its args JSON Schema
runId = (await workflow.startRun({ name: workflows[0]!.key, args: {} }, at)).runId
for (const event of early.splice(0)) await handle(event)
// Anything that became pending, or a Run that ended, before we were listening for this id.
await reconcile()This is a script: if a call throws, the process ends and the heartbeat with it. In a long-lived app, stop the
heartbeat and detach when the view closes, whatever happened, or questions keep waiting for a view that is gone.
A pending interaction with a form field is a native OpenCode form (a Unit's question call the engine could not
take over). Answer it with OpenCode's form API, client.session.form.reply({ sessionID, formID, answer }) with
the interaction's sessionID and form.formID, not with replyInteraction.
Without the typed client, a plugin RPC call is a plain POST to the OpenCode server: the input goes in input,
the answer comes back in output, and the project is the x-opencode-directory header (or ?location=):
curl -s -u "opencode:$OPENCODE_SERVER_PASSWORD" -H "x-opencode-directory: $P" -H "content-type: application/json" \
-X POST -d '{"input":{}}' http://127.0.0.1:4096/api/rpc/workflow/listWorkflows # → {"output":{"workflows":[…]}}GW=http://127.0.0.1:4320
P=/path/to/project
curl -s "$GW/v1/info" # versions, capabilities, locations
curl -s "$GW/v1/workflows?location=$P" | jq '.workflows[].key'
curl -N "$GW/v1/events?location=$P" # keep open: live events, and you are attached
curl -s -X POST "$GW/v1/runs?location=$P" \
-H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
-d '{"name":"review-diff","args":{"base":"main"}}' # → { "runId": "…" }
curl -s -X POST "$GW/v1/runs/$RUN/interactions/$ID/reply" \
-H "authorization: Bearer $TOKEN" -H "content-type: application/json" \
-d '{"answers":[["Run once"]]}'
curl -s "$GW/v1/runs/$RUN/result"Every route, parameter and response is in the OpenAPI document;
point your client generator at it, or at GET /v1/openapi.json on a running Gateway.
Reads need a token with read scope, except from the same machine to a Gateway bound to loopback (the
default). Control actions (start, stop, resume, answer, save, cleanup) need
Authorization: Bearer <token> with control scope:
- A browser page served by the Gateway itself pairs automatically (
POST /v1/pair/local). - Anything else gets a one-use code from the TUI (
/workflows pair, valid five minutes) and exchanges it atPOST /v1/pairwith{ "code": "…", "name": "my-app" }. Store the token; revoke it by deleting its entry in$XDG_STATE_HOME/opencode-dynamic-workflows/gateway-tokens.json(the running Gateway notices at once). - The plugin RPC route needs no Gateway token: it uses your OpenCode credentials.
The security guide covers the Gateway's host, origin and CORS rules, and binding it beyond loopback.
| Event | What to do |
|---|---|
run.started, run.updated, run.ended |
Update the Run's row: status, phase, usage, result preview. |
unit.updated |
Update one Unit. output may be elided; fetch the Unit when you show it. |
interaction.pending, interaction.resolved |
Show or clear a question, permission request or approval. |
library.changed |
A Run was added to or removed from history: refresh your list of Runs. |
activity.appended |
The Run's log line, phase change or capability use. |
resync.required |
You missed events (a restart, or you fell behind): re-read the Runs you show. |
Events carry a seq and an epoch. Over SSE, reconnect with Last-Event-ID to receive what you missed; over
RPC, eventsSince({ after, epoch }) does the same.