Dispatch

0 Front matter

Item Value
Title Dispatch Messaging Protocol (DMP)
This version the version in this copy's URL, https://dispatch.foo/protocol/<version>/
Latest version https://dispatch.foo/protocol/
Source packages/protocol-spec/spec/ in the repository https://github.com/wsoule/dispatch
Editor Wyat Soule
License Apache-2.0, for this text and its registries, schemas, test vectors and runner

0.1 Abstract

DMP is an addressable, persistent message bus for humans and agents. Every person, agent, unit of work, running session and channel has an address. A message is stored once and gets one delivery per recipient, which waits in the recipient's mailbox until a session of that recipient can take it. Channels reach many recipients at once, and humans take part as peers of agents. Questions can carry gates, which ask a deciding principal to approve an action before the host applies it, and a gate of a type a host does not know fails closed. Messages are presented to models so that no message body can pass for text the host wrote. A binding carries DMP messages over A2A, so agents that speak A2A can take part. A language-neutral kit of test vectors and a runner check an implementation's conformance claims.

0.2 Status of this document

This is a draft of DMP 1.0.0. It may change without notice until 1.0.0.

Every released version, drafts included, is frozen at its own URL, https://dispatch.foo/protocol/<version>/, and never changes there. The alias https://dispatch.foo/protocol/ names the latest release, or the latest draft before 1.0.0 exists. An implementation that claims conformance names the version it was measured against (§12.7).

Sections 1 to 14 are normative except where a section or paragraph says it is informative. The appendices are informative; Appendix C is also the rule set a Dispatch-profile claim is measured against (§12.3).

Proposed changes follow §14.2. A security flaw in the protocol is reported privately, never in a public issue (§14.3).

0.3 Changes since the last draft

1.0.0-draft.1 is the first draft. The changelog lists every change by release, each tagged with its type (§14.2) and naming the vectors that pin it: https://github.com/wsoule/dispatch/blob/main/packages/protocol-spec/CHANGELOG.md.

1 Introduction

Agents already talk to services through request and response protocols such as A2A. What those protocols leave out is the part a team of humans and agents needs to work together over days: mail that waits for a recipient who is not running, channels, humans as peers of agents, and the work a message is about. DMP defines that part. It was extracted from a working implementation, Dispatch, whose reference engine is MIT-licensed, and it speaks A2A at the edge (§8) rather than competing with it.

1.1 Goals

1.2 Non-goals

1.3 Relation to A2A, MCP and federation

1.4 Conventions

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals, as shown here.

2 Terminology

The terms below are used throughout this document with these meanings.

2.1 Hosts and principals

Host. An implementation of this document that stores messages and their deliveries and delivers them for one set of addresses. Dispatch runs one host per project. The logic of §3 to §7 may live in a library the host embeds; the rules apply to the host either way.

A host knows or does the following. DMP states each as an obligation; how a host meets it is its own business.

The host Meaning
knows a work item's session for a work item, its live session, if it has one (§2.2)
knows whether a session is live whether a session can take input now
knows a session's work item the work item a session stands for, or none for an auxiliary session
pushes puts a rendered message into a live session's context before its next turn (§6.2, §6.8)
notifies a session gives a live session a one-line digest of a message
notifies a human tells a human that a message arrived
wakes starts or continues a session for a work item or session, for a message (§6.5)
decides wake policy rules allow, ask or deny on waking a target for a message
names an owner the human to ask or tell about a target (§2.3)
computes implicit members members of a channel that the host computes when a message is sent (§3.6)
applies gate effects runs the effect of an answered gate of a type it implements, idempotently (§5.5)
keeps time the current time, for timestamps and guardrail windows
declares its gate types the gate types it implements, which always include wake (§5.6)
classifies external addresses optional: whether an address is reached through a binding, as a client or a peer, and whether to deliver to it, skip it or refuse it (§8)

Principal. An authenticated sender: a human, an agent, a session, or the host itself as the system address (§2.3). A principal sends as one address and either may or may not decide (§9).

Human and agent. The principals behind human: and agent: addresses (§3.4). An agent here is a registered install of an AI agent that runs outside the host's sessions; its registration is pending, approved or revoked, and only an approved agent sends. A human or an agent is an actor.

External. An address the host reaches through a binding, such as an A2A client or peer (§8). Its messages are presented and checked by stricter rules (§5.8, §6.8).

2.2 Work items and sessions

Work item. A unit of work that outlives any one session, addressed as task:<id>. Mail to a work item reaches its live session, or waits for its next one.

Session. One execution of an agent, such as a model conversation working on a work item, addressed as run:<id>. A session is live while it can take input.

