MA2HSpec v0.6FAQOH HAI (hub)GitHub
MA2H / Spec / v0.2

Superseded. The current version is v0.6. Rendered from /spec/v0.2.md.

MA2H — Multi-agent to Human Protocol

Version: 0.2 (Draft) · Date: 2026-06-03 · License: Apache-2.0 · Steward: Autonomy

Superseded by v0.3 (2026-06-12, in turn superseded by v0.5) — implement against v0.5. Supersedes v0.1. v0.2 is a breaking hardening pass — see CHANGELOG.

0. Abstract

MA2H is a vendor- and runtime-neutral protocol by which agents reach humans and receive decisions back. 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). MA2H is the agent↔human complement to A2A (agent↔agent) and MCP (agent↔tools).

This version (0.2) closes the trust-model, return-leg, concurrency, and durability gaps found in the v0.1 design review. It specifies candidate controls for the security- and concurrency-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.

Three words that v0.1 overloaded are kept distinct in v0.2 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).

2. Goals and Non-Goals

Goals

Non-Goals (v0.2)

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:

  1. 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.
  2. 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.
  3. On resolution the Hub POSTs the Response (§6), including state verbatim, to callback.url.
  4. The re-invoked process MUST verify the Response signature (§9.2) and reject replays, MUST verify the integrity of state it 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.
  5. 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 state or 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:

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.2/message.schema.json.

Field Type Req Notes
ma2h_version string MUST "0.2". 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 */ }
}

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.2/response.schema.json.

{
  "ma2h_version": "0.2",
  "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)
}

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:

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.2/capability.schema.json) so an agent can configure itself without out-of-band knowledge:

{
  "ma2h_version": "0.2",
  "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
}

8.1 Submit — POST /v1/messages

Body: a message envelope (§4–§5). On success the Hub returns 202 Accepted (schema schema/v0.2/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.2/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).

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

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.

9. Security and Privacy

9.1 Authentication and authorization

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", "resolution", "resolution_id", "resolved_at", "t"
}

serialized with RFC 8785 JSON Canonicalization Scheme (JCS) (deterministic key order, number/Unicode normalization), 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)>

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

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

10. Versioning and Roadmap

Roadmap (out of v0.2): human SSO mechanics; assignment/escalation/SLA (v0.3); 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 signature (§9.2).

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):

  1. 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.
  2. Prose audits (human sign-off during spec review, not executable) — the "spec contains a MUST" checks. Verifies text presence, not implementation conformance.
  3. 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_at beats default).

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, plus the safe callback-agent-resume and the callback-anti-pattern note.