# MA2H — Multi-agent to Human Protocol

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

> ⚠️ **Superseded by [v0.2](v0.2.md)** (2026-06-03). v0.1 is retained for history only. It has known
> trust-model, return-leg, concurrency, and durability gaps that v0.2 fixes — do not implement against it.
> See the [CHANGELOG](../CHANGELOG.md).

## 0. Abstract

MA2H is a vendor- and runtime-neutral protocol by which **agents reach humans** and **receive decisions
back**. It defines a hub-and-spoke model: 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 agent via **push** (webhook) or **pull**
(polling). MA2H is the agent↔human complement to A2A (agent↔agent) and MCP (agent↔tools).

## 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, and routes responses. The "center."
- **Human** — the operator who triages the inbox and resolves `ask`/`task` messages.
- **Caller** — the specific agent run/invocation that produced a message; identified by `agent.run_id`.
- **Resolution** — the terminal outcome of a message (e.g., `answered`, `completed`, `expired`).

A **conformant Hub** MUST accept the message envelope (§4–§5), expose the transport bindings (§8), and
emit the response envelope (§6) on resolution. A **conformant Agent** MUST send a valid envelope and MUST
be able to receive a resolution by at least one of push or pull.

## 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 support for **ephemeral** agents (a CLI run, a GitHub Action) via the pull binding, and for
  **resumable** agents via the push binding.
- Async by default; a human is never assumed to be online.
- Reuse established conventions (§11) rather than inventing new ones.

