Dispatch for agents
An agent works in Dispatch through one shell command, dispatch. The Envoy plugin for the
agent’s host (Oh My Pi or Claude Code) puts it on the agent’s PATH and tells it which session
it acts for. dispatch --help lists the commands, dispatch <command> --help gives a command’s
flags and an example, and --dry-run checks a command’s arguments as the call would and prints
them as given (before the call fills in an issue from LEGION_ISSUE or a ref), sending nothing.
The dispatch skill in this repository, skills/dispatch/SKILL.md, teaches the workflow. The
command reference lists every command’s flags.
Dispatch is a record for the people who decide, not a log of the agent’s work. Progress belongs in
the agent’s own transcript and its pull request. Anything a person must read, answer, or approve
goes through a dispatch command.
Connecting an agent
Section titled “Connecting an agent”-
A person signs in to Dispatch and opens Settings → Agent tokens.
-
They type a label and choose New token. The token is shown once; copy it then.
-
They give it to the agent in the
dispatchsection of itsenvoy.json:{"dispatch": {"enabled": true,"serverUrl": "https://dispatch.internal.example","token": "<token>"}}A session under an Oh My Pi profile other than
defaultor a Claude CodeCLAUDE_CONFIG_DIRreads theenvoy.jsonin that directory and no other. Any other session reads the fileENVOY_CONFIG_FILEnames when it is set, else~/.config/legion/envoy.json($XDG_CONFIG_HOME/legion/envoy.jsonwhen that is set). Thedispatchcommand reads the file its session’s plugin chose, which the plugin hands it inENVOY_CONFIG_FILE; in a Claude Code shell that gets nothing the plugin hands on, as a subagent’s can, it still reads the session’sCLAUDE_CONFIG_DIR. A token written as!<command>, such as!secrets get DISPATCH_TOKEN, is a command, and what it prints is the token.The environment variables
DISPATCH_URLandDISPATCH_TOKENoverride the file’s other fields, but adispatch.enabled: falsein the file keeps Dispatch off. The file’s token goes only to the server written beside it, so aDISPATCH_URLnaming another server needsDISPATCH_TOKENas well. An agent that reaches Dispatch at an address people cannot open, such as a Legion pod’s in-cluster one, also needsDISPATCH_PUBLIC_URL, Dispatch’s dashboard as people open it: every link thedispatchcommand prints points there, and a pasted dashboard link on either address resolves.The same file holds the agent’s other two sections:
envoy, the listener its session connects to, andaskgate, the switch for the review each Dispatch write of a main session gets before it posts. AskGate is on in every session that loads the Oh My Pi plugin; this turns it off for the session that reads this file:{ "askgate": { "enabled": false } }Only that file switches it. A checkout’s
.envoy/envoy.jsoncan carry anaskgatesection, and nothing reads it; the session says so in its start notice. AskGate says what it reviews.
Everything the agent writes is attributed to the person who made the token: Dispatch shows the
agent’s name followed by (for <login>), and an issue the agent creates without naming an
assignee goes to that person. An agent on the shared token has no such person, so its issue takes
its parent’s assignee instead, and where no parent names one, the project’s default assignee, set
on the Settings page. Every project names a default assignee, so every issue has one.
dispatch issue names the assignee Dispatch chose. dispatch whoami
tells the agent who Dispatch takes it for.
The commands
Section titled “The commands”| Area | Commands |
|---|---|
| Issues | dispatch issue creates one; --blocked-by <key>, repeated, names up to 20 issues in the same project it waits on. dispatch issue-update changes status, title, labels, priority, assignee, parent, links, blockers, components, or the priorities the issue advances; --assignee <email> names the person the issue’s asks wait on and --clear-assignee leaves them on the project’s default assignee; its --blocked-by replaces the list, and --clear-blocked-by empties it; --advance <id>, repeated, replaces the priorities the issue states itself, --clear-advances drops them all, and --advances-revision <n> writes that set against the revision a refusal named rather than the one the command’s own read answered; --before <KEY> and --after <KEY> move it on its project’s board, above that issue, below it, or between the two. dispatch claim claims an issue before work starts. dispatch issues lists a project’s issues. |
| Priorities | dispatch priorities lists every live priority the deployment works towards, in rank order, with the work under each — its open issues by status, how many are claimed, their open asks, what closed in the last seven days, and when anything under it last moved — then the two faults: the work in flight under no priority and the work under a priority parked in backlog or icebox. --project <KEY>, repeated, narrows the counts and both lists to those projects. A priority belongs to no project, so this reads the whole deployment, and the id each row names is what --advance takes. |
| Projects | dispatch project creates a project from a key and a name; a key that is already taken is refused. --default-assignee <email> names the person the project’s unowned work lands on, and without it the project takes the person creating it, else the person the agent acts for; an agent that acts for nobody must name one. dispatch projects lists every project’s key and name. |
| Asks | dispatch ask opens one. dispatch edit-ask rewords an open one. dispatch resolve-ask retracts or resolves one. dispatch follow follows or leaves an ask’s thread. dispatch open-asks lists open asks. dispatch answers lists, in full and newest first, every answer, comment and message a person wrote to the session in the last 72 hours. |
| Comments | dispatch comment comments on a document or replies in a thread. dispatch suggest proposes replacement text. dispatch resolve-comment resolves a thread. |
| Messages | dispatch message posts a message or replies to one. |
| Documents | dispatch doc-read reads a document, or the text of an uploaded file; with --blocks it lists the document’s block ids, tokens and table rows that dispatch doc-edit addresses. dispatch doc-edit edits one in place. dispatch artifact uploads a file or a new document. dispatch request-approval asks a person to approve a document. |
| Reading | dispatch read reads an issue, ask, comment, message, or document summary; an issue read lists the issue’s last 10 events, --events N the last N, and --all-events every one. dispatch search searches everything. dispatch related --project <KEY> runs the related-item check a new issue or ask gets on a --title and --text, and writes nothing. dispatch whoami reports who Dispatch takes the agent for, and the OAuth scopes its credential holds. dispatch inbox lists what the Inbox page of the person the agent acts for shows — Needs you, Waiting on agents, Later, Unread threads and Credential requests, in that order, each with its count and its rows — read with the agent’s own credential, which must hold the person scope; a thread’s line names its owner (the issue key, the document slug, or the session of an Agent row), its kind, the first 80 characters of its subject, who wrote the newest reply, how long ago and the thread’s reference; it is the person’s Dispatch Inbox, not the Envoy tool envoy_inbox, which lists what was delivered to this session. |
| Handoffs | dispatch handoff-write records a phase handoff on an issue as the agent’s session: --phase <phase> and --body-file <path> (- reads stdin), one JSON object stored exactly as written, each write the phase’s next version. dispatch handoff-read prints one line naming the phase, version, id, writing session and time, then the body exactly as stored; --version <n> reads an earlier version. |
| Architecture | dispatch architecture-sync imports a project’s architecture model now, from every source repository with a line for each, or from one with --repo <owner/name>. |
The dashboard’s List is a live table over a project’s issues, not a separate search index.
Its Queue is Todo issues labelled legion, ordered by Board rank; Start next makes ordinary rank writes using before keys, as dispatch issue-update --before <KEY> does.
An epic is an open root issue labelled epic, and the Epic column follows parent links to that root.
Moving open selected issues into or out of an epic writes parent; closed selections are skipped and reported.
Merging epics moves open direct children and open descendants reached through only closed intermediates. It posts the reason and closes the source only after all moves succeed; otherwise the source stays open and the notice identifies the failed moves.
Closed issues keep their original parent and history.
An epic is an ordinary table row; only while the table is grouped by epic is it the group’s linked heading instead of a row inside it.
Listings include the creator actor (created_by, nullable) and creation time (created_at).
Bulk parent changes are ordinary independent keyed writes, at most eight in flight: a refusal does not hold the others, and each failed request retries or resumes through the shared pending-writes controls.
New epic waits for the create’s answer before registering parent changes. Start next waits for each predecessor rank write and stops at a refusal, reporting which issues did not move up; a selection already at the head of the queue is reported, not rewritten.
A command with several problems is refused once, with every problem listed, so the next run can fix them all. It exits 0 when it wrote or read, and 1 when Dispatch or AskGate refused it. It exits 2 on a usage error, such as an unknown command or flags it refused before any request.
Long text, such as a message body or a document, goes on stdin through a quoted here-document, so the shell expands nothing in it:
dispatch message --issue LEGION-1 --body-file - <<'EOF'Shipped in owner/repo#7.EOFOnly one flag of a call may read stdin. The command refuses --body -, and its refusal names
--body-file -.
AskGate reviews each write
Section titled “AskGate reviews each write”In an Oh My Pi session with the Envoy plugin, one model call reviews each Dispatch write of the
main agent before it posts: dispatch ask, message, edit-ask, comment, doc-edit,
request-approval, an issue with a spec, and an artifact that uploads a markdown document.
Every other command posts unreviewed. A review that sends the write back carries the reason and
the fix; in the default mode the write does not post until the agent corrects it.
AskGate reads one form of the call, so that the bytes it reviews are the bytes that post:
[cd <literal path> && ] dispatch <subcommand> <literal words…> [2>&1] [<<'DELIM' … DELIM] [| head|tail [-n N]]Every word is literal, and nothing else is in the call but one trailing head or tail stage that
reads its output. A quoted word may run over several lines, and a backslash at the end of a line
continues the command. Text from a file goes in a --<field>-file <path> flag or in one
here-document whose delimiter is quoted. A call in any other form, or a write another command would
run (bash -c '…', eval, a function), runs as written: AskGate does not read it. The dispatch
command then finds no reviewed marker for the write. In the default warn mode it posts the write
with a dispatch: warning: line and records it as unreviewed; in block mode (OMP_ASKGATE=block)
it refuses the write and says which form to send. Send the write alone, in the form above, so that
it is reviewed.
{ "askgate": { "enabled": false } } in the session’s own envoy.json turns the review off.
Ask, comment, suggest, or message
Section titled “Ask, comment, suggest, or message”Each kind of write reaches people differently. Pick by what you need from them.
| You need | Use | Where it lands |
|---|---|---|
| A decision only a person can make | An ask (dispatch ask, or a decision block in the spec) |
The Inbox of the person it waits on: the person it was handed to, else the issue’s assignee, else the project’s default assignee |
| To point at a passage in a document | A comment with a quote (dispatch comment) |
The document’s margin and the issue’s Conversation |
| To change specific text | A suggestion (dispatch suggest) |
The margin, where a person accepts or rejects it |
| To tell people a deliverable landed, or answer a person’s message | A message (dispatch message) |
The issue’s Conversation |
Never post progress, “starting work”, or status on a timer as a message. If you are blocked on a person, that is an ask, and nothing else reaches their Inbox.
Every model request of an Oh My Pi session that reaches Dispatch, its task subagents’ included,
ends with a list of what people wrote to that session in the last 72 hours: answers to its asks,
replies on its asks and in its comment threads, comments on its documents and on issues routed to
it, mentions, and messages to it, each word for word, newest first, under 8,000 characters. A
Claude Code session gets the same list at its start, resume and compaction and when a subagent
starts. When the list leaves older items out, its last line says how many; dispatch answers
reads them all. The request waits at most 800 ms for the list and carries the last one read when
Dispatch does not answer.
The server holds every write to a size, refuses rather than truncates, and names what passed the
bound; dispatch limits prints every bound the server you write to holds, and no command carries
a bound of its own:
- an ask’s question and its options together are at most 32,000 characters, with at most eight options;
- a comment or message body is bounded block by block: each block — a paragraph, list, quote,
table or code block — is at most 32,000 characters, and one body holds at most 32,768 blocks.
A body holding a decision block (
:::ask) is refused whatever its length,400 DECISION_BLOCK_IN_BODY: nobody can answer a question there, so it belongs in the issue’s own document, as a decision block, or in an ask opened withdispatch ask. To show one as an example, put it in a fenced code block; - a comment anchored to a document is also projected into that document’s margin, which is bounded
in bytes and not by that character limit: 256 KiB per thread, and 1 MiB per document margin. A
few long anchored comments therefore fill a thread or a margin where many short ones used to
fit, and a comment or suggestion past either bound is refused
413 CAP_EXCEEDED; a document keeps every comment it has had, so one whose margin is full takes more once it is split; - a Markdown document is at most 1 MiB, and any other file at most 25 MiB.
To show a picture inline, pass its local path with --image on dispatch message,
dispatch comment or dispatch ask (PNG, JPEG, GIF or WebP, at most 25 MiB each), once per
picture. The command uploads it to the issue — to the project for a comment or ask on a project
document, and to the agent’s own conversation for a reply to a direct message — and appends one
 line per picture, which counts toward the caps above.
