Skip to content

Latest commit

 

History

History
894 lines (722 loc) · 53.3 KB

reference.md

File metadata and controls

894 lines (722 loc) · 53.3 KB

Reference

Everything the plugin does, beyond the README.

The OpenCode version

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

Updating the plugin

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-courier

opencode 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.

Options

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.

{
  "plugins": [{ "package": "opencode-courier", "options": { "maxDepth": 2, "memory": false } }]
}
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.

The tools

? 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.

The model a child runs on

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.

Session trees and their limits

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:

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 courier-orchestrate skill

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:

  1. At the root, call courier_recall once, before deciding; a session below it reads none, since the records describe whole trees, which the root weighed before it split.
  2. 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.
  3. When one core is tightly coupled, stage it: do the core first, then the parts that depend on it.
  4. 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.
  5. Start them in one join group, isolate: true where they edit files in parallel, model and variant unset 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 against maxTotal, 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). Propose once or reject with courier_answer for a permission request from below; ask the person a question from below with its own question tool, never answering for them.
  6. 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.
  7. 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.
  8. Report upward with one status, done once every part is verified and integrated or delivered, or at the top, write the tree's lesson with courier_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's report

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.

Join groups

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.

A child that fails

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 child that ends without a report

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.

A missed wake

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.

A child that asks for permission

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. once reaches 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.

A child that shows a form

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.

A child that asks a question

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.

Seeing and stopping a subtree

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:

  1. Only a session above the target may stop it.
  2. The courier_later messages scheduled for them are cancelled.
  3. They are interrupted deepest first. Each says interrupted, idle (it was not running), gone (OpenCode no longer has it) or failed (it may still run).
  4. With cleanup: true, their worktrees are removed by courier_cleanup's rules, without force.

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.

Steering a running tree

The person steers a running tree by talking to any session in it:

  • In the /courier tree panel (In the terminal), enter opens 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_send without a status is an ordinary message, not a report. It is delivered as steer (or after the current turn with queue: 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. A status belongs 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.

Roster

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.

Worktrees

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.

Scheduled messages

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.

/goal and courier children

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.

Memory

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.

The record

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.

The lesson

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.

Native child sessions

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_answer is the fallback, and courier_children lists them under pending.
  • 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.
  • autoaccept and opencode run in the top session answer native children's requests: autoaccept and run --auto approve them, run without --auto rejects them and cancels their questions and forms.
  • The roster, courier_children, courier_tree and the tree panel mark it: native: true in the listings, native in 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.

In the terminal

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.

The status entry

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.

The tree panel

/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.

The asks inbox

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, or Reject 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.

The memory view

/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.

  • enter shows the record in full: the tree, its parts and its lesson. esc goes back.
  • p pins or unpins, with no confirm. A pinned record is listed first and never swept.
  • x forgets the record, or on a lesson row in a record only the lesson, after a confirm.
  • esc closes.

Every letter but x and p types into the search. With memory off, the view says so.

Webhooks

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.

  1. Make the secret: openssl rand -hex 32 > ~/.config/opencode/courier-webhook-secret, then chmod 600 it. It is never read from opencode.json (a secret key is refused). Prefer a file with opencode service start, whose environment may not be your shell's. Without a secret the receiver does not start, and the server log says why.
  2. Forward a public URL to the receiver 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 (no inbound port).
  3. On GitHub, add a webhook to the repository (Settings → Webhooks): <public URL>/github, content type application/json, the same secret, and the events you want.
  4. courier_subscribe a session to owner/repo, owner/repo#N or 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 to owner/repo#N and owner/repo. Anything else with a repository (a push, a release) goes to owner/repo only. Pings, unfinished CI runs, edits, labels, assignments and review requests wake nobody. Fork pull requests get no check suites, so their CI reaches owner/repo only.
  • POST /hook/<name>: anything, for <name>. A JSON body's text, summary or message is 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 servers on one data directory

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_answer passes an answer on only from the server where the child waits, and courier_children lists only that server's requests.

Where you can, run one server per data directory, or point a second one at another (XDG_DATA_HOME).