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.2 follows 1.0.0-draft.1. Every registry entry that draft left
provisional is now permanent, with vectors: the gate types task-proposal,
memory and doc, the ref type doc, the work/v1 extension with the
task-state rows for handoffs, and the a2a address scheme for outbound peers.
Appendix B lists the memory tools, and Appendix F holds an op stamped too far
ahead of a replica's clock. 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
- One address grammar for people, agents, units of work, running sessions and channels (§3).
- Persistent messages. A message is stored once, each recipient gets a delivery with its own state, and nothing a host has accepted is lost across a restart (§4, §6).
- Mailboxes. Mail to a work item that has no running session waits for its next session, which receives it when it starts (§6.4, §7).
- Humans as peers. Humans send, receive and answer like agents, and only humans the host authorized, or the host itself, decide (§9).
- Decisions as data. Gates ask a deciding principal to approve an action before the host applies it; a gate of a type a host does not know fails closed (§5).
- Safe presentation. A message put in front of a model can never pass for text the host wrote (§6.8).
- Checkable conformance. Language-neutral test vectors and a runner check what an implementation claims (§12).
1.2 Non-goals
- Task storage. How a host stores, schedules and runs its work items, and what fields they have, is the host's business. DMP needs only the facts listed in §2.1.
- Memory. Dispatch's memory entries and their store are not wire format. Their gate type is registered (§11.6).
- A relay between hosts, and a normative federation wire before two independent transports implement one (Appendix F).
- A normative host API. Appendix A describes Dispatch's daemon API for information only.
- Authentication. How a host authenticates its principals is out of scope (§9.1).
- Roles. A later version may model a role as a channel whose membership the role manages.
1.3 Relation to A2A, MCP and federation
- A2A. A2A connects an agent to a service over request and response. DMP
hosts meet A2A clients and peers at the edge: §8 maps
DMP messages onto A2A messages and tasks, and defines three A2A extensions,
https://dispatch.foo/a2a/ext/envelope/v1,https://dispatch.foo/a2a/ext/gate/v1andhttps://dispatch.foo/a2a/ext/work/v1. Gate data never crosses a binding (§5.8). - MCP. Agents inside a host's sessions usually reach DMP through tools that the host offers over the Model Context Protocol. MCP is a transport between an agent and its host, not a DMP binding; Appendix B lists Dispatch's tools for information.
- Federation. Messages between the hosts of teammates are described in
Appendix F for information. It becomes
normative in a later minor version, once two transports implement it. Until
then its registry entries have the status
appendix, thefederationvector class is reserved, and the character@is reserved in addresses (§3.3).
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.
- JSON is as in RFC 8259; strings are Unicode. Every length in this document is a count of UTF-8 bytes, and 1 KiB is 1024 bytes.
- The size of
datais the UTF-8 length of its RFC 8785 (JCS) serialization. JCS serializes numbers and strings as ECMAScript does, and key order does not change the length, so this equals the length of ECMAScript'sJSON.stringifyof the same value. - Timestamps are RFC 3339 times in
UTC, in the fixed 24-character form
YYYY-MM-DDTHH:MM:SS.sssZ, because hosts compare them as strings. - Line breaks are CRLF, LF, CR, VT (U+000B), FF (U+000C), NEL (U+0085), LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029). A value is one line when it contains none of them. Text is split into lines at every one of them.
- Blank means empty after removing, from both ends, the characters of
ECMAScript's
String.prototype.trim: U+0009 to U+000D, U+0020, U+00A0, U+1680, U+2000 to U+200A, U+2028, U+2029, U+202F, U+205F, U+3000 and U+FEFF. The set is spelled out because other languages define whitespace differently; NEL is not in it. - Identifiers match
[a-z0-9][a-z0-9._-]*and are at most 64 bytes. - Message and delivery ids are identifiers, unique across hosts, that sort
bytewise in creation order within one host.
m-(messages) ord-(deliveries) followed by a lowercase ULID is RECOMMENDED, and is what the Dispatch profile uses. §13.7 sets their entropy. - Patterns are ECMAScript regular expressions. A pattern this document
writes matches a whole value. The render patterns a host declares (
header,hostLinesanddigestLead, §6.8) are searched instead, asnew RegExp(pattern).test(line)does: each matches anywhere in the line unless it anchors itself with^or$. - Names of fields are the JSON member names of §4.1,
written in
code. A path such asrefs[0].typenames a member of a list entry (§10.2). - Examples use the Dispatch profile's id forms (
t-4a8ccefor a work item,r-9f2c01for a session) and are informative. - Normative sources are this text and the test vectors (§12.4). Where they disagree, the text governs and the disagreement is an erratum, fixed in a patch release that corrects the vector (§14.1). The JSON Schemas (Appendix D) are informative, because JSON Schema cannot count UTF-8 bytes or express the one-line rule.
- Pinned rules. Sixteen rules of this document are stated exactly because a second implementation would otherwise have to guess them. Each is marked "(pinned rule N)", and the mark names the vectors that test it once they exist.
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:
- A reply's
tolist is first rewritten as §4.6 says, which can turn an ended session into its work item and make two entries equal. - Each entry that is not a channel is a target with the route
direct. - 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). - Targets are de-duplicated (§3.7).
- The sender, the work item of a sending session, and the system address are dropped: none of them gets a delivery.
- 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 |
doc |
a document | a section anchor |
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. doc is the Dispatch profile's
(§C.1); a host that does not implement it
refuses it as any unknown 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):
- 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, asforbiddenonfrom. - Replay an idempotency key (§4.9): a hit returns the first send's result and nothing below runs.
- Participation for a reply (§4.6): a
replyTothat names no message (an empty string included), or a message the sender does not participate in, failsnot-foundonreplyTo. - Validate the input: this section, and the gate rules of §5.
- The breaker for a reply from an agent or session
(§6.6):
limitedonreplyTo. - One answer: an answer to a question that already has one fails
conflictonreplyTo(§4.7). - The urgent quota (§6.6), which drops
urgentinstead 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:
- who may raise it (
raisedBy, §5.3); - its choices, which a gate of that type offers as its
choices; - the members of its data besides
type; - its effect (§5.5).
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:
- commit the answer, with the question's deliveries moved to
answered, in one transaction; - apply the effect, and wait for it to finish;
- record the effect as applied;
- 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.
- At send, a host refuses gate data on a kind other than
questionandhandoff, and adata.typethat is not a gate type it knows (§5.3), even from a sender whose message would not be a gate. - A stored gate of an unknown type, written by another build or version of
the host, stays a gate when its sender is the system address or a
human:address, since those could have raised it. Only a deciding principal may answer it (§5.4), and the choice rule of §4.7 applies to it. A question with gate data of an unknown type from an agent or a session could not have been raised as a gate, so it is not one (§5.1) and stays a plain question. - No effect. A host MUST NOT apply an effect for a type it does not know, and MUST NOT record such a gate as applied, so a host that does know the type applies it on its next recovery (§6.3), subject to §5.7.
- Locality (§5.8) does not depend on the type: it tests for gate data, known or not.
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 answer's kind becomes
message, and the host records it as voided, so the gate can take another answer; - each of the gate's deliveries leaves
answeredfor the state §6.1 would give it now (a humannotified, a live session or a work item with a live sessionsending, any other recipientheld), and is dispatched (§6.2).
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:
sendingtopushedornotifiedwhen the hook returns, or toheld, unbound, when it throws (§6.2) or when recovery finds its session gone (§6.3);heldtosending, bound, at session start (§6.4);- any state but
answeredtoreadwhen the recipient marks it read; - any state to
answeredwhen its question is answered or closed; answeredtonotified,heldorsendingwhen a voided answer reopens a gate (§5.7).
6.2 Mode selection
Once a message and its deliveries are committed (§6.3), the host dispatches each delivery:
- A
sendingdelivery is pushed into its session, and becomespushed, when its route isdirector the message isurgent. Otherwise the session is notified with a digest, and the delivery becomesnotified. A push reaches the session before its next turn; a digest is a single line. - When the push or notify fails, the delivery returns to
heldand is unbound, so it waits for the recipient's next session. - A
notifieddelivery to a human calls the host's notifier. A failing notifier never fails the send; the message is stored and readable.
(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:
- dispatches again every
sendingdelivery whose bound session is live (§6.2); - returns every other
sendingdelivery toheld, unbound; - 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:
- on
allow, asks to wake exactly that target (wake, with the target and the message; a session address for a session). When the wake fails, the message stays held, and the host SHOULD send the sender a notice saying why; - on
deny, leaves the message held, and SHOULD send the sender a notice; - on
ask, raises awakegate from the system address to the owner of the target (§5.9).
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 urgent quota. When a sender that is an agent or a session sends an
urgentmessage, and itsurgentmessages created in the past hour already number at least the quota, the host MUST dropurgentfrom the new message and report the downgrade in the send's result (downgraded: true). Humans and the system address have no quota. (pinned rule 13; vectors:core.guardrails.urgent-over-the-quota-is-downgraded,core.guardrails.the-urgent-quota-window-slides,core.guardrails.humans-have-no-urgent-quota) - The breaker. Agent-authored messages are those from sessions and agents,
never from the system address. When an agent-authored send replies into a
thread whose agent-authored messages created in the past hour number at least
the limit, the host MUST refuse it as
limitedonreplyTo. The host SHOULD then send the owner of the sender one notice per window, from the system address, replying to the target, withdata{ "type": "x-breaker", "thread": <thread id> }; it sends none while the thread already has anx-breakernotice from the system address created in the past hour. A human who replies in the thread is not limited. (pinned rule 12; vectors:core.guardrails.breaker-refuses-the-next-agent-turn,core.guardrails.breaker-tells-the-owner-once-per-window,core.guardrails.a-human-is-not-limited)
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:
header: a pattern that the first line of a pushed message matches;quotePrefix: the prefix that starts every line carrying body text;hostLines: patterns for the lines the host adds after the body, such as the message replied to, the choices, the choice, the refs and a prompt to answer;digestLead: a pattern for the host's own text that opens a digest, such as the sender and the kind, up to where the body's first line would start.
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:
- its first line matches
headerand contains no line of the body; - every later line that contains a line of the body starts with
quotePrefix; - at least as many later lines start with
quotePrefixas the body has lines; - every other later line matches one of
hostLinesand contains no line of the body and, for an external sender, none of its choices, its choice, theidoratof 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.
- Extensions. The card declares the three extensions of
§8.4 to §8.6 in
capabilities.extensions, each withrequired: false, so a client that knows none of them never meetsExtensionSupportRequiredErrorand reads the same facts as plain text instatus.message. - Security. The card declares one HTTP bearer scheme. Each client has its
own token, which the host honors only once a deciding principal has approved
the client's registration (§9.1). A
client sends as one
agent:address; the Dispatch profile names clientsagent:<requester>/a2a.<name>and reserves thea2a.prefix, so no other agent can register a name that passes for a client. - Skills.
askasks the host's owner (§2.3) a question; an answer can take hours, so a client sends withreturnImmediatelyand then streams, subscribes or polls.handoffproposes a unit of work withwork/v1(§8.6), which a deciding principal approves before anything runs.statusreports the state of the client's own handoffs. A host that does not take handoffs omits thehandoffskill and refuses a handoff request asinvalidonwork.skill. - Privacy. The card MUST NOT name the host's members, its owner's handle, counts of its work items or any internal URL. The ask skill's description states the host's bound on a blocking send (§8.10).
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:
from,idandthreadare ignored: the bearer decides who sends, and the host assigns the id and the thread.urgent: trueis refused asforbiddenonurgent, andwake: 'request'asforbiddenonwake: a client may not jump a mailbox or start a session.- An
x-kind is refused asforbiddenonkind, so a client cannot put an uninterpreted kind in front of a human; any other kind outside the list above isinvalidonkind. - A message without a
taskIdstarts a new exchange. A question opens a task; amessageor anoticeopens none, and the host answers it with aMessagerather than aTask. Whenwork/v1names askill(§8.6), the skill decides what the message starts: the envelope'skindstill passes the rules above, but does not decide it. Otherwise the message is never anansweror ahandoff: either kind isinvalidonkind. A client answers a question put to it by continuing the task that asks it, with that task'staskId(§8.2), and proposes work withwork/v1. Of a continuation's envelope onlychoiceandrefscount. - Each
toentry is one line of at most 512 bytes, at most 50 of them; each choice and the choice are one line of at most 200 bytes, at most 20 choices;blockingis a boolean. A violation isinvalidon the field or its slot. - An
a2a:entry is how a peer host names this host (§8.9), so it reads as the owner, once. - A client may address only the humans on its recipient list, which is the owner
unless a deciding principal named others when approving the client, and the
work items of its own approved handoffs. Anything else is
forbiddenonto[i], the same for a member of the host and a stranger, so the answer tells a client nothing about who exists. - Refs name only
taskandmessageids the client may already see: its own tasks and the messages in their scope (§8.7), and the work items of its approved handoffs. A ref that is not an object isinvalidonrefs[i]; one of another type isinvalidonrefs[i].type, one that carries anatonrefs[i].at, and one whoseidis not one line of at most 512 bytes onrefs[i].id. An id the client may not see isnot-foundonrefs[i], as an absent one is. - Without an envelope, the message is a blocking question to the owner.
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-a2a-recipient-reads-as-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:
task-proposal: "Waiting for the project owner to approve this handoff."tool-approval: "Waiting for the project owner to approve a tool call."scope: "Waiting for the project owner to approve a change of scope."wake: "Waiting for the project owner to approve waking the task."
Vectors: a2a.projection.row-8-an-open-gate-needs-authorization,
a2a.projection.row-8-gate-v1-lists-id-type-and-time,
a2a.projection.row-8-a-handoff-waits-on-its-proposal,
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.
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, not blank, 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. A request that is not an object is
refused on work, and a skill other than handoff or status on work.skill.
Vectors: a2a.work-ext.accepts-a-handoff-with-every-field,
a2a.work-ext.accepts-a-status-request,
a2a.work-ext.refuses-an-unknown-skill,
a2a.work-ext.refuses-a-handoff-without-a-title,
a2a.work-ext.refuses-a-title-over-200-bytes,
a2a.work-ext.refuses-a-multi-line-title,
a2a.work-ext.refuses-more-than-20-acceptance-criteria,
a2a.work-ext.refuses-a-criterion-over-500-bytes,
a2a.work-ext.refuses-a-multi-line-criterion,
a2a.work-ext.refuses-more-than-50-writes,
a2a.work-ext.refuses-a-write-outside-the-repository,
a2a.work-ext.refuses-a-write-over-512-bytes,
a2a.work-ext.refuses-an-unknown-priority,
a2a.work-ext.refuses-more-than-10-labels,
a2a.work-ext.refuses-a-request-that-is-not-an-object.
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. Vectors: core.gates.task-proposal-is-raised-only-by-the-system,
core.gates.task-proposal-needs-a-deciding-answer,
core.gates.task-proposal-is-answered-with-approve-or-decline.
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. Row 10 needs the proposal
approved: an unapproved draft with no open gate matches row 12.
Vectors: a2a.projection.row-1-a-canceled-task,
a2a.projection.row-2-an-ask-the-owner-declined,
a2a.projection.row-2-a-declined-handoff,
a2a.projection.row-2-a-handoff-dropped-by-the-owner,
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-4-a-deleted-handoff,
a2a.projection.row-5-an-answered-ask-completes,
a2a.projection.row-6-a-landed-handoff,
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-8-a-handoff-waits-on-its-proposal,
a2a.projection.row-9-a-working-handoff,
a2a.projection.row-9-a-handoff-in-review-has-a-stage,
a2a.projection.row-9-a-status-of-the-hosts-own,
a2a.projection.row-10-an-approved-draft-is-submitted,
a2a.projection.row-10-needs-approval,
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:
- related: the task's root; every message whose
replyTochain reaches the root; and, for an approved handoff, every message between the client and the handoff's work item (from the client totask:<id>, or from that work item or one of its sessions to the client); - visible: sent by the client, or carrying a delivery whose recipient is the client. This is the read rule of §9.3 for a principal that does not decide.
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:
- Data is wrapped. All external data is stored as
{ "https://dispatch.foo/a2a/ext/envelope/v1": <value> }, where the value is the single data part, or an array of the parts when there are several. It has no top-leveltype, so it can never be gate data,x-closedorx-breaker, whatever the sender writes. If the wrapped value exceeds 64 KiB, it is dropped and the body ends with "(data part over 64 KiB omitted)". - Bodies. A client's body over 64 KiB is refused as
invalidonbody, since the client can fix it; a peer's is cut to fit and ends with "[… truncated by Dispatch]". A body with no text becomes "(no text)" when the message has data; a client's message with neither is refused asinvalidonbody. - Choices a peer offers are trimmed, joined onto one line, deduplicated, limited to 200 bytes each and to the first 20; the rest are dropped, and a question left with none has no choices. A client's choices are validated as §8.4 says.
- Refs from a client follow §8.4; a peer's refs are dropped, since its ids mean nothing to this host.
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
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. Vectors:
a2a.peers.parses-an-alias, a2a.peers.an-alias-is-at-most-40-characters.
Adding a peer. Only a deciding principal adds a peer, by naming its card's URL. The host then:
- fetches the card over
https:(plainhttp:only when the host's operator allows it), following no redirect, within 10 s and 256 KiB; - picks the card's first interface with
protocolVersion1.0 overHTTP+JSONorJSONRPC, and refuses a peer that offers neither; - 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;
- requires a security scheme the host can satisfy with a bearer token or an API key in a header; and
- 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. Vector:
a2a.peers.a-delivery-to-a-peer-is-held. 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. Only a 401, or a 403 whose
ErrorInfo reason starts with AUTH_, marks the peer failed; any other 403
fails the one message like any 4xx.
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):
bounded-blocking-wait, a MUST deviation from A2A §3.2.2 (the TCK'sCORE-EXECUTION-MODE-001). ASendMessagewithoutreturnImmediatelywaits until the task is terminal,INPUT_REQUIREDorAUTH_REQUIRED, or until the host's bound passes, and then returns the task as it stands. An answer from a person can take hours, and tunnels and proxies cut idle requests long before that. The ask skill's description on the card states the bound (§8.1); the Dispatch profile waits 60 seconds by default and at most 600. A green TCK run says nothing about this deviation, since the TCK's scenarios finish inside any bound, so a host tests it on its own.application-json, a SHOULD deviation from A2A §11.1. Responses areapplication/json, which the pinned TCK'sHTTP_JSON-SVC-001requires, not theapplication/a2a+jsonthat A2A §11.1 recommends; requests may use either.
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 human sends as its
human:address. - An agent sends as its
agent:address. A host MUST refuse a send from an agent that has noapprovedregistration (it has none, or it ispendingorrevoked), asforbiddenonfrom. Every session of one agent install shares the install's address;sessionrecords which one sent (§4.1). - A session sends as its
run:address, and acts for its work item and that work item's other sessions (§2.2). - The system address is the host itself. Only the host's own code sends as it (§13.1).
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 | permanent | 1.0.0-draft.1 | §8.9 | a2a.peers.parses-an-alias, a2a.peers.an-alias-is-at-most-40-characters, a2a.peers.a-delivery-to-a-peer-is-held |
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 | permanent | 1.0.0-draft.1 | §4.3 | core.refs.a-local-doc-ref-is-accepted |
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, core.answers.an-agent-registration-gate-names-its-key |
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 | permanent | 1.0.0-draft.1 | §C.3 | system | approve, reject | apply or reject the memory proposal (C.3) | core.gates.memory-is-raised-only-by-the-system, core.gates.memory-is-refused-from-a-session-or-an-agent, core.gates.memory-needs-a-deciding-answer, env.envelope.a-memory-gate-names-a-proposal, env.envelope.a-memory-gate-has-a-project-or-team-scope, env.envelope.a-memory-gate-names-its-action-and-kind, env.envelope.memory-gates-have-a-fixed-shape |
task-proposal |
a2a | permanent | 1.0.0-draft.1 | §8.6 | system | approve, decline | promote or drop the drafted work item (8.6) | core.gates.task-proposal-is-raised-only-by-the-system, core.gates.task-proposal-needs-a-deciding-answer, core.gates.task-proposal-is-answered-with-approve-or-decline, a2a.projection.row-8-a-handoff-waits-on-its-proposal |
doc |
docs | permanent | 1.0.0-draft.1 | §C.3 | system | approve, reject | apply or reject the document change (C.3) | core.gates.doc-is-raised-only-by-the-system, core.gates.doc-needs-a-deciding-answer, env.envelope.a-doc-gate-names-its-doc-and-proposal, env.envelope.doc-gates-have-a-fixed-shape |
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 | permanent | 1.0.0-draft.1 | §8.6 | a2a.work-ext.accepts-a-handoff-with-every-field, a2a.work-ext.accepts-a-status-request, a2a.work-ext.refuses-an-unknown-skill, a2a.work-ext.refuses-a-handoff-without-a-title, a2a.work-ext.refuses-a-title-over-200-bytes, a2a.work-ext.refuses-a-multi-line-title, a2a.work-ext.refuses-more-than-20-acceptance-criteria, a2a.work-ext.refuses-a-criterion-over-500-bytes, a2a.work-ext.refuses-a-multi-line-criterion, a2a.work-ext.refuses-more-than-50-writes, a2a.work-ext.refuses-a-write-outside-the-repository, a2a.work-ext.refuses-a-write-over-512-bytes, a2a.work-ext.refuses-an-unknown-priority, a2a.work-ext.refuses-more-than-10-labels, a2a.work-ext.refuses-a-request-that-is-not-an-object, a2a.projection.row-2-a-declined-handoff, a2a.projection.row-2-a-handoff-dropped-by-the-owner, a2a.projection.row-4-a-deleted-handoff, a2a.projection.row-6-a-landed-handoff, a2a.projection.row-8-a-handoff-waits-on-its-proposal, a2a.projection.row-9-a-working-handoff, a2a.projection.row-9-a-handoff-in-review-has-a-stage, a2a.projection.row-9-a-status-of-the-hosts-own, a2a.projection.row-10-an-approved-draft-is-submitted, a2a.projection.row-10-needs-approval |
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:
- permanent: normative, with at least one vector. An entry becomes permanent only in the change that adds its vectors.
- provisional: specified, with or without an implementation, and no vectors yet. A stable release lists its provisional entries in a separate table marked "not part of this version"; they become permanent in a later minor version. A receiver meets them through its unknown-value rule (5.6 for gate types, 4.4 for ref types).
- appendix: defined by an informative appendix (Appendix F). A host that does not implement the appendix MUST NOT emit it and MUST ignore it on input. It becomes permanent when its appendix becomes normative.
- informative: listed so implementers recognize it; raised by hosts or bindings, never by the engine; no vector.
- reserved: held for a stated use. A host MUST NOT use it; its only vectors check that it is refused.
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.
- A MUST vector that fails, is skipped, or ends in an adapter error fails every claim that judges it. A MUST vector the adapter answers as unsupported is skipped, and fails the claim.
- A SHOULD vector runs, and its failure is reported (
shouldFailures) without failing the claim. The SHOULD vectors cover the notices a host sends: the sender's notice when a wake is denied or fails, the owner's breaker notice and the owner's notice about a voided answer. A MUST vector never checks a notice that only a SHOULD requires. - A MAY vector names a capability, and runs only when the adapter declares
it; otherwise it is skipped without failing any claim. The capabilities are
implicit-members(§3.6) andmuted-senders(§6.7). - A vector that needs a registered gate type the implementation does not
implement (
$unimplementedGateType, §12.4.4) is not applicable when there is none left: it neither passes nor fails.
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 ref 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:
$sNis the message that step N created (a successfulsend,reply,closeora2a.inbound). It may be used only after step N.$gateNis the N-th question, and$noticeNthe N-th notice, that the system address sent, counted in creation order, skipping messages a step created and rowsgiven.storeseeded.$systemis the system address the adapter declared.$unimplementedGateTypeis the first gate type the registry lists as permanent or provisional that the adapter did not declare.- Seeded rows keep their literal ids.
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
- Hello. The adapter declares the classes and profiles it runs, its
capabilities, its system address, its gate types (which include
wake, §5.6) and its render forms:quotePrefix, and the patternsheader,hostLinesanddigestLeadof §6.8, as strings, searched in a line as §1.4 says. The runner refuses a malformed hello. - Run. The adapter builds a host scripted from
given, so the host's synchronous questions (wake policy, owner, implicit members, live sessions) never cross the pipe and Core needs no network API. It drives the host's clock fromgiven.clockandadvanceMs, and loads seeded rows as given. - Observation. After the last step:
steps, one outcome per step; every message in creation order, with the fields of §4.1; every delivery, as{ "id", "message", "recipient", "session", "via", "state" }; the hook calls in order, as{ "hook", … };gateEffects, the gates recorded as applied;voided, the voided answers; every channel as{ "name", "members" }; andrender, the rendered text of eachrenderstep as{ "step", "text" }. - Unsupported. An adapter may answer a vector it cannot run as unsupported, with a reason; the vector is skipped (§12.2).
- Failures. A vector that takes longer than the timeout (10 seconds by default), a crash, or a line that is not JSON fails the vector in flight as an adapter error. The runner restarts the adapter at most three times in one run; after that every remaining vector is an adapter error.
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]
--claimmay be repeated; the default iscore.--vectorsruns another directory of vectors; the default is the kit's own.--reportnames the JSON report (defaultdmp-conformance.json);--junitadds the JUnit file.--tck-attestnames a JSON file describing an A2A TCK run,{ "commit", "transport", "level", "result", "deviations" }, which the runner copies into the report as attested; it never runs the TCK itself.--vectors-onlyjudges an A2A binding claim on its vectors alone and reports it asvectors-only.--deviationsnames a JSON list of{ "section", "summary" }(§12.6).--timeout-mssets the per-vector timeout.
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).
- Major: a conforming implementation stops conforming, or a valid message becomes invalid. Removing or redefining a permanent registry entry, lowering a limit, changing a state transition, and changing which error a case returns are major. So is adding a kind, an address scheme, or a delivery state a host exposes, because a receiver of an earlier version refuses or cannot route what it does not know.
- Minor: an addition that a receiver of an earlier version tolerates: a ref
type (received refs of an unknown type are kept, §4.4),
a gate type (an unknown gate fails closed, §5.6), an error
code (an unknown code is handled by its status class,
§10.3), a system marker (an unknown
x-type is private data to an earlier host), an optional field that earlier hosts ignore, a new extension version, a new binding section, and making an appendix normative (Appendix F). - Patch: editorial changes, errata, and vectors that pin what the text already requires. When the text and a vector disagree, the text governs, and the patch corrects the vector (§1.4).
14.2 Change process
- 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,additiveorbreaking), the vectors to add, change or retire, the impact on implementations, and security notes. - 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.
- From 1.0.0, a
clarification,additiveorbreakingchange gets aLast 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. - 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, memory under
/api/memory, which the tools of §B.3 reach,
documents under /api/docs, 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).
B.3 Memory tools
Four more tools read and write Dispatch's memory: the lessons, conventions and
preferences it keeps for later work. They are not DMP messages, but a shared
write reaches a human through a memory gate
(Appendix C), so they are listed here.
They use the same credentials as the tools of
§B.1 and return JSON the same way.
| Tool | Parameters | What it does |
|---|---|---|
memory_search |
query; optional scope, kind, includeStale, limit |
Searches the entries the caller may read, at most limit hits (at most 50). An empty query returns the caller's top entries. Stale entries are included and marked unless includeStale is false. |
memory_read |
id |
Returns one entry, by its handle or id: its body, who wrote it, how far it is trusted, and its revisions. |
memory_save |
scope, kind, title, body; optional refs, epic, appliesTo, supersedes, projectOnly |
Saves an entry. A personal entry, the caller's operator's own, is saved at once. A project or team entry becomes a proposal: unless the project's autonomy policy accepts it, the system raises a memory gate for it, which a deciding human approves or rejects. A dropped connection is retried once with the same idempotency key. |
memory_forget |
id, reason |
Retires an entry, with a one-line reason: at once for the operator's personal entry, and otherwise as a proposal, as memory_save does. |
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.
The profile implements the doc ref type (§4.3): its
id names a document by its doc- id, and its at, when present, a section
anchor in it. Vector: core.refs.a-local-doc-ref-is-accepted.
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, overseer-action and
task-proposal 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 seven permanent
types below, and a host that claims the profile declares all eight
(§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?, key? |
overseer-action |
system | confirm, cancel | conversation, actionId, summary |
task-proposal |
system | approve, decline | task, proposedBy, message |
memory |
system | approve, reject | proposalId, action, scope, kind |
doc |
system | approve, reject | doc, proposal, taskId?, runId? |
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. A request that
breaks one of these rules fails invalid on data, data.paths or
data.reason; other members of its data are not checked. 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.
key, when present, names the registration the gate decides (16 lowercase hex
digits); after the agent re-registers under a new key, an answer to a gate for
an earlier key has no effect. approve approves the agent; deny revokes it.
Vectors: core.answers.a-human-decides-an-agent-registration-gate,
core.answers.an-agent-registration-gate-names-its-key.
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.
task-proposal. The system raises it to the owner when an A2A client hands
off work (§8.6), as a blocking question replying to
the handoff: task names the drafted work item, proposedBy the client, and
message the handoff. approve moves the draft to ready; decline drops it,
and the client's task is rejected (§8.7). Vectors:
core.gates.task-proposal-is-raised-only-by-the-system,
core.gates.task-proposal-needs-a-deciding-answer,
core.gates.task-proposal-is-answered-with-approve-or-decline.
memory. A proposed memory entry waits on it
(§B.3). The system raises it to the owner when
a principal proposes to add, supersede or retire a project or team entry and
the project's autonomy policy does not accept the proposal itself; only the
system raises one, so a gate never names a proposal the system did not raise.
Its shape is fixed (§5.3): kind question, blocking true,
choices exactly approve then reject, and data
{ "type": "memory", "proposalId", "action", "scope", "kind" }. proposalId is
mp- and an uppercase ULID, and names the proposal, whose text stays with the
host and never travels in the gate. action is add, supersede or retire,
scope is project or team, and kind is one of preference, convention,
constraint, hazard, decision, fact or reference. A gate that breaks
one of these rules fails invalid on data or the member it names; other
members of its data are not checked. approve applies the proposal; reject
discards it. The system rejects a proposal no one decided within the project's
memory proposalTtlDays (14 by default) with an x-expired marker
(§C.4). Vectors:
core.gates.memory-is-raised-only-by-the-system,
core.gates.memory-is-refused-from-a-session-or-an-agent,
core.gates.memory-needs-a-deciding-answer,
env.envelope.a-memory-gate-names-a-proposal,
env.envelope.a-memory-gate-has-a-project-or-team-scope,
env.envelope.a-memory-gate-names-its-action-and-kind,
env.envelope.memory-gates-have-a-fixed-shape.
doc. The system raises it to the owner when a principal who may not edit
an accepted team document directly proposes an edit to it, with refs to the
document and to the proposer's work item and session when there are any. Its
shape is fixed (§5.3): kind question, blocking true,
choices exactly approve then reject, and data
{ "type": "doc", "doc", "proposal", "taskId"?, "runId"? }. doc is the
document's doc- id and proposal the proposed revision's rev- id; the
proposed text stays with the host and never travels in the gate. taskId and
runId name the proposer's work item and session. A gate that breaks one of
these rules fails invalid on data, data.doc or data.proposal; other
members of its data are not checked. approve applies the proposed revision;
reject discards it. When the project's autonomy policy approves the proposal,
or the proposal expires after the project's docs proposalTtlDays, the system
closes the gate (§4.8). Vectors:
core.gates.doc-is-raised-only-by-the-system,
core.gates.doc-needs-a-deciding-answer,
env.envelope.a-doc-gate-names-its-doc-and-proposal,
env.envelope.doc-gates-have-a-fixed-shape.
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, and its reject answer to a memory gate that no one decided within proposalTtlDays |
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>").
-
The header marks an urgent message, and an external sender (§2.1), like this:
[message from <from> · <kind> · urgent · <id>] [message from <from> (external) · <kind> · <id>] -
The body is split at every line break of §1.4, and each of its lines, blank ones included, follows the quote prefix:
│(U+2502) and a space. -
(in reply to …)appears on a reply;choices:on a message that offers choices, joined by|with a space on each side;choice:on an answer that makes one;refs:on a message with refs, each as<type>:<id>, with@<at>when it has anat, joined by a comma and a space; and the last line on a blocking message. -
For an external sender, the
choices:,choice:andrefs:lines follow the quote prefix too, since they carry the sender's text (§6.8).(in reply to …)names only the id of the message replied to, and the last line only the message's own id, so they stay as they are:[message from <from> (external) · <kind> · <id>] │ <each line of the body> (in reply to <replyTo>) │ choices: <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:
- A
run:target:allowwhen the sender is ahuman:address, elsedeny. - A
task:target:denywhen the work item does not exist, is an epic, or is landed or dropped. Otherwiseallowwhen the sender is ahuman:address, since waking a work item is that human's own call, at every rung and with no gate. For any other sender, the project's autonomy policy forwakeat the task's rung, capped by the task's risk:allowwhen the policy acts on its own there, elseask. - Any other target:
deny.
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;
}
- Replicas and keys. A replica id matches
^[A-Za-z0-9._-]{1,32}-[0-9a-f]{8}$; an op or key naming another is refused. Each replica has an Ed25519 signing key (RFC 8032) and an X25519 sealing key (RFC 7748), announced in itskeyop, the first op of its log. Keys never rotate in place: a replica that loses them starts over under a new id. - Verification. An op verifies when
sigverifies over its header with the publisher's key andbodyHashis the hash of its content. A stub verifies with the same signature, and is accepted only for the typesmail,stateandpresence. - The chain.
opHashis the hash of the JCS form of the header andsig, the same for an op and its stub. Each op carries theopHashof the publisher's previous op asprev, and itsseqexceeds that op's. A rewritten or forked log fails to verify, and halts at the op that fails. - The clock. An op's
hlcnames its own replica after the second dot, and exceeds the previous op's, comparing milliseconds and counter as numbers. A replica adopts the latest clock it accepts. An op stamped more than 5 minutes ahead of the replica's own wall clock is neither applied nor adopted; it waits, and its publisher's later ops behind it, until the clock catches up. - Size. A serialized op is at most 1 MiB, a sealed op names at most 256 recipients, and a state op carries at most 500 entries. A message always fits, since its body and data are at most 64 KiB each (§4.5).
- Unknown types. An op of a type a replica does not know is verified, kept, and applied after an upgrade that knows it.
| 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`
}
- The payload is encrypted once, under a random 32-byte key
K, withaad"dispatch-sealed-v1\n" + replica + "\n" + seq + "\n" + type, so a ciphertext cannot move into another op. - The key is wrapped for each recipient with single-shot
HPKE in base mode, with
DHKEM(X25519, HKDF-SHA256), HKDF-SHA256 and AES-256-GCM; the recipient's
sealing key as
pkR;infoset toaad + "\n" + <recipient replica>; and an empty HPKEaad. The keys ofkeysare exactlyto. - In the clear are the header (the recipients, the publisher,
seq,hlc, the type andbodyHash) and sizes: all a transport needs to route, de-duplicate and prune. - Who can read. Only the recipients can unwrap
K, and the op's signature binds the ciphertext to its publisher, so a recipient cannot publish the payload again under another replica's name.
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;
}
)[];
}
- The origin of a message is the replica that created it: a
mailop's publisher, or a forward's inner op's. A forward keeps the original op, and with it the original signature, so authorship survives forwarding. - The origin's resolution is final. Receivers never expand a channel again,
so a partition cannot give two replicas different recipients, and the origin
names in
wakeAtthe one replica that may wake each work item.
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:
- Duplicate. A message whose id is stored with the same content is a duplicate; one with other content is refused as an id collision.
- Local-only. Refuse local-only content, tested as above on the message and
on its stored
replyTotarget. - Authorize the sender. A remote agent must be approved in the replicated agent rows.
- Participation. When the
replyTotarget 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. - Validate the message as received (§4.4), with
canDecidefalse, 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. - The breaker and the urgent quota (§6.6), counted
by arrival time, so a sender cannot escape them by backdating
createdAt. - Answers settle at the question's origin (§F.6).
- 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 startread. - Commit the message, with
origin,hlcand its arrival time, its deliveries and its remote rows in one transaction, then push and notify as a send does (§6.3). - Wake a held work item of a wake request only on the replica
wakeAtnames, 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.
- At the settler, the first answer it applies, local or remote, is the
answer. It publishes a
settleentry naming it to the homes of every participant of the question and the answer. A later local answer failsconflict; a later remote one is kept as a reply, of kindmessage, and its sender is told the question was already answered. Every close by the settler is published the same way, withclosedset, and only the settler closes a question. - Elsewhere, the first answer seen is kept as the answer, pending, and later
ones as candidate replies. Answers seen before their question are checked
against it when it arrives: the first that passes participation and the checks
that need the question stays the answer, and the rest are candidates. A
settleentry makes the accepted answer the answer, every other a reply, and the question's deliveriesanswered, in one transaction; one that arrives before its answer waits for it. Asettlewithclosedwrites the close here. Arefusedentry for a local answer turns it back into a reply and reopens the question's deliveries it had answered. - Only the settler is believed. A
settleentry from any other replica is refused.
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
}
- Only
teamentries travel, active or retired, merged field by field, the laterhlcwinning. Proposals and theirmemorygates, personal and project entries, and recall history stay on their replica. - A receiver recomputes trust:
humanstands when the publisher speaks for the entry's human author,confirmedwhen it speaks for the human who decided it, and anything else arrives asagent. A change to an entry's content sets its trust by these rules; any other change never lowers the trust a replica already holds. - The receiver's own memory policy still applies: an entry it would not accept arrives as an open proposal there.
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.