Appearance
The protocol: MA2H
OH HAI implements the Hub role of MA2H, the open agent-to-human protocol. The protocol is vendor-neutral and Apache-2.0 licensed:
- Specification and repository: github.com/autnmy/ma2h-protocol
- Site: ma2h.org
What version the Hub speaks
The Hub at https://inbox.ohhai.app advertises its capabilities at an unauthenticated endpoint:
sh
curl -sS https://inbox.ohhai.app/v1/capabilityIt declares ma2h_version: "0.6" — the highest minor it speaks. The published @oh-hai/ma2h-core package (the vendored protocol layer the CLI, the MCP server and the Hub share) exports the same constant, MA2H_VERSION = "0.6", and oh-hai --version --json reports it as wire_version.
Minors are additive within major 0. An envelope is stamped with the lowest minor its features require: a plain notify/ask/task to the human inbox is stamped 0.3; an envelope carrying to (an agent destination), agent.session, or a session-qualified resolver is lifted to 0.5; an ask carrying permissions.allow_edit: true (oh-hai ask submit --allow-edit) is lifted to 0.6.
What the capability document tells a client
The fields OH HAI's clients read (values as served at the time of writing):
| Field | Meaning |
|---|---|
ma2h_version | 0.6. v0.6 adds the free-form answer (allow_edit on an ask → edited: true on the Response) and refuses duplicate options[].value and unanswerable input schemas (422) at every declared minor. A client refuses an addressed send or a session-scoped drain unless this is >= 0.5. |
inbound.poll_url, inbound.ack_url | The human-to-agent leg: GET /v1/inbox, POST /v1/inbox/ack. |
inbound.session_param | true: GET /v1/inbox?session=… drains the session-scoped mailbox. |
inbound.stream_url, inbound.stream_max_hold_seconds | The SSE stream (/v1/inbox/stream) and the ceiling on one hold (30 s). |
inbound.max_batch, inbound.visibility_timeout_seconds | Drain batch cap (50) and redelivery window for an unacked entry (30 s). |
replay_window_seconds | Signature freshness window a consumer enforces (120 s). |
presence.freshness_seconds | How long a presence signal stays fresh (90 s). |
sessions.* | The §16 session primitive: TTL bounds (60–3600 s), live sessions per agent (64 on production), retention of terminal sessions (3600 s), agent_list_visibility: true, operator_hard_stop: true. agent_list_visibility is the deployment ceiling on an agent reading its account's whole session list, not a statement that any account has opted in — each account's own Agent messaging switch is asked underneath it. |
agents.directory_url | https://inbox.ohhai.app/v1/account/agents — the account agent directory, which an agent bearer may read for its own account. Present only where the route is mounted: a Hub running without the account stores serves no directory and omits the field entirely, so its absence is a fact a client can act on rather than a 404 it has to interpret. It never names GET /v1/agents, which stays human-only. See Concepts → Agent. |
inter_agent.enabled, inter_agent.entry_kinds | The v0.5 agent-to-agent leg, with message, response and receipt entry kinds. Enabled per build; each account opts in separately (Settings → Agent messaging). |
signature_algs | hmac-sha256. The Hub signs with a server-side key and publishes no verification key, so no client can recompute the MAC. |
rate_limit.requests_per_minute | The Hub's submit ceiling on POST /v1/messages (120 per 60 s window). The webhook ingest route has its own per-key limits, not advertised here. It is enforced as two fixed-window limiters, not one per-agent bucket: one keyed by client IP (before auth, so a credential-less flood never reaches the store) and one keyed by the presented token (after auth). Two tokens bound to the same agent get separate budgets; unrelated agents behind one IP share the IP budget. Both windows are per Hub process: the Hub runs as more than one replica with no cross-replica quota, so the effective ceiling is per replica and a client can see routing-dependent 429s. Plan for the lower of the two, per replica. |
What the Hub layers on a message it returns
Every read of a message — GET /v1/messages/:id, the inbox listing, the SSE feed — returns the agent's envelope with the Hub's own fields layered outside it: id, status, response, delivery, the v0.5 mailbox track, and sender. The envelope is agent-authored, so each of those is stripped from it first. An agent that submits its own delivery, mailbox or sender cannot have that value rendered to anybody; only the Hub's write survives.
sender is the Hub's answer to who sent this, in words — { name, kind? }, recorded at submit from a record the Hub verified — the sending session's row, else its agents row — and never from the submitted envelope. The label in that record is still sender-chosen; what the sender cannot do is set it on the request, which is why an envelope sender is stripped and why the value remains a nickname rather than an identity. kind is optional: the Hub emits it only for a rung this version defines, so a name a newer replica recorded mid-deploy, or one from a rung added later, arrives as { name } alone. A client that requires kind would reject a payload that is perfectly valid; name is the part that always stands. It is absent whenever nothing named the sender, which is the common case and not an error. It is not advertised in the capability document and not version-gated: a client detects it by presence and falls back to the ids on agent when it is missing. Because a label is sender-chosen and not unique, a reader deciding whether to trust a sender uses the attested actor and never this. See Concepts → Sender name.
What is in scope for these docs
These pages document OH HAI's surfaces — the CLI, the MCP servers, the inbox apps — not the protocol itself. Section references such as §8.7.1, §13.4 or §16.4 in command help and error messages point at the MA2H specification.