Courier
An OpenCode V2 plugin that lets one session start other sessions, message them, and be woken by them, without polling.
1
1,207
1.2k in 7 days
41.4
Multi-signal model
15 hours ago
2026-10-04
Install and configure
opencode.jsonWrites to this project's opencode.json — applies to this repository only.
opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-courier@0.2.0"]
}Writes to ~/.config/opencode/opencode.json — applies to every project.
~/.config/opencode/opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-courier@0.2.0"]
}If you want to modify the plugin locally, install it into the project and reference the local path.
shell
pnpm add -D opencode-courierOpenCode loads npm dependencies through its embedded runtime on startup and caches them locally — no manual global install needed.
An OpenCode V2 plugin that lets one session start other sessions, message them, and be woken by them, without polling.
A parent session calls courier_spawn, gets a session id back immediately and ends its turn. The
child works on its own and, when it is done or stuck, calls courier_send with the parent's id.
That message lands in the parent's inbox and OpenCode starts a new turn for the parent if it is
idle. A child whose turn fails instead, so that it cannot report, is reported by the plugin, and a
child that waits for a permission or asks a question has it passed to the parent, who asks you and
passes your answer back.
Status: early. Passes an end-to-end test inside a live OpenCode V2 server driven by a scripted stand-in model, and a smoke test with real (free) models: see Development.
The parent does not wait. Prompt: "Spawn two children: one runs sleep 30 then lists the
exports of math.js, the other runs sleep 45 then lists the exports of text.js. Don't wait for
them: meanwhile write a short README.md for this folder yourself, and add their reports to it when
they arrive."

Watch the parent write its README and finish its turn 16 seconds in, while both children still
sleep. Each report then starts a new turn on its own (the Message from ses_… line is the child's
message): the first after about 30 seconds, the second after about 45, and the parent folds each
into the README and cancels the check-in it had scheduled for itself.
A child's question reaches you. Prompt: "Spawn a child session to add a multiply function to math.js. It must ask me first whether to name it multiply, times or product."

Watch the child's question open in the parent's session as OpenCode's own question form, where
times is picked. The parent passes it back, and the child adds times to math.js and reports.
The same two runs in OpenCode's web UI


