Superseded. The current version is v0.6. Rendered from /spec/v0.4.md.
MA2H — Multi-agent to Human Protocol
Version: 0.4 (Draft) · Date: 2026-06-30 · License: Apache-2.0 · Steward: Autonomy
Superseded by v0.5 (2026-08-10) — implement against v0.5. Supersedes v0.3. v0.4 is additive and backward-compatible: it adds a human→agent messaging leg (the inbound directive, §13) alongside the unchanged v0.3 agent→human legs. No v0.3 wire format, envelope, lifecycle, or signature changes. See CHANGELOG and MIGRATION.
0. Abstract
MA2H is a vendor- and runtime-neutral protocol by which agents reach humans and receive decisions
back, and — as of v0.4 — by which a human reaches a specific agent. Heterogeneous Agents POST one
of three message types — notify, ask, task — to a central Hub; a Human triages them from one
inbox; and answers to ask/task messages are routed back to the originating (often ephemeral) agent via
push (webhook) or pull (polling). The v0.4 inbound leg mirrors that return leg in reverse: a
Hub-attested human authors a directive addressed to one agent, and the agent drains it from a durable
per-agent mailbox by the same pull-first / webhook-optional mechanism (§13). v0.4 also adds two
cross-cutting primitives that enrich both directions: an acknowledgment/receipt (§14) closing the
loop on a delivered message or answer, and a derived presence signal (§15) telling a human whether an
agent is listening. MA2H is the agent↔human complement to A2A (agent↔agent) and MCP (agent↔tools).
This version (0.4) builds on the v0.3 payload-bound-signature pass — which itself built on the v0.2
hardening that closed the trust-model, return-leg, concurrency, and durability gaps found in the v0.1
design review — and adds the human→agent directive leg (§13) without changing any v0.3 leg. A 0.4
Hub remains backward-compatible with 0.3 agent→human envelopes. The new leg reuses v0.3's envelope
conventions, RFC 8785 JCS canonicalization, detached Hub signature, jti replay dedup, SSRF controls
(§9.4), and durability model (§3.1) rather than inventing new machinery. As in prior versions, it
specifies candidate controls for the security-critical requirements; final closure of those is proven
against a conformant Hub (see §12).
1. Conformance and Terminology
The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, MAY are to be interpreted as described in RFC 2119 / RFC 8174.
- Agent — a software process (any runtime) that originates MA2H messages. A "spoke."
- Hub — the central server that ingests messages, presents the human inbox, routes responses, and performs the security and lifecycle duties in this spec. The "center."
- Human — an authenticated operator who triages the inbox and resolves
ask/taskmessages. - Caller — the specific agent invocation that produced a message, identified by the tuple
(agent.id, agent.run_id). Responses route to the Caller. - Resolution — the terminal outcome of an
ask/task, carried in the Response (§6). - Directive — a human→agent message (v0.4, §13): a Hub-attested
human:<id>sends an instruction or FYI addressed to oneagent:<id>, delivered from that agent's mailbox. The inbound mirror ofnotify. - Mailbox — the durable, per-
agent.idqueue of pending directives an agent drains (§8.7, §13). - Acknowledgment (receipt) — a one-shot, terminal confirmation that the receiving party got the other
party's message or answer, optionally with a note (v0.4, §14). Cross-cutting; distinct from a
notify. - Presence — a derived, best-effort signal of whether an
agent:<id>is currently reachable/listening, from its existing poll/subscription activity (v0.4, §15). Owner-only.
Three words that v0.1 overloaded are kept distinct in v0.3 and MUST NOT be conflated:
| Term | Meaning |
|---|---|
status |
The lifecycle phase of a message (§7): open, delivered, or a terminal value. The wire field on the submit ack (§8.1) and GET body (§8.2). |
resolution |
The terminal status of an ask/task, surfaced in the Response (§6). Its value space is a subset of status. |
state |
The opaque, agent-owned blob round-tripped verbatim (§4, §6, §9). Never a lifecycle concept. |
A conformant Hub MUST accept the message envelope (§4–§5), expose the transport bindings (§8), enforce the security model (§9), apply the lifecycle rules (§7), and emit the Response (§6) on resolution. A conformant Agent MUST send a valid envelope, MUST be able to receive a resolution by at least one of push or pull, and MUST honor the agent-side duties marked in §6, §8, and §9 (dedup, signature verification, state-integrity verification).
The inbound leg (§13) is OPTIONAL to offer and to consume: a conformant Hub MAY omit it entirely (and
then MUST NOT advertise inbound in §8.0), and a conformant Agent need not drain a mailbox. A Hub that
does offer it MUST implement the §13 mailbox semantics, sign every delivery per §9.7, and advertise
inbound (§8.0); an Agent that does consume it MUST verify the §9.7 signature and deduplicate on the
directive id (§13.4). Offering or consuming the inbound leg is independent of full v0.3 conformance —
neither implies the other.
2. Goals and Non-Goals
Goals
- Runtime-agnostic: anything that speaks HTTPS + JSON can participate. No SDK required.
- Minimal core: three verbs, one envelope.
- First-class ephemeral agents (a CLI run, a GitHub Action) via pull and the resume pattern (§2.1).
- Async by default; a human is never assumed online.
- Server-authoritative trust: the Hub decides; the agent never trusts the return leg without verifying it.
Non-Goals (v0.4)
- The human-facing inbox UI (only the wire contract is normative).
- Human identity / SSO mechanics. MA2H requires the Hub to authenticate the operator and attest
actor/from, but does not specify the authentication method. - Multi-human assignment, escalation, SLA routing. (Post-v0.4.)
- Streaming partial agent output; multi-turn threaded asks. (Post-v0.4.)
- Channel fan-out (Slack/email/SMS delivery of an inbox item).
- Human→agent
ask/task. v0.4's inbound leg is deliberately scoped to the one-way directive (thenotifymirror). An inbound message that expects a routed agent response — the mirror of §6 — is a materially larger design (agent-side resolution, an attestedagent:actor on the return leg, a second signed leg) and is deferred to a future SCP (§10). - Agent liveness / push-to-wake. Delivering a directive to a mailbox does not start, wake, or guarantee the existence of a consuming agent; the mailbox is durable and drained whenever the agent next polls. How an agent is scheduled to poll (or is re-invoked by the optional webhook) is out of scope.
2.1 Ephemeral Agent Resume Pattern (normative)
The defining MA2H flow: an agent that exits after asking, and a new process that resumes when the human answers. This is how headless agents (GitHub Actions, CLI runs) participate without holding a process open.
Run #1 (exits) Hub Human Run #2 (re-invoked)
───────────── ─── ───── ──────────────────
1. seal state ── POST ask ───▶ ingest, validate
(encrypt-then-MAC, store, 202 {id,...}
key NOT in state) ◀──────── 202 ack
2. exit (nothing held open)
present to inbox ───▶ resolve
atomic terminal CAS
stamp resolution_id
sign Response ──────────────────────▶ 3. POST signed Response
(push) to re-invoke URL
4. verify signature + replay jti
5. verify+open state MAC
(untrusted until verified)
6. dedup (in_reply_to,
resolution_id); act once
Normative requirements:
- Before exiting, an agent that needs to resume MUST populate
state(§4) with enough context to reconstruct itself in a new process, and MUST integrity-protect it per §9.3. - The agent MUST set
callback.url(§5.2) to an endpoint that re-invokes the agent process (e.g., a workflow-dispatch URL), not merely a data sink. The callback host MUST satisfy §9.4. - On resolution the Hub POSTs the Response (§6), including
stateverbatim, tocallback.url. - The re-invoked process MUST verify the Response signature (§9.2) and reject replays, MUST verify the
integrity of
stateit applied (§9.3) and treat it as untrusted until it does, and MUST deduplicate on(in_reply_to, resolution_id)(§6, §7) and act at most once. - The state-seal key MUST be sourced per §9.3 — pre-positioned in the agent runtime, distinct from the
callback credential, and never carried in
stateor through the Hub.
actor MAY be agent:<agent.id> — an agent may resolve an ask on behalf of its operator (autonomous
approval). Any action a human resolver can take, an authorized agent resolver can take (§6, §9.1).
3. Architecture
┌─────────┐ POST /v1/messages ┌──────────────────┐ inbox ┌────────┐
│ Agent │ ────────────────────▶ │ Hub │ ─────────▶ │ Human │
│ (spoke) │ ◀──────────────────── │ ingest · atomic │ ◀───────── │ │
└─────────┘ signed push / or │ lifecycle · sign │ resolve └────────┘
│ agent polls poll_url │ · SSRF-guard │ (attested actor,
└── GET /v1/messages/{id} └──────────────────┘ authz checked)
Trust boundaries: the request leg is authenticated by per-agent.id credentials (§9.1); the return
leg is integrity-protected by a Hub signature the agent verifies (§9.2); state is opaque to the
Hub and integrity-sealed by the agent (§9.3) — the Hub is never trusted with state integrity.
3.1 Durability and Conformance
A conformant Hub MUST durably persist message lifecycle such that a Hub process restart does not lose:
- any open
ask/task, - any committed resolution,
- any
deliverednotify (it MUST remain pull-checkable per §8.2), - any pending push-delivery obligation (it MUST be re-attempted after restart while non-terminal),
- (inbound leg, §13) any un-acked directive in a mailbox (it MUST remain drainable until acked or expired), and any pending directive webhook obligation (re-attempted after restart while non-terminal).
The guarantee is stated as an observable outcome; the storage mechanism is implementation-defined. In-memory-only storage that loses the above on restart is non-conformant.
4. Message Envelope (common fields)
Every message is a JSON object. The schema is schema/v0.4/message.schema.json.
| Field | Type | Req | Notes |
|---|---|---|---|
ma2h_version |
string | MUST | "0.4" (a 0.3 agent still sends "0.3" — accepted, §10). Version handling per §10. |
type |
enum | MUST | notify | ask | task. Discriminator. |
created_at |
string | MUST | RFC 3339 timestamp (agent clock; informational — the Hub clock is authoritative for expiry, §9.5). |
agent |
object | MUST | Origin descriptor (§4.1). |
title |
string | MUST | One line for the inbox list. ≤ 200 chars. |
body |
string | SHOULD | Human-readable detail. Markdown. Treated as untrusted (§9.6). |
priority |
enum | SHOULD | low | normal | high | urgent. Default normal. |
tags |
string[] | MAY | Free-form filter labels. |
context |
Part[] | MAY | Attachments (§4.2). |
state |
object | MAY | Opaque, agent-owned resume blob. The Hub MUST store and return it verbatim in the Response and MUST NOT inspect or log it (§9.3). Required in practice for the resume pattern (§2.1). |
client_ref |
string | MAY | Opaque, agent-chosen correlation label. Never used for dedup. Not exposed to resolvers (§9.1). |
idempotency_key |
string | see §8.1 | Dedup key. REQUIRED for ask/task, MAY for notify. Scope and semantics in §8.1. |
expires_at |
string | MAY | RFC 3339. Auto-resolution boundary for ask/task (§7). MUST be in the future at submit or the Hub rejects with 422 (§8.5). |
The id is not an agent input — the Hub assigns it (§8.1, KTD: Hub-canonical ids).
Cross-type fields (request, action) are constrained by the discriminated oneOf in the schema: a
notify MUST NOT carry request or action; an ask carries request and MUST NOT carry action; a
task carries action and MUST NOT carry request.
4.1 agent descriptor
{
"id": "deploybot/dev-team", // stable logical agent identity (credential is scoped to this)
"run_id": "run_01H...", // this specific invocation; (id, run_id) = the Caller
"runtime": "github-actions", // github-actions | cli | cloud | desktop | openclaw | other
"project": "web-app", // optional grouping key (≈ A2A contextId)
"labels": { "model": "opus-4.8" } // optional free-form string map
}
run_id is opaque to the Hub and MUST NOT be used to authorize cross-run access (§9.1).
4.2 Part (convention borrowed from A2A)
A context entry is exactly one of:
{ "kind": "text", "text": "..." }
{ "kind": "data", "data": { "...": "..." } }
{ "kind": "file", "file": { "uri": "https://...", "name": "diff.patch", "mime_type": "text/x-diff" } }
Each Part MAY carry metadata. A Hub MUST NOT server-side-fetch a file.uri unless it passes the same
host controls as a callback URL (§9.4).
5. The Three Verbs
5.1 notify
Informational. No response is expected. Used for summaries, status updates, the daily digest. Carries only
the common envelope (§4). A Hub MUST NOT route a Response for a notify.
notify is fire-and-forget but not droppable: a notify reaches delivered on acceptance (the 202
ack carries status: delivered; there is no observable created state and no undeliverable state
machine). A delivered notify MUST be durable (§3.1) and pull-checkable via §8.2 for the retention TTL,
so a sender (e.g., a daily-digest agent) can confirm it landed.
5.2 ask
A decision the human must make. Adds a request block:
"request": {
"mode": "select", // select | input | confirm
"options": [ // REQUIRED when mode=select
{ "value": "ship", "label": "Ship to prod now", "description": "Deploy immediately." },
{ "value": "hold", "label": "Hold for review", "description": "Wait for a human PR review." }
],
"schema": { // REQUIRED when mode=input — FLAT JSON Schema (string/number/bool/enum)
"type": "object",
"properties": { "reason": { "type": "string", "x-ma2h-sensitive": false } },
"required": ["reason"]
},
"permissions": { // which affordances/terminals the human is offered (§7)
"allow_accept": true, "allow_edit": true, "allow_respond": true, "allow_ignore": true
},
"default_on_expire": "hold", // applied on expiry; MUST be a member of options[].value (select) or
// null, or an object validating against `schema` (input). See §7.
"allowed_resolvers": ["human:alice"], // optional authz; default fail-closed (§9.1)
"callback": { /* §8.3 */ }
}
mode=confirmis sugar: whenoptionsis omitted the Hub MUST synthesize exactly two options with valuesapproveanddeny;response.valueis one of those. Ifoptionsis supplied it MUST have exactly two entries.permissions→ lifecycle mapping (§7):allow_respond:falseremoves the answer affordance (the human may onlydecline);allow_ignore:falseremoves the decline/ignore affordance. The v0.1ignoredresolution does not exist — a human ignoring an ask resolves it asdeclined(§6, §7).
5.3 task
A manual action a human performs in the world. Adds an action block:
"action": {
"instructions": "Rotate API_SIGNING_KEY in the prod vault and confirm the webhook still signs.",
"checklist": [
{ "text": "Generate a new key in the secret manager", "done": false },
{ "text": "Update prod secret", "done": false }
],
"verification": "Webhook test event returns 200 with a valid signature.",
"allowed_resolvers": ["human:alice"],
"callback": { /* §8.3 — optional; notify the agent when marked done */ }
}
A task has no default_on_expire: on expiry it resolves expired with no default applied (§7).
6. Response Envelope (Hub → agent)
Emitted by the Hub when an ask/task reaches a terminal state. Delivered via §8. The schema is
schema/v0.4/response.schema.json.
{
"ma2h_version": "0.3",
"in_reply_to": "msg_01H...", // the original Hub-assigned message id
"resolution_id": "res_01H...", // Hub-assigned, opaque, random; identical on every channel/retry
"agent": { "id": "deploybot/dev-team", "run_id": "run_01H..." }, // routing only (responseAgent: id+run_id)
"resolution": "answered", // §7 terminal value for the verb
"response": { // present for ask; for task only when a comment/checklist is captured
"value": "hold", // ask only: chosen option value, or the input object (union by mode)
"edited": false,
"actor": "human:alice", // Hub-attested; ^(human|agent|system):.+$ (§9.1)
"resolved_at": "2026-06-04T18:04:11Z",
"comment": "Let's get a human eye on the migration first."
},
"defaulted": false, // true iff resolution came from default_on_expire (actor=system:...)
"state": { "...": "..." } // opaque agent blob, round-tripped verbatim (§9.3)
}
resolutionvalue space (the single canonical list; identical in §1, §7, the schema, and §11):ask→answered|declined|cancelled|expiredtask→completed|dismissed|expired
response.valueis a discriminated union keyed on the originalrequest.mode(string for select/confirm, object for input). It is absent fortask.responsepresence: REQUIRED forask; fortaskpresent only to carry a humancommentand/or finalcheckliststate (nevervalue); never present fornotify(no Response is emitted).- An expiry-defaulted Response MUST set
resolution: "expired",defaulted: true, andresponse.actor: "system:default_on_expire"— never a humanactor. Agents SHOULD fail-closed onexpiredfor high-impact decisions. - The agent MUST treat
(in_reply_to, resolution_id)as the at-most-once key andstateas untrusted until verified (§9.3).
7. Lifecycle / State Machines
notify: delivered (terminal on acceptance; durable, §3.1)
ask: open ──┬─▶ answered ← human answered (allow_respond)
├─▶ declined ← human refused / ignored (allow_ignore maps here)
├─▶ cancelled ← agent or Hub withdrew (ask only; §8.4)
└─▶ expired ← expires_at passed; default_on_expire applied if set
task: open ──┬─▶ completed ← human marked done
├─▶ dismissed ← human declined to act
└─▶ expired ← expires_at passed (no default)
Normative rules:
- Atomic single-writer. Every transition is a single atomic compare-and-set against the current status. The first transition to a terminal status wins and is immutable; later resolve/expiry/ cancel attempts are no-ops that return the existing Response.
- Expiry vs answer. A human resolution accepted at or before
expires_at(Hub clock, §9.5) beatsdefault_on_expire. Expiry and answer are evaluated against the same clock in the one atomic transition. - Resolve vs cancel. First terminal wins. A cancel arriving after a terminal status is a no-op that
returns the existing terminal Response (not a fake success), so the agent learns it lost the race. A
cancel arriving strictly after
expires_atlikewise loses todefault_on_expire— evaluated against the same clock as expiry-vs-answer — so an overdueaskresolves toexpired, nevercancelled. Acancelledmessage still emits a Response so push/pull agents get closure.cancelledis ask-only. - At-most-once delivery semantics. Once a message is terminal, the Hub MUST NOT deliver a different
resolution. In-flight delivery attempts for the terminal resolution MAY complete; they carry the same
resolution_id, and the agent deduplicates (§6). default_on_expireMUST validate against the request: a member ofoptions[].valueforselect, an object validating againstschemaforinput, ornull. (Forselectthis is schema-enforceable; forinputand for the cross-fieldoptionsmembership, the Hub enforces it and a conformance vector covers it — see §12.)
8. Transport Bindings (HTTP/JSON)
All endpoints are HTTPS, Content-Type: application/json, base path /v1. Plaintext MUST NOT be offered.
8.0 Discovery — GET /.well-known/ma2h
Returns a capability document (schema schema/v0.4/capability.schema.json) so an agent can configure
itself without out-of-band knowledge:
{
"ma2h_version": "0.4",
"max_body_bytes": 65536,
"max_part_bytes": 262144,
"max_context_parts": 16,
"auth_schemes": ["bearer", "apikey"],
"callback_auth_schemes": ["hmac", "bearer", "apikey"],
"signature_algs": ["hmac-sha256", "ed25519"],
"rate_limit": { "requests_per_minute": 60, "inbox_depth": 1000 },
"retention_days": 30,
"replay_window_seconds": 120,
"inbound": {
"enabled": true,
"poll_url": "https://hub.example/v1/inbox",
"ack_url": "https://hub.example/v1/inbox/ack",
"max_batch": 32,
"visibility_timeout_seconds": 60,
"retention_days": 30,
"signature_algs": ["hmac-sha256", "ed25519"],
"webhook_supported": true
},
"ack": { "enabled": true, "signature_algs": ["hmac-sha256", "ed25519"] },
"presence": { "enabled": true, "freshness_seconds": 90 }
}
The inbound object (v0.4, §13) advertises the human→agent leg. A Hub that does not offer inbound
directives MUST omit inbound (or set inbound.enabled: false); its absence means "no mailbox here," and
a pre-0.4 Hub omits it entirely. The ack object (v0.4, §14) advertises the acknowledgment/receipt
primitive (signature_algs are the algs the Hub uses to sign a pushed ack, §14.4); the presence
object (v0.4, §15) advertises the presence signal (freshness_seconds is the window after which a
last_seen reads offline, §15.2). A Hub offering neither omits them. All fields beyond ma2h_version
remain SHOULD.
8.1 Submit — POST /v1/messages
Body: a message envelope (§4–§5). On success the Hub returns 202 Accepted (schema
schema/v0.4/submit-ack.schema.json):
{ "id": "msg_01H...", "status": "open", "poll_url": "https://hub.example/v1/messages/msg_01H...",
"review_url": "https://hub.example/inbox/msg_01H..." }
For notify the ack carries status: "delivered".
idempotency_key (REQUIRED for ask/task). The Hub deduplicates on (agent.id, idempotency_key),
run-independent, for at least a stated TTL (RECOMMENDED ≥ 24h). A replay with the same key and an
identical payload MUST return the original ack including the original id and current status (so a
crashed agent recovers its outcome). A replay with the same key but a differing payload MUST be
rejected with 409 (§8.5). The agent retry recipe (lost ack): retry with the same idempotency_key
until a 202 or 409 is observed — this is what prevents a duplicate human decision when the 202 is lost and
the agent (holding no id yet) must blindly retry.
8.2 Pull — GET /v1/messages/{id}
Returns the message plus its current status and, once terminal, the embedded response (schema
schema/v0.4/get-message.schema.json). Reads are idempotent: a terminal message returns the same body on
every GET. The Hub SHOULD support long-poll via ?wait=<seconds> (server-max, cap 60): hold until
resolution or timeout, then return current state (a 200 with status: open on timeout is normal, not an
error). A ?since=<RFC3339> cursor MAY be used; the Hub SHOULD return 304 when status is unchanged.
A resolved message MUST remain pull-available for the retention TTL (RECOMMENDED 30 days; advertised in
§8.0). A deleted message returns 410 Gone; an unknown id returns 404. This is the source of
truth for ephemeral agents and the fallback when push fails (§8.3).
On a Hub that offers the ack primitive (§14), the GET body additionally carries an optional delivery
object — the receipt track (delivered-to-agent → acknowledged) — which the human reads to see whether
the agent got and acknowledged the answer. It is additive and orthogonal to status/response (§14.2).
8.3 Push — agent-supplied callback
An ask/task MAY include a callback so the Hub delivers the Response actively:
"callback": {
"mode": "push", // push | pull (pull = omit callback or mode:pull; agent polls 8.2)
"url": "https://agent.example/ma2h/resolve",
"auth": { "scheme": "hmac", "secret_ref": "env:MA2H_CALLBACK_SECRET" } // see §9.2
}
Omitting callback (or mode:pull) means the agent is in pull mode and MUST use §8.2. On resolution the
Hub POSTs the Response (§6) to url, signed per §9.2. Delivery is at-least-once: the Hub MUST
retry on 5xx/network errors with exponential backoff (≥ 5 attempts), MUST NOT retry on 4xx, and MUST
cap total attempts and total duration (advertised). The agent's callback receiver MUST deduplicate on
(in_reply_to, resolution_id) and act at most once. After retry exhaustion the resolution MUST remain
pull-available (§8.2) — it is never lost. Push is best-effort; pull is the source of truth, and on any
push/pull disagreement the GET value is authoritative.
8.4 Cancel — POST /v1/messages/{id}/cancel
Withdraws an open ask. Only the submitting principal may cancel (§9.1); a cancel from any other
authenticated agent is refused as if the id were unknown (404), never applied. On success returns
200 { "id", "status": "cancelled" } and emits the terminal cancelled Response, delivered like a resolve
(push and/or embedded for pull) so the agent gets closure. If the message already reached a different
terminal (e.g. answered), returns 409 with the existing { "id", "status", "resolution" } (the agent
reads the real outcome). Idempotent: re-cancelling an already-cancelled message returns the same 200.
Tasks are not cancellable in v0.3.
8.5 Error Model
Errors use a canonical envelope: { "error": { "code": "<machine_code>", "message": "<human>" } }.
| Status | code |
When |
|---|---|---|
| 400 | validation_error / version_not_supported |
malformed envelope; unknown major (§10) |
| 401 | unauthenticated |
missing/invalid agent credential |
| 403 | agent_id_mismatch / not_authorized |
credential ↔ agent.id mismatch; resolver not in allowed_resolvers |
| 404 | not_found |
unknown message id |
| 409 | idempotency_conflict / already_terminal |
replay with differing payload; cancel/resolve after terminal |
| 410 | gone |
message deleted (vs 404 unknown) |
| 422 | invalid_field |
e.g., expires_at in the past; default_on_expire ∉ options |
| 429 | rate_limited |
over a published limit; includes Retry-After |
Callback (Hub→agent) delivery follows the same 4xx-no-retry / 5xx-retry rule (§8.3).
8.6 Rate Limits and Quotas
A Hub MAY enforce, and advertises (§8.0): a per-agent.id submit rate and a max inbox depth (over-limit →
429 + Retry-After), a max envelope and per-Part byte size (over-limit → 422), and a cap on total
callback attempts and duration. These bound both the inbox denial-of-attention and the callback-amplifier
risks. Agents MUST respect Retry-After and MUST NOT retry immediately on 429.
8.7 Inbox — the agent mailbox (v0.4, inbound leg)
The transport for the human→agent leg (§13). Available only on a Hub advertising inbound (§8.0). Both
endpoints are authenticated by the agent's existing bearer token / API key scoped to one agent.id
(§9.1); the credential — not any request field — selects the mailbox, so an agent can only ever drain and
ack its own mailbox.
Drain — GET /v1/inbox. Returns the caller's pending directives (§13.1) in FIFO order, each paired
with its detached signature (§9.7):
{ "messages": [ { "directive": { /* §13.1 */ }, "signature": "MA2H-Signature: t=..,jti=..,v1=.." } ] }
?max=<n>caps the batch (Hub-max advertised asinbound.max_batch);?wait=<seconds>long-polls (server-max, cap 60) exactly like §8.2 — hold until a directive is available or timeout, then return (an emptymessageson timeout is normal, not an error).- A returned directive is marked in-flight for the
inbound.visibility_timeout_secondswindow: it is hidden from subsequent drains until either acked (removed) or the window lapses (redelivered — this is the at-least-once guarantee). The Hub MUST re-sign each delivery with a fresht/jti(§9.7). - FIFO is guaranteed on first delivery; redelivery MAY reorder relative to fresh directives, so the agent
MUST NOT depend on strict ordering for correctness — it deduplicates on the directive
id(§13.4).
Consume / ack — POST /v1/inbox/ack. Removes directives the agent has durably processed:
{ "ids": ["dir_01H...", "dir_01H..."] }
Returns 200 { "acked": <count> }. Idempotent: acking an already-acked or unknown id is a no-op (counted
or not, Hub's choice), never an error. Acking is the agent's explicit consume; an un-acked directive is
redelivered after the visibility window and retained until acked or expired (§13.3). Ack is submitter-bound
like drain: an ack referencing an id not in the caller's own mailbox has no effect (and MUST NOT reveal
whether that id exists elsewhere).
Webhook (optional). If the agent has pre-registered a §9.4-verified inbound callback, the Hub MAY also POST each signed directive to it, following the §8.3 at-least-once retry rules (5xx/network retry with backoff, no 4xx retry, capped attempts/duration). Push is best-effort; the mailbox (drain/ack) remains the source of truth and the fallback — a directive delivered only by webhook is still drainable until acked or expired. Registration of the agent's inbound callback URL is out-of-band and implementation-defined (the same host-ownership verification as §9.4).
9. Security and Privacy
9.1 Authentication and authorization
- Agent → Hub. Bearer token or API key scoped to one
agent.id. The Hub MUST reject an envelope whoseagent.iddoes not match the credential (403).run_idis opaque and MUST NOT be used to authorize cross-run access; the Hub MUST bind a message's poll (§8.2), callback (§8.3), and cancel (§8.4) access to the principal that submitted it. A non-submitting principal MUST NOT poll, receive the callback for, or cancel a message by learning or guessing itsid— cancel is state-terminating, so an unbound cancel lets one agent withdraw another's openask. To that principal the message SHOULD be indistinguishable from an unknown id (404over403), so the binding also serves as an id-enumeration guard. - Resolver authz.
actorMUST be Hub-attested — derived from the Hub's authenticated operator session, never from the resolving request body; format<type>:<id>,type ∈ {human, agent, system}. A message's optionalallowed_resolverslists who may resolve it. When absent, the default is fail-closed: only the submittingagent.id's own identity may resolve. (A fail-open default would ship the unauthenticated-resolver hole as opt-in.)client_refMUST NOT be exposed to resolvers.
9.2 Response integrity (all callback schemes)
The Hub MUST sign every pushed Response, for every callback.auth.scheme (not only hmac). The
signature is detached and computed over a canonical signed_context:
signed_context = {
"ma2h_version", "callback_url", "id", "in_reply_to", "jti",
"payload_sha256", "resolution", "resolution_id", "resolved_at", "t"
}
serialized with RFC 8785 JSON Canonicalization Scheme (JCS) (deterministic key order, IEEE-754 number
formatting, canonical minimal string escaping — RFC 8785 §3.1 preserves string content and does not
normalize Unicode), then signed with hmac-sha256 (secret from secret_ref) or ed25519 (advertised in
§8.0). Wire header:
MA2H-Signature: t=<unix>,jti=<nonce>,v1=<base64url(signature)>
payload_sha256 (v0.3) binds the response payload. It is the lowercase-hex SHA-256 of the JCS
serialization of the fixed-key object { "response": <the Response responseobject, ornull>, "state": <the Response state, or null> }. This binds the entire response detail the agent consumes —
value, comment, actor, edited, resolved_at — and the round-tripped state, so altering any of
them in transit invalidates the signature. The Hub MUST compute payload_sha256 over the payload it
delivers; the agent MUST recompute it from the payload it actually received — never trusting a
transmitted digest — and include it in the signed_context it reconstructs before verifying. Numeric
payload values MUST be canonicalized per RFC 8785 §3.2.2.3 so a conformant signer and verifier agree
byte-for-byte. RFC 8785 serializes a number as its IEEE-754 double value (the Number::toString form), so
ordinary decimals (0.1, 1e-7, -0.001) and in-range integers all canonicalize deterministically;
implementations MUST use a conformant JCS implementation (a JS JSON.stringify of a number already
conforms — a non-JS implementation MUST use a vetted JCS library, and MUST preserve string content as-is:
RFC 8785 §3.1 does not normalize Unicode). The one caveat is exactness, not conformance: because a
JSON number is a double, an application that must preserve an integer beyond ±(2^53−1) exactly MUST carry
it as a string (a bare number would silently round). The dp-004 conformance vector pins the canonical
bytes + digest for a numeric payload as the cross-language interop target. payload_sha256 is carried only
inside the signed_context covered by the MA2H-Signature
header; it is intentionally not a field of the Response body schema (a body-borne digest would be
attacker-suppliable, defeating the recompute requirement above).
resolution, resolution_id, and resolved_at are additionally bound as top-level fields. The Response's
top-level defaulted flag and its routing-only top-level agent object (agent.id / agent.run_id) are
not bound and MUST be treated as advisory: a conformant agent MUST derive the human-vs-expiry-default
distinction from the bound actor (system:default_on_expire vs a human:/agent: identity) and
resolution — never from defaulted — and MUST NOT use the top-level agent (e.g. agent.id) for
authorization, correlation, or state-key selection (the bound in_reply_to + callback_url are the
authenticated routing identity). Before v0.3 the answer value for a
select/input ask was unsigned — a terminating proxy could flip it and verification still passed.
The agent MUST verify the signature against the canonical signed_context it reconstructs, MUST reject a
t outside ±120s of its own clock (replay window), and MUST maintain a replay cache of seen jti
values with a TTL ≥ the replay window (this is independent of the 30-day message retention; a stable
pull response is not protected by jti and is instead trusted via §8.2 transport + the immutable terminal
record). Binding the signature to id + resolution_id + callback_url prevents cross-message and
cross-endpoint replay; payload_sha256 binds the decision payload itself. The body-signing key MAY be the same as the callback-auth credential or distinct;
either way rotation MUST be supported (the agent MAY hold two valid keys during overlap).
9.3 State integrity (agent-owned) and key provenance
Returned state is UNTRUSTED input. An agent MUST NOT act on state contents without verifying
integrity it applied itself. The Hub storing state opaquely and never inspecting it (§4) is necessary
but not sufficient — it does not stop a malicious Hub or resolver from tampering with the blob.
- An agent SHOULD AEAD-seal
state(encrypt-then-MAC) and MUST verify the MAC before use; a minimal HMAC is permitted for low-sensitivity state. - Key provenance (REQUIRED). The state-seal key MUST be a per-
agent.idsecret pre-positioned in the agent runtime (e.g., a CI/Actions secret, a vault env var), distinct from the callback credential, surviving re-invocation independently of any Hub-held value. The key MUST NOT be embedded instate(circular — zero integrity) and MUST NOT transit the Hub. If this key co-resides with a compromised callback host, the guarantee is void. - Anti-pattern (do not do this): placing the seal key inside the
stateblob, or deriving it from data the Hub can see. Either makes "verify before use" pass on attacker-controlled state.
9.4 Callback destination safety (SSRF)
Before attaching a referenced credential to a callback, the Hub MUST verify the callback.url host is
agent-owned (verification method implementation-defined: DNS-TXT, HTTP well-known, or operator approval).
The Hub MUST refuse private/link-local/metadata IP ranges, MUST apply that refusal at delivery time
(not only at registration — DNS-rebinding defense), MUST NOT follow redirects, and MUST only attach a
credential when the URL host matches a registered binding. A dev-mode relaxation MAY exist but MUST
fail closed in production. (v0.1's GitHub-PAT-to-/dispatches example is a confused-deputy anti-pattern;
see examples/callback-anti-pattern.md.)
9.5 Clock authority
The Hub clock is authoritative for expires_at evaluation and for the §9.2 signature t window
(the agent applies the window against its own clock for the inbound signature; the Hub uses its own clock
for expiry). Agents SHOULD synchronize via NTP.
9.6 Content safety and telemetry minimization
- A Hub MUST NOT render raw HTML from
bodyin the inbox (Markdown is sanitized to a no-raw-HTML profile; external links visibly marked, not auto-followed; remote images not auto-fetched). It MUST NOT server-side-fetchcontext.file.uriunless the URI passes §9.4 host controls. - Telemetry. Hub logs SHOULD exclude
body,context,response.value, andresponse.commentby default, and MUST NOT logstate. A property markedx-ma2h-sensitive: true(or a message-levelsensitive: true) strengthens the SHOULDs to MUST and excludes the value from any export/telemetry. A sensitive value MAY be carried as a reference ({ "secret_ref": "vault://..." }) so the raw secret never enters the agent's LLM context (the CHEQ idea).
9.7 Directive integrity (inbound leg, v0.4)
The inbound mirror of §9.2. A directive drives agent behavior, so the agent MUST authenticate its origin
and integrity before acting — a forged or tampered directive is a prompt-/command-injection vector. The Hub
MUST sign every directive delivery (drain and webhook alike), for every advertised
inbound.signature_algs scheme, with a detached signature over a canonical inbound_signed_context:
inbound_signed_context = { "from", "id", "jti", "ma2h_version", "payload_sha256", "t", "to" }
serialized with RFC 8785 JCS (§9.2) and signed with hmac-sha256 or ed25519. The wire header reuses
the §9.2 form: MA2H-Signature: t=<unix>,jti=<nonce>,v1=<base64url(signature)>.
payload_sha256is the lowercase-hex SHA-256 of the JCS serialization of the fixed-key object{ "directive": <the human-authored content object> }, where the content object carries exactly the directive's presenttitle,body,priority,tags, andcontextfields. This binds the instruction the agent consumes, so altering (or adding/stripping) any of it in transit invalidates the signature. Numbers canonicalize per RFC 8785 §3.2.2.3 exactly as in §9.2. As in §9.2,payload_sha256is carried only inside the signed context (never as a body field), and the agent MUST recompute it from the directive it actually received rather than trusting any transmitted digest.from,id, andtoare bound as top-level fields:fromauthenticates the attested author,idis the stable at-most-once key, andtobinds the directive to its addressed agent — so a directive signed foragent:Xcannot be re-addressed toagent:Ywithout breaking the signature (and the receiving agent additionally confirmstois itself, §13.4).- Unbound metadata (advisory).
created_at,expires_at, andsensitiveare not bound — neither top-level nor insidepayload_sha256(which covers only thetitle/body/priority/tags/contextcontent). They are Hub-authoritative advisory fields and MUST NOT drive an agent's security decisions: the Hub — not the agent — enforcesexpires_at(it drops an expired directive before delivery, §13.3), andsensitivegoverns Hub-side telemetry (§9.6). An agent MUST NOT, for example, extend trust based on a receivedexpires_atorcreated_at. (This mirrors §9.2 treating the top-leveldefaulted/agentas advisory.) - Per-delivery signing (REQUIRED). Unlike a §6 Response (delivered promptly on resolution), a directive
MAY rest in a mailbox for a long time before the agent polls. The Hub MUST therefore compute a fresh
tandjtiat each delivery, so a legitimately-old directive still lands inside the agent's replay window. Onlyt/jtichange per delivery; the boundfrom/id/to/payload_sha256are stable.
The agent MUST verify the signature against the inbound_signed_context it reconstructs, MUST reject a t
outside its replay window (±replay_window_seconds, §8.0) against its own clock, MUST maintain a jti
replay cache (TTL ≥ the window, §9.2), and MUST deduplicate acting on the directive id (§13.4). Because
every directive delivery is signature-verified on both channels — the pull mailbox is an unsolicited
inbound command surface, not a response the agent solicited — this is a MUST on pull as well as webhook,
which is intentionally stronger than the §8.2 "authenticated GET is trusted transport" stance for the
return leg. The signing key MAY be the same as, or distinct from, the §9.2 body-signing key; rotation MUST
be supported (the agent MAY hold two valid keys during overlap).
10. Versioning and Roadmap
ma2h_versionisMAJOR.MINOR. The major is the integer before the first dot. A Hub MUST reject a message whose major it does not recognize with400 version_not_supported. Within a recognized major, a Hub SHOULD accept any minor and MUST ignore unknown fields (robustness principle). The v0.4 schema accepts a canonical0.xviapattern: "^0\\.(0|[1-9]\\d*)$"— a leading-zero minor (e.g.0.03) is a non-canonical spelling and is rejected as avalidation_error, so it can never be mistaken for0.3and slip a push past the parity gate below. (The historical v0.3 schema used the looser^0\\.\\d+$; the tightening is additive — every canonical version stays valid.)- Spec text Apache-2.0; contributions under DCO. Breaking changes bump the version.
- v0.3 (breaking): the §9.2 signature now binds
payload_sha256(a digest of the response payload), so a v0.2 verifier and a v0.3 signer compute different canonical strings — a v0.2 agent rejects v0.3 signatures and vice-versa. There is no signature interop across this break within major0; upgrade and re-vendor both ends together. - Push version parity (v0.3). Because the break is in the signature, a v0.3 Hub's pushed Response
can be verified only by a v0.3+ agent. A Hub MUST therefore reject (or negotiate) a message that requests
a push callback at a pre-0.3
ma2h_versionwithversion_not_supported— emitting a callback the agent provably cannot verify is worse than a clear rejection. Pull is unaffected: a pulled Response is not signature-verified (§8.2, trusted via the authenticated GET), so a pre-0.3 pull message stays compatible. (§9.2.) The parity threshold is the signature-break minor (3), independent of the highest minor a Hub implements: a v0.4 Hub still accepts a 0.3 push (0.3 and 0.4 share the payload-bound §9.2 signature) and still rejects a pre-0.3 push. - v0.4 (additive, non-breaking). v0.4 adds the human→agent directive leg (§13) — a new inbound
message type, the
/v1/inboxtransport (§8.7), the §9.7 directive signature, and theinboundcapability field (§8.0). It changes no v0.3 wire format: every v0.3 leg (notify/ask/task + responses) is byte-for-byte unchanged, and a 0.4 Hub accepts 0.3 agent→human envelopes and signs their responses at the version carried (the §9.2 algorithm is unchanged). Newspec/v0.4.md+schema/v0.4/(a full snapshot; the agent→human schemas are re-$id'd to the v0.4 path unchanged,capabilitygainsinbound, andinbound-message.schema.jsonis added). Because the directive signature is a new context (§9.7), it interoperates only between a 0.4 Hub and a 0.4-aware agent — but a pre-0.4 agent simply does not consume the inbound leg, so nothing it already does breaks. v0.4 additionally adds the cross-cutting acknowledgment/receipt (§14) and presence (§15) primitives — both additive, capability-advertised (ack,presence, §8.0), and orthogonal to every existing leg (the ack track never touches the §7resolution; presence is a read-only derived signal).
Roadmap (out of v0.4): human SSO mechanics; assignment/escalation/SLA routing; human→agent ask/task
(a routed agent-response leg — the mirror of §6); multi-turn threads; streaming (AG-UI interop); channel
fan-out.
11. Provenance (what MA2H reuses)
| From | Reused in MA2H |
|---|---|
| A2A (Linux Foundation) | Part union; callback ← PushNotificationConfig auth schemes; project ≈ contextId; terminal-state irreversibility; input-required → the ask lifecycle; capability discovery ≈ Agent Card (§8.0). |
| HITL Protocol | 202 + poll_url/review_url submit handshake (§8.1). |
| MCP elicitation | select options as enum+label; flat input schema; accept/decline/cancel-shaped resolutions. |
| HumanLayer | opaque state round-trip; typed contact channels (roadmap). |
| LangGraph | request.permissions ← HumanInterruptConfig. |
| CHEQ (IETF draft) | keep human-entered secrets out of the agent's LLM context (§9.6). |
| RFC 8785 (JCS) | canonical serialization for the detached Response (§9.2) and directive (§9.7) signatures. |
| Durable message queues (SQS / AMQP) | the inbound mailbox: at-least-once delivery, visibility-timeout redelivery, explicit consume/ack, FIFO-per-consumer (§8.7, §13). |
| Messaging read-receipts / delivery status | the acknowledgment/receipt (§14): a terminal, per-message "delivered → acknowledged" track, orthogonal to the decision. |
| Presence / last-seen heartbeats (XMPP, chat) | derived presence (§15): a last_seen from existing activity, freshness-windowed, owner-scoped. |
12. Validation and Proof Obligations
Conformance vectors cannot prove the security/concurrency requirements — a JSON Schema checks shape,
not cryptographic soundness or race-freedom. Vectors are labeled by class (see conformance/README.md):
- Schema-validation vectors (executable now, no Hub) — each example validates, or is an intentional negative vector. Covers the envelope, return leg, resolution mapping, per-scheme auth conditionals, cross-type rejection.
- Prose audits (human sign-off during spec review, not executable) — the "spec contains a MUST" checks. Verifies text presence, not implementation conformance.
- Downstream proof obligations (validatable only against a conformant Hub) — the security/concurrency
controls. This spec specifies candidate controls for resume-hijack (§9.3), SSRF (§9.4), Response
integrity (§9.2), and concurrency (§7); closure is proven against the Hub, which MUST discharge:
the §9.2 signature test vector (known input → known signature), a replay-cache bypass test, an
SSRF-allowlist + DNS-rebinding bypass test, and a CAS-race test (two terminal transitions in a sub-ms
window → first wins, one
resolution_id; answer-at-expires_atbeats default).
The inbound leg (§13) adds vectors of the same three classes: schema-validation for the directive envelope
(valid; missing to; a non-human/system from; cross-type request/action/state rejection); the
§9.7 directive-signature obligation (deterministic fixture → known signature; recompute payload_sha256
from the directive; reject a tampered from/to/body; reject a replayed jti); and the mailbox
at-least-once obligation (a redelivered directive with the same id is acted on once; drain/ack are
submitter-bound so one agent cannot drain or ack another's mailbox).
The ack (§14) and presence (§15) primitives add: schema-validation for the ack and presence
envelopes; the §14.4 pushed-ack signature obligation (deterministic fixture → known signature; reject a
replayed jti/out-of-window t); the ack terminal-once obligation (first ack for an in_reply_to wins,
submitter-bound); and the presence obligations (last_seen derived from poll/long-poll/SSE activity;
online/offline/unknown by the advertised window; owner-only read — a non-owner sees unknown).
13. The Inbound Leg — Human → Agent Directives (v0.4)
The mirror of the agent→human legs: a Hub-attested human sends one agent a directive — an instruction
or FYI — and the agent receives it by draining its mailbox (§8.7). It is the inbound counterpart of notify
(one-way, no routed agent response — see §2 Non-Goals for why ask/task inbound are deferred), and it
reuses the v0.3 machinery throughout: the Part union (§4.2), Hub attestation of identity (§9.1), RFC 8785
JCS + detached signature (§9.7 ← §9.2), jti replay dedup (§9.2), SSRF controls (§9.4), and durability
(§3.1).
Human (attested) Hub Agent (drains when it polls)
──────────────── ─── ───────────────────────────
author directive ─────────▶ attest `from`, assign id
to agent:X enqueue in X's mailbox
(durable, FIFO)
┌── GET /v1/inbox ────────── 1. drain (bearer = agent:X)
sign each │ 2. verify §9.7 signature
delivery ──┤── {directive, signature} ─▶ 3. dedup on id; act once
(fresh t, │ 4. POST /v1/inbox/ack {ids}
jti) └── (redeliver if unacked ── (at-least-once)
past visibility window)
13.1 The directive envelope
A JSON object; schema schema/v0.4/inbound-message.schema.json. This is the delivered form (the agent
validates it): id and from are Hub-assigned/attested, never agent- or request-supplied.
| Field | Type | Req | Notes |
|---|---|---|---|
ma2h_version |
string | MUST | "0.4". |
type |
const | MUST | "directive" — the single inbound type in v0.4. |
id |
string | MUST | Hub-assigned canonical directive id. The agent's at-most-once dedup key (§13.4). |
from |
string | MUST | Hub-attested author, ^(human|system):.+$; derived from the authenticated operator session, never the request body (as §9.1 attests actor). human:<id> in v0.4. |
to |
string | MUST | ^agent:.+$ — the addressed agent; the mailbox routing key. |
created_at |
string | MUST | RFC 3339 (Hub clock; informational). |
title |
string | MUST | One line. ≤ 200 chars. |
body |
string | SHOULD | Markdown detail. Untrusted agent input — the inbound mirror of §9.6 (§13.5). |
priority |
enum | MAY | low | normal | high | urgent. Default normal. |
tags |
string[] | MAY | Free-form labels. |
context |
Part[] | MAY | Attachments (§4.2). |
expires_at |
string | MAY | RFC 3339. After it, the Hub stops delivering and drops the directive (§13.3). MUST be in the future at author time. |
sensitive |
bool | MAY | Excludes value(s) from export/telemetry (§9.6). |
A directive MUST NOT carry request, action, or state (schema-enforced): inbound ask/task are
deferred, and a directive is not an agent-owned resume blob.
13.2 Delivery
Pull-first, exactly as §8.7 specifies: the agent authenticates with its existing bearer token / API key
(§9.1) and drains GET /v1/inbox (FIFO, long-poll-capable), then consumes with POST /v1/inbox/ack.
Delivery is at-least-once: a drained-but-unacked directive is redelivered after the visibility window.
A Hub MAY additionally push each signed directive to a pre-registered, §9.4-verified agent webhook
(§8.7); push is best-effort and the mailbox remains the source of truth.
13.3 Retention and expiry
An acked directive is consumed and removed. An un-acked directive is retained and kept deliverable until it
is acked or until its expires_at (or, absent that, the Hub's inbound.retention_days, §8.0) passes, at
which point the Hub drops it. A directive emits no §6 Response — there is no routed agent-response leg
(it is the notify mirror). To confirm receipt/understanding to the human, the agent posts an
acknowledgment (§14): the POST /v1/inbox/ack consume doubles as the receipt (optionally with a note),
advancing the directive's delivery track to acknowledged (§14.2) — this is the first-class mechanism, not
a separate out-of-band notify.
13.4 Agent duties (normative)
A conformant consuming agent MUST, for each drained or pushed directive, in this order:
- Verify the §9.7 detached signature against the
inbound_signed_contextit reconstructs — recomputingpayload_sha256from the directive it received, rejecting atoutside the replay window, and rejecting a replayedjti(from ajticache with TTL ≥ the window, §9.7). It MUST treat the directive as untrusted until verified, and MUST also validate the directive's shape againstinbound-message.schema.json— becausepayload_sha256binds only the content fields, an on-path injector (or a buggy Hub) can append a forbiddenrequest/action/statefield without breaking the signature, so the agent MUST reject such cross-type data rather than acting on it. Per the §10 robustness rule the agent ignores unknown fields, but — since they are likewise unsigned — MUST NOT surface them to its execution/LLM context (strip to the known fields before acting). - Confirm the addressee. After verifying, the agent MUST confirm
tois its ownagent:<id>and reject the directive otherwise. The signature bindsto, so a valid signature proves the Hub intended a specific addressee — but only the recipient checkingtoagainst its own identity stops a directive validly signed foragent:Xfrom being acted on byagent:Y. The pull mailbox enforces this Hub-side (an agent drains only its own mailbox), but the webhook channel has no such gate, so the check is a MUST on the agent for both channels. - Deduplicate on the directive
idand act at most once — because at-least-once delivery (and any webhook/drain overlap) can present the sameidmore than once, possibly concurrently (a webhook and a poll racing) or after the visibility timeout while still processing. At-most-once for a non-idempotent side effect therefore requires reserving theidbefore the work begins (so an overlapping delivery is deduped in-flight), not only recording it after. This id-dedup gives at-most-once within the agent's dedup horizon; an agent that must remain at-most-once across a restart MUST either make its side effect idempotent or persist its dedup state, since a redelivered-but-unacked directive is re-presented after a restart clears an in-memory cache. - Ack (
POST /v1/inbox/ack) once it has durably processed the directive, so the Hub stops redelivering it. The safe ordering is verify → confirm addressee → act (durably/idempotently) → record theidin the dedup cache → ack: recording the dedup id or acking before durable processing risks silently dropping a directive on a crash. Acking is the explicit consume; id-dedup guards overlapping deliveries within the window, ack + redelivery guards a crash before processing.
13.5 Security considerations (inbound)
- Authenticity / injection. A directive enters the agent's execution/LLM context, so a forged or
tampered one is a command-injection vector. Defenses: Hub-attested
from, the §9.7 signature the agent MUST verify,payload_sha256binding the instruction bytes,to-binding against cross-agent replay, andjti+window against replay. An agent SHOULD apply its own authorization policy to a verified directive (whichfromprincipals may instruct it, and to do what) — verification proves origin, not intent-safety. - Isolation. The bearer credential (scoped to
agent.id) selects the mailbox; a principal MUST NOT drain or ack another agent's mailbox, and a foreign/unknown directive id MUST be indistinguishable from a non-existent one (the §9.1 submitter-binding, applied to the inbox). - Content safety. The agent MUST treat
body/contextas untrusted input; a Hub that echoes a directive into any HTML surface applies the §9.6 no-raw-HTML sanitization. - SSRF. The optional webhook reuses §9.4 wholesale (host-ownership verification, delivery-time private-range refusal / DNS-rebinding defense, no redirects, credential-host binding).
14. Acknowledgment / Receipt (cross-cutting, v0.4)
A cross-cutting primitive — modeled once, applied to both directions. An acknowledgment (receipt) is
a one-shot, terminal confirmation that the receiving party got the other party's message or answer,
optionally with a short human-readable note. It is distinct from a later notify: it closes the loop on a
specific message rather than starting a new one. It is additive and non-breaking — a separate
delivery/receipt track layered on top of the unchanged legs, advertised via ack in §8.0.
Applied in v0.4 to:
- (a) a human→agent directive (§13): the agent acks the directive it drained — "got it, on it."
- (b) an agent→human
ask/taskresponse (§6): when the human resolves, the agent — on receiving the Response — acks that it got the answer and is proceeding — "got your Ship it — resuming the deploy." This enriches the shipped v0.3 response leg without changing it.
14.1 The ack envelope
A JSON object; schema schema/v0.4/ack.schema.json. The Hub produces it when an ack action (§14.3) fires.
| Field | Type | Req | Notes |
|---|---|---|---|
ma2h_version |
string | MUST | "0.4". |
type |
const | MUST | "ack". |
in_reply_to |
string | MUST | The acknowledged id — a directive id (§13.1) or an ask/task message id (§6). |
by |
string | MUST | Hub-attested acking party, <type>:<id> (§9.1). agent:<id> in v0.4's two applications; the field is direction-agnostic. Never request-supplied. |
acked_at |
string | MUST | RFC 3339 (Hub clock). |
note |
string | MAY | A short, human-readable note. Untrusted agent→human text — the Hub sanitizes it to the §9.6 no-raw-HTML profile before rendering it to the human. |
resolution_id |
string | MAY | For an ask/task ack, the resolution_id of the Response being acknowledged (disambiguates which resolution). |
An ack is terminal for the receipt track: the first ack for a given (in_reply_to) wins; a repeat
is a no-op returning the existing ack (first-terminal-wins, mirroring §7).
14.2 Lifecycles (additive; orthogonal to the §7 resolution track)
The §7 resolution (open → answered/declined/…) is unchanged. Ack adds a separate delivery/receipt
track surfaced on the message GET body (§8.2) as an additive delivery object:
directive: queued ─▶ delivered ─▶ acknowledged
ask/task: answered ─▶ delivered-to-agent ─▶ acknowledged
delivered/delivered-to-agent— the Hub confirmed the receiving party got the message: a directive isdeliveredwhen first drained (§8.7); an ask/task Response isdelivered-to-agenton a successful push (2xx) or when the agent authenticated-GETs the resolved message (§8.2).acknowledged— the receiving party posted an ack (§14.3). Terminal for this track.
delivery is advisory to the flow but authoritative for the human's "did they get it / act on it?" view. It
never gates or alters the §7 resolution.
14.3 Transport — resource-scoped, per leg
Following the repo's resource-scoped sub-action convention (/…/resolve, /…/cancel):
- Response leg (ask/task):
POST /v1/messages/{id}/ackwith{ "note"?: "…", "resolution_id"?: "…" }. Only the submitting principal may ack (§9.1). Marks the messageacknowledged; returns200with the ack envelope. Requires the message to be terminal (a resolution exists) — acking anopenmessage is409. - Directive leg: the receipt folds into the existing mailbox consume (§8.7).
POST /v1/inbox/ackgains an optionalnote:{ "ids": [...], "note"?: "…" }— consuming a directive is acknowledging it (they are the same moment), so no separate call. The Hub marks each listed directiveacknowledgedand records the receipt (withnotewhen present). A note-less{ "ids": [...] }still acks (no human note).
Either action makes the ack readable by the human over the authenticated Hub transport (§14.4). A Hub with an ack webhook for the human's client MAY also push the signed ack.
14.4 Trust
An ack is informational to the human — it updates a receipt/status view and triggers no automated action (unlike a §13 directive, which drives agent behavior). Its trust model therefore mirrors the §6 response leg, not the §9.7 directive rule:
- Pull: the human reads the ack over an owner-authenticated Hub GET — the
deliveryobject on the human's own inbox/status surface for that message (or an ack feed) — trusted via transport exactly as a pulled v0.3 Response (§8.2) and the inbox itself. No signature required. (This is the human's owner-scoped read; it is distinct fromGET /v1/messages/{id}, which is submitter-bound to the originating agent, §9.1 — the samedeliveryalso appears there so the acking agent sees its own receipt.) - Push: a Hub that pushes acks to a human callback MUST sign each with a detached signature
(§9.2-style) over an
ack_signed_context = { ack_sha256, by, in_reply_to, jti, ma2h_version, t }, using an advertisedack.signature_algsalg.ack_sha256is the lowercase-hex SHA-256 of the JCS of a fixed-key object with exactly these five keys —{ acked_at, by, in_reply_to, note, resolution_id }— where each absent optional field (note,resolution_id) is serialized as JSONnull(exactly as §9.2's{ response, state }wrapper nulls absent members, so the digest is unambiguous regardless of which optionals the ack carries). The client MUST recomputeack_sha256from the ack it received using this same fixed-key/nullwrapper (never omitting the absent keys), verify the signature, and reject a replayedjti/ out-of-windowt, mirroring §9.2. Signing on push is REQUIRED when a push channel is used; verification on pull is not (transport-trusted).
by is Hub-attested; the free-text note is untrusted agent→human content sanitized per §9.6.
15. Presence / "Listening" (cross-cutting, v0.4)
A derived, best-effort signal of whether an agent:<id> is currently reachable/listening — so a human's
client can set expectations before or during a message exchange. It is a heuristic, not a guarantee
(there is no always-on connection; an agent may poll then exit a second later). Advertised via presence
(§8.0). Read-only; it changes no other leg.
15.1 Derivation (no new agent obligation)
The Hub derives a per-agent.id last_seen timestamp from the agent's existing authenticated
activity — the traffic it already handles: a mailbox drain (GET /v1/inbox, §8.7), a response long-poll
(GET /v1/messages/{id}?wait=, §8.2), or an SSE subscription (GET /v1/stream). No heartbeat endpoint and
no persistent connection are required. A Hub MAY additionally accept an explicit refresh ping, but that is
non-normative — the signal is the agent's real reachability traffic.
15.2 States
From an advertised freshness window W = presence.freshness_seconds (§8.0), evaluated against the Hub
clock (§9.5):
| State | Meaning |
|---|---|
online |
now − last_seen ≤ W — the agent polled/subscribed recently; likely reachable now. |
offline |
now − last_seen > W — the agent was seen but not within the window; it will receive on its next run. |
unknown |
no last_seen on record — the agent has never connected (distinct from offline). |
15.3 Read endpoint (owner-only)
GET /v1/agents/{agent_id}/presence → { "agent_id", "state", "last_seen"?, "freshness_seconds" } (schema
schema/v0.4/presence.schema.json). Owner-only, per-account: only the authenticated human whose
account the agent belongs to MAY read it; the Hub MUST NOT expose an agent's presence to other agents or
publicly, and an agent the caller does not own MUST be indistinguishable from unknown. Presence leaks
when an operator's automation runs, so it is privacy-scoped to the owner.
15.4 Applications
- Before a human→agent send (§13):
online→ "listening now" (the directive will be picked up promptly);offline/unknown→ "will receive on its next run" (the mailbox is durable, §13.3). - On a pending
ask/task(§6): while the agent is blocked waiting on the human, the same signal shows whether it is actively waiting (online→ answering resumes it near-instantly via its open long-poll/callback) or already exited (offline→ the answer is picked up later via callback or the next run). No separate mechanism — presence serves both.
Appendix A — Worked Examples
See /examples: notify-daily-digest, ask-dev-team-decision, ask-mode-input,
ask-mode-confirm, ask-sensitive-field, task-manual-action, and the Responses
response (answered), response-expired-default, response-cancelled, response-declined,
task-completion-response, the worked response-signature-v0.3 (the §9.2 payload-bound signature), plus
the safe callback-agent-resume and the callback-anti-pattern note. For the inbound leg (§13):
directive-inbound (a human→agent directive) and the worked directive-signature-v0.4 (the §9.7
directive signature).