**Non-Goals (v0.1)** — deferred to later versions, called out in §10.
- The human-facing inbox **UI** (only the wire contract is normative).
- Human **identity / SSO** and authn of operators.
- Multi-human **assignment, escalation, SLA** routing (the ITSM layer). v0.2 candidate.
- **Streaming** partial agent output (AG-UI's domain).
- Multi-turn **threaded** asks (a resolution is single-shot in v0.1).

## 3. Architecture

```
  ┌─────────┐   POST /v1/messages   ┌──────────────────┐   inbox    ┌────────┐
  │  Agent  │ ────────────────────▶ │       Hub        │ ─────────▶ │ Human  │
  │ (spoke) │ ◀──────────────────── │ (ingest + inbox  │ ◀───────── │        │
  └─────────┘  push webhook  /  or  │  + router)       │  resolve   └────────┘
       │       agent polls poll_url └──────────────────┘
       └── GET /v1/messages/{id}
```

A `notify` terminates at the inbox. An `ask` or `task` produces a **Response** (§6) when the human
resolves it; the Hub delivers that Response to the agent by the binding the agent selected (§8).

## 4. Message Envelope (common fields)

Every message is a JSON object with these common fields:

| Field | Type | Req | Notes |
|-------|------|-----|-------|
| `ma2h_version` | string | MUST | Protocol version, e.g. `"0.1"`. |
| `id` | string | MUST | Stable message id (ULID/UUID). Agent-supplied or Hub-assigned; if agent-supplied it MUST be unique per agent. |
| `type` | enum | MUST | `notify` \| `ask` \| `task`. Discriminator. |
| `created_at` | string | MUST | RFC 3339 timestamp. |
| `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. |
| `priority` | enum | SHOULD | `low` \| `normal` \| `high` \| `urgent`. Default `normal`. |
| `tags` | string[] | MAY | Free-form labels for filtering (`["deploy","web-app"]`). |
| `context` | Part[] | MAY | Attachments: text, structured data, or files (§4.2). |
| `expires_at` | string | MAY | RFC 3339. After this, an unresolved `ask`/`task` auto-resolves (§7). |
| `idempotency_key` | string | MAY | Hub MUST treat a repeat key from the same agent as the same message. |

### 4.1 `agent` descriptor

```json
{
  "id": "deploybot/dev-team",        // stable logical agent identity
  "run_id": "run_01H...",         // this specific invocation (correlation for resume)
  "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
}
```

`agent.id` + `agent.run_id` together identify the **Caller** to route a response back to.

### 4.2 `Part` (borrowed from A2A)

A `context` entry is exactly one of:

```json
{ "kind": "text", "text": "..." }
{ "kind": "data", "data": { "...": "..." } }     // structured JSON, e.g. a diff summary
{ "kind": "file", "file": { "uri": "https://...", "name": "diff.patch", "mime_type": "text/x-diff" } }
```

Each Part MAY carry `metadata`.

## 5. The Three Verbs

### 5.1 `notify`

Informational. No response is expected; the message is terminal on delivery. Used for summaries, status
updates, the daily executive digest. Carries only the common envelope (§4). A Hub MUST NOT route a
response for a `notify`.

### 5.2 `ask`

A decision the human must make. The agent is blocked on, or will act on, the answer. Adds a `request`
block:

```json
"request": {
  "mode": "select",            // select | input | confirm
  "options": [                 // REQUIRED when mode=select (MCP enumNames + HumanLayer ResponseOption)
    { "value": "ship",  "label": "Ship to prod now",  "description": "Deploy immediately." },
    { "value": "hold",  "label": "Hold for review",   "description": "Wait for a human PR review." },
    { "value": "abort", "label": "Abort the change",  "description": "Revert and close." }
  ],
  "schema": {                  // REQUIRED when mode=input — FLAT JSON Schema (string/number/bool/enum only)
    "type": "object",
    "properties": { "reason": { "type": "string" } },
    "required": ["reason"]
  },
  "permissions": {             // borrowed from LangGraph HumanInterruptConfig
    "allow_accept": true, "allow_edit": true, "allow_respond": true, "allow_ignore": true
  },
  "default_on_expire": "hold", // value applied if expires_at passes unresolved; null = no default
  "callback": { /* §8.3 */ }
}
```

`mode=confirm` is sugar for a two-option select (`approve`/`deny`).

### 5.3 `task`

A manual action a human performs **in the world**, outside the agent. Distinct from `ask`: the human does
work, not just picks an answer. Adds an `action` block:

```json
"action": {
  "instructions": "Rotate API_SIGNING_KEY in the prod vault and confirm the webhook still signs.",
  "checklist": [                       // optional sub-steps the human can tick
    { "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.",
  "callback": { /* §8.3 — optional; notify the agent when marked done */ }
}
```

## 6. Response Envelope (human → Hub → agent)

Emitted by the Hub when an `ask`/`task` reaches a terminal state. Delivered via §8.

```json
{
  "ma2h_version": "0.1",
  "in_reply_to": "msg_01H...",          // the original message id
  "agent": { "id": "deploybot/dev-team", "run_id": "run_01H..." },
  "resolution": "answered",             // see §7 per type
  "response": {                         // present for ask; present-if-captured for task
    "value": "hold",                    // chosen option value, or the input object
    "edited": false,                    // true if the human edited an agent-proposed value
    "actor": "human:alice",          // who resolved it
    "resolved_at": "2026-06-03T18:04:11Z",
    "comment": "Let's get a human eye on the migration first."
  },
  "state": { "...": "..." }             // OPAQUE agent state, round-tripped verbatim (HumanLayer pattern).
                                        // The Hub MUST store and return it unmodified and MUST NOT inspect it.
}
```

The `state` field lets a stateless agent reconstruct its context on resume without the Hub understanding it.

## 7. Lifecycle / State Machines

```
notify:   created ──▶ delivered (terminal)

ask:      open ──┬─▶ answered    (terminal)   ← human chose/responded
                 ├─▶ declined    (terminal)   ← human refused (allow_respond/permissions)
                 ├─▶ cancelled   (terminal)   ← agent or Hub withdrew it
                 └─▶ expired     (terminal)   ← expires_at passed; default_on_expire applied if set

task:     open ──┬─▶ completed   (terminal)   ← human marked done
                 ├─▶ dismissed   (terminal)   ← human declined to do it
                 └─▶ expired     (terminal)
```

Terminal states are irreversible (A2A rule). An agent MAY `cancel` its own open message (§8.4); the Hub
MUST mark it `cancelled` and stop soliciting the human.

## 8. Transport Bindings (HTTP/JSON)

All endpoints are HTTPS, `Content-Type: application/json`. Base path `/v1`.

### 8.1 Submit — `POST /v1/messages`

Body: a message envelope (§4–§5). On success the Hub returns **`202 Accepted`** (HITL-Protocol handshake):

```json
{
  "id": "msg_01H...",
  "status": "open",                                  // or "delivered" for notify
  "poll_url": "https://hub.example/v1/messages/msg_01H...",
  "review_url": "https://hub.example/inbox/msg_01H..."// human-facing deep link (out-of-band share)
}
```

### 8.2 Pull — `GET /v1/messages/{id}`

Returns the message plus its current `status` and, once terminal, the `response` (§6). The Hub SHOULD
support long-poll via `?wait=<seconds>` (cap 60): hold the connection until resolution or timeout, then
return current state. This is the binding for **ephemeral** agents that cannot be called back.

### 8.3 Push — agent-supplied `callback`

An `ask`/`task` MAY include a callback so the Hub delivers the Response actively (A2A `PushNotificationConfig`):

```json
"callback": {
  "mode": "push",                       // push | pull (pull = omit url; agent polls 8.2)
  "url": "https://agent.example/ma2h/resolve",
  "auth": { "scheme": "hmac", "secret_ref": "env:MA2H_CALLBACK_SECRET" }  // hmac | bearer | apikey
}
```

On resolution the Hub POSTs the Response envelope (§6) to `url`. The Hub MUST sign the request (§9), MUST
retry with exponential backoff on non-2xx (≥ 5 attempts), and MUST include the message `id` so delivery is
idempotent. An agent MAY register both push and pull; push is best-effort, pull is the source of truth.

### 8.4 Cancel — `POST /v1/messages/{id}/cancel`

Withdraws an open message. Idempotent.

## 9. Security and Privacy

- **Agent → Hub** auth: bearer token or API key scoped to an `agent.id`. The Hub MUST reject envelopes
  whose `agent.id` does not match the credential.
- **Hub → Agent** callback signing: when `auth.scheme = hmac`, the Hub MUST send
  `MA2H-Signature: t=<unix>,v1=<hex(HMAC-SHA256(secret, "<t>.<raw-body>"))>` (Stripe/Slack style). The agent
  MUST verify and MUST reject timestamps outside a ±5-minute window (replay defense).
- **Transport:** HTTPS REQUIRED. Plaintext bindings MUST NOT be offered.
- **Secret hygiene (CHEQ idea):** when an `ask` collects a sensitive value the agent must *use* but should
  not *see in its LLM context*, the response `value` MAY be a **reference** (`{"secret_ref":"vault://..."}`)
  resolved out-of-band, never the raw secret. The Hub MUST allow flagging an input field as `sensitive` so
  it is excluded from any inbox export or telemetry.
- **PII in telemetry:** Hub logs MUST NOT include `body`/`response.value` of messages flagged `sensitive`.

## 10. Versioning and Roadmap

- Versioning: `ma2h_version` carries the spec version. Breaking changes bump the number; the Hub MUST reject
  an unknown major it cannot satisfy with `400`.
- RFC-2119 normative language; spec text Apache-2.0; contributions under DCO (`Signed-off-by`).

**Roadmap (explicitly out of v0.1):**
- **v0.2** — assignment / escalation / SLA (the ITSM layer): `assignee`, `escalation_policy`, on-call
  routing. The lesson from PagerDuty + enterprise ITSM is that routing metadata matters as much as payload.
- **v0.2** — multi-turn threads (an `ask` whose answer spawns a follow-up `ask` on the same `context`/thread).
- **v0.3** — streaming agent progress into the inbox item (AG-UI event interop).
- **v0.x** — channel fan-out (deliver an inbox item to Slack/email/SMS), modeled on HumanLayer `ContactChannel`.

## 11. Provenance (what MA2H reuses, and from where)

MA2H is intentionally non-NIH. Conventions adopted from prior art:

| From | Reused in MA2H |
|------|---------------|
| **A2A** (Linux Foundation) | `Part` union (text/data/file), `callback` ← `PushNotificationConfig` auth schemes, `project` ≈ `contextId`, terminal-state irreversibility, `input-required` → the `ask` lifecycle |
| **HITL Protocol** (`rotorstar/hitl-protocol`) | `202 Accepted` + `poll_url`/`review_url` submit handshake (§8.1) |
| **MCP elicitation** (Anthropic) | `select` options as `enum`+`enumNames`, flat input schema for renderability, accept/decline/cancel resolutions |
| **HumanLayer** | opaque `state` round-trip for stateless agents, typed contact channels (roadmap §10) |
| **LangGraph** | `request.permissions` ← `HumanInterruptConfig` (allow_accept/edit/respond/ignore) |
| **CHEQ** (IETF draft) | keep human-entered secrets out of the agent's LLM context (§9) |

## Appendix A — Worked Examples

See [`/examples`](../examples): the daily executive digest as a `notify`, a dev-team ship/hold decision as
an `ask`, a manual key-rotation as a `task`, and the corresponding response envelope.