Both recordings are real runs on opencode2 v0.0.0-beta-19271 with a free model on OpenCode
Zen (Muse Spark 1.3).
How the wake works
There is no polling anywhere. courier_send calls the plugin API's session.synthetic, which
admits a message into the target session's inbox and, unless resume: false is passed, calls
execution.wake on it (packages/core/src/session/session.ts on OpenCode's v2 branch).
OpenCode's own background subagents report to their parent the same way
(packages/core/src/session/subagent-completion.ts).
Delivery is steer by default (injected into the target's running turn, or starts one if idle);
queue: true waits until the current turn ends.
Install
Supported OpenCode version
Requires OpenCode V2: the opencode command from @opencode/cli (the same binary is also installed
as opencode2, the name of the beta line, so a shell that still calls that keeps working). Each
release of this plugin is built and tested against exactly one OpenCode V2 version, the
@opencode/plugin version pinned in package.json (the CLI and the plugin API share a version).
The end-to-end suite runs on that version with every change, and once more on the newest
@opencode/cli release, where a failure is a warning rather than a red build, so a host release
that breaks the plugin shows up in CI first. A newer OpenCode may still break tools;
docs/plugin-api-notes.md lists what the pinned version already needed
working around, and what changed the last time the pin moved. A release that moves the pin adds a
row here. When the plugin loads on an OpenCode whose version is not the pinned one, it writes one
line to the server log naming both versions, so a mismatch is named before a tool fails.
| opencode-courier | OpenCode V2 (opencode and @opencode/plugin) |
|---|---|
| 0.2.0 | 2.0.22 |
| 0.1.6 | 0.0.0-beta-19271 (the beta line: opencode2 from @opencode-ai/cli, and @opencode-ai/plugin) |
0.2.0 was tested on 2.0.22 and on the dev build 0.0.0-dev-20534 of 2026-10-04, the newest build
then (no 2.x release above 2.0.22 existed), where the suite passed too. Of the older hosts tried, it
loads on 2.0.4 and 2.0.21 (nothing in between was run, and the suite was not), and fails to load on
2.0.0 and 2.0.3, which lack the model domain the plugin API gained in 2.0.4. The version in
package.json protects nobody on its own: OpenCode says nothing about it, since
opencode plugin add installs the plugin whatever your OpenCode version and its loader warns about
nothing either (a recorded experiment, in
docs/plugin-api-notes.md,
which also says why the peer dependency stays exact rather than a range: it picks the copy of the
plugin API the plugin runs on). The plugin's own log line above is the only runtime signal, apart
from the load failure on those hosts before 2.0.4. Check yours with opencode --version, and
install the matching CLI with:
npm install -g @opencode/cli@2.0.22
The plugin
Then install the plugin:
opencode plugin add opencode-courier
This installs the package from npm and adds "opencode-courier" to plugins in the global
configuration (~/.config/opencode/opencode.json). To receive webhooks, replace that entry with the
object form shown under Webhooks, which carries a webhook option.
From a local clone
git clone <this repo> && cd opencode-courier
bun install && npm run build
Then list it in opencode.json (V2 uses plugins, plural). A local plugin path must be a
directory; OpenCode loads its index.js, and ignores a path to a file with a warning:
{
"plugins": ["/absolute/path/to/opencode-courier/dist"]
}
Using it
- Keep the background server running so sessions can be woken while you are away (
opencode service start;opencode service statusto check). - Give the agents that run children the permissions their work needs. A child that hits an approval
prompt has it passed to its parent, which asks you (see A child that asks for
permission), but the child waits until you
answer, so keep prompts for what you want to decide yourself. A child's question goes to the
parent the same way (see A child that asks a
question), so let the agents that run children
use the
questiontool if they should be able to ask you; OpenCode's default agent may. - Use
isolate: truewhenever children edit files in parallel. The child's worktree is made from the last commit, so an uncommittedopencode.jsonis not there and the child falls back to your global config: keep providers and models in the global config, or commit the file. When you are done with an isolated child,courier_cleanupit so its worktree does not linger. - A child that crashes before calling
courier_sendnever wakes the parent. When you spawn a long-running child, alsocourier_latera check-in for yourself, andcourier_cancelit when the child reports.
Tools
| Tool | Does |
|---|---|
courier_spawn |
Creates a session (optionally in its own git worktree with isolate: true) on the parent's model, sends it the task plus a brief naming the parent and how to report back, and returns at once. |
courier_send |
Delivers a message to a session, signed with the sender's id, waking it if idle. |
courier_status |
One look at a session: outcome, idle time, last reply and the permission requests and questions it waits on. For check-ins, not for waiting. |
courier_children |
Lists the sessions this one (or a given sessionID) started with courier_spawn, each with what courier_status reports plus its directory, whether it is isolated and when it was started. |
courier_cleanup |
Removes the git worktree of a child started with isolate: true and drops the child from courier_children. Keeps a worktree with uncommitted changes or commits on no branch, tag or remote and lists them, unless force: true is passed. |
courier_answer |
Passes the person's answer to a permission request or a question that a session started from this one waits on, after the plugin relayed it here: reply (once, always or reject, with an optional message) for a permission request, answers for a question. See A child that asks for permission and A child that asks a question. |
courier_later |
Schedules a message for a session (this one by default) in delayMinutes or at an ISO time, and returns an id. When due it is delivered like courier_send, queued behind any running turn and waking the session if idle. |
courier_cancel |
Drops a message scheduled with courier_later, e.g. because the child it was waiting for reported first. |
courier_subscribe |
Subscribes a session (this one by default) to webhook deliveries for a topic: owner/repo, owner/repo#12 (one pull request or issue) or a generic name. Each matching delivery arrives as a message, queued behind any running turn and waking the session if idle. Needs the webhook receiver. |
courier_unsubscribe |
Drops one topic, or all of a session's, e.g. once its pull request is merged. |
How children reach you
The short version; the long one, with every edge, is docs/reference.md.
- A child runs on its parent's model, not on OpenCode's default, so a parent you moved to
another model starts children that can reach theirs too. An
agentwith a model of its own keeps it. More. - A child that fails cannot report, so the plugin does: every failed turn of a spawned session
sends its parent a message marked
failed="<error type>", with the error, waking it if idle. More. - A child that asks for permission has the request passed to the session at the top, with what
it asks for and the choices OpenCode offers (
once,always,reject). That session asks you and answers withcourier_answer; the child carries on. More. - A child that asks a question with OpenCode's question tool has it shown in the top session. When that session asks you the same question, your answer goes to the child's waiting call as if you had answered there; a question stays answerable across an interrupted turn or a server restart. More.
- The plugin remembers. Each parent's children (
courier_children), pendingcourier_latermessages and open questions survive a compaction or a restart; entries are dropped after 14 days. Roster, Scheduled messages. - Worktrees are yours to remove. An isolated child's worktree is kept until
courier_cleanup, which refuses to drop uncommitted changes or unbranched commits unless told to. More.
Webhooks
The receiver is off unless the plugin has a webhook option. Put it in the global config
(~/.config/opencode/opencode.json), since there is one receiver per OpenCode server:
{
"plugins": [
{
"package": "opencode-courier",
"options": { "webhook": { "port": 4097, "secretFile": "~/.config/opencode/courier-webhook-secret" } }
}
]
}
From a local clone, package is the path to its dist directory instead. "webhook": true takes
every default. If the option is given more than once, for example in a project's config as well, the
first location to load wins, and the others log that their settings are ignored.
| Option | Default | |
|---|---|---|
port |
4097 |
Port to listen on. |
host |
127.0.0.1 |
Address to bind. Only this machine can reach the default. |
secretFile |
File holding the shared secret (~ is expanded). |
|
secretEnv |
COURIER_WEBHOOK_SECRET |
Environment variable holding it, when there is no secretFile. |
maxBytes |
1048576 |
Largest body accepted. |
The secret is never read from opencode.json itself (a secret key is refused), so the config can
be committed. Make one with openssl rand -hex 32 > ~/.config/opencode/courier-webhook-secret and
chmod 600 it. A file is the safer choice with opencode service start, whose environment may not
be your shell's. Without a usable secret the receiver does not start, and the server log says why.
On GitHub, add a webhook to the repository (Settings → Webhooks) with content type
application/json, the same secret, and the events you want (pull request reviews, review comments,
issue comments, pull requests, check suites or workflow runs). GitHub must reach the receiver, and
by default it only listens on 127.0.0.1: forward a public URL to it with a tunnel you trust
(cloudflared tunnel --url http://127.0.0.1:4097, ngrok http 4097, or smee --url https://smee.io/<channel> --target http://127.0.0.1:4097/github, which needs no inbound port at
all) and use <public URL>/github as the payload URL. Whatever you expose, only signed deliveries
are acted on.
The receiver starts when OpenCode loads the plugin, which after a server start happens the first time a project is used. Until then deliveries fail; GitHub does not retry them on its own, but lists them under Recent Deliveries with a Redeliver button.
Once it runs, courier_subscribe ties a session to a topic and every matching delivery arrives as a
message, queued behind any running turn and waking the session if idle:
POST /githubtakes GitHub webhook deliveries: reviews, comments, pull requests and issues being opened, closed or merged, and completed CI runs reach the sessions subscribed toowner/repo#Nandowner/repo; other events with a repository reachowner/repo.POST /hook/<name>takes anything else, for sessions subscribed to<name>: a JSON body'stext,summaryormessagefield, otherwise the body itself.
Every delivery must be signed (X-Hub-Signature-256, HMAC-SHA256 under the shared secret), replays
are refused, and what a session sees is a short summary marked as outside text. Which events wake
whom, the signing scheme for /hook/<name>, limits and status codes: the
reference.
Development
bun install
bun test # unit tests, with a fake plugin context
npm run typecheck
npm run build # emits dist/
OPENCODE_BIN=$(which opencode) npm run test:e2e # live test, about two minutes, no API key
OPENCODE_BIN=$(which opencode) e2e/real-model.sh # smoke test with a real model, not in CI
e2e/run.sh starts a real OpenCode V2 server in a throwaway project and home directory, with this
plugin loaded and e2e/mock-model.mjs as the model: an OpenAI-compatible server that replies from a
fixed script. It walks every tool through the behaviour above, including a permission request, a
question answered on either side, a server restart with pending work, a signed and an unsigned
GitHub delivery, and the package installed from a stand-in npm registry. It needs node, npm, bun,
git, curl, jq and openssl. New behaviour gets a scenario there.
e2e/real-model.sh runs the same server with a real model (by default a free one on OpenCode
Zen, no key needed) and asks the parent to fan a small task out to two
children; COURIER_SCENARIO=permission and COURIER_SCENARIO=question exercise the two relays with
the script in the person's place. It checks that the parent spawns instead of doing the work, ends
its turn instead of polling, and that each report wakes it. Which models pass and what was tuned for
them: docs/real-model.md.
CI (.github/workflows/ci.yml) runs both suites on every push to main and every pull request,
with the OpenCode CLI at the same version as the pinned plugin API, and the live suite once more
with the CLI at its latest dist-tag: that job may fail, and its step summary names the version it
ran on, so a host release that breaks the plugin is seen without blocking the build. Releases start from GitHub and
end with a maintainer's 2FA approval on npm: docs/releasing.md. What the plugin
API does that the plugin had to work around, and what changed when the pin last moved:
docs/plugin-api-notes.md.
Contributing
Questions and bug reports go to the issues.
Pull requests are welcome: a change in behaviour comes with a unit test, an e2e/run.sh scenario
and its line in this README or docs/reference.md, and CI must be green.
Maintainers release from GitHub as docs/releasing.md describes.
License
MIT, see LICENSE.
Similar plugins
Dynamic Workflows
@malhashemi/opencode-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.
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)
Oh My Opencode Slim
Featuredoh-my-opencode-slim
Lightweight agent orchestration plugin for OpenCode - a slimmed-down fork of oh-my-opencode