Appearance
Concepts
The vocabulary the rest of these docs use. Each entry says what the thing is on the production Hub today.
Account
The tenant. Every message, agent, token and session belongs to exactly one account. Today an account is one human: the person who signed up is the owner, and there is no way to invite a second member. Owner-only settings (Agent messaging, sender allowlists) therefore apply to the one person who can log in.
Human
The account's person, identified on the wire as human:<usr_…>. Sign-in is email + password or Google; the iOS app also offers Apple. A human session token authenticates the web app, the iOS app, oh-hai mail and oh-hai agents.
Agent
A principal an account creates to send and receive messages. Its id is agnt_<uuid>, minted by the Hub; the label addresses nothing — the Hub never resolves a label to an id, so a label in --account, to or a resolver is simply unknown. It is not inert, though: it is the second rung of a message's sender name. Many concurrent runs can share one agent id; that is why sessions exist. Revoking an agent invalidates every token it has.
Listing the agents in an account
Two routes, deliberately not one:
GET /v1/account/agents— the directory. An agent bearer may read it for its own account, and so may a human. It answers{ id, label, revoked, source }per agent and nothing else: no creation time, no token counts, no session counts. It is what letsoh-hai setupwrite the bridge's default sender policy on a machine that has never had a human login, and whatoh-hai agents listfalls back to under an agent identity. A client finds it atagents.directory_urlin the capability document and nowhere else — a Hub that does not advertise it has none, and probing the path would confuse an old Hub with a proxy or a half-rolled-out deploy.GET /v1/agents— the rich human listing (creation time, live session counts, and the token panel beside it). It is human-only and stays that way: it answers403to an agent bearer, and that 403 is load-bearing. The web token gate and the iOS sign-in validator prove a pasted token is a human session precisely by it, so opening the route would let an agent token pass both gates and render a plausible, wrong inbox on builds already installed.oh-hai agents createandoh-hai agents revokestay human-only for the same reason they always were — they mint and destroy credentials.
Where an identity authenticates from
Both listings carry source:
stored— anagentsrow. It carries a label, mints tokens, and is revocable (DELETE /v1/agents/:id).config— anMA2H_AGENT_TOKENSpair in the Hub's environment and nothing else. There is no row for the API to act on, so neither revoke nor mint works and neither ever will; it is retired by editing that environment and restarting the Hub.both— an env pair whose id also has a stored row. Every control on the row works, and the env pair keeps authenticating after each one succeeds. Revoking there stops the row's minted tokens, not the env pair.
Config identities are opt-in, with ?include=config on either listing. An unrecognised value is a 422 rather than a silent "no" — a client that got a short list back from a typo would conclude the account has no config identities — so absence is the only way to mean it. A caller that does not pass the flag sees exactly the rows it saw before config identities were listed at all, stored ones only; source itself rides on every row either way, and both shipped clients tolerate a field they do not know.
A config-backed row is a different shape, which is why it is not served unasked: its label is "", and it has no created_at key at all — omitted, never null. Both shipped clients validate created_at as an optional string, so a null would fail the check, and one malformed element rejects the whole array and blanks an Agents screen a server deploy cannot reach. Config pairs all resolve to the Hub's bootstrap account, so no other account's listing changes with or without the flag.
Two surfaces ask for the flag, for opposite reasons. The web and iOS Agents screens ask because they are the ones taught to render a row they cannot manage, and they badge it. The CLI's sender-policy step — the one oh-hai setup and oh-hai login --human run — asks because a config-backed peer left out of [bridge] allow_from is then refused as a sender, which is the omission source was added to end. It asks on both reads: /v1/agents?include=config under a human login, the directory under an agent credential. Everything else, the send pickers included, reads the listing without the flag.
System source
The third actor class, alongside human: and agent:. A webhook source — Sentry, GitHub, PagerDuty, CloudWatch, Shopify, anything that POSTs — is identified on the wire as system:<label>, where the label is the one you gave the source when you minted its key. system: has always been in the MA2H actor grammar and the Hub already uses it for its own receipts (system:hub); webhook sources are the first thing outside the Hub to use it.
A sender like that is deliberately not modelled as an agent, and the difference is not cosmetic:
- No session. An agent registers sessions so a particular run can be addressed and so presence means something. A webhook source is one long-lived configuration in somebody else's settings page; there is no run to address and no presence to report.
- No mailbox of its own. Nothing can be sent to a source. It pushes, and only pushes: its entries are always the push kind, never an ask or a task, so there is nothing to answer and no return leg.
- An exportable credential. An agent token stays on the machine that holds it, in the OS keychain. An ingest key is pasted into a third party's configuration by design — that is its whole job. Giving a webhook sender an agent token would hand a vendor's settings page the ability to speak as your agent; instead it holds a key that can post to one destination as one source and do nothing else (Ingest keys).
So from on an ingest entry is attested by the Hub from the key, never read from the body, and it names the source rather than pretending to be a principal that could act. Setting one up: Webhook sources.
Token
An agent's bearer credential: 256 random bits, base64url, no prefix. Only its SHA-256 is stored, so it is shown once at mint time and never again. One agent may hold several tokens (each atok_… row has its own label and can be revoked on its own). The token identifies the agent: the Hub cross-checks agent.id on every submitted envelope against the credential and answers 403 on a mismatch. How to get one: Minting tokens.
Message
What an agent submits: exactly one of notify, ask, task. Ids are msg_…. An ask or task carries a required idempotency key; a notify may carry one. A message is either for the human inbox (no to) or addressed to another agent (to: agent:<id> or agent:<id>#sess_…).
Sender name
Who sent a message, in words (#949). agent.id names a principal every run on one machine shares, so an inbox that showed it read identically on every row of a fleet. The Hub records a name at submit time instead, and returns it on every read as sender: { name, kind? }.
It takes the first that exists: the submitting session's label (kind: "session"), else that agent's label (kind: "agent"). With neither, sender is absent — a real answer meaning nothing named this sender, not an error. A client then falls back to the ids it already holds rather than printing a bare one. Every message predating the feature is absent for the same reason; nothing was backfilled.
kind cannot be inferred from the rest of the message: one can carry agent.session and still be named after its agent, because that session registered no label of its own. It is also optional — the Hub emits it only for a rung it recognises, so a name written by a newer replica mid-deploy arrives as { name } alone. Read a missing kind as unknown provenance, never as grounds to reject the name.
A name is a nickname, not an identity. Labels are sender-chosen, are not unique, and the Hub attests only that it recorded this one — so nothing deciding whether to trust a sender may key on it. That is the actor's job, which is why the bridge prints the name beside the attested actor rather than in place of it. A sender an agent writes into its own envelope is stripped on read, so a name a sender invents for itself can never be rendered.
Where it shows: the web and iOS inbox, and oh-hai mail list, mail read, messages list and messages get.
Priority
low, normal, high, urgent. Optional; default normal. The inbox shows a pill for high and urgent only, and the Tasks view sorts by priority.
Ask modes
select (pick one of >= 2 options), confirm (exactly two options; Yes/No by default), input (a structured answer validated against a JSON Schema the agent supplies). The resolution carries status (answered, declined, cancelled, expired), the chosen value, the resolving actor, and an optional comment. An ask that sets permissions.allow_edit: true (oh-hai ask submit --allow-edit, select/confirm only) may be answered off-menu; the Response then carries edited: true and value is free human text. Since v0.6 the Hub also refuses an ask whose options[].value repeat or whose input schema cannot be answered (422), at every declared minor.
Task resolutions
completed, dismissed, expired. A task is terminal by default; an agent opts into learning the outcome with a pull or push callback.
Resolver
Who may resolve an ask or task, written as an actor: human:<usr_…>, agent:<agnt_…>, agent:<agnt_…>#sess_…, or system:….
- Omit it. With no
allowed_resolvers, the account owner resolves it. On a one-human account that is always the right answer. --resolver human:<id>narrows to that human. The owner can still always resolve. An omitted or empty list is owner-only, never "any human".- Find your own id under Settings → Resolver ID in the web app, or with
oh-hai whoami --checkwhen logged in as a human. - Never use the literal
human:owner. It is the actor of the legacy single-tenant bootstrap principal, not a real account login. The Hub matches resolvers exactly, so it yields403 not_authorized: resolver human:… is not permitted for this messagewhen that person tries to answer. - On an addressed ask (
topresent) there is no owner override: with no list, only the addressee may resolve; with a list, only listed actors.
Idempotency key
A body field, idempotency_key, scoped to (account, agent). Replaying the same key with an identical payload returns the original id and its current status (202); the same key with a different payload is 409 conflict. The CLI mints a fresh key per ask/task submit; to retry safely, capture the envelope with --dry-run and replay it with --envelope, because a freshly composed envelope changes created_at and reads as a different payload. The MCP tools accept an explicit idempotency_key.
Session
An addressing scope for one live run. agent.id names a principal many runs share; a session says which run. It is Hub-minted (sess_<uuid>), lease-bound, non-secret, and confers nothing on its own — every call still presents the bearer.
- Register.
oh-hai bridgeregisters this run's session for you (POST /v1/sessions) and keys its local state on the agent session — never the working directory — so two agents in one checkout get two sessions.oh-hai session startregisters one by hand, for a run that wants an address with no bridge. A session's label (--labelon either) is the first rung of a message's sender name, so labelling a run is what makes its mail tell itself apart from a sibling's. - Lease. Default 900 s; a requested
--ttlis clamped into 60–3600 s. There is no heartbeat endpoint: presenting the session renews the lease — a drain (GET /v1/inbox?session=), an ack, an SSE connect, or a submit stamped withagent.session. A merely-open socket renews nothing. - States.
active,closed,expired. Terminal states are immutable; a lapsed lease readsexpiredimmediately, and terminal sessions stay readable for 3600 s. At most 64 live sessions per agent on the production Hub (sessions.max_live_per_agentin the capability document); the next registration is a 429. - Address.
agent:<id>#sess_…reaches exactly that run;agent:<id>lets any live session of the agent claim the entry, first-claim-wins — except the session that sent it, which is never a candidate for its own principal-addressed message. Two things are untouched by that:agent:<id>#<your own session>is an explicit self-delivery and remains claimable, and a submit carrying no session at all excludes nobody. With the sender's session the only live one, the entry simply staysqueueduntil a sibling comes up. - Attaching. A CLI send carries a session as its return address, resolved as
--session>MA2H_SESSION_ID> this agent session's live bridge > a sessionoh-hai session startremembered — so with a bridge running there is nothing to set. An addressed (--to) send with none is refused rather than minting a throwaway.MA2H_SESSION_IDstill pins a session for the CLI and for the stdio MCP server. A new session is a new address. - Stand-down.
oh-hai session closestops the bridge's background connection, closes the session and forgets the id, in that order; a bridge that watched it exits15.
Presence and reachability
Every authenticated drain touches the agent's agent_seen; a signal is fresh for 90 s. An addressed send's ack carries a destination snapshot: online (fresh signal), offline (signal gone stale — the entry queues until the agent returns), or unknown (the Hub has no signal at all — never a claim that the peer is down).
The snapshot counts the same claimants the drain does, so a bare-principal send excludes the sending session from them too. Address your own principal with no sibling live and the ack reads unknown, not online — online there would promise prompt pickup for mail no drain can take. A sibling appearing flips the same send back to online. A retry is judged on the original submitter, not on whatever session the retry names, since agent.session sits outside the idempotency hash.
Delivery track
For an addressed message: queued (accepted and stored; nobody has seen it) → delivered (the addressee drained it) → acknowledged (the addressee acked it — the only state that means it got through), or the terminals bounced (the destination session ended before the ack) and expired (retention or expires_at passed while still queued — always never-delivered). An undeliverable ask auto-resolves cancelled, a task dismissed, attributed to system:undeliverable; a real answer that already landed wins.
Directive
A message a human sends to an agent from the inbox (POST /v1/directives), scoped to (account, human, idempotency_key). Delivered on the agent's principal mailbox (oh-hai inbox watch) and, if addressed to a session, on that session's mailbox (oh-hai bridge, oh_hai_inbox). Each delivery is re-signed with a fresh timestamp and jti. A webhook source delivers on the same leg: its events are directives from: system:<label>, always on the principal mailbox, never session-addressed, and never expiring.
The inter-agent leg (MA2H v0.5)
Agents in one account can message each other: notify, ask and task with --to agent:<id>[#sess_…] (CLI) or to (MCP). It is off by default per account. The owner turns it on under Settings → Agent messaging (web and iOS); PUT /v1/inter-agent { enabled } is the owner-only scriptable path. With it off, an addressed send is 403 not_authorized: the inter-agent leg is not enabled for this account. Turning it off refuses new sends and retracts nothing already queued. The same switch decides whether oh-hai fleet ls shows other agents' sessions (scope: account) or only your own (scope: own).
Two policies, in two places:
- Sender allowlist (server-side, owner-managed per agent via
PUT/DELETE /v1/agents/:id/sender-allowlist/:sender): who may address this agent at all. Empty means any agent in the account. A blocked sender gets422 unknown_destination, indistinguishable from an unknown one. --allow-from(client-side, onoh-hai bridge): which senders'ask/taskentries the bridge will hand to the runtime. No default; see Staying reachable.
Operator kill-switch
A human can close any agent session from the inbox (DELETE /v1/sessions/:id). That is the §16.4 kill-switch: the session goes closed with closed_by_operator: true, queued mail for it bounces, waiting asks auto-resolve, and the Hub records a durable operator stop for that agent so it cannot register a new session until a human lifts the stop (403 session_closed_by_operator on registration). A bridge presenting that session exits 14 and must not be restarted; the stdio and hosted MCP servers latch the stop for the life of the process. The web Agents screen shows stops in force and offers Resume; the CLI deliberately has no command to lift a stop.
Hub
The server at https://inbox.ohhai.app: the MA2H Hub role. It ingests messages (POST /v1/messages), renders the inbox, signs resolutions, holds mailboxes and sessions, and advertises what it speaks at GET /v1/capability. Version and capabilities: Protocol.