A session stands for at most one work item. A session that stands for none is auxiliary (Dispatch's review and verify sessions): it never receives mail addressed to a work item and is reachable only as run:<id>.

A session acts for itself, for its work item, and for that work item's other sessions. An auxiliary session acts only for itself.

2.3 Deciding principals and the system address

System address. One agent address that a host announces as its own identity. Only the host's own code sends as it (§13.1). It sends gates, notices, closes and markers, and it never receives a delivery. Dispatch's system address is agent:dispatch (Appendix C).

Deciding principal. A human: address that the host authorized to decide, or the system address. No other address decides (§9.2).

Owner. The human a host names for a target: it is asked whether to wake the target, and told when agents are stopped from looping in a thread or when an answer to a gate is set aside. Each host chooses its owners; Dispatch names the project owner.

Participant. Of a message: its sender, or a recipient of one of its deliveries, where a session also counts for every address it acts for and for every delivery bound to it (§4.6).

2.4 Mailboxes and deliveries

Message. One envelope (§4.1), stored once however many recipients it has.

Thread. A message that replies to nothing, and every message that replies, directly or through other replies, to it. A thread is named by the id of its first message.

Delivery. One recipient's record of one message: the recipient, the route that reached it (direct or channel), its state (§6.1) and the session it is bound to, if any.

Mailbox. The deliveries of one address. Its held deliveries wait for the recipient (§7.1).

Channel. A named group of work items and actors (§7.2). A message to a channel reaches its members; the channel itself never receives a delivery.

Push, notify and hold are the three ways a delivery reaches, or waits for, its recipient (§6.2).

2.5 Gates, gate data and markers

Gate data. A message's data when it is a JSON object whose type member is a string that does not start with x-. The definition does not depend on the message's kind.

Gate. A question or handoff with gate data, when the host knows data.type or the sender is the system address or a human: address (§5.6). Any other question or handoff with gate data is a plain one. A gate's gate type is data.type. A gate asks a deciding principal to choose, and the host applies the gate's effect when one does (§5). Each gate type is registered with who may raise it, its choices, its data and its effect (§11.6).

Private data. data whose type starts with x-. A host never interprets private data, except a marker from the system address.

Marker. Private data whose type is a registered system marker, x-closed or x-breaker (§11.7). A marker means something only when the system address sent it (§13.14).

Notice. A message of kind notice: information that expects no reply. The system address sends notices to tell a sender or an owner what happened.

Close. An answer from the system address that settles a question without applying any effect (§4.8).

3 Addresses

Every sender and recipient is an address: a scheme, a colon, and a part whose form the scheme defines. Addresses are compared as strings.

human:wyat                  agent:wyat/claude-code.macbook      actors
task:t-4a8cce               run:r-9f2c01                        work
channel:auth-refactor       channel:epic/e-c25f9c               many-to-many

3.1 Grammar

An identifier matches [a-z0-9][a-z0-9._-]* (§1.4). Handles, operators, ids and channel name segments are identifiers.

Form Scheme Parts
human:<handle> human the human's handle
agent:<handle> agent the agent's handle
agent:<operator>/<handle> agent the handle of the human who operates the agent, then the agent's handle
task:<id> task the work item's id
run:<id> run the session's id
channel:<segment>(/<segment>)* channel the channel's name: one or more segments joined by /

A host MUST refuse, as invalid on the field that carried it (§10.2), a string that has no colon or nothing before its first colon, that names a scheme the host does not implement, or whose part does not have its scheme's form.

A host MAY narrow the grammar of work item and session ids, and then MUST refuse an id outside its narrower grammar as invalid in the same way. The Dispatch profile narrows them to [te]-[0-9a-f]{6,12} and r-[0-9a-f]{6,12} (Appendix C).

A host reports a parsed address as its kind (the scheme), its parts by name (handle, operator, id or name; operator is null for an agent without one) and the address itself. The conformance kit reads this form (§12.4).

3.2 Limits

An address is at most 256 bytes, and each handle, operator, id and channel name segment in it is at most 64 bytes. A host MUST refuse a longer address or part as invalid on the field that carried it. With the limit of 50 recipients (§4.5), these bound the addresses of one send for every host, binding and receiver.

A host that derives handles from other names, such as email local parts, keeps the handles it derives within 64 bytes, so that every address it assigns is valid everywhere.

3.3 Reserved characters

@ is reserved for a federation authority qualifier (§11.2). The grammar of §3.1 already excludes it, so a host refuses an address that contains it, and a host MUST NOT assign an address that contains it.

3.4 Schemes

Scheme Stands for Receives mail
human a person through the host's notifier (§6.1)
agent a registered agent install; every session of the install shares its mailbox, and session records which one sent (§4.1) held in its mailbox
task a work item (§2.2) pushed or notified in its live session (§6.2), or held for its next one
run exactly one session pushed or notified while it is live (§6.2); a direct send to one that is not live fails, with the exceptions of §6.1
channel a named group (§7.2) never: a channel expands to its members (§3.5)

The A2A binding adds the scheme a2a for outbound peers (§8.9). The registry of schemes is §11.1; adding a scheme is a major change (§14.1).

3.5 Resolution

A host resolves a send's to list into targets, each a recipient and the route that reached it:

  1. A reply's to list is first rewritten as §4.6 says, which can turn an ended session into its work item and make two entries equal.
  2. Each entry that is not a channel is a target with the route direct.
  3. A channel expands to its explicit members, then its implicit members (§3.6), each a target with the route channel. A stored member that is itself a channel is skipped (§7.3).
  4. Targets are de-duplicated (§3.7).
  5. The sender, the work item of a sending session, and the system address are dropped: none of them gets a delivery.
  6. Each remaining target gets its initial state and session (§6.1).

A channel that has never had a member joined and has no implicit members is unknown. A send naming an unknown channel MUST fail not-found on the caller's own to[i]: i is the index of the channel in the to list the sender wrote, even when the rewriting of step 1 changed or collapsed other entries. Every other error about a recipient likewise names the first to[i], as the sender wrote it, that resolved to that recipient. (pinned rule 2; vectors: core.send.unknown-channel-names-the-callers-own-slot, core.send.an-ended-session-error-names-the-callers-own-slot, core.send.unknown-channel-is-not-found)

3.6 Implicit members

A host MAY compute some channels' members when a message is sent, instead of storing them. Implicit members are added after the explicit ones and de-duplicated with them. The Dispatch profile computes the members of channel:epic/<id> as the work items under that epic (Appendix C). Vectors that test implicit members are MAY vectors with the capability implicit-members (§12.2).

3.7 De-duplication

A recipient reached more than once by one send gets exactly one delivery. When it was reached both directly and through a channel, the delivery's route is direct; otherwise it is the route that reached it. The order of a message's deliveries has no meaning.

4 Messages

A message is one JSON object, stored once. A sender gives a send input; the host checks it, assigns the fields only a host may set, and stores the result with one delivery per resolved recipient.

4.1 Envelope

A stored message has these sixteen fields. session, data, choices and choice are absent when not set; every other field is always present.

Field Type Set by Meaning
id message id host unique, sorts in creation order (§1.4)
thread message id host the id of the thread's first message: the reply target's thread for a reply, else the message's own id
replyTo message id or null sender the message this one replies to
from address host the authenticated sender (§9.1)
session string sender which session of an agent install sent it; informational
to addresses sender the recipients, after a reply's rewriting (§4.6)
kind string sender §4.2
body string sender the text, in Markdown
refs list of refs sender what the message is about (§4.3); empty when none
data JSON value sender a structured payload: gate data (§5.1), private data, or a marker
urgent boolean sender push even to a recipient reached through a channel; the host may drop it (§6.6)
blocking boolean sender the sender is waiting for the answer; questions and handoffs only
choices list of strings sender the answers a question or handoff offers (§4.7)
choice string sender the choice an answer makes
wake none or request sender request asks the host to wake a recipient that is not live (§6.5)
createdAt timestamp host when the host stored it (§1.4)

A send input has the sender's fields above, all optional except to, kind and body, plus idempotencyKey (§4.9), which is not stored in the envelope. refs defaults to empty, urgent and blocking to false, wake to none and replyTo to null. A handoff without choices gets accept and decline (§4.7). A stored message keeps every field of its send input unchanged, except that a handoff may gain those default choices, a reply's to is rewritten (§4.6), and the urgent quota may drop urgent (§6.6).

4.2 Kinds and private use

Kind Meaning
message ordinary mail
question expects one answer; open until it has one
answer replies to a question or handoff, and may make a choice
handoff asks the recipient to take the work over; answered with a choice
notice information that expects no reply

A kind matching x-[a-z0-9][a-z0-9-]* is private use: a host stores and delivers it as it does a message and never interprets it. Private kinds are never registered. A host MUST refuse any other kind as invalid on kind. Adding a built-in kind is a major change (§14.1).

4.3 Refs

A ref points at something a message is about: { "type", "id" }, with an optional at.

Type id names at
task a work item none
run a session none
file a file path the commit the path is read at
commit a commit none
message a message id none

A ref's id is required and non-empty. id and at are each one line of at most 512 bytes. A local send whose ref has a type the host does not know MUST fail invalid on refs[i].type. The registry of ref types is §11.5; a host MAY implement any type it lists.

4.4 Refs received from other hosts

A message that arrives from another host or an external client through a binding is validated as received. A received ref whose type is an identifier the host does not know MUST be accepted when its id and at meet §4.3's limits; the host stores it unchanged and presents it like any other ref. A received ref whose type is not an identifier is refused as invalid on refs[i].type. This is what lets a later minor version add a ref type without a peer of an earlier version refusing the whole message.

4.5 Validation and limits

A host MUST refuse a send input that breaks any rule below, with the error and field shown. When one input breaks several rules, the host returns the error of one of them.

Field Rule Error
to 1 to 50 entries invalid on to
to[i] an address (§3) invalid on to[i]
kind a built-in or private kind (§4.2) invalid on kind
body a string, not blank, except that an answer that carries a choice may have a blank body invalid on body
body at most 64 KiB invalid on body
data at most 64 KiB, measured as §1.4 says invalid on data
data gate data only as §5 allows as §5
session one line, at most 200 bytes invalid on session
idempotencyKey one line, at most 200 bytes invalid on idempotencyKey
refs at most 50 entries, each as §4.3 and §4.4 say invalid on refs or refs[i].type, refs[i].id, refs[i].at
blocking true only on a question or handoff invalid on blocking
choices only on a question or handoff; 1 to 20 distinct entries, none blank invalid on choices
choices[i] one line, at most 200 bytes invalid on choices[i]
choice only on an answer; one line, at most 200 bytes; as §4.7 says invalid on choice
replyTo required on an answer, and must name a question or handoff invalid on replyTo

These limits are the same for every host, binding and receiver: a limit a host enforced more loosely would let one send exceed another host's ceiling.

The order of checks in a send. A host checks a send in this order, and the first check that fails is the send's error (pinned rule 1; vectors: core.gates.only-humans-and-the-system-decide, core.idempotency.a-revoked-sender-gets-no-replay, core.idempotency.a-retried-answer-replays, core.participation.absent-and-foreign-look-alike, core.participation.comes-before-validation, core.participation.an-empty-reply-to-names-no-message, core.participation.a-reply-authorizes-before-its-target):

  1. Authorize the sender (§9): refuse an agent that is not approved, and a sender that claims to decide from an address that is neither a human: address nor the system address, as forbidden on from.
  2. Replay an idempotency key (§4.9): a hit returns the first send's result and nothing below runs.
  3. Participation for a reply (§4.6): a replyTo that names no message (an empty string included), or a message the sender does not participate in, fails not-found on replyTo.
  4. Validate the input: this section, and the gate rules of §5.
  5. The breaker for a reply from an agent or session (§6.6): limited on replyTo.
  6. One answer: an answer to a question that already has one fails conflict on replyTo (§4.7).
  7. The urgent quota (§6.6), which drops urgent instead of failing.

Resolution (§3.5) and the initial states of §6.1 follow, and can fail the send too. A send that fails stores no message and no delivery of its own.

4.6 Threads and participation

A message that replies to nothing starts a thread; a reply joins the thread of the message it replies to (§4.1).

Participation. A sender participates in a message when it is the message's sender, or a recipient of one of its deliveries, counting a session as every address it acts for (§2.2) and as the recipient of every delivery bound to it. The system address and deciding principals are exempt: they may reply to, and read, any message.

A send that sets replyTo, of any kind, MUST come from a participant of the message it names. A replyTo that names no message, and one that names a message the sender does not participate in, MUST both fail not-found on replyTo, at the participation step of §4.5, before the input is validated. A non-participant therefore cannot tell a message that exists from one that does not (§13.5).

Replies to an ended session. In a reply, an entry of to that is the reply target's sender or one of the target's to addresses, when that entry is a session that is no longer live and stands for a work item, is rewritten to that work item, so the work item's live or next session hears the reply. Entries that become equal are collapsed, and the stored to is the rewritten list. A reply to an ended auxiliary session is not rewritten; its delivery is held on the session (§6.1). Any other ended session a reply names fails as §6.1 says. (pinned rule 5; vectors: core.answers.a-reply-to-an-ended-session-goes-to-its-work-item, core.answers.a-reply-reaches-the-successor-session, core.answers.a-reply-rewrites-and-collapses-an-ended-session, core.answers.a-reply-to-an-ended-auxiliary-session-is-held-on-it, core.answers.a-reply-to-an-ended-recipient-goes-to-its-work-item, core.answers.a-reply-to-an-ended-recipient-reaches-the-successor-session, core.answers.a-reply-naming-an-ended-session-its-target-never-reached-fails)

Replying to a message. A host MAY offer a shorthand that replies to one message: it sends to the target's sender, as an answer when the target is a question or handoff and as a message otherwise, with replyTo set to the target. When the target does not exist it fails not-found on replyTo. The shorthand is a send and keeps the order of §4.5: a sender refused at the first step fails forbidden on from whether or not the target exists.

4.7 Questions, answers and handoffs

A question or handoff is open until it has an answer. It takes exactly one: a second answer, including one after a close (§4.8), MUST fail conflict on replyTo. When the answer is stored, every delivery of the question moves to answered (§6.1).

A handoff without choices gets the choices accept and decline. An answer carrying a choice may have a blank body. An answer to a gate or a handoff MUST carry one of the question's choices when the question has any, and MUST carry none when it has none. An answer to any other question MAY carry a choice, which MUST be one of the question's choices; it may instead answer in its body alone. A choice that breaks this fails invalid on choice. (pinned rule 6; vectors: env.envelope.body-is-required-unless-an-answer-chooses, env.envelope.a-gate-answer-must-choose, env.envelope.a-handoff-answer-must-choose, env.envelope.a-choice-must-be-one-of-the-questions, env.envelope.free-text-may-answer-a-plain-question, core.answers.a-handoff-defaults-to-accept-or-decline)

Answering a gate needs a deciding principal (§5.4).

4.8 Close

A host MAY close an open question or handoff itself, for example when the session that asked it has ended. A close is an answer from the system address, to the question's sender, whose data is { "type": "x-closed", "reason": … } and whose body gives the reason (its wording is not normative). A close applies no gate effect, creates no delivery and dispatches nothing: the question's deliveries move to answered, and the close is published to readers like any other message. Closing a question that already has an answer fails conflict on replyTo; closing a message that is not a question or handoff fails invalid on replyTo; closing a message that does not exist fails not-found on replyTo. x-closed data is a close only when the system address sent it (§13.14). (pinned rule 8; vectors: core.close.answers-without-effect-or-deliveries, core.close.moves-the-question-to-answered, core.close.an-absent-question-is-not-found, core.close.an-answered-question-conflicts, core.close.a-non-question-is-invalid, core.markers.close-is-honored-only-from-the-system)

4.9 Idempotency and replay

A send input MAY carry an idempotencyKey: one line of at most 200 bytes, scoped to its sender, so that two senders may use the same key. A host MUST keep each key as long as the message it names, across restarts, so a retry after a crash still finds it.

A send whose sender already sent a message with the same key MUST return that first message and its current deliveries, with replayed: true, and MUST run no hooks and create nothing. The lookup comes right after the sender is authorized and before every other check of §4.5: a retried answer replays instead of meeting conflict, and a retry after the breaker has tripped replays instead of meeting limited. Because it comes after authorization, a sender whose authorization was revoked gets no replay. A key reused with a different input still returns the first message; hosts are not required to detect it. (pinned rule 15; vectors: core.idempotency.replays-the-first-message, core.idempotency.keys-are-per-sender, core.idempotency.a-key-is-one-line-of-at-most-200-bytes, core.idempotency.a-revoked-sender-gets-no-replay, core.idempotency.a-retried-answer-replays)

5 Gates

A gate is a question or handoff that asks a deciding principal to approve something the host will then do: release a parked tool call, wake a work item, apply a proposed change. The choice is data, the effect is the host's, and every rule below exists so that only a deciding principal's choice ever takes effect.

5.1 Gate data

A message has gate data when its data is a JSON object whose type member is a string that does not start with x-, whatever the message's kind. A message is a gate when it is a question or handoff with gate data, and the host knows data.type or the sender is the system address or a human: address (§5.6); its gate type is data.type. A question or handoff with gate data of an unknown type from an agent or a session is not a gate; locality (§5.8) still applies to it, since it tests for gate data alone.

Each gate type is registered (§11.6) with:

Core defines one gate type, wake (§5.9). The Dispatch profile's types are described in Appendix C; other entries name the public document that defines them. data whose type starts with x- is private data or a marker, never gate data, so a sender that wants to attach its own payload to any message uses an x- type.

5.2 Deciding principals

Only a deciding principal decides: a human: address the host authorized to decide, or the system address (§9.2). A deciding principal may answer a gate (§5.4), raise a gate type whose raisedBy is system-or-decider (§5.3), reply in a thread it does not participate in (§4.6), and read any message (§9.3). An answer takes effect only when it comes from a deciding principal, which a stored answer shows by its from being a human: address or the system address (§5.7).

5.3 Raising a gate

A host MUST refuse, as invalid on data.type, a send whose input has gate data when its kind is neither question nor handoff, or when its data.type is not a gate type the host implements (§5.6), whoever sends it. Private payloads use an x- type instead.

A host MUST refuse, as forbidden on data, a gate that the type's raisedBy does not let the sender raise:

raisedBy Who may raise the gate
system only the system address
system-or-decider the system address, or a deciding principal
session only a session

A gate type the registry does not list is raised by the system only. A host also checks each gate of a type it implements against that type's definition: a session type fixes the whole shape of its gate (kind, blocking, choices and data), and a gate in another shape, or with bad data, fails invalid on data or on the member at fault, such as data.paths. (pinned rule 16; vectors: core.gates.unregistered-type-refused, core.gates.gate-data-on-a-notice-refused, core.gates.an-unimplemented-registered-type-is-refused, core.gates.wake-is-raised-only-by-the-system, env.envelope.only-sessions-raise-scope-gates, env.envelope.agents-may-not-forge-tool-approval-gates, env.envelope.scope-gates-have-a-fixed-shape, env.envelope.a-scope-gate-names-a-path)

5.4 Answering a gate

An answer to a gate MUST come from a deciding principal; any other sender's answer fails forbidden on replyTo. This holds for a stored gate of a type the host does not implement, as §5.6 says. The answer carries one of the gate's choices, or none when the gate has none (§4.7). A close (§4.8) settles a gate without deciding it: it is an answer from the system that applies no effect.

5.5 Effects

When a deciding principal answers a gate of a type the host implements, and the answer is not a close, the host MUST apply the gate's effect in this order:

  1. commit the answer, with the question's deliveries moved to answered, in one transaction;
  2. apply the effect, and wait for it to finish;
  3. record the effect as applied;
  4. only then publish the answer to readers and dispatch its deliveries.

So no one hears of an answer before its effect has been attempted. When the effect fails, the answer stays committed, is published and dispatched, and the effect is left unrecorded for recovery to apply again (§6.3). An effect may therefore run more than once for one answer, and a host MUST make every effect idempotent. Only answers from a human: address or the system address take effect (§5.7). (pinned rule 7; vectors: core.wake.approve-applies-before-publishing, core.answers.a-gate-effect-runs-before-the-answer-is-published, core.answers.a-failing-effect-still-commits-the-answer, core.recover.replays-an-unapplied-gate-effect)

5.6 Unknown types fail closed

A host's known gate types are the types it implements, which it declares (§2.1). Core requires wake; the Dispatch profile requires its own types too (§12.1). A type the registry lists is not known to a host that does not implement it.

A later minor version can therefore add a gate type (§14.1): a host of an earlier version refuses to raise it and lets only deciding principals answer it.

5.7 Ignored answers

An answer to a gate whose from is neither a human: address nor the system address never takes effect. The send rules of §5.4 refuse such an answer, so a host meets one only when an older build stored it, or when some path skipped validation. When a host finds one, at send or at recovery, it MUST, in one transaction, void the answer and reopen the gate:

The host then publishes the voided message and the reopened deliveries. It SHOULD send the owner of the voided answer's sender a notice, replying to the gate, saying that the answer was set aside because only deciding principals answer gates, and that the gate is open again. The gate stays answerable by its original id, so a host's handler that finds a gate by its id or its data still finds it.

5.8 Locality

Gate data never leaves the host through a binding. A binding MUST NOT export a message that has gate data, or that replies to a message that has gate data, whatever its kind and whether or not the host knows the type; it may say that a gate exists, as §8.5 does, but never carry its message or its data. A host MUST refuse, as forbidden on data, a send in which the message, or the message it replies to, has gate data while any resolved recipient is external (§2.1), before anything is stored. Each binding states what else it exports (§13.9); for A2A that is §8.

5.9 The wake gate

The system address raises a wake gate when a host's wake policy answers ask (§6.5).

Item Value
Kind question, blocking: true
From the system address; raisedBy is system
To the owner of the target
Choices approve, deny
Data { "type": "wake", "target": <address>, "message": <message id> }: the target to wake, and the held message
Refs a message ref to the held message
Body names the sender and the target and quotes the held message's first line; the wording is not normative

Effect. On approve, the host wakes the target for the message (§6.5), unless the target no longer qualifies: a work item that has a live session able to take its mail needs no wake, and a target the host's wake policy would now deny (a work item that has finished, say) is not woken. On deny, the message stays held. Either way the effect is recorded as applied. When the target is not woken, or waking it fails, the host SHOULD send the held message's sender a notice saying why.

6 Delivery

Each resolved recipient of a message gets one delivery (§3.5). A delivery has an id, the message, the recipient, the route that reached it (direct or channel), a state, the session it is bound to (or null), and the time it last changed.

6.1 States

State Meaning
held waiting in the recipient's mailbox; bound to no session
sending bound to a live session while the host pushes or notifies it; internal, and a host MAY hide it from its API
pushed put into the bound session's context (§6.2)
notified a digest was given to the bound session, or, for a human, the host's notifier was called
read the recipient marked it read (§7.1), or its sender is muted (§6.7)
answered its message is a question or handoff, and that question has an answer or was closed

A host MUST NOT expose a state this table does not list, except a state of an appendix it implements (§11.8). Adding a state a host exposes is a major change (§14.1).

Initial state. A delivery starts as follows:

Recipient Initial state and session
any recipient, when the sender is muted read, no session
human: notified, no session
agent: held, no session
task: with a live session sending, bound to that session
task: without one held, no session
run: that is live sending, bound to it
run: that is not live, reached through a channel no delivery
run: that is not live, being replied to held, no session (§4.6)
run: that is not live and stands for a work item, named in a wake: "request" message from a human: sender held, no session; the host is then asked to wake exactly that session (§6.5)
any other run: that is not live the send fails invalid on the caller's to[i]

The last row covers a wake request from an agent or a session, a human's wake request naming a session that stands for no work item, whose held mail no session start could claim (§6.4), and a human's message that does not request a wake. A session is "being replied to" when it is the sender of the reply's target and §4.6 did not rewrite it. A channel is never a recipient: it expands to its members, and it is never a member (§7.3). (pinned rule 3; vectors: core.send.human-mail-is-notified, core.send.agent-mail-is-held, core.send.task-held-without-live-session, core.send.direct-mail-pushes-to-the-live-session, core.mailbox.a-muted-senders-mail-starts-read, core.send.a-session-that-is-not-live-is-invalid, core.send.an-ended-session-reached-through-a-channel-gets-nothing, core.answers.a-reply-to-an-ended-auxiliary-session-is-held-on-it, core.wake.a-humans-wake-holds-mail-for-an-ended-session, core.wake.only-a-humans-wake-reaches-an-ended-session, core.wake.a-humans-wake-to-a-session-of-no-work-item-fails, core.channels.a-stored-channel-member-is-skipped)

Transitions. A delivery changes state only as follows:

6.2 Mode selection

Once a message and its deliveries are committed (§6.3), the host dispatches each delivery:

(pinned rule 4; vectors: core.send.direct-mail-pushes-to-the-live-session, core.send.channel-mail-notifies-the-live-session, core.send.urgent-channel-mail-is-pushed, core.send.human-mail-is-notified, core.send.a-failed-push-returns-to-held, core.send.a-failed-notify-returns-to-held)

6.3 Outbox and recovery

A host MUST commit a message and all of its deliveries in one transaction before it runs any hook for them (push, notify, wake, a gate's effect): the store is an outbox. Every change of a delivery's state MUST apply only if the delivery is still in the state the change expects: when two changes race, the one that finds the delivery already moved does nothing, and the newer state stays.

A host MUST run recovery when it starts, before it accepts sends, and MAY run it at other times. Recovery:

  1. dispatches again every sending delivery whose bound session is live (§6.2);
  2. returns every other sending delivery to held, unbound;
  3. for every answered gate of a type the host knows whose effect is not recorded as applied, and whose answer is not a close: applies the effect again and records it (§5.5) when the answer is from a human: address or the system address, and otherwise voids the answer and reopens the gate (§5.7).

So a crash between commit and hook never loses a message or a decision. (pinned rule 10; vectors: core.recover.dispatches-sending-rows-whose-session-is-live, core.recover.returns-sending-rows-to-held, core.recover.leaves-settled-rows-alone, core.recover.replays-an-unapplied-gate-effect, core.recover.skips-applied-and-closed-gates)

6.4 Session start

When a session of a work item starts, the host MUST claim every held delivery addressed to that work item, and every held delivery addressed to one of the work item's earlier sessions: each is bound to the new session as sending and dispatched as §6.2 says (a direct delivery pushed, a channel one notified unless its message is urgent). Mail left on an ended session therefore reaches the work item's next session. Deliveries in any other state are not claimed. (pinned rule 11; vectors: core.session-start.claims-held-mail-for-the-work-item, core.session-start.claims-mail-left-on-an-earlier-session, core.session-start.claims-only-held-mail)

6.5 Wake policy

A message with wake: "request" asks the host to wake recipients that are not live, so its mail is seen soon. After dispatching such a message (§6.2), the host considers each of its deliveries that is then held and addressed to a work item, and, when the sender is a human: address, each that is then held and addressed to a session. For each, the host asks its wake policy about exactly that recipient address (decide, with the target and the message), and:

A failing wake, a failing policy and a failing notice never fail the send: the message is already committed. A notice to a sender that is an ended session goes to its work item, as a reply would (§4.6). Which targets a host allows, asks about or denies is its own policy; the Dispatch profile's is in Appendix C. (pinned rule 9; vectors: core.wake.allow-wakes-the-held-work-item, core.wake.deny-leaves-the-message-held, core.wake.ask-raises-a-wake-gate-to-the-owner, core.wake.a-failed-wake-never-fails-the-send, core.wake.a-humans-wake-holds-mail-for-an-ended-session, core.wake.a-humans-wake-to-a-session-of-no-work-item-fails, core.wake.only-held-work-items-of-a-wake-request-are-considered, core.wake.a-failed-wake-tells-the-sender, core.wake.deny-tells-the-sender, core.wake.a-notice-to-an-ended-sender-goes-to-its-work-item)

6.6 Guardrails

A host MUST enforce two guardrails whose numbers are its configuration (the Dispatch profile's defaults are in Appendix C):

The past hour is the hour ending at the host's current time; a message counts when its createdAt is at or after the start of that hour.

6.7 Muted senders

A host MAY let a human mute an agent. The deliveries of a message from a muted sender MUST start as read (§6.1): the message is stored and readable in its thread, but never pushed, notified or claimed at session start. Vectors that test muting are MAY vectors with the capability muted-senders (§12.2).

6.8 Presenting messages to models

A host MUST present every message it puts into a model's context, in whatever form (a push, a digest, or what a read of a mailbox or thread returns (§7.1)), so that no line of its body can pass for a header or for a line the host writes, and so that the model can tell an external sender's message (§2.1) from a local one.

A host that pushes a message into a model's context MUST do so with quotePrefix: it writes the text of the body only on lines that start with quotePrefix, at least one such line for each line of the body, so no line of the body forms or starts the header or a host line. A host that notifies a session (§6.2) MUST give it a digest instead: the host's own text followed by at most the first line of the body, on one line, so the text of the body never starts a line. The host declares the forms of its pushes and digests (§12.4); header, hostLines and digestLead are patterns, searched in a line as §1.4 says:

For a pushed message whose sender is external (§2.1), every line after the header that carries text the sender wrote MUST start with quotePrefix: the lines of its body, its choices, its choice and its refs, and any text a host line echoes from a message, such as the first line of the message it replies to. A host line that carries none of that text, such as one naming only the id of the message replied to or a prompt to answer, need not start with quotePrefix.

A read that returns messages as structured data, such as the JSON an agent's tool or a route returns, keeps each body inside its string value. It MUST write every line break of §1.4 inside a string value as an escape, so no raw line break reaches the model and no body text starts a line. JSON already escapes CR, LF, VT and FF but may leave NEL, U+2028 and U+2029 raw, so a JSON read writes them as \u0085, \u2028 and \u2029. It MUST also mark each message whose sender is external, so a model reading it can tell it from a local one; a host with no binding has no external senders to mark. How a read presents messages is a property of each read surface, which no vector tests (§12.5).

The host's own text may repeat a body by chance: an answer whose body is approve also carries the choice approve. So the render vectors (§12.4.6) test these requirements on bodies whose lines occur in no text the host writes for the message, such as a header naming another sender, where a line that contains a line of the body can only be carrying it; for an external sender, its choices, its refs and the message it replies to are chosen the same way. Splitting the rendered text and such a body into lines at every line break of §1.4, a pushed message MUST meet all of these:

  1. its first line matches header and contains no line of the body;
  2. every later line that contains a line of the body starts with quotePrefix;
  3. at least as many later lines start with quotePrefix as the body has lines;
  4. every other later line matches one of hostLines and contains no line of the body and, for an external sender, none of its choices, its choice, the id or at of any of its refs, or any line of the message it replies to.

Blank lines of the body are left out of rules 1, 2 and 4 and of the digest rule, and blank lines of the message replied to out of rule 4, since every line would contain them. On a body whose lines occur in no text the host writes, a digest MUST be one line whose first match of digestLead starts it. That match is the host's own text: it MUST contain no line of the body and MUST NOT be the start of the body's first line, as it would be if the body forged it. Body text is judged only after the match ends, so what follows may be the body's first line, whole or cut short, or no body text at all; each later line of the body that the digest holds there MUST be contained in the body's first line. The Dispatch profile's exact forms are in Appendix C.

7 Mailboxes and channels

7.1 Mailboxes

An address's mailbox is the deliveries addressed to it, with their messages, in every state. Its held deliveries are mail waiting for the recipient: for a work item until its next session starts (§6.4), for an agent until it reads them, and for an ended session until a later session of its work item claims them. A host lets a recipient list its mailbox, optionally filtered by state, and open the threads its messages belong to.

Marking read. A recipient marks a delivery read by its id. A delivery in any state but answered becomes read; marking a delivery that is already read or answered changes nothing and succeeds. Marking a delivery that does not exist fails not-found.

Open questions. A host lets a deciding principal list the open blocking questions and handoffs: those with blocking: true and no answer yet. Answering one closes it for everyone (§4.7).

7.2 Channels

A channel is a named group of recipients. Its name is one or more identifiers joined by / (§3.1). A channel exists from its first join: joining a member to a channel that does not exist creates it, and a channel no one has joined, with no implicit members (§3.6), is unknown (§3.5).

A message to a channel reaches every member at send time, through the route channel, and members who join later do not receive earlier messages. A session reached through a channel is notified rather than pushed unless the message is urgent (§6.2), and a session that is not live gets nothing (§6.1).

7.3 Membership

A channel's members are work items and actors: task:, human: and agent: addresses. Membership outlives any one session, so a session joins as its work item. A host MUST refuse, as invalid on member, a join whose member is a session (run:) or a channel (channel:), and MUST skip a stored member that is a channel when it resolves a send, so that no channel is ever a recipient (§3.5). (pinned rule 14; vectors: core.channels.a-session-cannot-join, core.channels.a-channel-cannot-join-a-channel, core.channels.a-stored-channel-member-is-skipped)

Joining an existing member again changes nothing. Leaving removes an explicit member and reports whether one was removed; it never removes an implicit member, which the host computes. Which principals may join or remove which members is the host's policy; the Dispatch profile lets a principal join or leave as an address it acts for (Appendix A).

8 A2A binding

This section binds DMP to A2A 1.0. A host that implements it serves one A2A agent to A2A clients over A2A's HTTP+JSON binding, and may send messages to A2A agents of its own choosing, its peers. A2A's terms (agent card, Task, Message, Part, Artifact, contextId, the TASK_STATE_* names) keep A2A's meaning; the A2A specification is normative for everything this section does not say.

The requirements here on A2A's own wire (the card, the HTTP binding, streams and errors) are attested by a run of the A2A TCK (§8.10, §12.1). The requirements on DMP messages are tested by the a2a-binding vectors each subsection names. An A2A client and an A2A peer are external (§2.1): a host that implements this binding classifies their addresses, and every rule of this document for external senders and recipients applies to them.

8.1 Agent and skills

A host serves one agent card per project: one set of addresses behind one DMP host is one A2A agent. The card is served without authentication at /.well-known/agent-card.json on the root of the host's A2A listener (A2A §8.2), and names an interface with protocolBinding HTTP+JSON and protocolVersion 1.0.

8.2 Mapping

DMP A2A 1.0 Rule
a project (one host) one agent card skills ask, handoff, status (§8.1)
the question or handoff that opens an exchange a Task; Task.id is that message's id ids are generated by the host; clients treat them as opaque strings; a message or notice opens no task (§8.4)
thread contextId a client's contextId must name a thread the client takes part in; any other is refused, never replaced (A2A §3.4.1)
body text parts inbound text parts are joined with a blank line; outbound text is text/markdown, or text/plain when the client accepts only that
data one data part (application/json) inbound data is stored wrapped (§8.8) and unwrapped when it goes back to the client that sent it
from, to, kind, replyTo, choices, choice, refs envelope/v1 (§8.4) without it, to is the owner and the kind follows the skill
idempotencyKey (§4.9) the client's messageId required: one line of at most 200 bytes, so a retried request replays
a blocking question to the client TASK_STATE_INPUT_REQUIRED, the question in status.message the client answers with a message carrying that taskId
an open gate that concerns the task TASK_STATE_AUTH_REQUIRED, gate/v1 (§8.5) never answerable by the client
a handoff a Task that moves through work/v1's lifecycle §8.6, §8.7
results Artifacts: answer, and work/v1's pr, diffstat, evidence nothing else (§8.8)
deliveries, channels, wake, mute none they stay inside the host; a client's urgent and wake request are refused (§8.4)
a recipient a2a:<alias> one SendMessage to that peer per message §8.9

Parts. URL parts (at most 20, each at most 2 KiB) are appended to the body as markdown links, one per line; the host never fetches them. A raw (bytes) part is refused with ContentTypeNotSupportedError. All the metadata of one message together is at most 64 KiB (§8.3), and the host caps a request body at 256 KiB; DMP's limits of §4.5 then apply to the message a request becomes.

States. A failed session never makes a task TASK_STATE_FAILED, because the work item resumes. TASK_STATE_UNSPECIFIED is never sent. A task's state is decided by §8.7.

Errors. A host maps the errors of §10 onto A2A's (google.rpc.Status carrying a google.rpc.ErrorInfo; A2A §11.6), with the same answer for an absent target and one the client may not see (§13.5):

DMP error A2A response
invalid on a field 400 INVALID_ARGUMENT with a google.rpc.BadRequest naming the A2A path of the field (body is message.parts)
forbidden or not-found on the task 404 TASK_NOT_FOUND, the same for both
forbidden or not-found on contextId 400 INVALID_ARGUMENT on message.contextId, "unknown contextId", the same for both
forbidden or not-found on replyTo or refs[i] 400 INVALID_ARGUMENT on that field, "unknown message", the same for both
not-found on to[i] 400 INVALID_ARGUMENT on that field
forbidden on to[i], urgent, wake or kind 403 PERMISSION_DENIED, with the reason FORBIDDEN_ADDRESS, URGENT_NOT_ALLOWED, WAKE_NOT_ALLOWED or KIND_NOT_ALLOWED
conflict, or a message to a task in a terminal state 400 UNSUPPORTED_OPERATION
limited 429 RESOURCE_EXHAUSTED with Retry-After

A missing or unknown bearer is 401 with WWW-Authenticate: Bearer, and the host gives one answer for every bearer that is not a client's own, so its public port cannot confirm that some other token of the host is valid.

8.3 Extension activation and placement

The three extensions carry what A2A 1.0 has no field for: DMP addressing, why a task is waiting on its owner, and handoffs of work. Each extension URI serves its own subsection together with this one.

An extension is active for a request when its URI appears in the A2A-Extensions header or in message.extensions. The response echoes the active URIs in A2A-Extensions, and a host MUST write extension metadata only for active extensions. A client that activates none gets the same facts as plain text in status.message.

Extension data sits in metadata[<URI>]. On a Message (including status.message) and on an Artifact, the object's extensions list also names the URI. A Task has no extensions field, so task-level data lives only in Task.metadata[<URI>], written when the request activated the extension.

All metadata on one message together MUST be at most 64 KiB as JSON; a host refuses more as invalid on message.metadata. The extensions fit well inside it: work/v1 tops out near 36 KB and envelope/v1 near 15 KB with inbound refs restricted as §8.4 says. Body (64 KiB), data (64 KiB), metadata (64 KiB) and A2A's framing fit a 256 KiB request. Vectors: a2a.envelope-ext.refuses-metadata-over-64-kib.

Each extension is versioned by its URI: a breaking change mints a new URI ending /v2 (§14.4). The registry of extension URIs is §11.10.

8.4 envelope/v1

https://dispatch.foo/a2a/ext/envelope/v1 is a profile on Message.metadata: a DMP envelope (§4.1) riding on an A2A message.

interface EnvelopeExtV1 {
  id?: string; // host to client: the DMP message id
  thread?: string; // host to client: the DMP thread id
  from?: string; // host to client: the sender's address; ignored inbound
  to?: string[]; // client to host only: recipients (default: the owner)
  kind?: 'message' | 'question' | 'answer' | 'handoff' | 'notice';
  replyTo?: string; // a DMP message id the caller takes part in
  blocking?: boolean; // questions only; a client's question always blocks
  choices?: string[]; // at most 20, each one line of at most 200 bytes
  choice?: string; // answers only; one of the question's choices
  refs?: { type: string; id: string }[]; // inbound: at most 50, task and message ids only
  urgent?: boolean; // inbound true is refused
  wake?: 'none' | 'request'; // inbound 'request' is refused
}

Inbound. A host reads a client's envelope before anything else of the message, and MUST apply these rules on top of §4.5:

Vectors: a2a.envelope-ext.accepts-recipients-choices-and-message-refs, a2a.envelope-ext.accepts-no-envelope, a2a.envelope-ext.refuses-urgent, a2a.envelope-ext.refuses-a-wake-request, a2a.envelope-ext.refuses-a-private-use-kind, a2a.envelope-ext.refuses-an-unknown-kind, a2a.envelope-ext.refuses-a-bad-recipient, a2a.envelope-ext.refuses-a-ref-type-other-than-task-or-message, a2a.envelope-ext.refuses-a-ref-anchor, a2a.envelope-ext.refuses-more-than-20-choices, a2a.envelope-ext.refuses-a-choice-over-200-bytes, a2a.envelope-ext.refuses-blocking-that-is-not-a-boolean, a2a.external.the-host-sets-id-thread-and-from, a2a.external.defaults-to-a-blocking-question-to-the-owner, a2a.external.a-client-reaches-only-the-owner, a2a.external.an-opening-answer-or-handoff-is-refused, a2a.external.an-answer-from-a-client-is-never-a-close.

Outbound. A host MUST NOT write to to a client. To a peer it writes only the peer's own address, so no other recipient leaves the host. Refs are written as the DMP sender wrote them, as opaque strings.

External data is stored wrapped under this URI, as { "https://dispatch.foo/a2a/ext/envelope/v1": <value> }, so it has no top-level type (§8.8); it is unwrapped when it goes back to the client that sent it.

8.5 gate/v1

https://dispatch.foo/a2a/ext/gate/v1 is data only, and read only: it rides on status.message.metadata while a task is TASK_STATE_AUTH_REQUIRED (§8.7), and says which gates hold the task open.

interface GateStateV1 {
  gates: {
    id: string; // the gate's DMP message id
    type: string; // the gate's type (§5.6)
    openedAt: string; // ISO 8601
    waitingOn: 'owner';
  }[];
}

Only a deciding principal answers a gate, and a client never can (§5.2). A gate's data never leaves the host (§5.8): a tool-approval gate quotes a tool's input and a scope gate names paths. Only each gate's id, type and time leave, here, and status.message carries one fixed sentence per type in place of the gate's body. The Dispatch profile's sentences:

Vectors: a2a.projection.row-8-an-open-gate-needs-authorization, a2a.projection.row-8-gate-v1-lists-id-type-and-time, a2a.egress.gate-data-never-reaches-a-client, a2a.egress.a-reply-to-a-gate-never-reaches-a-client.

8.6 work/v1

https://dispatch.foo/a2a/ext/work/v1 is a profile plus a sub-state of TASK_STATE_WORKING. It is registered as provisional (§11.10) until the task-proposal gate it relies on is permanent, and until then no vector tests it or the rows of §8.7 that concern handoffs.

A client sends a request on Message.metadata; it selects the skill, and its absence means ask:

type WorkRequestV1 =
  | {
      skill: 'handoff';
      title: string; // required; one line, at most 200 bytes
      acceptance?: string[]; // at most 20 entries, each one line of at most 500 bytes
      writes?: string[]; // at most 50 repo-relative paths or globs, each at most 512 bytes
      priority?: 'urgent' | 'high' | 'medium' | 'low' | 'none'; // capped at 'medium'
      labels?: string[]; // at most 10, each at most 50 bytes
    }
  | { skill: 'status'; task?: string }; // an A2A task id; absent means all of the caller's

A host MUST refuse a request that breaks these limits as invalid on the field or its slot, under work. (work.title, work.writes[1]). Lengths are UTF-8 bytes. A write is relative to the repository: an absolute path, a drive letter, a backslash or a .. segment is refused.

Handoffs. A handoff becomes a draft work item that a deciding principal approves before anything runs. The host raises a task-proposal gate for it (§11.6): only the system address raises one, its choices are approve and decline, and its data names the drafted work item, the client that proposed it and the handoff message. Approving it promotes the draft; declining it drops the draft. A handoff starts at the host's highest risk, so none of its own gates decides itself; a client chooses no risk, its priority is capped at medium, and its labels are stored under an a2a/ prefix.

The host tells the client its handoff's state on Task.metadata and on status.message.metadata:

interface WorkStateV1 {
  task: string; // the DMP work item id
  title: string;
  status: string; // draft, ready, working, review, landing, landed, dropped, or a host's own
  stage?: 'review' | 'landing'; // the TASK_STATE_WORKING sub-state
}

Results go on Artifact.metadata, and an artifact's artifactId equals its kind:

type WorkArtifactV1 =
  | { kind: 'answer'; messageId: string; choice?: string }
  | { kind: 'pr'; url: string; number: number; state?: 'open' | 'merged' }
  | {
      kind: 'diffstat';
      files: number;
      insertions: number;
      deletions: number;
      perFile: { path: string; insertions: number; deletions: number }[]; // at most 200
    }
  | {
      kind: 'evidence';
      items: {
        command: string;
        exitCode: number;
        durationMs: number;
        summary: string;
      }[]; // at most 50
    };

Each artifact is at most 64 KiB as JSON; past that, perFile and items are cut to fit and metadata.truncated is true. There is no branch artifact: branch names and commit ids do not leave the host (§8.8).

8.7 Task-state projection

A host decides a task's state from the facts it holds, and MUST report the state of the first row that matches. The same decision drives every read of a task: GetTask, ListTasks and every stream event.

# Condition State status.message
1 the client canceled the task CANCELED "Canceled by the client."
2 a handoff whose proposal was declined, or whose work item was dropped by anyone but the client; or an ask the owner declined REJECTED the decline or drop, with any note
3 an ask whose root the system address closed (§4.8), or whose recipient work item was dropped FAILED the reason
4 a handoff whose work item no longer exists FAILED "The task was deleted."
5 an ask whose root has an answer COMPLETED the answer, and the answer artifact
6 a handoff whose work item is landed COMPLETED "Landed.", and the work artifacts
7 an open blocking question to the client, in scope, that has no gate data and replies to no message that has gate data INPUT_REQUIRED the oldest such question
8 an open gate in scope AUTH_REQUIRED the gate's fixed sentence, and gate/v1 (§8.5)
9 a handoff whose work item is working, review, landing or a status of the host's own WORKING, with the stage review or landing the latest in-scope message not from the client, else "Working."
10 a handoff whose work item is draft or ready after approval SUBMITTED "Approved; waiting to be scheduled."
11 an ask whose root deliveries are all held SUBMITTED a sentence naming the mailbox the ask waits in
12 otherwise WORKING the latest in-scope message not from the client, else "Working."

Row 3 reads a close only from the system address, as every host reads the system markers (§13.14): the same data from any other sender is an answer, and row 5 matches. Row 7 tests gate data with the rule of §5.8, on the question and on the message it replies to, whatever the gate's type and whether or not the host knows it: a gate, or an answer to one, never strands a client in INPUT_REQUIRED with nothing it may answer. A host already refuses to send such a question to a client (§8.8); row 7 also covers rows an older host stored. The fixed sentences are the Dispatch profile's and are not normative.

Rows 4, 6, 9 and 10, and row 2 for a handoff, concern handoffs, which exist only through work/v1 and its task-proposal gate (§8.6). A host that takes no handoffs never meets them, and no vector tests them while work/v1 is provisional.

Vectors: a2a.projection.row-1-a-canceled-task, a2a.projection.row-2-an-ask-the-owner-declined, a2a.projection.row-3-a-system-close-fails-an-ask, a2a.projection.row-3-a-close-from-anyone-else-is-an-answer, a2a.projection.row-3-a-dropped-recipient-fails-an-ask, a2a.projection.row-5-an-answered-ask-completes, a2a.projection.row-7-an-open-question-needs-input, a2a.projection.row-7-never-a-question-with-gate-data, a2a.projection.row-8-an-open-gate-needs-authorization, a2a.projection.row-8-gate-v1-lists-id-type-and-time, a2a.projection.row-8-an-open-question-comes-first, a2a.projection.row-11-an-ask-held-in-a-mailbox, a2a.projection.row-11-needs-every-root-delivery-held, a2a.projection.row-12-otherwise-working.

Scope. A task's scope is every message that is both related to the task and visible to the client:

A member's reply to the owner inside the ask's thread, the owner's answer to a gate and the gate itself therefore never reach the client, in history, in status.message or in a stream. Besides in-scope messages, the only text in status.message is the host's fixed sentences.

A gate is in scope when it concerns the task: the task's own task-proposal gate, or a gate about the handoff's work item or one of its sessions. The Dispatch profile counts a tool-approval gate of one of the work item's sessions, a scope gate one of them raised, and a wake gate whose target is the work item. Only the gate's id, type and time leave (§8.5); the gate message is never in scope.

8.8 External senders

A host that implements this binding classifies each address it reaches through it as a client or a peer, and decides for every external recipient of a send whether to deliver to it, skip it or refuse the send (§2.1). A direct recipient it refuses fails the send with that error; a recipient reached through a channel that it refuses is skipped, as a session that is not live is when a channel reaches it (§3.5). Which recipients it admits is the host's own policy; the vectors script a host that admits every one (§12.4.2) and check that the decision is asked for. Vectors: a2a.egress.plain-mail-to-a-client-is-admitted-by-the-host.

Gate data never goes external. A send in which the message, or the message it replies to, has gate data (§5.1) while any resolved recipient is external MUST fail as forbidden on data before anything is stored, as §5.8 says, whoever sends it and whatever other recipients it has. Vectors: a2a.egress.gate-data-never-reaches-a-client, a2a.egress.a-gate-with-a-local-recipient-is-refused-whole, a2a.egress.a-reply-to-a-gate-never-reaches-a-client.

Inbound content. What a client or a peer sends becomes a send input only through these rules:

Vectors: a2a.external.gate-shaped-data-is-wrapped, a2a.external.several-data-parts-become-one-array, a2a.external.an-empty-message-is-refused, a2a.external.data-alone-reads-no-text.

Never the system address. A message from a client is sent as the client's own address, and one recorded from a peer as the peer's a2a: address (§8.9); neither decides (§9.2). Whatever an envelope names as from, an external sender MUST NOT become the system address, and a close or a marker it sends never reads as one (§13.1, §13.14). Vectors: a2a.external.never-speaks-as-the-system, a2a.external.the-host-sets-id-thread-and-from, a2a.external.an-answer-from-a-client-is-never-a-close.

Presenting. A host marks an external sender's message wherever it puts one in a model's context, and quotes every line that carries the sender's text, as §6.8 says; a body that imitates the host's header or a host line stays inside quoted lines. Vectors: a2a.external.a-clients-question-is-quoted-when-pushed, a2a.external.a-forged-header-stays-quoted, a2a.external.a-clients-digest-starts-with-host-text.

What leaves. Besides gate data, a host implementing this binding MUST NOT send through it anything in the right-hand column below; the left-hand column is all it sends of its own accord (§13.9). A host cannot vet what a human or an agent chooses to write into a message addressed to a client or a peer, or into an evidence summary, which is free text a session writes; it marks external recipients in its agents' tools and in rendered headers so they know.

Exported Never exported
in-scope messages (§8.7): their body, data, refs as opaque strings, choices and sender address; a task's state and stage; a handoff's title; the answer, pr, diffstat (paths and counts) and evidence (command, exit code, duration, summary) artifacts; to a peer, the envelope of each message addressed to it, with to reduced to the peer gate data and gate messages; messages the client neither sent nor received, and other threads; the other recipients of a message; branch names and commit ids; session logs, transcripts and diff contents; file contents and attachments; memory entries; documents; ledger entries; review findings; the host's member list (addressing a human: depends on the client's recipient list, not on membership); the owner's handle on the card; work items other than the client's own

8.9 Outbound peers

This subsection is provisional: the scheme a2a is registered as provisional (§11.1), and no vector tests it yet.

A peer is another A2A agent the host sends to, addressed as a2a:<alias>. The alias follows the handle grammar of §3.1 ([a-z0-9][a-z0-9._-]*), at most 40 characters. An a2a: address is valid in to and as a channel member; it is from only on a message the host records from that peer, which never decides. No principal ever sends as one.

Adding a peer. Only a deciding principal adds a peer, by naming its card's URL. The host then:

  1. fetches the card over https: (plain http: only when the host's operator allows it), following no redirect, within 10 s and 256 KiB;
  2. picks the card's first interface with protocolVersion 1.0 over HTTP+JSON or JSONRPC, and refuses a peer that offers neither;
  3. requires the interface's origin to equal the card's, unless the operator confirms the difference, so a hostile card cannot point the host at a service inside its network;
  4. requires a security scheme the host can satisfy with a bearer token or an API key in a header; and
  5. keeps the credential apart from the host's messages and configuration.

A URL that a principal other than the host's operator supplies MUST pass a public-URL check before every fetch: https: only, no user information and no IP literal host, and no address that resolves to a loopback, private, link-local, shared (100.64.0.0/10), multicast or unspecified range, or an IPv4-mapped form of one. The card's name, description and skills are shown to humans and sessions as quoted, untrusted text. The host refreshes the card daily; a refresh that changes the interface's origin disables the peer, and a 401 or 403 from the peer marks it failed, each with one notice to the owner.

Sending. Only an active peer receives mail. A direct send to a peer that is absent or inactive fails as not-found on to[i]; a channel member that is an inactive peer is skipped, with at most one notice a day to the owner. A direct send over the host's per-peer hourly quota fails as limited on to[i] and stores nothing; a channel delivery over it waits. The host's own system sends never address a peer, and only a deciding principal may make a peer a member of a channel, since every later message to the channel would then leave the host.

A delivery to a peer is held (§6.2), and the host relays it outside the send, so a sender never waits on the network. For each delivery the host sends one SendMessage with returnImmediately, whose messageId is the DMP message id (so a peer that deduplicates never processes a retry twice), whose parts are the body and the data, and whose envelope/v1 carries from, to reduced to the peer, kind, replyTo, choices, choice and refs. A handoff to a peer also carries work/v1 with the handoff's first line as its title. On acceptance the delivery moves from held to pushed. A network error or a 5xx is retried with backoff; after 24 hours the host gives up, closing a question and sending the sender of any other kind a notice. A 4xx is not retried: the sender gets the peer's error as a notice, and a question is closed.

Replies. The host follows each open task of a peer, by stream or by polling, and records each peer event as at most one message from the peer, through the inbound rules of §8.8, with an idempotency key made from the peer's task id, its state and the event's message id or time, so a replay or a reconnect never records it twice:

Peer's task Recorded in the thread
a direct Message response an answer to a question addressed to the peer directly; otherwise a message reply
COMPLETED for a question addressed to the peer directly, an answer with the status text and artifacts; otherwise a notice reply with the same
INPUT_REQUIRED a blocking question from the peer to the original sender; its answer goes back to the peer's task
AUTH_REQUIRED a notice to the sender that the peer waits on its own authorization
a WORKING status message a notice reply, at most one a minute
FAILED, REJECTED, CANCELED a question addressed to the peer directly is closed with the peer's reason; otherwise a notice
no terminal state after 7 days a question is closed

A peer answers only a plain question addressed to it directly. It never answers a gate, since gate data never reaches it, and its reply to a question it received through a channel is recorded as a message, because one member's answer would close the question for every member. A handoff to a peer is closed by the system address when the peer first reports a decisive state (WORKING, COMPLETED or REJECTED); later states and the peer's artifacts arrive as notices. Messages from peers count as agent turns for the breaker (§6.6), so a session and a peer cannot answer each other forever.

8.10 Declared deviations

An A2A binding claim needs an attested run of the A2A TCK (§12.1) at commit 263b9cfaf16a554bdfb166a7ba5b67716e946349, over HTTP+JSON, at its MUST level. The run passes when every failure is one of these two deviations, which this binding declares on purpose; any other fails the claim (§12.6):

Refusing a contextId the client does not take part in is not a deviation: A2A §3.4.1 lets an agent reject a contextId it cannot accept, and this binding never replaces one (§8.2). A TCK scenario that expects the other behavior allowed there fails against such a host, so Dispatch attests its TCK run against a test subject that keeps a client's contextId, which §3.4.1 also allows.

9 Identity and authorization

9.1 Principals

A host authenticates every principal before this document's rules apply to what it sends; how it does so (tokens, operating system users, a binding's own authentication) is out of scope. The result is a sender: one address, and whether it may decide (§9.2).

A work item and a channel never send. An address a host authenticates is attribution: it says who sent a message. Whether it is also a security boundary depends on the deployment (§13.16).

9.2 Deciding principals

A sender that may decide is a deciding principal. A host MUST refuse, as forbidden on from, a sender that claims to decide from an address that is neither a human: address nor the system address. The system address always decides. Which humans decide is the host's policy; the Dispatch profile lets a team member decide at the decide tier (Appendix A).

A deciding principal may answer gates (§5.4), raise gate types whose raisedBy is system-or-decider (§5.3), reply in any thread without participating (§4.6), and read any message (§9.3). No other principal can approve anything through a gate, including its own tool calls, wake-ups or registration.

9.3 Reads

A principal may read a message when it is the system address, a deciding principal, or a participant of the message (§4.6). A message that does not exist is readable by no one. A thread is readable when any of its messages is, and then all of it is.

Every surface through which a host lets a principal read messages, their answers, their deliveries or threads (an HTTP API, a WebSocket stream, a user interface, an agent's tools) MUST apply this rule, and MUST answer a principal that may not read a message exactly as it answers for a message that does not exist (§13.5). The kit tests the rule itself, through the canRead step (§12.4), but not each surface (§12.5).

10 Errors

Every refusal in this document is one error with a code and, in most cases, a field. A host that fails an operation performs none of it; for a send, that means it stores no message and no delivery of its own (§4.5).

10.1 Codes

Code HTTP status Meaning For example
invalid 400 the input breaks a rule of this document a malformed address, a limit exceeded, a gate type the host does not implement, a direct send to an ended session
forbidden 403 the sender may not do this an agent that is not approved, raising or answering a gate without the authority
not-found 404 what the input names does not exist, or the sender may not know that it does a replyTo naming an absent or foreign message, an unknown channel, an unknown delivery
conflict 409 the current state does not allow it a second answer to a question
limited 429 a guardrail refused it a reply into a thread that reached the breaker's limit (§6.6)

A host that speaks HTTP answers each code with its status. A binding carries the code, or maps it onto its own error model as its section says. The registry of codes is §11.9.

10.2 Fields

field names the part of the caller's input that failed, as a path: a member name (to, kind, body, data, replyTo, choice), a list index in brackets (to[1], choices[2]) and a member of an entry or object after a dot (refs[0].type, data.type, data.paths). from names the sender, and operations other than a send name their own inputs (member for a join). An error about a recipient names the caller's own to[i], as the sender wrote the list (§3.5).

An error's text is not normative and the kit never compares it. A host SHOULD make it say how to fix the input, since the sender is often an agent that will retry.

10.3 Unknown codes

A later minor version may add an error code (§14.1). A client MUST treat a code it does not recognize as the class of its HTTP status: a 4xx code as a refusal of this request, a 5xx code as a failure of the host that may pass.

unavailable (503) is an informative code: a store or service the host needs is down. Hosts and bindings raise it; the rules of this document never do.

11 Registries

This section is generated from registries/registries.json by scripts/registries.ts; edit the JSON, not this file. Registration policy is in 11.11.

11.1 Address schemes

Value Scope Status Since Defined in Vectors
human core permanent 1.0.0-draft.1 §3.4 env.address.parses-each-scheme, env.address.refuses-an-operator-on-a-human
agent core permanent 1.0.0-draft.1 §3.4 env.address.parses-each-scheme, env.address.parses-an-agent-without-an-operator
task core permanent 1.0.0-draft.1 §3.4 env.address.parses-each-scheme, env.address.refuses-an-id-outside-the-identifier-grammar
run core permanent 1.0.0-draft.1 §3.4 env.address.parses-each-scheme
channel core permanent 1.0.0-draft.1 §3.4 env.address.parses-each-scheme, env.address.refuses-an-empty-channel-segment
a2a a2a provisional 1.0.0-draft.1 §8.9 none

11.2 Address characters

Value Scope Status Since Defined in Vectors
@ federation reserved 1.0.0-draft.1 §3.3 env.address.refuses-the-reserved-at-sign

11.3 Envelope fields

Value Scope Status Since Defined in Vectors
id core permanent 1.0.0-draft.1 §4.1 core.render.body-lines-never-pass-for-a-header, core.render.dispatch-push-format
thread core permanent 1.0.0-draft.1 §4.1 core.render.body-lines-never-pass-for-a-header
replyTo core permanent 1.0.0-draft.1 §4.1 env.envelope.an-answer-needs-reply-to, env.envelope.a-reply-to-no-message-is-not-found
from core permanent 1.0.0-draft.1 §4.1 core.render.body-lines-never-pass-for-a-header, env.envelope.only-sessions-raise-scope-gates
session core permanent 1.0.0-draft.1 §4.1 env.envelope.a-session-is-at-most-200-bytes
to core permanent 1.0.0-draft.1 §4.1 env.envelope.to-is-a-non-empty-list, env.envelope.names-the-bad-recipient-index
kind core permanent 1.0.0-draft.1 §4.1 env.envelope.unknown-kind-refused-x-kind-accepted
body core permanent 1.0.0-draft.1 §4.1 env.envelope.body-is-required-unless-an-answer-chooses, env.envelope.body-is-at-most-64-kib
refs core permanent 1.0.0-draft.1 §4.1 env.envelope.a-local-ref-type-must-be-registered, env.envelope.refs-hold-at-most-50-entries
data core permanent 1.0.0-draft.1 §4.1 env.envelope.data-is-at-most-64-kib-as-jcs
urgent core permanent 1.0.0-draft.1 §4.1 core.render.body-lines-never-pass-for-a-header, core.render.dispatch-push-format
blocking core permanent 1.0.0-draft.1 §4.1 env.envelope.blocking-only-on-questions-and-handoffs
choices core permanent 1.0.0-draft.1 §4.1 env.envelope.choices-only-on-questions-and-handoffs, env.envelope.choices-are-distinct
choice core permanent 1.0.0-draft.1 §4.1 env.envelope.a-choice-must-be-one-of-the-questions, env.envelope.a-gate-answer-must-choose
wake core permanent 1.0.0-draft.1 §4.1 core.render.body-lines-never-pass-for-a-header
createdAt core permanent 1.0.0-draft.1 §4.1 core.render.body-lines-never-pass-for-a-header
idempotencyKey core permanent 1.0.0-draft.1 §4.9 core.idempotency.replays-the-first-message, core.idempotency.a-key-is-one-line-of-at-most-200-bytes
origin federation appendix 1.0.0-draft.1 §F.4 none
hlc federation appendix 1.0.0-draft.1 §F.4 none

11.4 Kinds

Value Scope Status Since Defined in Vectors
message core permanent 1.0.0-draft.1 §4.2 env.envelope.accepts-a-plain-message
question core permanent 1.0.0-draft.1 §4.2 env.envelope.blocking-only-on-questions-and-handoffs
answer core permanent 1.0.0-draft.1 §4.2 env.envelope.an-answer-needs-reply-to
handoff core permanent 1.0.0-draft.1 §4.2 env.envelope.blocking-only-on-questions-and-handoffs, env.envelope.a-handoff-answer-must-choose
notice core permanent 1.0.0-draft.1 §4.2 env.envelope.choices-only-on-questions-and-handoffs

11.5 Ref types

Value Scope Status Since Defined in Vectors
task core permanent 1.0.0-draft.1 §4.3 env.envelope.a-local-ref-type-must-be-registered
run core permanent 1.0.0-draft.1 §4.3 env.envelope.a-local-ref-type-must-be-registered
file core permanent 1.0.0-draft.1 §4.3 env.envelope.a-local-ref-type-must-be-registered
commit core permanent 1.0.0-draft.1 §4.3 env.envelope.a-local-ref-type-must-be-registered
message core permanent 1.0.0-draft.1 §4.3 env.envelope.a-local-ref-type-must-be-registered
doc docs provisional 1.0.0-draft.1 §4.3 none

11.6 Gate types

Value Scope Status Since Defined in Raised by Choices Effect Vectors
wake core permanent 1.0.0-draft.1 §5.9 system approve, deny wake the target unless it no longer qualifies (5.9) core.wake.approve-applies-before-publishing, core.wake.ask-raises-a-wake-gate-to-the-owner
tool-approval dispatch permanent 1.0.0-draft.1 §C.3 system approve, approve-session, deny release or refuse the parked tool call (C.3) env.envelope.agents-may-not-forge-tool-approval-gates, core.answers.a-gate-effect-runs-before-the-answer-is-published
scope dispatch permanent 1.0.0-draft.1 §C.3 session grant, deny widen the session's writes (C.3) env.envelope.only-sessions-raise-scope-gates, env.envelope.scope-gates-have-a-fixed-shape
agent-registration dispatch permanent 1.0.0-draft.1 §C.3 system approve, deny approve or refuse the agent (C.3) core.answers.a-human-decides-an-agent-registration-gate
overseer-action dispatch permanent 1.0.0-draft.1 §C.3 system confirm, cancel run or drop the overseer's action (C.3) core.answers.a-human-decides-an-overseer-action-gate
memory memory provisional 1.0.0-draft.1 §C.3 system-or-decider approve, reject apply or reject the memory proposal (C.3) none
task-proposal a2a provisional 1.0.0-draft.1 §8.6 system approve, decline promote or drop the drafted work item (8.6) none
doc docs provisional 1.0.0-draft.1 §C.3 system approve, reject apply or reject the document change (C.3) none

11.7 System markers

Value Scope Status Since Defined in Vectors
x-closed core permanent 1.0.0-draft.1 §4.8 core.close.answers-without-effect-or-deliveries, core.close.moves-the-question-to-answered, core.markers.close-is-honored-only-from-the-system
x-breaker core permanent 1.0.0-draft.1 §6.6 core.guardrails.breaker-tells-the-owner-once-per-window

11.8 Delivery states

Value Scope Status Since Defined in Internal Vectors
held core permanent 1.0.0-draft.1 §6.1 no core.send.task-held-without-live-session, core.recover.returns-sending-rows-to-held
sending core permanent 1.0.0-draft.1 §6.1 yes core.recover.dispatches-sending-rows-whose-session-is-live, core.recover.returns-sending-rows-to-held
pushed core permanent 1.0.0-draft.1 §6.1 no core.send.direct-mail-pushes-to-the-live-session, core.session-start.claims-held-mail-for-the-work-item
notified core permanent 1.0.0-draft.1 §6.1 no core.send.human-mail-is-notified, core.send.channel-mail-notifies-the-live-session
read core permanent 1.0.0-draft.1 §6.1 no core.mailbox.inbox-lists-and-mark-read-reads, core.mailbox.marking-read-leaves-answered-alone
answered core permanent 1.0.0-draft.1 §6.1 no core.answers.a-reply-answers-and-closes-the-question, core.close.moves-the-question-to-answered
forwarded federation appendix 1.0.0-draft.1 §F.4 no none
refused federation appendix 1.0.0-draft.1 §F.4 no none

11.9 Error codes

Value Scope Status Since Defined in HTTP status Vectors
invalid core permanent 1.0.0-draft.1 §10.1 400 env.envelope.unknown-kind-refused-x-kind-accepted
forbidden core permanent 1.0.0-draft.1 §10.1 403 env.envelope.a-gate-answer-needs-a-deciding-principal
not-found core permanent 1.0.0-draft.1 §10.1 404 env.envelope.a-reply-to-no-message-is-not-found, core.participation.absent-and-foreign-look-alike
conflict core permanent 1.0.0-draft.1 §10.1 409 core.answers.a-question-takes-one-answer, core.close.an-answered-question-conflicts
limited core permanent 1.0.0-draft.1 §10.1 429 core.guardrails.breaker-refuses-the-next-agent-turn
unavailable memory informative 1.0.0-draft.1 §10.3 503 none

11.10 Extension URIs

Value Scope Status Since Defined in Vectors
https://dispatch.foo/a2a/ext/envelope/v1 a2a permanent 1.0.0-draft.1 §8.4 a2a.envelope-ext.accepts-recipients-choices-and-message-refs, a2a.envelope-ext.accepts-no-envelope, a2a.envelope-ext.refuses-urgent, a2a.envelope-ext.refuses-a-wake-request, a2a.envelope-ext.refuses-a-private-use-kind, a2a.envelope-ext.refuses-an-unknown-kind, a2a.envelope-ext.refuses-a-bad-recipient, a2a.envelope-ext.refuses-a-ref-type-other-than-task-or-message, a2a.envelope-ext.refuses-a-ref-anchor, a2a.envelope-ext.refuses-more-than-20-choices, a2a.envelope-ext.refuses-a-choice-over-200-bytes, a2a.envelope-ext.refuses-blocking-that-is-not-a-boolean, a2a.envelope-ext.refuses-metadata-over-64-kib, a2a.external.the-host-sets-id-thread-and-from, a2a.external.defaults-to-a-blocking-question-to-the-owner, a2a.external.a-client-reaches-only-the-owner, a2a.external.an-opening-answer-or-handoff-is-refused, a2a.external.never-speaks-as-the-system, a2a.external.gate-shaped-data-is-wrapped, a2a.external.several-data-parts-become-one-array
https://dispatch.foo/a2a/ext/gate/v1 a2a permanent 1.0.0-draft.1 §8.5 a2a.projection.row-8-an-open-gate-needs-authorization, a2a.projection.row-8-gate-v1-lists-id-type-and-time, a2a.egress.gate-data-never-reaches-a-client, a2a.egress.a-gate-with-a-local-recipient-is-refused-whole, a2a.egress.a-reply-to-a-gate-never-reaches-a-client
https://dispatch.foo/a2a/ext/work/v1 a2a provisional 1.0.0-draft.1 §8.6 none

11.11 Registration policy

Every entry has a value; a scope (core, dispatch, a2a, memory, docs or federation); a status; the version it appeared in (since); the section that defines it; for a non-core entry, a reference to the public document that defines it; and the ids of its vectors. Gate types add who may raise them, their choices, their data and their effect; delivery states add whether a host MAY hide them from its external API; error codes add an HTTP status.

An entry has one of five statuses:

New entries follow Specification Required: a public document plus at least one vector, and the editor's approval. Names starting x- are private use and are never registered, except the two system markers, which keep the x- spelling because stored messages carry it.

Drift rule: for each registry whose values an engine exports (address schemes, kinds, ref types, gate types, system markers, delivery states and error codes), every permanent entry is in the export, and every exported value has a permanent or provisional entry. A value already listed as provisional may be implemented without a registry change; any other new value needs a provisional entry in the same change.

12 Conformance

An implementation conforms to DMP by passing the conformance kit's vectors for a claim. The kit is a set of JSON test vectors, a runner, dmp-conformance, and an adapter the implementer writes, which runs each vector against the implementation and reports what happened (§12.4). The vectors are normative with this text (§1.4).

12.1 Claims

Claim Name for the runner Vectors judged Also required
Envelope envelope the envelope class, core profile none
Core core the envelope and host-core classes, core profile none
Dispatch profile dispatch-profile Core's, plus every dispatch-profile vector of those classes at least one dispatch-profile vector ran; the adapter declares every gate type the registry lists as permanent (§11.6)
A2A binding a2a-binding Core's, plus every a2a-binding vector of the core profile an attested A2A TCK run at the pinned commit whose result is pass and whose only deviations are those of §8.10
Federation reserved the federation class, in the minor version that makes Appendix F normative two transports implement Appendix F

An Envelope claim needs only a validator: its vectors parse addresses and validate send inputs, and deliver nothing.

The union rule. An A2A binding claim made beside a Dispatch-profile claim also judges the dispatch-profile vectors of the a2a-binding class. A Dispatch-profile A2A claim is therefore the union of the two, and a failing dispatch-profile a2a-binding vector that is a MUST fails the A2A binding claim.

A claim needs vectors. A claim fails when any class it judges has no vector that ran (passed or failed), so an empty kit, or an adapter that declares too little, never passes.

12.2 Levels

Every vector carries the BCP 14 keyword of the requirement it tests.

12.3 Profiles

A vector's profile is core unless it depends on any Dispatch value, which makes it dispatch: the narrowed id grammar (Appendix C), agent:dispatch as a literal, the rendering format, the default limits, epic channels, a profile gate type, a profile marker, or the wording of a notice. core vectors use ids valid under both grammars (t-4a8cce, r-9f2c01), write the system address as $system, and set limits explicitly, so the reference and any other host run the same files. Appendix C is the rule set that dispatch vectors check.

12.4 The kit

The kit ships in the package @dispatch-foo/protocol-spec: the vectors under vectors/, the registries, informative JSON Schemas, and the runner. The runner needs Node 22.13 or later and has no runtime dependency. This section defines the vector format, the adapter protocol, the reports and the runner. The expectation noDeliveries, the world change agentStatus and the step a2a.inbound are the format's latest additions.

12.4.1 Vector files

A vector file is vectors/<class>/<area>.json, holding { "kit": <version>, "class", "area", "vectors": [...] }. kit is the kit version the file was written for, class is its directory, and area matches [a-z0-9-]+. A vector has these members, and no others:

Member Meaning
id <prefix>.<topic>.<case>, matching [a-z0-9]+(\.[a-z0-9-]+){2}; the prefix names the class: env for envelope, core for host-core, a2a for a2a-binding, fed for federation. The topic is free, often the file's area. Unique across the kit, never reused
title one sentence stating what the vector checks
class envelope, host-core, a2a-binding or federation (reserved: no vectors yet); the same as its file's
level MUST, SHOULD or MAY (§12.2)
profile core or dispatch (§12.3)
sections the numbered sections of this document it tests; each exists
capability the capability a MAY vector needs; only a MAY vector has one, and every MAY vector does
tags optional labels; structural marks a refused input whose fault a JSON Schema can express
given the world the adapter scripts its host from (§12.4.2)
when the steps to run, in order (§12.4.3)
then what the runner expects (§12.4.5); never sent to the adapter

A vector retired from the kit moves to vectors/retired.json, as { "id", "reason" }, so a report against an older kit stays readable, and its id is never used again. The runner refuses a malformed file before any adapter runs.

12.4.2 The world

given describes the world at the start of a vector. Each vector starts from an empty store and a fresh world.

Member Meaning
clock the host's time at the start, a timestamp; default 2026-09-23T10:00:00.000Z
seed a number for a deterministic id source; ids are still compared only through symbols (§12.4.4)
workItems work items, as { "id", "liveSession"?, "sessions"? }: the live session and the earlier sessions, as run: addresses
auxSessions live auxiliary sessions, as run: addresses
agents registered agents, as { "address", "status", "muted"? }, status being pending, approved or revoked
channels channels and their explicit members, as { "name", "members" }
implicit implicit members, as a map from channel name to addresses
owner the owner the host names for every target
rulings the wake policy, as a map from target address to allow, ask or deny; deny for any target not listed
wakeResults what waking a target returns: { "ok": true, "session" } or { "ok": false, "reason" }; success when not listed
failPush sessions whose push and notify fail
failOnAnswered when true, every gate effect fails
limits urgentPerHour and agentTurnsPerThreadPerHour
external for a2a-binding vectors, a map from address to client or peer (§8)
store rows seeded into the store before the first step: messages and deliveries with literal ids, and appliedGates, the ids of gates whose effect is recorded

Seeded rows stand for state an older build or another host wrote, which a current send could not produce, such as an agent's answer to a gate.

The scripted host makes the choices §8 leaves to a host this way: it admits every recipient of a send that external names, recording each admitExternal call (§8.8); and the recipient list of each client there is the owner alone, with no approved handoffs, so a client that names anyone else in to is refused as §8.4 says. No member of given changes either.

12.4.3 Steps

Each entry of when is an object with an op and that op's members. A step that fails fails only itself: later steps still run. Each step's outcome is { "ok": true, "result"? } or { "ok": false, "error": { "code", "field"? } } (§10).

Op Members Result
send as ({ "address", "canDecide" }), input (a send input), origin? { "message", "downgraded", "replayed"? }
reply as, message (the target), input (body, choice?, refs?, data?, session?) as send (§4.6)
close question, reason { "message" } (§4.8)
markRead message, recipient: the delivery of that message to that recipient { "state" }
inbox recipient, states? { "messages": [ids] }
thread thread { "messages": [ids] }
canRead message, as (§9.3) { "readable" }
openBlocking none { "messages": [ids] }
join channel, member {}
leave channel, member { "removed" }
deliverHeld session, workItem: that session of that work item starts (§6.4) { "deliveries": [{ "message", "recipient", "state" }] }
recover none (§6.3) { "retried", "reverted", "replayed", "voided"? }
parseAddress input the parsed address (§3.1)
validate as, input, replyTarget? (a message id), origin? (local or received, §4.4) {}
render message, form? (push, the default, or digest), external? { "text" } (§6.8)
a2a.validate extension (envelope or work), raw {} (§8)
a2a.project facts { "state", "stage"?, "gates"? } (§8.7)
a2a.inbound as, envelope, body, parts?, answers?: a message arriving through the A2A binding as send (§8.8)
world change: exactly one of the changes below no result

validate runs only step 4 of the order in §4.5: it skips step 1's authorization and step 3's participation check, stores nothing and needs no delivery. Its reply target is the message replyTarget names. Of step 3 it keeps only the lookup: an input that sets replyTo when replyTarget names no message fails not-found on replyTo. parseAddress refuses an address as invalid (§3.1); no input field carries it, so no vector names the error's field. send, reply, close and a2a.inbound create a message.

a2a.validate checks raw, an extension's metadata as a client sent it, as the only metadata of one message: the budget of §8.3, then the extension's rules. a2a.inbound takes a message as a client sent it through the binding: as is the client's address, envelope its envelope/v1 metadata (or null), body its text and parts its data parts; the host applies §8.3, §8.4 and §8.8, the recipient rule against the scripted host's list (§12.4.2), and sends it as the client, with no decide authority. Without answers the message has no taskId and starts a new exchange (§8.4). answers names an open question to the client, and stands for the taskId of the task that asks it: the message then answers that question, as a continuation does (§8.4).

An a2a.project step's result carries gates when the state is AUTH_REQUIRED: the gates list of gate/v1 (§8.5) that the host writes on status.message for a client that activated that extension. Its facts names only what its row reads, and every other fact takes its default, so a task is by default an ask that matches row 12: skill (ask or handoff; ask), root (the message that opened the task; a blocking question from the client to the owner), canceledAt and declinedAt (times; null), answer (the root's answer; null), openQuestions (open questions to the client; none), openGates ({ id, type, openedAt } of the open gates in scope; none), task (the handoff's work item, { id, title, status, approved }, or "deleted"; null), dropped (client or other; null), recipientTaskDropped (false), rootDeliveries (the states of the root's deliveries; none) and scope (the in-scope messages; none). A message fact lists only the fields it needs and takes the default root's for the rest.

A world step changes the world between steps:

Change Value
startSession { "workItem", "session" }: the session becomes the work item's live session
endSession { "workItem" }: the work item's live session ends
ruling { "target", "ruling" }
wakeResult { "target", "result" }
failPush the sessions whose push and notify now fail
failOnAnswered whether gate effects now fail
advanceMs milliseconds to move the clock forward
agentStatus { "address", "status" }: an agent's registration changes

Sessions are always written as run: addresses, in given, in steps, in results and in expectations.

12.4.4 Symbols

Vectors name messages by role, so an optional message never shifts the name of a required one:

The runner replaces $system and $unimplementedGateType before a vector crosses to the adapter; a vector that uses $unimplementedGateType when every listed type is implemented is not applicable. The adapter resolves $sN, $gateN and $noticeN in a step before running it. Symbols may appear inside strings, such as an exact rendered text. A delivery is named by its message and its recipient, never by its id.

12.4.5 Expectations

then holds any of these members:

Member Checks
steps per step, null (not checked) or the expected outcome. ok must match; an error's code must match, and its field when the vector gives one; a result is matched as a subset. Error text is never compared
messages the named messages exist, once each, in this order relative to each other, with the fields listed; fields not listed are free (so notice bodies stay free)
noOtherMessages when true, no other message exists, except seeded rows, messages the vector names anywhere, and notices from the system address
deliveries for each message the list mentions, its deliveries are exactly the listed recipients, each once, with via, state and session compared where listed
noDeliveries the listed messages have no deliveries
calls the host's hook calls that name a named or seeded message, exactly and in order
callsInclude those calls include the listed ones, in order
gateEffects the gates whose effect is recorded as applied, as a set
voided the answers voided as §5.7 says, as a set
channels every channel and its explicit members, as sets
render for a render step, its exact text (used by dispatch-profile vectors)

Matched as a subset, an object checks only the members it lists, each matched the same way, while any other value compares exactly as a JSON value: a list item by item, and an object inside a list with the same members, in any order.

The hook calls are push { session, message }, notify { session, message }, notifyHuman { actor, message }, wake { target, message }, decide { target, message }, onAnswered { question, answer }, published { message } (the host made the message visible to readers, as it does for every stored message and every close) and, for a2a-binding vectors, admitExternal { recipient, message } (the host decided whether an external recipient gets the message, §8.8).

Always checked. Whatever then says, every message id and every delivery id is unique and an identifier of at most 64 bytes; generated message ids increase bytewise in creation order; and in the dispatch profile, generated ids are m- or d- followed by 26 lowercase Crockford base-32 characters. Seeded rows keep their literal ids and are exempt from the increase and the Dispatch grammar.

12.4.6 Rendering in Core

A render step renders a stored message as the host would push it (or, with form: "digest", as a digest). Core vectors send bodies whose lines occur in no text the host writes for the message (§6.8): lines that imitate a header or a host line without repeating the host's own, every line break of §1.4 and, for external senders, choices, refs and a message replied to. They then check a pushed rendering against rules 1 to 4 of §6.8, using the forms the adapter declared and, with external: true, rule 4's clause for an external sender, so a host line left unquoted carries none of the sender's text; and they check a digest against the digest rule of §6.8 alone, using the declared digestLead, since a correct digest holds the body's first line, if any, on its only line. dispatch-profile vectors also compare the exact text. No vector reads a mailbox or thread; how each read surface presents messages is listed in §12.5.

12.4.7 Adapter messages

The runner starts the adapter's command and exchanges JSON lines over its standard input and output: one JSON object per line. A line ends at LF; a CR before it is dropped. The runner writes U+2028 and U+2029 as the JSON escapes \u2028 and \u2029, so an adapter whose line reader also breaks at them still reads whole lines, and it splits the adapter's output at LF only, so the adapter may write them raw or escaped. Whatever the adapter writes to its standard error goes to the runner's log. An implementation that embeds the adapter in its main program can enter adapter mode with a flag; the kit only needs a command line.

runner  → {"dmp":"hello","kit":"<kit version>"}
adapter ← {"dmp":"hello","implementation":{"name":"…","version":"…"},
           "classes":["envelope","host-core"],"profiles":["core"],
           "capabilities":["implicit-members"],"systemAddress":"agent:…",
           "gateTypes":["wake"],
           "render":{"quotePrefix":"…","header":"…","hostLines":["…"],
                     "digestLead":"…"}}
runner  → {"dmp":"run","vector":{…the vector without then…}}
adapter ← {"dmp":"observation","id":"…","steps":[…],"messages":[…],
           "deliveries":[…],"calls":[…],"gateEffects":[…],"voided":[…],
           "channels":[…],"render":[…]}
       or {"dmp":"unsupported","id":"…","reason":"…"}
runner  → {"dmp":"bye"}                       the adapter exits 0

12.4.8 Reports

The runner writes a JSON report and, when asked, a JUnit XML file.

{
  "dmp": "1.0.0-draft.1",
  "kit": "1.0.0-draft.1",
  "implementation": { "name": "dispatch", "version": "0.2.0" },
  "claims": { "core": "pass", "dispatch-profile": "pass" },
  "classes": {
    "envelope": {
      "pass": 88,
      "fail": 0,
      "skipped": 0,
      "notApplicable": 1,
      "shouldFailures": 0
    }
  },
  "vectors": [
    {
      "id": "…",
      "class": "envelope",
      "level": "MUST",
      "profile": "core",
      "outcome": "pass",
      "reasons": []
    }
  ],
  "declaredDeviations": [
    {
      "section": "9.3",
      "summary": "/ws sends message.new to any request-tier token"
    }
  ],
  "runner": "@dispatch-foo/[email protected]",
  "date": "2026-10-01T12:00:00.000Z"
}

A claim is pass, fail, or, for an A2A binding claim run on vectors alone, vectors-only. A vector's outcome is pass, fail, skipped, not-applicable or adapter-error, with the reasons; a class's fail count includes adapter errors. With an A2A binding claim the report also has a2a: the attested TCK run (commit, transport, level, result, attested: true) and its declared deviations (§12.6).

The JUnit file has one testsuite per class and one testcase per vector. A MUST vector that did not pass, and is not not-applicable, is a <failure>; a failed SHOULD vector is <skipped message="SHOULD not met: …">; every other vector that did not pass is <skipped> with its outcome and reasons.

12.4.9 The runner

dmp-conformance --adapter "<command>" [--claim core|envelope|dispatch-profile|a2a-binding]…
                [--vectors <dir>] [--report <file>] [--junit <file>]
                [--tck-attest <file>] [--deviations <file>] [--vectors-only]
                [--timeout-ms 10000]

The runner exits 0 when every claim passes or is vectors-only, 1 when a claim fails or the run cannot complete, and 2 on a usage error: an unknown flag or claim, an unreadable file, or a deviation that is not allowed.

12.5 Requirements the kit does not test

These requirements have no vector, so a claim is honest about what passing means. An implementation that does not meet one lists it as a declared deviation (§12.6).

Section Requirement Why no vector
6.8 read presentation: a structured read escapes every line break inside its string values and marks external senders a property of each read surface (an agent's tools, routes and user interfaces); no vector reads a mailbox or thread
9.3 every read surface applies the read rule the rule itself is tested through canRead; whether every HTTP, WebSocket or user interface surface calls it is a property of the deployment
13.1 no principal sends as the system address authentication happens before the rules of this document; the A2A binding's vectors cover external senders
13.7 identifier entropy statistical
13.8 retention is documented, deletion offered documentary
13.9 gate data never leaves through a binding tested for A2A by a2a-binding vectors; another binding brings its own
13.10 logs hold no bearer tokens, message bodies or gate data operational
13.11 gate data carries references, not content a design property of each gate type
13.16 local identity is attribution, not a boundary a property of the deployment
B.2 blocking waits are bounded a timeout in an agent's tool, outside the host's rules

12.6 Declared deviations

A report may declare deviations only from the requirements of §12.5; the runner refuses any other section. A claim whose report declares any must be quoted with them (§12.7).

For the A2A binding, the attested TCK run may carry only the deviations §8.10 declares: bounded-blocking-wait (a MUST deviation from A2A: a blocking wait is bounded) and application-json (a SHOULD deviation: responses are application/json). The runner refuses an attestation that lists any other.

12.7 Claim wording

An implementation whose report passes a claim may say that it implements the Dispatch Messaging Protocol, naming the version it was measured against and, in parentheses, each claim that passed: "implements the Dispatch Messaging Protocol (DMP) 1.0 (Core)", or "implements DMP 1.0 (Core, A2A binding)". When its report declares deviations, it adds "with declared deviations". Publishing the report beside the claim lets anyone check it.

Using the protocol's title in such a claim is the only use of the name "Dispatch" this document grants. It gives no right to use "Dispatch" in the name of a product, package or service; Apache-2.0 grants no trademark rights.

13 Security and privacy considerations

This section collects the rules that protect principals from each other. Each subsection states its keyword and the vector class that tests it; the ones no vector tests are listed in §12.5.

13.1 The system address

Only the host's own code sends as the system address. A host MUST NOT let any principal, binding or receiver present the system address as a message's from, whatever the principal claims and whatever a received message says. Closes, markers, the voiding of answers and the exemption from participation all trust the system address, so a forged one would defeat them.

Keyword: MUST. Vectors: a2a-binding, which check that an external sender never maps to the system address and that a close or marker it sends never reads as one (a2a.external.never-speaks-as-the-system, a2a.external.an-answer-from-a-client-is-never-a-close); otherwise not tested, since authentication happens before this document's rules (§12.5).

13.2 Deciding principals

Deciding principals are human: addresses the host authorized to decide, and the system address (§9.2). A host MUST refuse a sender that claims to decide from any other address.

Keyword: MUST. Vectors: host-core.

13.3 Raise and answer authority

Each gate type may be raised only by the senders its raisedBy allows (§5.3), and answering a gate needs a deciding principal, whether or not the host knows the gate's type (§5.4, §5.6). Otherwise an agent could approve its own tool call or grant itself access.

Keyword: MUST. Vectors: envelope, host-core.

13.4 Effects need a deciding answer

Only an answer from a human: address or the system address takes effect. A host MUST void any other stored answer to a gate and reopen the gate (§5.7), so an answer an older build accepted cannot take effect on a newer one.

Keyword: MUST. Vectors: host-core.

13.5 Non-participants learn nothing

A non-participant's send, reply or read that names a message by id MUST get the answer an id that does not exist gets: not-found on replyTo for a send or reply (§4.6), and the host's absent-id answer for a read (§9.3). A principal therefore cannot probe which messages exist. The A2A binding answers both cases with one error too (§8).

Keyword: MUST. Vectors: host-core (sends, and the read rule through canRead); a host's read surfaces are not tested.

13.6 Reads

Messages, answers and threads are readable by their participants and by deciding principals (§9.3).

Keyword: MUST. Vectors: host-core, for the rule itself through canRead.

13.7 Identifier entropy

Message and delivery ids SHOULD carry at least 80 random bits per millisecond of creation time. Within one millisecond an implementation may instead increment the previous id, as ULID's monotonic form does. With §13.5, a guessed id discloses nothing even when it exists.

Keyword: SHOULD. Not tested.

13.8 Retention and deletion

A host MUST document how long it keeps messages and deliveries, and SHOULD let principals delete them where its audit duties allow. The Dispatch profile keeps them for the life of a project's local run state and offers no deletion in 1.0 (Appendix C).

Keyword: MUST (documentation), SHOULD (deletion). Not tested.

13.9 Egress through bindings

Gate data never leaves a host through a binding (§5.8). Each binding MUST state what else it exports. For A2A, the export table of §8.8 is normative; for federation, Appendix F states them for information.

Keyword: MUST. Vectors: a2a-binding (a2a.egress.gate-data-never-reaches-a-client, a2a.egress.a-gate-with-a-local-recipient-is-refused-whole, a2a.egress.a-reply-to-a-gate-never-reaches-a-client); another binding brings its own (§12.5).

13.10 Logging

A host's logs MUST NOT record bearer tokens, and SHOULD NOT record message bodies or gate data.

Keyword: MUST, SHOULD NOT. Not tested.

13.11 Gate payloads as references

Gate data SHOULD carry references (ids, anchors) to what is being decided, not its content, so that the content stays where its own access rules apply. The Dispatch profile's tool-approval gate carries a preview of a tool's input of at most 8 KiB by design (Appendix C).

Keyword: SHOULD. Not tested.

13.12 Injection-safe presentation

A host MUST present every message it puts into a model's context, in whatever form, so that no body line passes for a header or a host line, and so that a model can tell an external sender's message from a local one. A push quotes the body's lines with quotePrefix, and every line that carries an external sender's text; a digest MUST carry at most the body's first line, after the host's own text on one line, so no body text starts a line; and a structured read escapes every line break inside its string values and marks external senders (§6.8).

Keyword: MUST. Vectors: host-core for pushes and digests (structural), dispatch profile (exact text). Reads are not tested: how each read surface presents messages is listed in §12.5.

13.13 Size limits

The envelope limits (§4.5) and the address limits (§3.2) bound what one send can store or push, for every host, binding and receiver.

Keyword: MUST. Vectors: envelope.

13.14 System markers

A close and the system markers mean something only when the system address sent them. A host MUST honor x-closed as a close, and x-breaker as the breaker's flag, only from the system address; from any other sender they are private data (§4.8, §6.6).

Keyword: MUST. Vectors: host-core.

13.15 Idempotency scope

Idempotency keys are scoped per sender, so one sender cannot replay, or learn of, another's message by guessing its key, and a sender whose authorization was revoked gets no replay (§4.9).

Keyword: MUST. Vectors: host-core.

13.16 Local identity

On one machine, any process that runs as the same operating system user can usually read the host's credential files, so there identity gives attribution and consent, not a security boundary. It becomes a boundary when messages cross a network, where a binding authenticates each peer (§9.1).

Informative. Not tested.

14 Versioning, change process and security process

This section governs the document itself: how it is versioned, how it changes, and how a flaw in it is reported and fixed. DMP has one editor, Wyat Soule, and an open process: anyone may propose a change. Contributions to the repository are made under its contributor license agreement.

14.1 Document versions

DMP versions follow Semantic Versioning. The first drafts are 1.0.0-draft.N; after 1.0.0, each minor version is preceded by drafts 1.<minor>.0-draft.N. A released version, draft or not, is frozen at https://dispatch.foo/protocol/<version>/ and never changes there (§0.2).

14.2 Change process

  1. A change starts as an issue in the repository, from its "Protocol change" form: a summary, the motivation, the sections affected, the type (editorial, clarification, additive or breaking), the vectors to add, change or retire, the impact on implementations, and security notes.
  2. One pull request changes the text, the vectors, the reference implementation and the registries (regenerating §11) together, and adds one entry per change to the "Unreleased" section of the changelog, tagged with its type and naming its vector ids. No normative change lands without a vector. The editor approves.
  3. From 1.0.0, a clarification, additive or breaking change gets a Last call: <date> comment on its issue and merges no sooner than 14 days later. Editorial changes and errata merge at once. Drafts change without notice.
  4. The next release ships the change: a release freezes the text, the registries and the schemas under the version's URL.

14.3 Security process

A security flaw in the protocol, its conformance kit or its extension URIs never starts as a public issue or pull request. It is reported privately, as the repository's security policy says (.github/SECURITY.md), and fixed in a private fork attached to a GitHub security advisory. The editor tells every implementation listed in the kit's README about the fix 7 days before the release, under embargo, then merges the fix and releases it with the advisory. The 14-day last call of §14.2 does not apply.

14.4 Extension versions

Each A2A extension URI (§8) is versioned on its own: a breaking change to an extension gets a new URI ending /v2. Adding a new extension version is a minor change to DMP; dropping a version is a major one. The extension URIs under https://dispatch.foo/a2a/ext/ stay canonical, and any other URI for them is an alias.

14.5 Package versions

The version of the conformance kit's package, @dispatch-foo/protocol-spec, is the version of this document; a release that fixes only the runner is a patch release, noted "kit only". Implementations version their code on their own. The reference implementation, @dispatch-foo/protocol, reports the DMP version it implements as PROTOCOL_VERSION; it takes the version 1.0.0 once, together with DMP 1.0.0, and moves independently afterwards. A host's storage format version is not part of this document.

Appendix A Dispatch daemon HTTP and WebSocket API

This appendix is informative. It describes how Dispatch's daemon, dispatchd, offers DMP to the desktop app, the command line, the agent tools of Appendix B and a teammate's browser. DMP defines no host API (§1.2), so another host may offer the same rules through a different one. Every route below is under /api on the daemon's port, takes and returns JSON, and answers a refusal as { "error": <text>, "field"?: <path> } with the HTTP status of its code (§10.1).

A.1 Authentication and tiers

Every request except GET /api/health, signing in (POST /api/session, with the token in its body) and signing out (DELETE /api/session, which only clears the cookie) carries a credential: a bearer token (Authorization: Bearer <token>), or the session cookie of a teammate's page that the daemon served.

Credential Held by Resolves to
app token the person who runs the daemon: the desktop app, and the command line given DISPATCH_APP_TOKEN the owner's human: address, at the operator tier
team token a teammate, issued at a tier by dispatch team invite the teammate's human: address, at that tier
run token a session of a Dispatch run, read from the file DISPATCH_RUN_TOKEN_FILE names, while the run is live the run's run: address
agent token one external agent install, returned once by POST /api/agents/register, valid once the agent is approved the agent's agent: address
shared agent token any local process that reads the daemon file, ~/.dispatch/daemons/<key>.json the request tier, and no sender: it may register an agent, but not send or read messages

Most of the daemon's routes need a tier on a ladder of three, each holding everything below it: request drives the project (a new teammate and the shared agent token sit here), decide adds adjudication (answering gates, approving agents, handing out credentials), and operator adds acting on the host machine as the person who runs it (the app token sits here).

The messaging routes whose caller is "principal" below skip the ladder. They resolve the credential to a sender (§9.1) and apply DMP's rules to it. A human credential at the decide tier or above is a deciding principal (§9.2); a run or an agent never decides. These routes refuse the shared agent token, a pending agent's token and a teammate's token beyond the licensed seats with 403, and an unknown token, an expired team token, an ended run's token and a revoked agent's token with 401. An A2A client's token works only on the A2A listener (§8).

A.2 Routes

"Principal" means any sender that A.1 resolves, which the route and the engine then check; "deciding human" means a human credential at the decide tier or above. The engine calls are those of the reference implementation, @dispatch-foo/protocol.

Method Path Caller Engine call Notes
POST /api/messages principal send Sends as the caller (§4); 201. An Idempotency-Key header is the send's idempotency key, so a repeat returns the first send with 200 (§4.9).
GET /api/messages/:id principal getMessage, canRead One message. A caller that may not read it gets the absent id's 404, no message <id> (§13.5).
POST /api/messages/:id/reply principal reply An answer when :id is a question or handoff, a plain message otherwise (§4.7); 201. The body is body, and optionally choice, refs, data and session. A non-participant gets not-found on replyTo, as for an absent id.
GET /api/messages/:id/answer principal canRead, answerOf { "answer": <message or null> } for a question or handoff, 400 for another kind, and the absent id's 404 where GET /api/messages/:id gives it. ?wait=1 waits up to 30 seconds for the answer, for the asker, a session acting for it, or a deciding human.
GET /api/threads deciding human none: the store's recent threads The threads with the latest messages, newest first: ?limit= (50 by default, at most 200), and ?about=task:<id> for the threads of one work item and its sessions.
GET /api/threads/:id principal canReadThread, thread A thread's messages and their deliveries. A caller that may read none of its messages, and every caller of a thread with no messages, gets no message <id> with 404, so a thread id answers as a message id does (§9.3).
GET /api/mailbox principal inbox The caller's mailbox (§7.1) in delivery order; a session's also holds its work item's and the deliveries bound to the session. ?address= reads a mailbox the caller acts for (403 otherwise), and ?state= filters by delivery state (§6.1).
POST /api/deliveries/:id/read principal markRead Marks one delivery read, for its recipient, a caller acting for the recipient, or a deciding human.
GET /api/channels principal none: the store's channels Every channel and its members, each epic's implicit epic/<id> channel included (Appendix C).
POST /api/channels/:name/members principal join Adds member, by default the caller, to a channel (§7.3); a session joins as its work item; 204. Adding another address needs a caller that acts for it.
DELETE /api/channels/:name/members principal leave Removes the caller, a session as its work item; 204, or 404 when it is not a member.
DELETE /api/channels/:name/members/:addr principal leave Removes :addr for a caller that acts for it; 204, or 404 when it is not a member.
GET /api/agents/roster request tier none: the store's agents Every registered agent with its status and whether it is muted, never its token.
POST /api/agents/register request tier send Registers agent:<handle>/<name> as pending, where <handle> is the requesting human's (the owner's for the shared agent token), and the system raises the agent-registration gate to the owner (Appendix C). The body is name and client; 201 returns the agent's token, once.
POST /api/agents/:addr/approve decide tier reply Approves the agent, as the answer to its open registration gate when there is one.
POST /api/agents/:addr/revoke decide tier reply Revokes the agent the same way. Its token stops working, and a revoked A2A client's open asks close.
POST /api/agents/:addr/mute decide tier none: the agent's record Mutes the agent (§6.7).
POST /api/agents/:addr/unmute decide tier none: the agent's record Ends the mute.
GET /api/decisions/open deciding human openBlocking The open blocking questions addressed to a human, gates included.

The read routes (GET /api/messages/:id, its answer route and GET /api/threads/:id) apply the read rule through the engine's canRead and canReadThread, and answer a caller that may not read exactly as they answer an id that does not exist (§13.5). The kit tests the rule, not these routes (§12.5).

The rest of the daemon's API (work items, runs, reviews, and the A2A bridge's administration under /api/a2a/) is outside DMP and this appendix.

A.3 Events

A client with a request-tier credential opens a WebSocket at /ws. A browser cannot set headers on one, so the token may also be the token query parameter. The daemon sends each event as one JSON object with a type. Two events carry messaging:

Event Members Sent when
message.new message, the stored message a message is stored
delivery.changed deliveryId, messageId a delivery changes state (§6.1); a client re-reads the thread to see how

The daemon sends both only to sockets whose credential may read the message (§9.3): a human credential at the decide tier or above, or one whose address sent the message, is in its to, or holds one of its deliveries. Sockets opened with the shared agent token receive neither.

Appendix B Agent tools

This appendix is informative. It lists the tools through which agents reach DMP in Dispatch: the same tools serve a session of a Dispatch run and an external agent install, over the Model Context Protocol (§1.3). MCP is a transport between an agent and its host, not a DMP binding, so another host may offer different tools. Each tool calls one of Dispatch's daemon routes (Appendix A) as the agent's principal.

B.1 Tools

The parameter names below are exactly those the tools register.

Tool Parameters What it does
msg_send to, kind, body; optional refs, data, urgent, blocking, choices, wake Sends a message (§4) and returns the stored message, its deliveries and whether the urgent quota downgraded it. A dropped connection is retried once with the same idempotency key (§4.9). With blocking true it also waits for the answer (§B.2).
msg_reply messageId, body; optional choice Replies to the message with that id: an answer when it is a question or handoff (§4.7), a plain message otherwise. The host decides which, and rewrites the recipients as §4.6 says.
inbox_read optional state, limit, markRead Lists the caller's mailbox (§7.1), newest first, at most limit items (50 by default). Without state it lists only held, notified and pushed deliveries, so read mail never crowds the limit. It marks those it returns read unless markRead is false, and returns the ids it marked.
thread_read threadId Returns the messages of one thread, with their deliveries.
channel_join name; optional member Joins a channel by its bare name, without the channel: scheme. member defaults to the caller; a session joins as its work item (§7.3).
channel_leave name; optional member Leaves a channel, with the same default member as channel_join.
channel_list none Lists every channel and its members, the implicit epic/<id> channels included (Appendix C).

Every tool returns its result as JSON, and escapes each line break of §1.4 inside a string value, NEL, U+2028 and U+2029 included (§6.8).

An external agent's tool process makes one session id when it starts and sends it as session on every msg_send and msg_reply (§4.1). A session of a Dispatch run is one session and sends none. A session asks to write outside its declared paths with msg_send, as a scope gate to its human (Appendix C).

B.2 Blocking waits

A msg_send with blocking true waits for the answer by asking Dispatch's answer route in requests of at most 45 seconds, for at most:

Recipients Longest wait
any human: address among them 30 minutes
no human: address the project's agentBlockingTimeoutSec: 600 seconds by default, and at most 1800 seconds

These bounds keep a wait inside the tool timeout of the MCP client that runs a session. When the wait ends with no answer, the tool returns the message with answer: null and a note that the answer will arrive in the mailbox. The question stays open, and a late answer still reaches the sender: pushed to a live session, or held in its mailbox. An answer the tool does receive arrives that usual way too, and the tool's result says where, so an agent does not act on it twice. Dispatch denies a scope gate that no one decided 29 minutes after it was sent, so a session hears the denial before its 30-minute wait ends (Appendix C).

The waits are the tool's own, outside the rules a host follows, and the kit does not test them (§12.5).

Appendix C Dispatch profile

This appendix is informative for Core. It is also the rule set a Dispatch-profile claim is measured against (§12.1, §12.3): the dispatch vectors check what it says, and a host that claims the profile does all of it. Dispatch's host is its daemon, one per project on each machine.

The profile computes one kind of implicit member (§3.6): the members of channel:epic/<id> are the work items whose parent is that epic. They are added when a message is sent, never stored, and cannot leave the channel. No other channel has implicit members.

C.1 Identifiers

Identifier Dispatch form Example
work item id [te]-[0-9a-f]{6,12}: t- for a task, e- for an epic t-4a8cce, e-c25f9c
session id r-[0-9a-f]{6,12}: a session is one Dispatch run r-9f2c01
message id m- and a lowercase ULID m- and 26 characters
delivery id d- and a lowercase ULID d- and 26 characters
agent agent:<operator>/<name>: the handle of the human it registered under, then its own name agent:wyat/claude-code.macbook

The profile narrows the grammar of work item and session ids, and refuses an id outside these forms as invalid on the field that carried it (§3.1). Vectors: env.address.dispatch-grammar-admits-epic-ids, env.address.refuses-a-task-id-outside-the-dispatch-grammar, env.address.refuses-a-run-id-outside-the-dispatch-grammar.

A ULID is 48 bits of milliseconds and 80 random bits, written as 26 characters of Crockford base 32. Dispatch writes them in lowercase and keeps each host's ids strictly increasing, even for sends in the same millisecond or after the clock steps back, so its ids sort in creation order (§1.4) and carry the entropy §13.7 asks for. In the dispatch profile the kit checks this form on every id a host generates (§12.4.5).

C.2 System address and owner

The system address is agent:dispatch (§2.3). Only the daemon's own code sends as it, and it never receives a delivery.

The owner Dispatch names for every target is the project's owner: the human the daemon runs for. The owner is asked whether to wake a target (§C.8), told when the breaker stops agents in a thread (§C.7) and told when an answer to a gate is set aside (§5.7). The system also raises the tool-approval, agent-registration and overseer-action gates to the owner (§C.3). Which humans may decide follows from the daemon's credential tiers (Appendix A).

C.3 Gate types

Dispatch implements wake (§5.9) and the four permanent types below, and a host that claims the profile declares all five (§12.1). Each effect is idempotent and applied as §5.5 says.

Type Raised by Choices Data besides type
tool-approval system approve, approve-session, deny requestId, runId or conversation, tool, input, truncated?, floor
scope session grant, deny paths, reason
agent-registration system approve, deny agent, client, requestedBy?
overseer-action system confirm, cancel conversation, actionId, summary

tool-approval. The system raises it to the owner as a blocking question when a session or the overseer parks a tool call for approval, with refs to the run and its work item. requestId names the parked call, runId the session (or conversation the overseer conversation) it is parked in, and tool the tool. input is the call's input when its JSON fits in 8 KiB, and otherwise that text cut on a code point to 8 KiB, with truncated: true; the host keeps the full input (§13.11). floor says whether Dispatch's irreversible-action floor holds the call, judged on its full input. approve releases the call once, approve-session releases it and allows the same tool for the rest of the session, and deny refuses it, with the answer's body as the reason when it is not blank. When the project's autonomy policy allows the call at the task's rung, the system answers for the owner with an x-policy marker (§C.4), never for a call the floor holds. Vectors: env.envelope.agents-may-not-forge-tool-approval-gates, core.answers.a-gate-effect-runs-before-the-answer-is-published.

scope. A session asks its human to let it write outside its declared paths. Only a session raises it, and its shape is fixed (§5.3): kind question, blocking true, choices exactly grant then deny, and data { "type": "scope", "paths", "reason" }, with a non-empty list of non-empty paths and a reason that is not blank. Any other shape fails invalid on data, data.paths or data.reason. grant widens the session's writes by the paths; deny leaves them as they were. The system grants a request the autonomy policy allows, with an x-policy marker, and denies one that no one decided within 29 minutes, with an x-expired marker (§C.4). Vectors: env.envelope.only-sessions-raise-scope-gates, env.envelope.scope-gates-have-a-fixed-shape, env.envelope.a-scope-gate-names-a-path, env.envelope.a-scope-gate-names-no-empty-path, env.envelope.a-scope-gate-gives-a-reason.

agent-registration. The system raises it to the owner when an agent install registers: agent is the address it asks for, client the program it runs in, and requestedBy the human who asked, under whose handle it registers. approve approves the agent; deny revokes it. Vector: core.answers.a-human-decides-an-agent-registration-gate.

overseer-action. The system raises it to the owner when Dispatch's overseer, the project's own assistant, wants to take an action it queued: conversation names the overseer conversation, actionId the action, and summary says what the action does. confirm runs the action; cancel drops it. Vector: core.answers.a-human-decides-an-overseer-action-gate.

Provisional types. Three more types are registered as provisional (§11.6), and join the profile when the Dispatch feature that raises each one ships with vectors:

Type Raised by Choices Data besides type Effect
memory system-or-decider approve, reject proposalId, action (add, supersede or retire), scope (project or team), kind apply or reject a proposed memory entry
task-proposal system approve, decline task, proposedBy, message promote or drop a drafted work item (§8.6)
doc system approve, reject doc, proposal, taskId?, runId? apply or reject a proposed document change

C.4 Markers

Besides the core system markers x-closed and x-breaker (§11.7), Dispatch's system address sends two markers of its own. They are listed here for information, are not registry entries, and mean something only from the system address.

Marker Data Meaning
x-policy { "type": "x-policy", "gate", "rung" }, gate being approval or scope on the system's answer to a tool-approval or scope gate: the project's autonomy policy decided it at that rung
x-expired { "type": "x-expired" } on the system's deny answer to a scope gate that no one decided within 29 minutes

Both are answers from the system address, so the gate's effect applies as for any deciding answer (§5.5). A close carries { "type": "x-closed", "reason" } (§4.8), and the breaker's notice { "type": "x-breaker", "thread" } (§6.6).

C.5 Retention

Dispatch keeps messages and deliveries for the life of a project's local run state: one database in the machine-local run-state directory of the project, never committed to its repository. A voided answer stays stored as a message (§5.7). Dispatch offers no deletion of messages or deliveries in 1.0. This is the documentation §13.8 requires.

C.6 Presentation

Pushes. Dispatch pushes a message into a session as this text:

[message from <from> · <kind> · <id>]
│ <first line of the body>
│ <each later line of the body>
(in reply to <replyTo>)
choices: <choice> | <choice>
choice: <choice>
refs: <type>:<id>@<at>, <type>:<id>
The sender is waiting. Answer with msg_reply(messageId: "<id>").

Digests. Dispatch notifies a session with one line, which names the channel when the message was sent to one: the first channel among its recipients, without the channel: scheme.

📬 <kind> from <from>: <first line> (<id>)
📬 #<channel> · <kind> from <from>: <first line> (<id>)
📬 <kind> from <from> (external): <first line> (<id>)

The first line ends at the first line break of §1.4. One longer than 80 code points is cut to its first 79 and … (U+2026).

Declared forms. Dispatch's adapter declares these forms (§12.4.7), as the render member of its hello:

{
  "quotePrefix": "│ ",
  "header": "^\\[message from ",
  "hostLines": [
    "^\\(in reply to ",
    "^choices: ",
    "^choice: ",
    "^refs: ",
    "^The sender is waiting\\. "
  ],
  "digestLead": "^📬(?: #[^ ]+ ·)? [^ ]+ from [^ ]+(?: \\(external\\))?: "
}

Vectors: core.render.dispatch-push-format, core.render.dispatch-external-push-format, core.render.dispatch-digest-format, core.render.dispatch-digest-cuts-a-long-first-line.

Notices. The wording of a notice is not normative in Core (§12.3). Dispatch's are:

Notice Body
a close, on its answer Closed: <reason>
the breaker, to the owner Agents have sent <n> messages in thread <thread> this hour; further agent replies are paused.
a voided answer, to the owner An answer from <from> to gate <id> was set aside; only deciding humans answer gates. The gate is open again.
the wake notices §C.8

C.7 Guardrail defaults

Setting Default Meaning
urgentPerHour 10 the urgent quota of each agent or session (§6.6)
agentTurnsPerThreadPerHour 20 the breaker's limit of agent-authored messages in one thread (§6.6)
agentBlockingTimeoutSec 600 the longest blocking wait on an agent, at most 1800 (§B.2)

A project overrides them in its configuration as positive integers, read once when the daemon starts; a malformed file falls back to the defaults. A blocking wait on a human lasts at most 30 minutes (§B.2). The breaker's owner is the project's owner (§C.2), who gets one notice per thread and hour.

C.8 Wake policy

Dispatch rules on each wake (§6.5) as follows:

On allow, Dispatch starts a run. A human's wake of a work item continues its latest run when that run ended unreviewed with a session and is not a failed run waiting to be resumed; otherwise, and always for another sender, it starts a run that resumes the work item's latest session. A human's wake of a session continues exactly that run, when it ended unreviewed with a session and a worktree and nothing has continued it yet. A wake never pushes into a finished run.

On ask, the system raises a wake gate to the owner whose body names the sender and the target and quotes the held message's first line, cut as a digest's is (§C.6):

<from> wants to wake <target>:

> <first line>

Approving the gate does nothing while the work item has a live run that can take its mail. Otherwise Dispatch checks the work item again and wakes it, or tells the sender why not. Either way the gate's effect is recorded as applied.

The notices a wake sends its sender are:

Case Body
the wake failed Could not wake <target>: <reason>. Your message is waiting for it.
the policy denied it Waking <target> was not allowed. Your message is waiting for it.
an approved gate found it cannot be woken Not woken: task <id> is <state>.
an approved gate could not read it Not woken: task <id> could not be read: <error>.

<state> is missing, an epic, landed or dropped.

Vectors: core.wake.a-failed-wake-notice-names-the-reason, core.wake.deny-notice-says-waking-was-not-allowed, core.wake.the-wake-gate-quotes-the-first-line, core.wake.the-wake-gate-quote-ends-at-any-line-break, core.wake.a-first-line-at-the-body-cap-still-raises-the-gate, core.wake.an-ended-session-notice-names-the-reason.

Appendix D JSON Schemas

This appendix is informative. The kit ships JSON Schemas (draft 2020-12) for the wire shapes of this document, under schemas/ in the kit's package and on the web at https://dispatch.foo/protocol/<version>/schemas/<name>.schema.json, where <version> is this document's version. Each schema's $id is that address.

The schemas are informative (§1.4). JSON Schema cannot count UTF-8 bytes or express the one-line rule, so the byte limits, the one-line fields, blank bodies and every rule that depends on another field's value are left to the text. The text and the vectors govern. The kit's own tests keep the schemas from contradicting them: send-input.schema.json accepts every input an envelope vector accepts, and refuses every input a vector refuses for a reason a schema can express (the vectors tagged structural, §12.4.1); vector.schema.json describes every vector file, and adapter.schema.json every run line the runner sends.

D.1 Schemas

Schema Describes
message.schema.json a stored message: the sixteen fields of §4.1, and origin and hlc of Appendix F
send-input.schema.json a send input (§4.1), with the structural rules of §4.5 only
ref.schema.json a ref (§4.3), whose type may be any identifier, as a received ref's may (§4.4)
delivery.schema.json a delivery (§6), as the kit observes it
gate-data.schema.json the gate data of each permanent gate type (§11.6): wake, and the Dispatch profile's types (Appendix C)
vector.schema.json a vector file (§12.4.1); its runnable definition is a vector without then, as the runner sends it
adapter.schema.json the lines between the runner and an adapter (§D.2)

message.schema.json also defines the shapes the others share: an identifier, an address and a timestamp (§1.4). A schema names another by a relative reference, so the schemas of one version refer only to each other.

What the vector schema leaves to the runner: a file's class is its directory, each vector's class is its file's, ids are unique across the kit and never reused, then.steps lists no more results than when has steps, each $sN names an earlier step that created a message, and a render row names a render step (§12.4).

D.2 Adapter messages

adapter.schema.json describes each line of the adapter protocol of §12.4.7 as one definition:

Definition Direction Line
runnerHello runner to adapter { "dmp": "hello", "kit" }: the first line
hello adapter to runner the implementation, classes, profiles, capabilities, system address, gate types and render forms it declares
run runner to adapter { "dmp": "run", "vector" }, the vector without then
observation adapter to runner the steps' outcomes, messages, deliveries, hook calls, applied gates, voided answers, channels and render texts
unsupported adapter to runner { "dmp": "unsupported", "id", "reason" }: the vector is skipped
bye runner to adapter { "dmp": "bye" }: the adapter exits 0

The runner checks more than the schema says: each render form is a pattern that compiles, and an observation or unsupported names the vector it answers.

Appendix E Examples

This appendix is informative. Each example is the first input of a vector, in a block that names the vector, followed by what the vector expects. The kit's tests keep each block equal to its vector's input, so an example cannot drift from the kit. The examples use the Dispatch profile's id forms and the symbols of §12.4.4: $sN is the message step N created, and $system the system address.

E.1 Examples

An address. env.address.parses-each-scheme

"human:wyat"

The first step parses human:wyat into { "kind": "human", "handle": "wyat", "address": "human:wyat" } (§3.1). The later steps parse one address of each core scheme; agent:wyat/claude-code.macbook, for one, gives the operator wyat and the handle claude-code.macbook.

A valid send input. env.envelope.accepts-a-plain-message

{
  "to": ["task:t-4a8cce"],
  "kind": "message",
  "body": "hi"
}

Validated as the session run:r-9f2c01, which cannot decide, the input is valid: recipients, a kind and a body are all a message needs (§4.5).

A refused kind. env.envelope.unknown-kind-refused-x-kind-accepted

{
  "to": ["task:t-4a8cce"],
  "kind": "shout",
  "body": "hi"
}

shout is neither a built-in kind nor a private one, so validation fails invalid on kind. The vector's second step sends the same input with the private kind x-review-ping, which is valid (§4.2). The vector is tagged structural, so send-input.schema.json refuses the first input too (Appendix D).

Mail held for a work item. core.send.task-held-without-live-session

{
  "to": ["task:t-4a8cce"],
  "kind": "message",
  "body": "hi"
}

The work item t-4a8cce has no live session, and human:wyat sends. The send succeeds and is not downgraded. The message starts its own thread, and its one delivery, to task:t-4a8cce, is direct, held and bound to no session (§6.1). The only hook call is published.

Mail pushed to a live session. core.send.direct-mail-pushes-to-the-live-session

{
  "to": ["task:t-4a8cce"],
  "kind": "message",
  "body": "hi"
}

This time t-4a8cce has the live session run:r-9f2c01. The delivery is direct and pushed, bound to run:r-9f2c01, and the host calls published and then push for that session (§6.2).

Held mail at session start. core.session-start.claims-held-mail-for-the-work-item

{
  "to": ["task:t-000002"],
  "kind": "message",
  "body": "direct"
}

The session run:r-000001 sends this message to the work item t-000002, which has no live session, and then a notice to channel:auth, whose member t-000002 is. Both wait held. When the session run:r-000002 of t-000002 starts and the host delivers its held mail (§6.4), the direct message is pushed and the channel notice notified, both bound to run:r-000002.

A question and its answer. core.answers.a-reply-answers-and-closes-the-question

{
  "to": ["human:wyat"],
  "kind": "question",
  "body": "which?",
  "blocking": true,
  "choices": ["a", "b"]
}

The session run:r-000001 asks human:wyat this blocking question, and the human replies with the choice a and a blank body. The reply is an answer in the question's thread, addressed to the asker and pushed to its session. The question's delivery to the human moves to answered, no blocking question stays open, and no gate effect runs (§4.7).

A gate and its effect. core.answers.a-gate-effect-runs-before-the-answer-is-published

{
  "to": ["human:wyat"],
  "kind": "question",
  "body": "Run Bash?",
  "blocking": true,
  "choices": ["approve", "approve-session", "deny"],
  "data": {
    "type": "tool-approval",
    "requestId": "req-1",
    "runId": "r-000001",
    "tool": "Bash",
    "input": {},
    "floor": false
  }
}

A dispatch vector. The system raises this tool-approval gate to human:wyat, who approves it. The hook calls are published and notifyHuman for the gate, then onAnswered for the gate and its answer, and only then published for the answer; the gate's effect is recorded as applied (§5.5).

A close. core.close.answers-without-effect-or-deliveries

{
  "to": ["human:wyat"],
  "kind": "question",
  "blocking": true,
  "choices": ["approve", "deny"],
  "body": "wake t-000002?",
  "data": {
    "type": "wake",
    "target": "task:t-000002",
    "message": "m-elsewhere"
  }
}

The system raises this wake gate, and the host then closes it with the reason the run ended. The close is an answer from the system with data { "type": "x-closed", "reason": "the run ended" }. It has no delivery and applies no effect, and the gate's delivery moves to answered. A second close fails conflict on replyTo (§4.8).

A wake the policy asks about. core.wake.ask-raises-a-wake-gate-to-the-owner

{
  "to": ["task:t-000003"],
  "kind": "message",
  "body": "please look\nmore",
  "wake": "request"
}

The session run:r-000001 sends this message, with a wake request, to the work item t-000003, which has no live session, and the wake policy rules ask for task:t-000003. The message waits held, and the system raises a blocking question to the owner human:wyat, with the choices approve and deny, data { "type": "wake", "target": "task:t-000003", "message": "$s1" } and a ref to the held message (§6.5). It is the only open blocking question.

The urgent quota. core.guardrails.urgent-over-the-quota-is-downgraded

{
  "to": ["task:t-000002"],
  "kind": "notice",
  "body": "x",
  "urgent": true
}

With an urgent quota of 2 an hour, the session run:r-000001 sends this urgent notice three times. The first two stay urgent; the third is stored without urgent, and its send result says downgraded: true (§6.6).

A mailbox. core.mailbox.inbox-lists-and-mark-read-reads

{
  "to": ["agent:wyat/claude-code.macbook"],
  "kind": "message",
  "body": "hello"
}

human:wyat sends this message to the agent agent:wyat/claude-code.macbook, whose delivery waits held in its mailbox. The agent's inbox lists it; marking it read moves the delivery to read, after which the inbox of held mail is empty and the inbox of read mail lists it (§7.1).

Implicit members. core.channels.implicit-members-are-notified

{
  "to": ["channel:eng/web"],
  "kind": "notice",
  "body": "api changed"
}

A MAY vector with the capability implicit-members. The channel eng/web has the implicit members t-000001 and t-000002, both with live sessions, and the session run:r-000001 of t-000001 sends this notice to it. Only t-000002 gets a delivery: through the channel, notified, in run:r-000002 (§3.6).

Recovery replays an effect. core.recover.replays-an-unapplied-gate-effect

{
  "to": ["human:wyat"],
  "kind": "question",
  "blocking": true,
  "choices": ["approve", "deny"],
  "body": "wake task:t-000002?",
  "data": {
    "type": "wake",
    "target": "task:t-000002",
    "message": "m-elsewhere"
  }
}

Every gate effect fails at first. The system raises this wake gate and human:wyat approves it: the answer is committed and published, and its effect is left unrecorded. Once effects work again, recovery applies the effect again and records it, reporting one replayed gate; a second recovery replays nothing (§6.3).

A push in the Dispatch profile. core.render.dispatch-push-format

{
  "to": ["task:t-4a8cce"],
  "kind": "question",
  "body": "Did you change the API shape?\nsecond line",
  "refs": [
    {
      "type": "file",
      "id": "src/api.ts",
      "at": "abc123"
    }
  ],
  "urgent": true,
  "blocking": true,
  "choices": ["yes", "no"]
}

A dispatch vector. human:wyat sends this urgent, blocking question to t-4a8cce, and the host renders its push as exactly this text (Appendix C):

[message from human:wyat · question · urgent · $s1]
│ Did you change the API shape?
│ second line
choices: yes | no
refs: file:src/api.ts@abc123
The sender is waiting. Answer with msg_reply(messageId: "$s1").

Appendix F Federation wire and receive

This appendix is informative, and tracks Dispatch's federation design. It describes how the hosts of teammates exchange messages: each host is a replica, one project on one machine, and the replicas of a team publish signed operations to a shared transport, such as a git branch or a relay, and apply each other's. It becomes normative in a later 1.x minor version, together with the federation vector class, once two transports implement it (§1.3, §12.1).

Until then its registry entries (the envelope fields origin and hlc, and the delivery states forwarded and refused) have the status appendix (§11.11): a host that does not implement this appendix never emits them, and ignores them on input. The character @ stays reserved in addresses for a later authority qualifier (§3.3).

Every binary value below is base64url without padding, and every hash is SHA-256, written as lowercase hex.

F.1 FederatedOp

Every change a replica publishes is one op: a small header that its publisher signs, and the content the header commits to by hash. An op whose content was pruned keeps a stub that still verifies.

type OpType =
  | 'key'
  | 'roster'
  | 'task'
  | 'presence'
  | 'agent'
  | 'channel'
  | 'mail'
  | 'state'
  | 'memory'
  | 'doc';

interface OpHeader {
  v: 2;
  replica: string; // the publisher
  seq: number; // strictly increasing along the publisher's chain; may skip values
  prev: string; // the opHash of the publisher's previous op; 64 zeros on its key op
  hlc: string; // `<ms>.<counter>.<replica>`, strictly increasing along the chain
  type: OpType;
  to?: string[]; // sealed types only: the recipient replicas, sorted, 1 to 256
  bodyHash: string; // the hash of JCS({ body?, sealed? })
}

interface FederatedOp extends OpHeader {
  body?: JsonValue; // the plain types, and the clear part of a forward
  sealed?: Sealed; // mail and state (F.3)
  sig: string; // Ed25519 over "dispatch-op-v2\n" + JCS(the header)
}

// What replaces an op a transport pruned: the header and its signature.
interface OpStub extends OpHeader {
  sig: string;
  pruned: true;
}
Type Content Who may publish Applied by
key the replica's handle, device, build and public keys the replica itself, once, as its first op every replica
roster admissions, roles, revocations, invites and the license by role every replica
task a change to a work item any admitted replica every replica
presence the replica, or one of its sessions, is live the replica, for itself and its own sessions every replica
agent an agent install registered on the replica the replica it registered on every replica
channel one membership change any admitted replica every replica
mail a sealed message (§F.4) any admitted replica, for the senders it speaks for (§F.5) the replicas in to
state sealed delivery states, settlements and refusals a home of each recipient it reports; a question's origin for a settlement; a replica that refused the replicas in to
memory a team memory entry (§F.7) any admitted replica every replica
doc a team document revision (§F.7) any admitted replica every replica

Only mail and state are sealed. The other types are the team's shared state and travel in the clear, so a transport can read the board, team memory and documents, presence and the roster, but not the mail.

F.2 Canonical JSON and domain tags

Canonical JSON is RFC 8785 (JCS): object keys sorted by UTF-16 code units, and strings and numbers written exactly as ECMAScript's JSON.stringify writes them. Non-finite numbers are refused. It is the serialization that measures the size of data (§1.4).

Domain tags. Every signature and seal starts its input with its own tag and a line feed, so that none can be replayed as another:

Tag Signs or seals
dispatch-op-v2 every op header
dispatch-sealed-v1 sealed payloads and their key wraps
dispatch-ack-v1 a transport's acknowledgements
dispatch-relay-v1 a relay's connection challenge
dispatch-invite-v1 an invited key's proof
dispatch-recovery-v1 an admin's recovery proof
dispatch-fp-v1 fingerprints

Fingerprints, which people compare out of band, are the first 15 bytes of the hash of "dispatch-fp-v1\n" + signPub + "\n" + sealPub, over the base64url public keys, in Crockford base 32, printed in six groups of four characters.

F.3 Sealing

interface Sealed {
  nonce: string; // 12 random bytes
  ct: string; // AES-256-GCM(K, nonce, JCS(payload), aad), the 16-byte tag appended
  keys: Record<string, { enc: string; ct: string }>; // K sealed to each replica in `to`
}

F.4 Mail and state payloads

interface MailPayload {
  message: Message; // as stored at its origin, `hlc` included, `origin` left out
  targets: MailTarget[]; // the origin's resolution of `to`
}

interface MailTarget {
  recipient: Address;
  via: 'direct' | 'channel';
  homes: string[]; // the replicas that deliver it
  wakeAt?: string; // for a work item of a wake request: the one replica that may wake it
}

// A forward: a `mail` op whose body is { forward: <the original op, verbatim> },
// sealed to one replica, with this payload:
interface ForwardPayload {
  target: Address; // one of the original's targets
  key: string; // the original op's content key K
}

interface StatePayload {
  entries: (
    | {
        t: 'delivery';
        message: string;
        recipient: Address;
        state: 'held' | 'pushed' | 'notified' | 'read' | 'answered';
        at: string;
      }
    | {
        t: 'settle';
        question: string;
        answer: string; // the accepted answer's id, or the close's
        closed?: string; // set when the origin closed the question
        at: string;
      }
    | {
        t: 'refused'; // this home refused a message (F.5)
        message: string;
        reason: string; // the error's code and message
        at: string;
      }
  )[];
}

Envelope fields. A message received from another replica is stored with two fields besides those of §4.1:

Field Meaning
origin the replica that created the message; absent on a message created here
hlc the origin's hybrid clock when it sent the message, <ms>.<counter>.<replica>; a replica sets it on every message it sends

Neither is part of a send input, and validation ignores them. A thread is ordered by hlc, then by id, and messages that have no hlc sort first, so a reply sorts after its question whatever the machines' clocks say; createdAt is for display.

Delivery states. A recipient whose homes are other replicas gets a remote row instead of a delivery (§6). Besides the states of §6.1, a remote row may be:

State Meaning
forwarded handed to the transport for the recipient's homes, with no report yet
refused every home of the recipient refused the message (§F.5)

A remote row's state is the highest its homes report, in the order held, then pushed or notified, then read, then answered; sending is never reported. refused holds only while every home of the recipient has refused, and any later report from a home replaces it. A home reports the states of its deliveries in state ops sealed to the message's origin and to every replica the message went to, and an entry counts only from a replica that homes the recipient.

F.5 Receive

Homes. Every address has homes: the replicas that store and deliver its mail, computed from the replicated roster, presence and agent ops, so that every replica computes the same ones.

Address Homes
human:<handle> every admitted replica of that handle, and every replica that hosts it
task:<id> the replica whose presence shows a live session of the work item; otherwise the homes of its assignee
agent:<operator>/<name> the replica it registered on
channel:<name> expanded to its members first, then each member's homes
run:<id> none: a reply to a remote session's message goes to that message's origin
the system address, A2A clients and peers, the overseer never federated

An address with no homes stays local.

What never leaves. A message is local-only when it or its stored replyTo target has gate data (§5.1), tested for gate data alone, whatever its type and whether the host knows it; when its data.type is a marker (x-closed, x-breaker, and the Dispatch profile's x-policy and x-expired); when a participant is the overseer, an A2A client or an A2A peer; or when its thread's root is local-only. A send that would carry a local-only message to another replica fails: forbidden on data when it has gate data, and otherwise forbidden on the to[i] of a direct recipient homed elsewhere. So gate data never leaves a host through federation (§5.8, §13.9), and no gate is decided across hosts.

Speaks for. A mail op's origin may carry a message whose from is a human: address of its own handle or one it hosts, a session bound to it by presence, an agent it registered, or the system address, for a notice with no data whose refs name a message it exchanged with this replica. A message from the system address of another replica is an ordinary agent-authored message here, never the system. Any other from is dropped.

Receiving. A replica applies a mail op addressed to it in this order, after it has verified the op, its chain and the speaks-for rule, and decrypted the payload:

  1. Duplicate. A message whose id is stored with the same content is a duplicate; one with other content is refused as an id collision.
  2. Local-only. Refuse local-only content, tested as above on the message and on its stored replyTo target.
  3. Authorize the sender. A remote agent must be approved in the replicated agent rows.
  4. Participation. When the replyTo target is stored here, the sender must participate in it (§4.6), counting remote rows too. When it is not stored here, the thread is partial on this replica: the sender must participate in the thread's root when that is stored here, and otherwise the message is accepted.
  5. Validate the message as received (§4.4), with canDecide false, so no remote sender decides, and a received ref of an unknown identifier type is kept. When the reply target is missing, the checks that need it are skipped.
  6. The breaker and the urgent quota (§6.6), counted by arrival time, so a sender cannot escape them by backdating createdAt.
  7. Answers settle at the question's origin (§F.6).
  8. Plan each target: one whose homes include this replica is planned as a local send's would be (§6.1); every other gets a remote row, forwarded. A muted sender's deliveries start read.
  9. Commit the message, with origin, hlc and its arrival time, its deliveries and its remote rows in one transaction, then push and notify as a send does (§6.3).
  10. Wake a held work item of a wake request only on the replica wakeAt names, under that replica's own wake policy (§6.5), which treats a remote human's wake as an agent's.

A refusal in steps 1 to 6 stores nothing. The refusing replica sends the origin a refused state entry; when every home of a recipient has refused, the origin sets that recipient's remote row to refused and sends the sender a notice.

Presentation. A pushed message from another replica names its sender with (remote: <handle>), and every line after its header is quoted, which meets the rule for an external sender (§6.8).

F.6 Settlement

A question takes one answer (§4.7) across replicas too. The settler of a question is its origin, the replica it was created on.

No gate effect runs on a settlement, since gates never cross hosts (§F.5).

F.7 Team memory and doc ops

Memory. A memory op carries one team memory entry:

interface MemoryBody {
  memory: string; // `mem-` and a ULID
  kind: 'put' | 'remove';
  fields?: Record<string, unknown>; // the entry's fields
  trust: 'human' | 'confirmed' | 'agent'; // the publisher's claim; receivers recompute it
}

Docs. A doc op carries one revision of a team document, as Dispatch's documents design defines it: the document's id, put or remove, the author (by), and an optional revision and metadata. A receiver checks the author against what the publisher speaks for, keeps a revision whose parents have not arrived until they do, and applies a revision in clock order. A revision fits one op.