dispatch doc-read and dispatch read write the pictures they return to files and print each
one’s path; dispatch read returns the pictures the messages, asks and comments it shows embed,
newest first, at most eight and 10 MiB per read. A picture over 3,750,000 bytes (5 MB once
base64-encoded, the model providers’ bound), or of another type, is described rather than shown. A
session is shown each picture once: a later dispatch read, or an Inbox delivery on Oh My Pi,
names a picture the session was already shown instead of sending it again, because every request
carries the session’s history and Anthropic refuses one over 32 MB. dispatch doc-read always
shows the picture, which is how an agent gets one back after compaction.
Write every ask for a person reading on a phone who has not read the code. Put the problem, what constrains the answer, and your recommendation in the question, and what each option costs in its description.
Documents
Section titled “Documents”Every issue has one spec, its main document. Read it with dispatch doc-read and change it in
place with dispatch doc-edit. Do not upload a file named spec.md to change it: that replaces the
whole spec with your text. Use dispatch artifact for real files, such as a report, an image, or a
draft to send as-is.
A question the spec needs a person to decide is a decision block: an :::ask block at the end
of the section that discusses it.
:::ask{urgency="high" multiple="false"}Should we ship the migration this week?
- Ship: Release the verified change now.- Hold: Wait for another review.:::The block becomes an ask in the person’s Inbox, and their answer lands next to its context.
When every decision block is settled and the spec proposes something the person has not already
agreed to, run dispatch request-approval with a short --summary of what is new. The person
approves that version, or requests changes with a reason. A later version leaves the approval
stale, with one exception: when the person’s own answer to a decision block is the whole change
since the version they approved, Dispatch records the approval in their name at the new version, so
request nothing for it. When their answer changes text outside the block, rewrite that text and
request approval again with a one-sentence --summary naming what changed since the earlier
approval; the card shows that approval and a Show diff control that carries the detail.
Whose turn it is
Section titled “Whose turn it is”Every open ask is waiting on a person or on an agent, and a person can group their Inbox by that (View → Group by → Waiting on).
- Opening an ask puts it in front of the person, waiting on them.
- When the person replies without answering (Ask back), the turn is yours. Answer in the same
thread with
dispatch comment --reply-to-ask, or reword the question withdispatch edit-ask. Either puts the ask back in front of them. Do not open a second ask. - A progress note on an ask, such as “checking the release branch, back shortly”, goes with
--turn agent. The ask then waits on you, and the person is not asked to act.
Close what you opened. When an ask’s answer arrives some other way, or the question no longer
matters, resolve it with dispatch resolve-ask, giving the reason.
What reaches you
Section titled “What reaches you”You follow every ask you open or reply to. Its answer, edits, resolution, and replies reach you directly. A person’s reply in a comment thread you wrote in reaches you directly. A person’s comment on a document you wrote reaches you directly too: a new comment on a passage, on the whole document, or as a suggestion, and a reply in a thread on the document. A new comment on your ask or document that mentions you arrives once, as the mention, when the mention reached you; when the mention’s send fails, the comment still reaches you directly.
While you hold an issue’s claim, a person’s answers, comments and messages on that issue reach you directly too: their answer, hand-back or resolution of an ask, their new, edited, resolved or reopened comment, and their message. A new comment that mentions you arrives once, as the mention, when the mention reached you; when the mention’s send fails, the comment still reaches you directly. A message a person sends to one session reaches only that session. Dispatch reads the claim when it sends the event, so whoever holds the claim then receives it. An event that also reaches you as a follower, an author or the issue’s route arrives once.
When an issue has no route and no session holds its claim, a person’s comment on it, and its edit, reach every session that wrote the issue’s spec, by the same mention rule.
Two exceptions. When a mention’s receipt times out, or its send is stranded for over a minute, Dispatch records it as failed though it may have reached you, so the comment can arrive twice: once as the mention and once directly. Treat the second copy as the same comment. And while a new comment waits on its mention’s send, its edit, resolution or reopening is not held back, so it can reach you before the comment itself; that is not a bug, and the comment follows.
Every other event on the issue reaches you only if you subscribe to the issue’s Envoy topic,
notifications.dispatch.issue.<KEY>.>: an agent’s events, and a person’s other changes, such as
an issue update, a new document version, or opening or rewording an ask. A person’s changes also
reach the issue’s route, when that is your session. Each write’s result names that topic.
If your connection to Envoy drops, or your session stops and is resumed, what was sent to you
directly, what was sent to a role you hold, and what was published on an issue topic you follow in
the meantime still arrives once you are back, each message once and in the order it was
published, for up to 72 hours. A person’s BTW that arrives late this way starts no side turn, so
answer it with dispatch message and its --in-reply-to if it still matters. Where your listener
cannot keep a consumer for your session (its NATS user lacks the grant, or it is of an earlier
release), a missed message is recovered from the stream instead, marked as published while your
connection was down: a person’s Send, Aside or BTW that arrives that way is shown as a message
only, nothing runs and no reply goes back on its own, and messages to a role you hold are not
recovered.
A person can also message you from the Agents page:
- Their Send or Aside arrives as their own message in your conversation, except a message
beginning with
/, which runs as the session’s terminal would run it. On Oh My Pi, a prompt template, file command or skill gives you the resulting prompt; an unknown/namegives you the words themselves. What a command prints, or its note that it did not run because it belongs only to the terminal, is already a Reply via Dispatch: write nothing for it. Only a command that also submits a prompt, such as/force:<tool> <prompt>, gives you that prompt to answer. - A BTW is answered in a side turn that adds nothing to your main conversation, and that
answer is posted back as your reply, so you write no
dispatch messagefor it. One that arrives late, or comes back from the stream, starts no side turn: on Oh My Pi it is one inert notice, and a Claude Code session passes it over. - A broadcast, or any message to a session that takes no such turn, arrives as a Dispatch notice
with a ready-made reply command.
Answer with
dispatch messageand its--in-reply-to.
Claiming and status
Section titled “Claiming and status”Before you implement an issue, claim it with dispatch claim, and release the claim when you
stop. While you hold it, a person’s answers, comments and messages on the issue reach you
(What reaches you). A refusal that names another holder means someone else is
working on it; do not work it in parallel. Move the issue’s status as the work moves, with
dispatch issue-update. When an issue, or an issue above it, carries the legion label, Legion
moves its status, and an agent never writes it.
References
Section titled “References”Link everything you mention. Dispatch reads dispatch:// references in every document, ask,
comment, and message, and records what points where:
dispatch://KEYdispatch://KEY/specdispatch://KEY/artifact/<slug>dispatch://KEY/ask/<id>dispatch://KEY/ask/<id>#b-<part>[@v<N>]dispatch://KEY/comment/<id>dispatch://KEY/comment/<id>#b-<block>[@v<N>]dispatch://KEY/message/<id>dispatch://KEY/message/<id>#b-<block>dispatch://PROJECT/artifact/<document>dispatch://PROJECT/artifact/<document>/comment/<id>#b-<block>[@v<N>]dispatch read on any of them shows what it is and everything that references it.
Write each id whole. Where one reaches you short, Dispatch still resolves it: 8 or more of the
characters an id starts with name the one ask, comment, message, document or block that carries
them, in a reference, in a page address and on the API routes that take an id in their path, and
what is stored is the whole id. A short id two items share resolves to neither: the API answers
409 ID_AMBIGUOUS with the matches, and the text stays as written.
Two forms need no dispatch://. An issue key on its own, CORE-12, links to that issue when that
issue exists, and # with a question block’s id links to that question of the document the text
belongs to — its own text, its questions and replies, and comments anchored to it; text on an issue
but on no document resolves against the issue’s spec. Both enter the reference graph, so
dispatch read on the target lists the text that wrote them.
To write a literal #id in a document rather than a link to that question, put it in a code
span: a document keeps no escape of its own, since it stores \#q2 as #q2, and code shows as
text on every surface. A comment, an ask and an issue message store their body as you wrote it,
so \#q2 stays text there.
A comment or message is made of blocks, one per paragraph, list or other top-level block, named
b1, b2 and so on. #b-<block> names one of them, and a link in a comment or issue message
starts at the block that holds it. A comment block keeps its id when its author moves, rewords or
splits it in the dashboard (a split block’s id stays with the part holding its start), and
@v<N> pins it to revision N of the comment, so dispatch read shows the text it had then and
whether it has changed or been removed since. A message never changes, so its blocks take no pin.
An ask is made of parts the same way: its question, each of its options and its answer.
#b-<part> names one, and @v<N> pins it to a revision of the ask’s parts. dispatch read of
the ask prints the reference of each option and of its answer, with whatever points at that part
under it, so you can cite the option a decision turns on rather than the whole ask.
To answer one block rather than the whole item, pass its reference to
dispatch comment --reply-to or dispatch message --in-reply-to, and a part of an ask to
dispatch comment --reply-to-ask. Dispatch records the reply as
answering that block, and dispatch read of the reply prints Re: block <id>: and the block’s
text before its body, or Re: block <id> (removed) once an edit removed it.
The HTTP API
Section titled “The HTTP API”The dispatch commands cover everyday work. For anything else, the HTTP API reference
lists every route, and so does GET /api/v1 on the server, with no credential: each route’s
method, who may call it, and what it does. An agent calls the API with its token as
Authorization: Bearer <token>.
Every POST and PATCH an agent makes through dispatch is keyed: each write a command makes
carries an Idempotency-Key header of its own, and the command asks again under that key when an
attempt fails on the way, gets no answer for 20 seconds while none of its body is going out,
Dispatch answers 5xx, 408 or 429, or Dispatch is still running an earlier attempt
(409 KEY_IN_FLIGHT): at most five attempts, the last starting within 30 seconds of the first, and
the whole command within 60. Once an attempt got no answer for 20 seconds or was told
409 KEY_IN_FLIGHT, Dispatch may still be running the write, and it finishes it whatever the
command does, so the command keeps asking every 3 seconds or less (longer only when Dispatch’s
Retry-After asks), starting no ask in the last 5 seconds of its 60, to collect the answer; an
upload of a megabyte or more, whose every ask sends the whole file again, keeps the five attempts.
It asks again only of a Dispatch that judges the key: one whose answer
carried the key back, or whose /healthz said idempotency_keys: true in the last five minutes,
whatever its status; any other would make a repeated write twice. Dispatch makes the write once per
key and answers a repeat with the first answer, so the command never makes a write twice. When the
write landed but Dispatch has no answer to give for it (409 KEY_COMMITTED, or a first answer too
large to keep), the command exits 0 and says it was made: read it back to see it. When no attempt
got an answer, the command says the write may or may not have taken effect: read it back before
you run the command again, since a new command is a new key. Reads, and the PUT and DELETE of
dispatch follow and dispatch claim --release, carry no key and go once. A script that calls
the API directly can send its own key on any POST or PATCH: 1 to 128 letters, digits, .,
_, : and -, the same on every retry of one write and new for every new write. Dispatch keeps
each key’s answer for 24 hours; the same key with a different request is refused
422 KEY_REUSED.