Everything the plugin does, beyond the README.
Each release is built and tested against one OpenCode V2 version: the exact @opencode/plugin
version in its package.json. Every release and its version:
| opencode-courier | OpenCode V2 (opencode and @opencode/plugin) |
|---|---|
| 0.13.7 | 2.2.5 |
| 0.13.6 | 2.2.4 |
| 0.13.5 | 2.2.3 |
| 0.13.2, 0.13.3, 0.13.4 | 2.2.2 |
| 0.13.1 | 2.2.1 |
| 0.13.0 | 2.2.0 |
| 0.11.1, 0.12.0 | 2.1.4 |
| 0.11.0 | 2.1.3 |
| 0.10.2, 0.10.3 | 2.1.2 |
| 0.10.1 | 2.1.0 |
| 0.7.3, 0.8.0, 0.9.0, 0.10.0 | 2.0.32 |
| 0.7.2 | 2.0.31 |
| 0.7.1 | 2.0.30 |
| 0.5.0, 0.6.0, 0.7.0 | 2.0.29 |
| 0.4.0, 0.4.1 | 2.0.28 |
| 0.2.3, 0.3.0 | 2.0.26 |
| 0.2.2 | 2.0.24 |
| 0.2.1 | 2.0.23 |
| 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) |
OpenCode never checks the version. The plugin loads on any host from 2.0.4 on, but its tools may
break on another version. On 2.0.0 to 2.0.3 it fails to load with
TypeError: undefined is not an object (evaluating 'host.model.list'). The terminal part is tested on
OpenCode 2.2.5 only.
On a version other than the pinned one, the plugin writes one line to the server log when it loads
(opencode serve --print-logs shows it):
opencode-courier 0.2.0 was built and tested against OpenCode 2.0.22; this server is 2.0.30 (channel beta). Its tools may fail: see every release and the OpenCode it is pinned to, https://github.com/ivopogace/opencode-courier/blob/main/docs/reference.md#the-opencode-version
OpenCode checks a plugin for updates only when its entry is not an exact version.
opencode-courier, opencode-courier@latest and opencode-courier@^0.2.0 are checked;
opencode-courier@0.2.1 never is. To fix an exact entry, replace it and restart OpenCode:
opencode plugin remove opencode-courier@0.2.1 # the entry as it appears in plugins
opencode plugin add opencode-courieropencode plugin check then shows (update available) when there is one, and
opencode plugin update installs it. A failed check reads as "no update" and is only logged
(failed to check plugin update in ~/.local/share/opencode/log/); look at your .npmrc's
registry and proxy settings.
The server options go on the plugin's entry in the global config,
~/.config/opencode/opencode.json. From a local clone, package is the path to its dist
directory.
| Option | Default | |
|---|---|---|
maxDepth |
5 |
The deepest a spawned session may be. Your session is depth 0. |
maxChildren |
5 |
Live children one session may have. |
maxTotal |
30 |
Live spawned sessions in one tree; the top is not counted. |
memory |
true |
false turns memory off. |
nativeChildren |
false |
The default for a child started without native: true creates non-isolated children as OpenCode subagents (native child sessions). |
webhook |
off | An object, or true for every default, starts the webhook receiver. |
The limits are positive integers. A value that is not valid keeps the default, and the server log says so. A change applies once OpenCode loads the plugin again.
The terminal part gets no options from opencode.json. It reads them from its own config,
~/.config/opencode/cli.json:
// ~/.config/opencode/cli.json
{
"plugins": [{ "package": "opencode-courier", "options": { "animations": false } }]
}| Option | Default | |
|---|---|---|
animations |
true |
false keeps the tree panel still: no spinner, flash or moving mole. |
Git is looked for in /usr/bin, /usr/local/bin, /opt/homebrew/bin and
/run/current-system/sw/bin (on Windows, C:\Program Files\Git\cmd\git.exe and its (x86) twin),
never through PATH. Set OPENCODE_COURIER_GIT to an absolute path, in the server's environment,
for a git elsewhere.
? marks an optional parameter.
| Tool | Parameters | Does |
|---|---|---|
courier_spawn |
task, title?, agent?, model?, variant?, isolate?, native?, group? |
Creates a session, sends it the task and a brief, and returns at once. model and variant choose what it runs on. isolate: true gives it its own worktree. native says whether it is an OpenCode subagent (native child sessions). With a group, its report is held with the group's. Refused past the limits. |
courier_send |
sessionID, message, status?, artifacts?, queue? |
Delivers a message, signed with the sender's id, waking the session if idle. It steers into a running turn, or waits for its end with queue: true. From a child to its parent, a status makes it the child's report. |
courier_children |
sessionID? |
One look at the sessions this one (or sessionID) started, each with its last turn's outcome, how long it has been idle, when it was archived, its last reply, the requests and questions it waits on (pending), its directory, whether it is isolated, native: true when it is a subagent, when it started, its group and its model when that is not the parent's. |
courier_tree |
sessionID? |
The whole tree under a session. |
courier_stop |
sessionID, cleanup? |
Stops a session below this one and everything under it. |
courier_cleanup |
sessionID, force? |
Removes an isolated child's worktree and forgets the child. |
courier_answer |
sessionID, requestID, reply?, message?, answers? |
Proposes once or reject for a permission request, or passes on your answers to a question. |
courier_later |
message, sessionID?, delayMinutes? or at? |
Schedules a message for a session (this one by default), in delayMinutes or at an ISO 8601 time. Returns its id. |
courier_cancel |
id |
Drops a scheduled message. |
courier_subscribe |
topic, sessionID? |
Subscribes a session (this one by default) to webhook deliveries: owner/repo, owner/repo#12 or a name for /hook/<name>. |
courier_unsubscribe |
sessionID?, topic? |
Drops one topic, or all of a session's. |
courier_recall |
sessionID? |
Lists the project's tree records, or gives one in full. |
courier_note |
text |
Writes the lesson of the caller's tree. |
A child runs on its parent's model, unless its agent names a model of its own, or courier_spawn's
model names one, as provider/model the way OpenCode names them (opencode/gpt-5-nano): the
argument goes first, then the agent's model, then the parent's. variant, OpenCode's name for a
model's reasoning effort (low, medium, high, or whatever the model has; default is the
provider's own), replaces the variant of whichever model was chosen. A parent whose session names
no model runs on OpenCode's default, which counts as its model: its children are started on it, and
stay on it if the default changes later. A model without a
variant runs at that model's default, not at the parent's variant, since variants are a model's
own.
Both are checked against the models OpenCode lists for the location: a model it does not list, or
a variant the model has not, is refused with a tool error naming what is listed, and nothing is
started. When the listing cannot be read, the choice goes through unchecked, and OpenCode fails the
child's first turn, which is reported. A child on another model than its
parent's is named with it, as provider/model#variant, in the spawn's result, in courier_children
and in the tree panel's row. The courier-orchestrate skill says to leave both
unset unless the part plainly calls for a cheaper or a stronger model.
Any spawned session can call courier_spawn too, so sessions form a tree. The session nobody
spawned is its top, at depth 0. courier_spawn refuses a spawn, naming the limit and what to do
instead, when the child would be deeper than maxDepth, or when maxChildren or maxTotal
sessions are already live.
A session is live while:
- its turn runs;
- it owes its parent a report;
- a message sent to wake it waits in its inbox, unless it is stalled;
- a session below it is live.
So a child that ends its turn without reporting counts until it reports, and a session waiting for
its own children counts too. Renaming, archiving or switching the model of a session does not make
it live. A child counts from the moment courier_send to it returns, so a spawn in the same step
counts it. Several spawns made at once cannot get past a limit together.
Whether a session's turn runs is read from its times: on OpenCode 2.2.2 and later, from when its
latest execution started (time.busy), which a rename, an archive, a switch or a message waiting
in its inbox does not move, so a session at rest is told so without reading its transcript. Only
when that start is later than its last turn's end is the transcript read, which tells a running
turn from one ended by a plain permission decline, which records no end. On an older OpenCode, or
for a session that has not run since the upgrade, the transcript is read whenever anything changed
the session after its last turn's end. The same reading serves courier_tree, the tree panel, the
footer, the memory's record and the missed-wake check.
The brief. Each spawned session is told its depth, the limits and how to report. Below
maxDepth it is told to split its task when it has two or more independent, substantial parts,
one session per part, and to do it itself otherwise. It never starts exactly one session for the
whole task; one session for one stage is a part: a staged core when 2 or more parts follow it, a
review of the merged parts, the fixing of what a review found once verified. One session alone,
with nothing of the task done before it by the session or by 2 or more parts, and fewer than 2
parts after it, is the whole task. It says which in one line: split: no, because …; considered: …
or split: <n> parts: …. A session that splits starts the parts in one join group, ends its turn
without polling, checks each part and sends one combined report. At maxDepth it is told to do the
task itself. Any spawned session is told to send its parent a finding the other parts need (an
assumption of the split that is wrong, a shared file or API that behaves otherwise) at once, without
a status, and carry on; progress alone is not worth a message. A message the person writes in its
session, outside a <courier> envelope, is for the whole job: it sends it to its parent at once,
without a status and quoted as the person's, before it acts on it, then carries on.
The role. Every spawned session carries one line in its system prompt, opencode-courier role: …,
in the role entry of its session instructions (OpenCode 2.2.0's <context key="role"> block),
written as it is started; a root gets its own at its first spawn, which its transcript shows once as
"Instructions updated: api/role, api/standing" (with api/lessons when the project has a lesson).
On an older OpenCode the plugin adds the line as a system part on each
model request instead. A session below maxDepth is a sub-orchestrator. One at maxDepth is a
leaf, and its requests go without courier_spawn, courier_recall and courier_note. A top
session that has started no session yet is pointed to the skill. The root may also hand a heavy task
whole to one session, to keep its own context: split: 1 part: <the task>, to keep my context.
The lessons. Beside its role, every spawned session below maxDepth gets the project's lessons
in a lessons entry, written as it is started: the lessons of the project's tree
records as courier_recall lists them, pinned first and then newest first, at most 5, each with its
tree's title, under one line saying that courier_recall has the records. A root gets its own at its
first spawn, beside its role. When courier_note writes a lesson, the entry is written again on the
writer's tree's top, the top's current children below maxDepth and the writer, where it changed.
A leaf gets none, as it cannot split. Nothing is written with the memory option off, or while no
record of the project holds a lesson.
The standing. A parent carries what courier_children shows in a standing entry: one line per
session it started, oldest first, with its state (started, working, sent progress, reported done, ended its turn without a report, its turn failed, its turn was interrupted, stopped)
and, for a group member, its group's standing (its report held with the group, the
group waiting for it, the member out of it and why, or the group delivered). The plugin writes it
when a child is started, reports or sends progress, is held with or released from its group, fails,
ends its turn silently, is interrupted, deleted, stopped or forgotten, only when its text changed,
and removes it once the parent has no children left; so each change is at most one update message at
the parent's next step, and the parent needs no courier_children call to see where its children
stand. It lists at most 10 children and counts older ones. A held report's body is not in it: the
group's release delivers the reports as one message, and the entry carries state, not content.
On an older OpenCode neither entry is written, nor where a put fails (logged); the lessons are
still in courier_recall and the standing in courier_children.
The plugin registers one OpenCode skill, courier-orchestrate, in every location. Models load it
at the start of an implementation task that may have independent parts. A spawned session loads it
too, as its role part says; a leaf, which has no courier_spawn, is sent straight to its report. In
the TUI, /skills attaches it to your prompt. Loading it asks the skill permission like any skill. To hide it,
deny it in permissions:
{ "action": "skill", "resource": "courier-orchestrate", "effect": "deny" }.
It says, in order:
- At the root, call
courier_recallonce, before deciding; a session below it reads none, since the records describe whole trees, which the root weighed before it split. - Decide whether to split, by the brief's rule. Keep the deciding, checking and integrating; hand off the long reading and writing. Only the root may start one session for a whole heavy task. Several whole jobs in one prompt are that many parts, each running the playbook for its job. The parts a task can have besides its edits: a scout that reads and reports, the implementers, tests and documentation, and a review stage after the merge: one review session, given the diff to read, that may hand the reading to reviewers, then starts one verifier with their findings, which drops what does not hold and starts one implementor for what holds (2 or more, isolated, when the fixes are independent); the review session reports what was found, held, fixed and refused, and running the checks on the fixed tree stays the parent's. Under an isolated session the review session, the verifier and the implementor are isolated too, each worktree from the one above. One session for one stage is a part by the brief's rule above.
- When one core is tightly coupled, stage it: do the core first, then the parts that depend on it.
- Plan the parts so that no two edit the same files, name in each task the skills the session should load for it, from those the parent sees listed, and say when a part is a whole job that delivers itself.
- Start them in one join group,
isolate: truewhere they edit files in parallel,modelandvariantunset unless a part plainly calls for a cheaper or a stronger model, and end the turn without polling; count 6 live sessions per part that runs a review stage againstmaxTotal, start the parts that fit in one group and the rest in a group of their own once the first group has reported, and start a job that depends on another after that one is done, isolated and told the delivering session's branch to merge first when that one was delivered rather than merged. Pass a message that changes what other parts need (a child's finding without a status, one from the person or from its own parent) on to the children it affects, and only to them, before they go further; stop or re-brief the parts it breaks, and send it up too when the parent's other parts need it. A child's account of what the person told it is the person's word: pass it on, do not judge it against the original task, and never tell a session to undo what the person asked. Progress goes to no one (steering a running tree). Proposeonceorrejectwithcourier_answerfor a permission request from below; ask the person a question from below with its own question tool, never answering for them. - Read each report by its status, and verify first-hand; on a review stage's report, settle the
refusals it disagrees with itself. Run checks through the project's scripts
(
bun run <script>), so one permission rule covers them. - Merge each reported branch, then
courier_cleanup; a job with a deliverable of its own, such as a ticket with its pull request, is delivered by its session, told so in its task, and verified, not merged. - Report upward with one status,
doneonce every part is verified and integrated or delivered, or at the top, write the tree's lesson withcourier_note.
It also says what to do when a part fails or goes quiet, when a spawn is refused (never retry in a
loop), after losing track (one courier_tree) and to abandon a subtree (courier_stop).
Without the skill, the brief and the role part still carry the split rule, the one group, no polling, the limits, how to report and to send a finding up. The skill alone adds staging, planning the files, isolation, check-ins, passing findings on, merging, cleanup and the lesson.
A child reports with courier_send to its parent, with a status:
done;partial: some of the task is left, and the report says what and why;blocked: it needs a decision from its parent, and says what;failed.
Any other status is refused. A report may list artifacts: branch (a string), commits and
files (lists of strings), and checks (a list of { command, result }). The parent reads:
<courier from="ses_child" status="done">
Fixed the total in checkout.
Artifacts:
- branch: fix/checkout
- commits: abc1234 Fix the total
- files: src/checkout.ts, test/checkout.test.ts
- checks:
- bun test: 412 passed
- bun run typecheck: clean
</courier>
A message with a status settles the child's report, blocked included. A message without one is
progress and settles nothing.
Each report wakes the parent separately. Give a batch of children the same group in
courier_spawn, and their reports are held until every member has reported and the parent's turn
that started them has ended. Then they reach the parent in one message. A group name is 1 to 60
letters, digits, dots, dashes or underscores, scoped to the parent.
What is held. A done, partial or failed report is held. A later report from the same member
replaces it. Everything else reaches the parent at once: a blocked report (the member stays out
until it reports again), progress, a failed turn, a silent end, a permission request, a question
and a form.
The release. The message is wrapped in <courier from="<members>" group="<name>" reports="<n>">. It counts the reports by status, then lists each in join order, as
[i/n] <sessionID> "<title>": <status> with its text and artifacts, then the members that left
without a report. The group's name is then free again. A child started into a group that is still
open joins it. A report after the release is delivered on its own.
Leaving. A member leaves without a report when its turn fails or is interrupted, when OpenCode deletes it, or when the parent drops it. The group is released once the others have reported. A group whose members all left is dropped unsent. A member that reports after leaving, before the release, rejoins.
Dropping a member. A member that will not report holds back the others: one that ended its turn
silently, one whose failure the plugin missed (the server was down), or one you gave up on. Drop it
with courier_cleanup { sessionID }: with the parent's turn ended, the held reports arrive at once.
An isolated member whose worktree holds unsaved work is kept, and stays in the group, until you
commit that work or pass force: true.
courier_children shows each member's group: { name, report }, with report one of held,
out, failed, interrupted, deleted, released (the group was delivered) or unknown; the
parent's standing entry says the same in words. Held
reports survive a restart. A group's message does not overtake the notice of a member's failed turn, unless a scheduler tick
falls between the two.
For every failed turn of a spawned session (no model, missing credentials, the provider down), the
parent gets a message from that child marked failed="<error type>", with the error. It wakes the
parent if idle. The parent can message the child to try again, start a replacement, or carry on.
When the failure is a rate or quota limit and the provider says how long to wait (OpenCode 2.1.2 and
later send retryAfterMs), the message says when that is, in minutes and in UTC, and not to
message the child before then, as the turn would fail again. Courier does not retry for you;
courier_later can remind the parent when the time comes. The tree panel shows the child's failed
row as limit, resumes 14:32 (the panel's local time, with the date when it is not today) until that time
passes, the child runs again, or courier_stop stops it.
An interrupted turn is not a failure. A failure while the server is down is not seen; a
courier_later check-in covers that.
A spawned session owes its parent a report from the moment a task or message reaches it until it reports with a status, or its turn fails or is interrupted. What you type in the child's own session does not make it owe one.
Before it goes idle owing a report, the child is reminded once: a message in a <courier from="courier" reminder="report"> envelope, naming the session to report to. It runs in the same
turn, on the warm prompt cache, and the parent is not woken. One reminder per prompt that reached
the child: if it still ends its turn without a report, the parent gets a message marked
ended="without-report", with the child's last reply. The parent can tell the child to carry on or
to report, or start a replacement. The reminder is not a prompt of its own: the child owes the same
report, and it is not watched as a missed wake. It needs OpenCode 2.2.4's
experimental experimental.idle hook; an older OpenCode, or one where the hook changed, skips the
reminder and only tells the parent. A child with an active /goal may get the
reminder alongside the goal's continuation; it says to carry on if work is left.
The parent is not told while the child waits on something: its own children's reports, a permission request or question, a scheduled message, or a webhook topic it subscribed to since its last prompt. Nor is the child reminded then, or while background work of its own (a shell, a subagent) still runs, whose end wakes it. A turn stopped by a server shutdown resumes on the next start and is not told. A turn ended by a plain permission decline or a dismissed question is told, without a reminder: an interrupted turn does not reach the hook.
OpenCode starts a turn when a message lands in an idle session's inbox. Rarely, it does not. So the
plugin watches every message it sends to wake a session. Notices sent without waking, such as a
permission request answered elsewhere, are not watched. One that has waited in an idle session for 30 seconds is
sent again under the same id, which wakes the session without a second copy. If the session still
has not taken it 30 seconds later, it is stalled: the footer counts it and the tree panel marks
its row. Prompting the session delivers the message and ends the stall. Nothing is sent a third
time. The check runs every 15 seconds, so each step can take up to 15 seconds longer. Before sending
again, the check reads the session: on OpenCode 2.2.2 and later, a turn started since the message was
left waiting and still running (time.busy) is the one that takes it, even when the plugin missed the
event, and the message is not sent again; on an older OpenCode the session's transcript tells.
When a child's tool call needs approval, OpenCode holds it until someone answers in the child's
session. The plugin sends the top session a message marked asks="permission" and
request="<id>", saying what the child asks for. A request from any depth goes to the top, and only
the top can answer it.
The top session's model cannot approve. It proposes an answer with
courier_answer { sessionID, requestID, reply, message? }:
once: OpenCode first checks the request against the top session's own rules. When they say ask, it shows you its permission prompt there.oncereaches the child only if you allow it. A rule that denies it in the top session refuses it.reject: refuses it, with a reason if given.
In the prompt, Allow once passes it on. Reject with a reason fails courier_answer with that
reason, which you read in its result, and refuses the child's request with the same reason. A plain
Reject ends the top session's step and leaves the child's request waiting.
always is not offered through courier_answer, since it would save a rule for the parent's whole
project. Grant a standing rule where OpenCode shows the child's own request: in the child's session,
or the asks inbox.
A rejection always carries a message, so the child's call fails and the child carries on.
A request answered elsewhere (in the child's session, the asks inbox, or by an always that covers
it) is settled: the top session gets a short message marked answered="<reply>". It waits in the
inbox for the next turn and wakes nothing. A rejection in OpenCode rejects the session's other
pending requests too; the message then says the child may have stopped and courier_send restarts
it. A courier_answer after that passes nothing on, and a prompt already open in the top session
passes nothing on when allowed. courier_children lists pending requests under pending.
Some OpenCode forms can only be answered in the child's session. The common one is web search's
first call with no provider chosen. The plugin tells the top session, with a message marked
asks="form", form="<id>" and, for web search, kind="websearch.provider". Answer it in the
child's session or the asks inbox.
OpenCode waits a minute for a web search choice, then the child's search fails and it carries on.
The choice is global: make it once, in any session, or under OpenCode's "Third-party search"
setting. Once a form is answered or withdrawn, the top session gets a message marked
settled="answered" or settled="cancelled". Forms of sessions courier did not spawn, such as MCP
servers' requests, are ignored.
When a child calls OpenCode's question tool, the plugin sends the top session a message marked
asks="question" and request="question_<id>", with the questions and their options. The top
session should ask you the same questions with its own question tool. When the wording and options
match, the two are linked: your pick goes straight to the child, which carries on in the same turn.
Otherwise the top passes your answer with courier_answer { sessionID, requestID, answers }: one
entry per question, a label, your own text, or a list of labels.
The question stays in the child's session too, and the first answer counts. Dismissing it in the child's session ends the child's turn. Dismissing it in the top session ends the top's turn, and the child carries on without an answer.
A question whose call is cut off (the child's turn stopped, a restart) stays answerable. The top
session gets a message marked stopped="true" or restarted="true", and your answer reaches the
child as a message. Stored questions are kept 14 days.
A child whose agent may not use the question tool (OpenCode's general agent, or a deny rule) can
still ask with courier_send.
courier_tree lists the sessions under a session, depth first, at most maxTotal: the live ones
first, then the newest. Past that it says how many it left out (truncated). Each entry has what
courier_children shows, plus parentID, depth and report: owes, progressed, ended, or
the status of its last report. Anyone can read any tree.
courier_stop { sessionID, cleanup? } stops a session and every session under it:
- Only a session above the target may stop it.
- The
courier_latermessages scheduled for them are cancelled. - They are interrupted deepest first. Each says
interrupted,idle(it was not running),gone(OpenCode no longer has it) orfailed(it may still run). - With
cleanup: true, their worktrees are removed bycourier_cleanup's rules, withoutforce.
The target's parent is told once, ended="stopped", unless it is the caller or the target was not
running. Sessions under the target are not told. A stopped session stays on the roster and can be
messaged again. Scheduled messages and webhook subscriptions the stopped sessions made for others are
left alone. A session started by a running turn of the subtree while the stop runs may escape it.
The person steers a running tree by talking to any session in it:
- In the
/courier treepanel (In the terminal),enteropens any session, and what the person types goes to it like in any chat. A running session takes it into its current turn. - To reach the rest of the tree, the person asks that session to tell its parent, or messages the parent directly.
courier_sendwithout astatusis an ordinary message, not a report. It is delivered assteer(or after the current turn withqueue: true) and wakes the target if it is idle. It has no target restriction, so a parent that got a finding can pass it on to its other children, which steers their running turns. Astatusbelongs on a report upward: on a message to a session below the sender it is dropped, the message goes without it, and the result says so.
A child that finds something the other parts need, or is told it, sends it to its parent without a status (the brief), and the skill has a parent pass such a message on to the children it affects (the skill). Without the skill, ask the parent to pass it on.
courier_spawn records each child under its parent, so courier_children finds them after a
compaction or restart. A child deleted in OpenCode is forgotten at once, unless it is isolated or
has children of its own. Other entries are dropped 14 days after the child started, except isolated
children whose worktree is still there.
An isolated child works in a git worktree under OpenCode's data directory, on a detached HEAD. It is
told to commit on a branch named courier/<its session ID> and to name it in artifacts.branch.
Its parent merges that branch.
The worktree starts from the project's last commit. When an isolated child spawns an isolated child,
the new worktree starts from the commit the parent's worktree is on now. Uncommitted work is not
handed down, so an isolated session is told to commit before it spawns, and courier_spawn says so
when the parent has uncommitted changes. If the parent's HEAD cannot be read, the spawn fails and
starts nothing; start it without isolate.
Nothing removes a worktree on its own. When the parent has what it needs, it calls
courier_cleanup { sessionID }. The worktree is kept, and the result says why, when it holds
uncommitted changes (untracked files included, ignored ones not), or commits on no branch, tag or
remote. Commit or branch what you want to keep, or call again with force: true to discard it. A
child without isolate has no worktree: courier_cleanup only forgets it. courier_cleanup never
deletes a session. Call it after the child has reported.
Pending courier_later messages are checked every 15 seconds, so one can arrive up to 15 seconds
late. They are queued behind a running turn and survive a restart. A crash between delivering a
message and forgetting it can deliver it twice. After a start,
OpenCode loads plugins when a project is first used, so messages that fell due meanwhile arrive
then.
A spawned session cannot use OpenCode's own schedules (schedule_create and the rest, with
experimental.schedules on): its session's rules deny them. A wake-up of theirs goes round the tree,
so a child that scheduled one and ended its turn would read as having ended without a report.
courier_later is the way for a child to wait. A root session keeps them.
OpenCode's /goal (2.2.4 and later) has a reviewer check the session each time its turn ends and
prompts it to continue unless the goal is met, blocked or paused. It holds back only for OpenCode's
own background work in that session: subagents of the task tool, image jobs and shells. Courier
children, native ones too, are not in that list, and a plugin cannot add to it; an upstream
request asks OpenCode for a way to.
So a session with an active goal that spawns children and ends its turn to wait for their reports is
prompted to continue at once. It may poll, redo the children's work or spawn again, and after three
rounds in a row with the same reason or no tool call the goal pauses (Not making progress). The
children carry on, and their reports wake the session as usual.
Don't set a goal on a session that waits on courier children: their reports wake it, and
courier_later covers a check-in. A goal on a session that does the work itself is not affected.
Once the reports are in, /goal resume picks a paused goal up again.
The plugin keeps one record per session tree in the project, under its top session. It is rewritten at each turn end in the tree, from facts the plugin already has:
- when the tree started and last moved, and the top session's title;
- the top's
split:line, and up to 3 later ones that read otherwise; - for each session started in it: title, depth, isolation, group, status, duration, cost, context use and spawns refused;
- whether the tree is finished;
- the lesson, the one thing a model writes.
It never holds a report's body, artifacts or replies. A part's status is a report's status, or
interrupted, stopped, stalled, out (owes a report), or gone (deleted before it settled).
courier_recall { sessionID? } lists the project's records, pinned first, then newest first,
one line each; its description asks the root for the one call and a spawned session for none:
- ses_old 2026-10-03: "Add memory to courier" · split: 3 parts: core, e2e, docs · 3 parts: 2 done, 1 partial, 2 levels deep, 2 isolated, 1 spawn refused · ran 42 min · last moved 5 days ago · $1.28 · finished · lesson (2026-10-03): "The docs part was ten lines; next time do it yourself."
With sessionID, a listed top, it gives that record in full as JSON. Each call counts on the other
trees' records, as how often each was listed and opened; the memory view shows
the counts.
A record is one JSON object under the storage key memory/<projectID>/<topSessionID>, where
projectID is shared by every worktree of a repository. Times are epoch milliseconds, durations
milliseconds:
{
"startedAt": 1760000000000,
"movedAt": 1760000900000,
"title": "Add memory to courier",
"split": "split: 3 parts: core, e2e, docs",
"finished": true,
"recalled": { "at": 1760000001000, "calls": 1 },
"lesson": { "text": "The docs part was ten lines; next time do it yourself.", "at": 1760000890000 },
"parts": [
{ "sessionID": "ses_core", "title": "Core", "depth": 1, "isolated": true, "group": "wave", "status": "done", "duration": 540000, "cost": 0.42, "context": 0.31 },
{ "sessionID": "ses_e2e", "title": "E2E scenario", "depth": 1, "isolated": true, "group": "wave", "status": "partial", "duration": 600000, "cost": 0.81, "refused": 1 }
]
}| Field | Holds |
|---|---|
startedAt, movedAt |
When the top was created, and when a turn in the tree last ended. |
title |
The top session's title. |
split |
The top's first split: line as written, or not stated. |
revisions |
Absent until the top states another split line: [{ text, at, parts }], the newest 3. |
finished |
Whether nothing in the tree ran or waited and every part had settled. |
recalled |
{ calls, at? }: courier_recall calls from the tree; at when the top first called. |
lesson |
{ text, at, depth? }, from courier_note; depth for a sub-orchestrator's. |
reads, pinned |
Merged in when read, from keys beside the record (below); never written with it. |
parts |
One per session started in the tree, in tree order: sessionID, title, depth, isolated, group, status, duration, cost, context (0 to 1) and refused. |
Beside the record, each key with one writer: …/<topSessionID>/pin ({ at }, the memory view's
pin) and …/<topSessionID>/reads/<readerTopSessionID> ({ shown, read, at }, from that reader
tree's courier_recall calls, summed when read).
To seed a record by hand for a test, set it with e2e/kv.ts against a running test server:
bun e2e/kv.ts set 'memory/<projectID>/<topSessionID>' '<json>' '<a key the plugin stored>'.
Missing fields take their defaults, a part without sessionID is dropped, and a value without
parts is not a record. A lesson may be a bare string; one over 300 characters is clipped.
courier_note { text } writes the caller's tree's one lesson: one line, at most 300 characters,
naming the job and the reason. A longer one is refused, not cut. A second note replaces the first,
so write it last, once the work is verified. The top session or a sub-orchestrator may write it; a
sub-orchestrator's is marked with its depth. A leaf may not. A lesson is not a rule: a rule belongs
in AGENTS.md. It is read by every later session in the project, so it holds no code or secrets.
Besides the listing, the project's lessons reach every session that can split in its system prompt,
through the lessons entry.
Retention. A record you pin in the memory view is never dropped. Of the rest, each project keeps 30, those with a lesson first, and none older than 90 days. The others are dropped when the plugin loads.
Off. "memory": false writes no records. courier_recall says it is off, and courier_note is
refused.
A native child is created with OpenCode's own parentID, so OpenCode treats it as the parent's
subagent. The parent chooses per spawn with courier_spawn's native: true makes that child a
subagent, false keeps it a standalone session, and unset leaves it to the nativeChildren option,
which is off by default and, with "nativeChildren": true, makes every child started without
isolate a subagent. A tree may mix both kinds, in one group too; the skill has a parent make a
part native when nobody needs to open it on its own (it reads, checks or looks something up, reports
once and is done), and leave it off for a part the person may want to open or that should outlive
the parent. Use native children only with OpenCode's TUI or the paired app, which show their prompts.
What changes for a native child:
- Asks are answered in OpenCode's view. The TUI's view of the top session, and the paired app's
request dock, show the permission requests, questions and forms of every native child under it.
The top session's model is only told (a notice marked
shown="native") and told again once it is settled. A native child under an isolated session shows its asks in that session's view instead, and they are relayed as without the option. With a bare API client nothing shows the prompts:courier_answeris the fallback, andcourier_childrenlists them underpending. - Deleting a session deletes its native children.
- The child gets the courier's own ruleset (OpenCode's schedule tools denied, Scheduled messages), not its parent's session rules, as without the option.
- Providers see a subagent. Requests carry the parent's id for cache routing. GitHub Copilot meters subagents differently; not tested on Copilot.
- A native child leaves the TUI's session lists. Reach it from the parent's subagent tab.
autoacceptandopencode runin the top session answer native children's requests:autoacceptandrun --autoapprove them,runwithout--autorejects them and cancels their questions and forms.- The roster,
courier_children,courier_treeand the tree panel mark it:native: truein the listings,nativein the panel's row.
An isolated child cannot be native: native: true with isolate: true is refused, and nothing is
started; with the option on, an isolated child is a standalone session. A tree with isolated children
shows in OpenCode as several trees, split at each isolated child; courier_tree shows the whole.
The package ships a part for OpenCode's terminal UI, loaded from the server's plugin list. The server part works the same without it. What the terminal does (answering, stopping, forgetting) goes through the same paths as the tools.
In the prompt's footer, for the open session:
courier 1 waiting on you · 1 stalled · 2 running · group pair 1/2 · 1 idle
It counts every session under the open one: waiting on you (a permission request, question or
form), stalled, running, or idle. Then each open join group of its children, as reported over
members. A zero part is left out. A click on waiting on you opens the asks inbox.
While a permission prompt or form shows, OpenCode hides the footer; use ctrl+x k.
/courier tree (or Courier session tree in the palette) opens a panel beside the session, with
that session as its first row and the tree under it. It is fullscreen under 108 columns.
█▀██▀██ courier tree [█████░░░░░] 1/2 reported
▄▀▀▀▀▀▀▀ 1 waiting on you · 1 idle
▸ Fix the login bug (on screen) idle
│ ran 4m 10s · last courier_spawn 2m 05s ago · ctx 12% · $0.03
├ ✓ Report back to your parent. idle · reported done · group pair held
│ ran 12s · last courier_send 3s ago · $0.00
└ ● CHILD-ASKS waiting on you · owes a report · group pair out
asks permission: shell echo courier-asks
↑↓ select · enter open · s stop · r refresh · f fullscreen · q close
The first row is the session the panel was opened in, selected at first, with what it does, what it
asks and its stall; its bar is the header's, and the header's counts are of the sessions under it.
With nothing started under it yet, the panel says so below that row. s and x leave it alone: the
panel acts as that session. Tree guides join each session to the one that started it.
Each row has a mark (✓ reported, a spinner while running, ● waiting on you, ! stalled, ✗
failed or interrupted), the title, what it does, its report, its group's standing, isolated,
native for a subagent and its model when
that is not its parent's.
Under it: a progress bar for an orchestrator, each ask for a session waiting on you, and for a leaf
how long it ran, its last tool call, its context use and its cost. Digby, the courier's mole, digs
in the header and follows the tree's state, the panel's own session included: while any of it runs he
sits up, glancing, with a short dig every ten seconds or so, and only a spawn, a report, an ask or a
stall moves more than that. He sleeps only once every session in it is idle.
Keys: ↑/k and ↓/j move, enter or a click opens the session, s stops it, x forgets a
gone one, r refreshes, f toggles fullscreen and q closes. <leader>left and
<leader>right move the keyboard between the session and the panel. The panel follows you to other
sessions and hides where there is no tree.
Stopping. s, or /courier stop <session> (/courier stop alone lists the tree to pick from),
asks you to confirm, then stops as courier_stop would from the open session.
Forgetting. x on a gone row (deleted in OpenCode, kept for its worktree or its children)
forgets it as courier_cleanup would, never with force, after a confirm. The server refuses a
session OpenCode still has, or cannot look up, so x on a live row forgets nothing.
Sounds. A child's report plays OpenCode's subagent_done sound. A group's release plays done,
with a notification when the terminal is not focused.
Every permission request, question and form waiting on you anywhere in the tree, answered with no
model in between. Open it with ctrl+x k (<leader>k), /asks (or /ask), the palette's
"Courier: what waits on you", or a click on the footer.
Waiting on you (3)
Permission (top session): courier_answer once for "shell echo courier-asks" Root top session
Permission: shell echo courier-asks Root › Fix the bug › Write tests depth 2 · group pair · the top's prompt asks
Question: Greeting: Which greeting? Root › Fix the bug depth 1
Each session is named by its current title, or the title it was spawned with when the terminal
does not know it. The top session's own requests come first, chiefly the prompts courier_answer once opens. Allowing
such a prompt passes once on to the child. The list follows the tree while open and closes when
nothing is left. Choosing an ask opens its dialog; esc goes back.
- A permission request:
Allow once,Always allow(when the request has a rule to save),Reject, orReject with a reason…. - A form: each field in turn, checked against its limits before it is sent. A form that opens a page is left to its own session.
- A question: each question in turn, one or several choices, or
Type your own answer….
New asks play OpenCode's own sounds. Asks already waiting when the TUI starts, or when it first reads a tree, are not announced.
/courier memory (or "Courier: memory" in the palette) lists the project's tree records:
pinned first, then grouped by the day each last moved, in local time.
Memory (3)
Pinned
Split the parser 2 parts: 2 done · 12 min · $0.70 · shown 7 times, opened once
lesson (2026-09-02): "The lexer and the parser collided in one file; give them to one session."
Today
Add memory to courier (this session) 3 parts: 2 done, 1 partial · 42 min · $1.28
lesson (2026-10-17): "The docs part was ten lines; next time do it yourself."
forget x pin p
Each row: the top's title, the parts by status, how long it ran, its cost, not finished when it
is not, and how often other trees' courier_recall listed and opened it. The lesson is under it.
entershows the record in full: the tree, its parts and its lesson.escgoes back.ppins or unpins, with no confirm. A pinned record is listed first and never swept.xforgets the record, or on a lesson row in a record only the lesson, after a confirm.esccloses.
Every letter but x and p types into the search. With memory off, the view says so.
GitHub events, or anything signed posted to /hook/<name>, can wake a subscribed session. The
receiver is off unless the plugin has a webhook option, in the global config: there is one
receiver per OpenCode server. "webhook": true takes every default. If several locations set it,
the first to load wins.
{
"plugins": [
{
"package": "opencode-courier",
"options": { "webhook": { "port": 4097, "secretFile": "~/.config/opencode/courier-webhook-secret" } }
}
]
}| 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. |
Setup.
- Make the secret:
openssl rand -hex 32 > ~/.config/opencode/courier-webhook-secret, thenchmod 600it. It is never read fromopencode.json(asecretkey is refused). Prefer a file withopencode service start, whose environment may not be your shell's. Without a secret the receiver does not start, and the server log says why. - Forward a public URL to the receiver with a tunnel you trust:
cloudflared tunnel --url http://127.0.0.1:4097,ngrok http 4097, orsmee --url https://smee.io/<channel> --target http://127.0.0.1:4097/github(no inbound port). - On GitHub, add a webhook to the repository (Settings → Webhooks):
<public URL>/github, content typeapplication/json, the same secret, and the events you want. courier_subscribea session toowner/repo,owner/repo#Nor a name.
The receiver starts when OpenCode first loads the plugin after a start. Deliveries before that fail; GitHub lists them under Recent Deliveries, with a Redeliver button.
What wakes whom. Each delivery reaches the subscribed sessions as a short summary, queued behind a running turn:
POST /github: a review, a review comment, a comment, a pull request or issue opened, reopened, closed, merged or marked ready, or a completed check run, check suite or workflow run on a pull request goes toowner/repo#Nandowner/repo. Anything else with a repository (a push, a release) goes toowner/repoonly. Pings, unfinished CI runs, edits, labels, assignments and review requests wake nobody. Fork pull requests get no check suites, so their CI reachesowner/repoonly.POST /hook/<name>: anything, for<name>. A JSON body'stext,summaryormessageis delivered, else the body itself.
The summary holds the event, repository and number, who, the state or conclusion, a link, and at most 1500 characters of a review or comment body. It is marked as outside text, to be treated as data. Anyone who can comment on the repository writes those bodies, so subscribe only to repositories whose commenters you trust.
Signing. Every delivery needs an X-Hub-Signature-256 header: sha256= and the HMAC-SHA256 hex
digest under the secret. For GitHub it is of the raw body. For /hook/<name> it is of the name, a
newline and the body, so a delivery cannot be replayed to another topic:
sig=$(printf '%s\n%s' deploys "$body" | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
curl -X POST -H "x-hub-signature-256: sha256=$sig" --data-binary "$body" http://127.0.0.1:4097/hook/deploys| Status | When |
|---|---|
202 |
Delivered, with the number of sessions reached, which can be 0. |
200 already delivered |
The same delivery was accepted before (the last 1000 are remembered). |
401 |
Missing or wrong signature. |
413 |
Body over maxBytes. |
A delivery that reached no session because every send failed is forgotten, so GitHub's Redeliver
retries it. A generic sender posting the same text twice should add something unique, such as a
timestamp. A session OpenCode no longer knows loses its subscriptions at the next delivery, and
courier_subscribe refuses an unknown session. GitHub has no event for a merge conflict.
Two OpenCode servers on one data directory, such as opencode serve next to opencode service,
share the plugin's storage but not its memory:
- Scheduled messages and released groups are delivered by one server. If it stops, another takes over within about 75 seconds.
- A child's permission request, failure or silent end is told once, by the server running it. Whether a child owes a report compares both servers' clocks, so run them on one machine.
courier_answerpasses an answer on only from the server where the child waits, andcourier_childrenlists only that server's requests.
Where you can, run one server per data directory, or point a second one at another
(XDG_DATA_HOME).
{ "plugins": [{ "package": "opencode-courier", "options": { "maxDepth": 2, "memory": false } }] }