Appearance
CLI reference
oh-hai is the published command-line tool (@oh-hai/cli on npm, or the standalone binary from https://ohhai.app/install.sh). Every command is listed here with its flags, its --json shape and the exit codes it can produce.
oh-hai [global flags] <command> [subcommand] [flags]Run oh-hai <command> --help for the built-in help.
Global flags
Available on every command.
| Flag | Meaning |
|---|---|
--base-url <url> | Hub base URL. Env MA2H_BASE_URL. Default https://inbox.ohhai.app. |
--account <id> / --agent <id> | The identity to act as. Env MA2H_AGENT_ID. Must be the id bound to the bearer, or the Hub returns 403. |
--json | Machine output: one envelope on stdout, no prompts, no colour. --json=false overrides a configured output = "json". |
--quiet / -q | Suppress non-essential human output. |
--verbose / -v | Extra diagnostics on stderr. Never the token. |
--no-color | Disable ANSI colour. Also honours NO_COLOR. |
--config <path> | Read this file instead of the user config. Env MA2H_CONFIG. A missing or unparseable path is a usage error naming the path — it never falls back to the default file. |
--timeout <ms> | Per-request timeout. Env MA2H_TIMEOUT_MS. A negative or non-integer value is a usage error. |
--help / -h | Help. |
--version | The CLI version. With --json: { version, wire_version } (wire_version is 0.6, the highest MA2H minor the CLI speaks; envelopes still stamp the lowest minor their features need). |
A string flag given with no value (--title --dry-run, or --title as the last token) is a usage error; write --flag=<value> for a value that starts with --. An unknown flag is a usage error (exit 2).
Exit codes
| Code | error.code | Meaning |
|---|---|---|
0 | — | Success. ask await / task await that reach a terminal state exit 0; branch on data.status. |
1 | error | Generic or unexpected error. |
2 | usage | Bad flags or arguments, before any Hub call. |
3 | auth | No token, or the Hub answered 401/403. |
4 | not_found | Hub 404, and Hub 410 Gone (a terminal session; an addressed send whose destination session died — destination_gone: pick a live session from oh-hai fleet ls). Also a --session you named that does not exist, oh-hai session close with nothing to stand down, and a --wait-delivered send that BOUNCED. |
5 | network | DNS, connection refused, unreachable. |
6 | server | Hub 5xx, or a malformed Hub body. |
7 | timeout | The per-request --timeout, the await poll budget, or a --wait-delivered budget that ran out (QUEUED … not yet acknowledged). |
8 | conflict | Hub 409: a duplicate idempotency key with a different payload. |
9 | bad_request, validation_error, payload_too_large, version_not_supported | Hub 400 / 422 / 413. |
10 | already_running | Another live process on this machine already holds what you asked for: a second inbox watch for an identity, a second bridge connection on one session, a second mail consumer, or a second mail watch --auto-ack tail. For oh-hai bridge it also means two agents appear to share one agent session — give each its own --session <name> or export MA2H_AGENT_SESSION_ID per agent. (A single agent re-running never trips it: it displaces its own previous reader.) On oh-hai bridge and oh-hai inbox watch the refusal names the live holder — pid, host, how long it has held, how old its heartbeat is, and on the session leg its lease — and says outright when the holder's record exists and could not be read, rather than printing a confident pid unknown. Do not kill that process to clear the error: it is the healthy one, and relaunching a bridge over a live consumer changes the session's address. inbox watch adds that the principal mailbox has no session lease to lapse, so stopping that watcher costs delivery rather than the address. The two mail legs are terser — they report pid <n>, or pid unknown when the record could not be read, and nothing more. |
11 | session_terminal | bridge only: a session you pinned (--session / MA2H_SESSION_ID) is terminal. The background connection replaces its own lapsed id without exiting, so only a pin reaches this. Restart — after repointing or clearing the pin, or the relaunch exits 11 again. |
12 | signature_failure | bridge and inbox watch: an entry in the agent's own mailbox failed verification. Stop; surface to a human. |
13 | retries_exhausted | bridge and inbox watch: reconnects with backoff were exhausted. Restart with your own backoff. |
14 | session_closed_by_operator | bridge only: an account human closed the session — the operator kill-switch. Stop. Do not restart. |
15 | stood_down | bridge only: this agent stood its own session down with oh-hai session close, and this bridge watched it happen. Nothing failed; run oh-hai bridge again only if this run should have a new address. |
128 + N | — | Killed from outside by a signal the process does not handle, at that signal's default disposition. One class, not more numbered codes, and 0–15 keep their meanings. Nothing failed and nothing was lost: relaunch when mail should flow again. oh-hai bridge and oh-hai inbox watch both handle SIGTERM and SIGINT themselves — clean stops at exit 0, the connection left running — so for them this class is SIGHUP 129, SIGQUIT 131, SIGKILL 137. |
The --json envelope
Every command prints exactly one line on stdout under --json:
json
{ "ok": true, "command": "ask.submit", "data": { "id": "msg_…", "poll_url": "https://…" }, "error": null }
{ "ok": false, "command": "notify", "data": null, "error": { "code": "network", "message": "Hub unreachable at https://…" } }command is <command> or <command>.<subcommand>. Absent optional fields are omitted, not null. The token is never present. Exceptions: bridge --json and inbox watch --json emit one envelope per record (NDJSON); both streams carry bridge.event transition records alongside entries, and data carries kind and received_at beside the signed payload. mail watch --json emits one bare { event, message } object per line (and --raw the whole spool record).
Configuration and identity resolution
Each value resolves as flag > environment > config file > built-in default.
| Setting | Flag | Env | Config key |
|---|---|---|---|
| Base URL | --base-url | MA2H_BASE_URL | base_url (user config only) |
| Account | --account / --agent | MA2H_AGENT_ID | directory binding (oh-hai use), then default_account, then this Hub's default identity (oh-hai use --default) |
| Output | --json | — | output (human / json) |
| Timeout | --timeout | MA2H_TIMEOUT_MS | timeout_ms |
| Colour | --no-color | NO_COLOR | color |
| Bridge sender policy | --allow-from | — | [bridge] allow_from (user config only) |
| Bridge session label | --label | — | [bridge] label (user config only) |
| Bridge wait bound | --wait | — | [bridge] wait_seconds (user config only; default 540) |
| Unattended bound | --unattended-max-seconds | — | [bridge] unattended_max_seconds (user config only) |
| The config file itself | --config | MA2H_CONFIG | — |
Files, all under $XDG_CONFIG_HOME or ~/.config:
oh-hai/config.toml— user config. The only file that may setbase_url,default_accountand the[bridge]section. Flatkey = valuelines or an[oh-hai]section.--config <path>(envMA2H_CONFIG) puts a different file in this role — a second profile, a fixture, a CI run — and a project file still layers on top under the same rules..oh-hai/config.tomlin a project (found by walking up from the current directory) — may set onlyoutput,timeout_ms,color. Abase_url, anaccountor a[bridge]key in it is ignored with a warning: a checked-out repo must never redirect the bearer, or widen who may ask this agent.oh-hai/bindings.json— directory-to-agent bindings, and each Hub's default identity, written byoh-hai use.oh-hai/credentials— the0600file fallback when no OS keychain is available.
The token never comes from a config file. MA2H_AGENT_TOKEN in the environment wins over the keychain and file; otherwise the keychain entry oh-hai / <hub origin>|<agent id>; otherwise the file. When no account is configured, the CLI uses this Hub's default identity — recorded at your first login for it, changed with oh-hai use --default <agent> — and failing that the sole stored identity of the kind the command needs (agent for notify/ask/task/whoami; human for mail and agents). Two stored identities of the same kind with no applicable default are not guessed between — pass --account, or record a default. Other env-only inputs: MA2H_AGENT_RUN_ID (the run id stamped on envelopes), MA2H_STATE_SEAL_KEY (required by --state), MA2H_SESSION_ID (a Hub session to attach — optional: sends attach this agent session's live bridge session on their own), MA2H_AGENT_SESSION_ID (an agent-session key, for a program hosting several agents), NO_UPDATE_NOTIFIER (silences the ambient update notice, and the check behind it), MA2H_NO_SHELL_RESIDUE_WARNING (silences the shell-residue note on an inline --body / --instructions). The last two are boolean by presence: any non-empty value turns the thing off.
Full detail: Minting tokens.
setup
oh-hai setup [--no-teach] [--yes]First-run onboarding: the device-code flow in the browser, where you pick or create an agent. Once connected it teaches this machine's agents that OH HAI exists — the same files oh-hai teach --global writes (~/.codex/AGENTS.md, plus ~/.claude/CLAUDE.md and ~/.gemini/GEMINI.md when they already exist; it never creates a vendor file). It shows the plan and asks first (default yes); with no terminal to ask on it prints the plan and the oh-hai teach --global --yes to run instead. Then it reminds you to run oh-hai teach inside each project too. --no-teach skips that step. --yes skips the confirm (implied under --json, where the outcome rides in the envelope as teach: { files: [{ path, action }], skipped?, wrote }). A failure in the teach step is reported and never fatal: the credential is already stored, so the fix is oh-hai teach --global. A machine that is already set up is left exactly as it is. The bearer is stored in the OS keychain (or the 0600 file). Bare oh-hai with no stored auth runs this automatically. On a machine that is already set up, an interactive re-run offers to connect another agent or re-authorise; --json reports { status: "already_configured", base_url, agent_id, source }. At the end it points at oh-hai teach and offers to run oh-hai doctor --self-test.
It also writes the bridge's sender policy when — and only when — none exists: every agent currently minted in your account goes into [bridge] allow_from, so a peer agent can reach this one without anybody typing --allow-from. An existing policy, however narrow, is left alone and never widened. It never fails setup; under --json the outcome rides as data.policy. See the sender policy for what it can and cannot keep current.
A machine holding only an agent token reads the account agent directory (GET /v1/account/agents — { id, label, revoked, source } per row and nothing else) and writes the policy unattended. Against a Hub that does not advertise one, nothing is written, the bridge stays fail-closed, and the printed fix is oh-hai login --human. Config-token identities (MA2H_AGENT_TOKENS) have no agents row, so setup and upgrade ask for them with ?include=config; source says which store each row came from — stored, config or both. A config-backed peer is kept even when a stored row of the same id is revoked, because it still authenticates; a revoked row with no config pair is dropped, and an unrecognised source falls through to revoked.
An id the bridge could never match is dropped rather than written: one carrying a # (the session delimiter), whitespace, a control character, both quote kinds at once (the escape-free config dialect can then quote it with neither, and the failed write would cost the whole policy), or one longer than the Hub's 512-byte bound. An id carrying only one quote kind is written, quoted with the other. Operator-chosen ids such as takumi/dev-team, team+bot and agent@example.com are kept.
The known limit, stated plainly: the policy is written once and never refreshed (#1127). The never-widen rule is the first thing setup and upgrade check, and it cannot tell this tool's own earlier output from a list you typed — so once any policy exists, re-running writes nothing. An agent minted after that write is therefore refused indefinitely, and re-running setup or upgrade will not admit it, whatever the bridge's refusal suggests. The fix is by hand: edit [bridge] allow_from in ~/.config/oh-hai/config.toml to add the peer, or clear the key entirely and re-run oh-hai upgrade so the list is written fresh.
login
oh-hai login [--base-url <url>]
oh-hai login --human [--base-url <url>]
oh-hai login --token-stdin [--base-url <url>] --account <id>
oh-hai login --human --token-stdin [--base-url <url>]| Flag | Meaning |
|---|---|
| (none) | Device-code flow. Prints the code + verification URL on stderr, opens the browser, polls until approved. Stores the agent bearer. |
--human | Sign in as the account owner (a human session token) — what oh-hai mail and oh-hai agents use. Same browser approval. The stored id is human:<userId>, taken from the Hub. A successful --human login also writes the [bridge] allow_from sender policy when none exists (see setup). |
--token-stdin | Read a token from stdin and store it without printing it. Needs an agent id — --account <agnt_…> (the exact agent id), or MA2H_AGENT_ID / a directory binding; a human: id on this path is refused — use --human. |
--human --token-stdin | Pipe a human session token; the CLI attests it against GET /auth/whoami and refuses an agent token. |
A token is never accepted as a flag value. An existing stored token for the same identity is overwritten. --json → { agent_id, base_url, storage: "keychain" | "file" }. Errors: denied approval or an expired code → exit 3.
logout
oh-hai logout [--account <id>] [--all]Removes the stored token for the resolved identity (or every identity with --all). Offline, idempotent; removing an absent token exits 0.
whoami
oh-hai whoami [--check] [--session <sess_…>]Prints the identity without the secret: the agent (or human) id, how it resolved (flag / env / binding (<dir>) / user config / default identity / discovered), this Hub's default identity (printed whether or not it is what resolved), the base URL, token presence with the last 4 characters and its source (keychain / file / env), and the attached session if any (--session, else MA2H_SESSION_ID, else this agent session's live bridge — reported offline, as session_source: "bridge"). --check probes the Hub: exit 0 when the token is valid, 3 on 401/403, 5 when unreachable; when signed in as a human it also prints resolver: human:<id>. --json → { agent_id, kind, base_url, token: { present, source, fingerprint? }, account_source, binding_path?, default_identity?, resolver?, session?, session_source? }.
use
oh-hai use <agent> # bind this directory (and its subtree) to <agent>
oh-hai use --list # bindings, plus each Hub's default identity
oh-hai use --unset
oh-hai use --default <agent> # who this machine acts as for this Hub, by default
oh-hai use --default --unsetWhich agent to act as, at two scopes. A binding answers it for this directory tree; the default identity answers it for this machine and this Hub when nothing else did — so connecting a second agent never unsettles the first. The default is recorded at your first login for a Hub; --default changes it.
Both live in ~/.config/oh-hai/bindings.json, scoped per Hub origin, and are never read from a repo. Binding resolution walks up from the current directory to the nearest bound ancestor. Precedence: --account / MA2H_AGENT_ID > binding > default_account > default identity. Offline; the agent need not be logged in yet.
notify
oh-hai notify --title <t> [--body <b>] [--body-file <path|->] [--priority <low|normal|high|urgent>] [--tag <t>]...
[--strict-title] [--state <json>] [--to agent:<id>[#sess_…] | --to label:<exact>] [--wait-delivered[=<s>]]
[--session <sess_…>] [--no-session] [--dry-run]| Flag | Meaning |
|---|---|
--title | Required, non-empty. Capped at 200 characters by the protocol; an over-cap title is cut, not refused (below). |
--strict-title | Refuse an over-cap --title (exit 2, before any Hub call) instead of cutting it. |
--body | Optional body (Markdown). |
--body-file <path|-> | Take the body from a file, or stdin with -, verbatim — no shell runs a backtick or $(…) on the way in. |
--priority | low, normal, high, urgent. Default normal. |
--tag | Repeatable. |
--state | Sealed resume context. Requires MA2H_STATE_SEAL_KEY in the environment. |
--to | Address another agent instead of the human inbox: agent:<id>, agent:<id>#sess_…, or label:<exact>. |
--wait-delivered[=<seconds>] | Needs --to. Hold until the delivery track settles and report that instead of acceptance (default 120 s). |
--session / --no-session | Name the session to attach, or send with none. With neither, the CLI attaches one for you (below). |
--dry-run | Print the envelope, POST nothing. |
No idempotency key: a notify is fire-and-forget and must not be retried. --json → { id, … }.
Four rules are shared by notify, ask submit and task submit:
The result line. In human mode the first line of stdout is the result: SENT <id> <status> on success, NOT SENT <error.code> <one-line reason> on a failure (parser errors included) with the same non-zero exit, and MAYBE SENT <error.code> <one-line reason> when the failure cannot prove the message was not accepted — a timeout or socket reset on the POST, or a 202 whose ack could not be read (the accepted id rides in the reason). A blind retry after MAYBE SENT duplicates: a notify has no idempotency key. --json is unchanged.
Titles truncate. --title is capped at 200 characters by the protocol. An over-cap title is cut to the cap by code point (an emoji is never split), ends in …, and the full original title is prepended to the body as a Full title: … line plus a blank line, with one note on stderr — a notify is usually an escalation, and a refused escalation reaches nobody. Nothing is lost, but the stored body is then not the text you sent, so a readback (oh-hai messages get) will not match the file you passed. --strict-title refuses instead (exit 2, before any Hub call) and leaves the body byte-identical. --truncate-title is now the default and is accepted as a deprecated no-op; with --strict-title it is a usage error.
Text a shell must not touch. An inline --body is argv, and zsh and bash run a backtick or $(…) inside it before oh-hai starts — paths and SHAs arrive already missing and the send still reports success, because nothing reaches the CLI to notice. --body-file <path|-> (and --instructions-file <path|-> on task submit) reads the bytes as UTF-8 and sends them verbatim, trailing newline kept. Passing an inline flag and its file form together, an unreadable path, or - when stdin is a terminal are each exit 2.
When inline text still carries the shape a shell leaves behind — an unpaired backtick (odd total count) or a $( with no closing ) — one line goes to stderr naming the flag and the shape. It is advisory and never a gate: the exit code is unchanged, the body goes on the wire byte-identical, and under --json stdout still carries exactly one envelope. It echoes none of the text. The trigger is deliberately narrower than "contains a backtick": markdown pairs its backticks, and a warning that fired on every markdown body would be silenced before the send that needed it. The substitution that already happened cannot be detected at all — that is what --body-file is for. Set MA2H_NO_SHELL_RESIDUE_WARNING to any non-empty value to silence it.
Your return address is attached for you. The session a send carries resolves as --session > MA2H_SESSION_ID > this agent session's live bridge > a session oh-hai session start remembered. There is nothing to export on the reachable path: run oh-hai bridge and a peer's reply reaches the run that is reading. A --to send with none of them is refused — exit 2, NOT SENT, no Hub call, nothing registered — naming the fixes: run oh-hai bridge, pass --session, or pass --no-session to send with no return address (legal, but no answer or bounce receipt can reach you). The CLI never registers a throwaway session.
--to label:<exact> addresses a peer by the caption oh-hai fleet ls shows (exact, case-sensitive; the bridge fills it in as <folder> · <git branch> · <harness>). Labels are not unique, so zero or several live matches is exit 2 before anything is sent, listing the candidates; a Hub that lists only this agent's own sessions cannot establish uniqueness and is refused too, as is --dry-run with a label.
--wait-delivered[=<seconds>] holds after the send until the delivery track settles and prints that as the result line: SENT <id> acknowledged (exit 0), BOUNCED <id> <state> with prior=queued|delivered (exit 4), or QUEUED <id> not yet acknowledged when the budget runs out (exit 7). It works for ask and task too. Under --json the outcome rides as data.delivery.
An addressed notify acks queued, never delivered — that is acceptance, not delivery. The Hub has the message; it is sitting in a mailbox that may never be drained. Do not report it as delivered. The ack also prints the Hub's destination reachability snapshot verbatim: online and offline are real reads, while unknown means the Hub has no signal about that destination — it is a statement about the Hub, not about the peer, and never means the peer is offline. A 410 destination_gone means the session you addressed is dead: pick a live one from oh-hai fleet ls.
ask
oh-hai ask submit --mode <select|input|confirm> --title <t> [--body <b>] [--body-file <path|->] [--strict-title]
[--option <value:Label>]... [--allow-edit] [--schema <json>] [--resolver <actor>]...
[--callback pull|push] [--callback-url <url>] [--idempotency-key <k>] [--created-at <ts>]
[--state <json>] [--to agent:<id>[#sess_…] | --to label:<exact>] [--wait-delivered[=<s>]]
[--session <sess_…>] [--no-session] [--dry-run] [--envelope <json|@file|@->]
oh-hai ask await --id <id> [--interval <ms>] [--await-timeout <ms>]Mode rules, enforced locally before any Hub call:
select— at least two--option value:Label.confirm— exactly two options, or none for the default Yes/No.input—--schema <json>is required (a JSON Schema object for the expected answer; the inbox renders it as a form).--allow-edit— onselect/confirm, let the human answer with free text not in--option; the Hub then stamps the Responseedited: true(MA2H v0.6 §5.2).ask awaitsurfaces it — anedited:line in human output,data.edited: trueunder--json— so readvaluerather than matching it against your options when it is set. With--mode inputit is a usage error (an input ask is already free-form).
--resolver is optional. With none, the account owner can answer. --resolver human:<id> narrows to that human (the owner can always answer regardless). The value must be human:<id>, agent:<id> or agent:<id>#sess_…; wildcards are rejected. Do not use the literal human:owner — the Hub matches resolvers exactly and that string names nobody on a multi-tenant account.
An ask carries a required idempotency key, fresh by default. To retry a submit without minting a new message, capture the envelope once with --dry-run and replay it with --envelope (inline JSON, @path, or @- for stdin). A replayed key with a different payload is a 409 → exit 8.
--callback pull (default) means resume with ask await. --callback push --callback-url <url> asks the Hub to POST the resolution to your URL instead.
Push callbacks on the hosted Hub cannot be verified by the receiver
The Hub signs a pushed callback with a detached HMAC. The signing key is a per-callback secret only when the envelope carries callback.auth with scheme: "hmac" and a secret_ref; a bearer scheme adds Authorization: Bearer <token> to the POST and an apikey scheme adds x-api-key: <token> — the transport header differs, and a receiver configured to look for Authorization rejects every API-key callback — while both leave the signature keyed with the Hub's own server key. Only hmac changes the signing key, so a receiver must never try to validate the MAC with a bearer or API key. Those refs resolve on the Hub's side, and the CLI has no flag to set any of them, so a CLI-submitted push is signed with the Hub's own server key, which OH HAI does not publish. Your receiver therefore cannot check the signature; it can only trust the transport and the URL being secret. On hosted OH HAI, use --callback pull and ask await unless you accept an unauthenticated callback. A self-hosted Hub whose operator configures an hmac secret_ref can verify pushes with that secret; bearer / apikey only authenticate the transport.
await polls GET /v1/messages/:id every --interval (default 2000 ms) for up to --await-timeout (default 120 000 ms; --timeout on await is accepted as a legacy alias for the budget). Timeout → exit 7 with the ask still open.
--json: submit → { id, poll_url, review_url? }; await → { id, status, actor?, value?, comment? } with status one of open, answered, declined, cancelled, expired.
With --to, the ask is answered by that agent. The answer routes back to your own session, so the send needs one to attach — this agent session's live bridge, normally — and is refused when there is none (see notify). It never registers one for you. The result line, title, --body-file, label: and --wait-delivered rules are the ones under notify.
task
oh-hai task submit --instructions <text> | --instructions-file <path|-> --title <t> [--body <b>] [--body-file <path|->]
[--strict-title] [--checklist <text>]...
[--resolver <actor>]... [--callback pull|push] [--callback-url <url>]
[--idempotency-key <k>] [--created-at <ts>] [--state <json>]
[--to agent:<id>[#sess_…] | --to label:<exact>] [--wait-delivered[=<s>]]
[--session <sess_…>] [--no-session] [--dry-run] [--envelope <json|@file|@->]
oh-hai task await --id <id> [--interval <ms>] [--await-timeout <ms>]
oh-hai task done --id <msg_…> [--comment <text>] [--session <sess_…>] [--no-session]
oh-hai task dismiss --id <msg_…> [--comment <text>] [--session <sess_…>] [--no-session]--instructions (or --instructions-file) and --title are required. --checklist is repeatable (one step per flag). A task is terminal by default: with no --callback, await has nothing to poll. --callback pull makes it resumable with task await; --callback push --callback-url <url> POSTs the outcome to your URL, with the same caveat as ask: on the hosted Hub the receiver cannot verify the signature, so prefer pull. Resolver, idempotency and replay rules are the same as ask. --json: submit → { id, review_url }; await → { id, status, actor?, comment? } with status one of open, completed, dismissed, expired.
done and dismiss are the receiving end: when a task is addressed to your agent, resolve it as completed or dismissed instead of sending a notify back — a notify resolves nothing, so the task stays open and the submitter's await never returns. Those two are the protocol's only task terminals, so there is no fail verb: a task you cannot do is a dismiss with the reason in --comment.
Both present a session rather than minting one — --session, else MA2H_SESSION_ID, else this agent session's live bridge, else the session oh-hai session start remembered on this machine — and send it as ?session=, which is what makes the Hub attest you as agent:<id>#sess_…, the only form a session-qualified resolver entry matches. With nothing attached your bare principal resolves, and the CLI names the attested actor on stderr.
A refusal exits 3 and states the Hub's rule — the submitter's explicit allowed_resolvers when one was set, otherwise the addressee principal — but never which of those applied: the Hub answers every refusal identically so that probing a message id discloses nothing. 4 is an unknown or gone id, 8 an already-terminal task, 9 a refused body. --json → { id, status, resolution_id?, actor?, session?, session_source?, response? }, carrying the Hub's own resolution body.
inbox
oh-hai inbox watch [--interval <ms>] [--once] [--wait <seconds>] [--max <n>] [--force] [--raw] [--state-home <abs path>] [--json]The v0.4 principal mailbox: directives a human sends to the agent id, and the events a webhook source delivers. It runs on the same durable consumer as oh-hai bridge (#1024): a detached background connection long-polls GET /v1/inbox?wait=25 into a local spool, and this command tails it — so a foreground killed by a harness cap loses nothing (re-run it), a Hub outage backs off and keeps retrying (saying so on stderr), and a dead credential exits 3 instead of looping. Each record is printed (human: one summary line — kind, sender, title, id; --json: one inbox.watch envelope carrying { directive, signature } verbatim, plus kind and received_at), stdout flushed, then acked with POST /v1/inbox/ack { ids } — emit-then-ack is at-least-once, and the ack chunk is the Hub's max_batch. Every answered drain is also the presence heartbeat that keeps the agent online under the Hub's 90 s freshness window. --interval (default 5000 ms) is a floor applied only when a Hub answers a hold instantly.
The plain command does not return: it streams until an error stops it or you do. --once is the bounded, cron-shaped form: it prints what is waiting, waits up to --wait (default 30 s) if nothing is, and exits 0; --wait without --once is a usage error. --max caps a batch (the Hub caps at 50). --raw (--json's equivalent) prints the spool envelope verbatim; --state-home names the state root it shares with bridge (the entry is keyed on the agent id, never on an agent session). A bare MA2H_AGENT_TOKEN with no resolvable agent id is a usage error (exit 2). A bearer echoed inside a delivery is redacted on stdout; the spool record keeps the exact bytes.
One watcher per identity per machine: a second one is refused with exit 10; --force takes over the lock. Watchers on different machines are not detected and split the mailbox. Exit codes: 0, 2, 3, 10, 12, 13, 128 + N; 11, 14 and 15 are session classes and cannot occur on this leg. SIGTERM and SIGINT are clean stops (exit 0, the connection left running).
For mail addressed to a session (and anything from a peer agent), use bridge.
ingest
oh-hai ingest add <label> [--to <agent>]
oh-hai ingest ls
oh-hai ingest listen <label> [--once] [--wait <s>] [--interval <ms>] [--max <n>] [--force] [--raw]
[--state-home <abs path>]
oh-hai ingest revoke <label>
oh-hai ingest rotate <label>Webhook sources: an external push system posting its own body with a narrow bearer — POST /v1/ingest/<source> (header form) or …/<source>/<key> (URL form) — normalized into one directive on an agent's principal mailbox. The <source> segment is advisory metadata; identity comes from the key. Full page: Webhook sources.
add needs two facts — a label you type and a destination agent you pick. --to states the destination and makes the command non-interactive; with no --to it asks, offering create a new agent first, and with nowhere to ask (--json, a pipe, no terminal) it is a usage error naming --to. It prints the URL, the Authorization: Bearer ohik_… header and — only for a sender with no custom-header field — the URL form, which is a documented downgrade. The key is printed once, on both output paths including --json: it exists to be pasted into somebody else's settings page, and it can post to one destination as one source and nothing else.
ls lists each source with its destination, status and id, never a secret. revoke kills the key with no confirmation prompt and frees the label; delivered entries are untouched. rotate re-mints the secret while the label, destination and URL stay the same.
listen resolves the label to its destination agent and runs inbox watch as that agent — its flags and exit codes are that command's. It refuses to run with MA2H_AGENT_TOKEN set (exit 2): that credential would decide which mailbox is drained whatever the label says, and it displaces the human identity the label lookup needs. Where the destination has no local credential it runs the device-code flow for that agent, and stops (exit 3) if the approval binds a different one.
add, ls, revoke and rotate resolve a human identity (oh-hai login --human), and so does listen, to read the label. --json: ingest.add, ingest.ls, ingest.revoke, ingest.rotate; ingest listen emits inbox.watch records, because it is that command.
bridge
oh-hai bridge [--wait <seconds>] [--raw] [--force] [--state-home <abs path>]
[--session <sess_…>|<name>] [--transport auto|sse|poll] [--label <text>]
[--interval <ms>] [--max <n>] [--max-retries <n>] [--unattended-max-seconds <n>]
[--allow-from agent:<id>[#sess_…] | any-in-account]... [--json]
oh-hai bridge --exit-codes [--json]oh-hai bridge has exactly one behaviour. It resolves this agent session, makes sure exactly one detached background connection is draining your session into a local spool, prints every message waiting, and exits 0. If none is waiting it waits for the next one, up to --wait, and prints what arrives; reaching the bound with nothing to print is also exit 0, and nothing is lost — the cursor never moved.
Run it as a background task and re-run it after every exit, branching on the exit code — never a blind loop. That is the only documented way to run it, for every harness and every shell. There are no modes and no variants.
| Flag | Meaning |
|---|---|
--wait <seconds> | How long to wait when nothing is already spooled. Default 540 ([bridge] wait_seconds), which sits under the smallest harness task cap measured. It is also the deadline the reader declares, so every run carries one; 0 or less is a usage error. |
--force | Take over a reader lock whose holder is gone (a live pid with a stale heartbeat). |
--state-home | Absolute path of the state root every session entry lives under, instead of $XDG_STATE_HOME / ~/.local/state. A relative path is a usage error, never a silent fall-through. |
--raw | Print the spool envelope byte-verbatim instead of a summary line (--json implies it). |
--session | A sess_… value pins that Hub session (also MA2H_SESSION_ID). Any other value is an agent-session key you chose — the answer whenever the CLI cannot tell two agents apart, and what a service unit must pass. |
--transport | auto (default: SSE when the Hub advertises inbound.stream_url, else long-poll), sse, poll. sse against a Hub with no stream is a usage error. |
--interval | Long-poll cadence, default 5000 ms. Long-poll only. |
--max | Drain batch size (long-poll only). The Hub clamps to its own cap. |
--max-retries | Reconnect attempts on genuine faults before exit 13. Default 8; 0 fails on the first fault. |
--allow-from | Repeatable. Which senders may ask/task this agent: agent:<id>, agent:<id>#sess_…, or any-in-account. The by-hand route: it overrides [bridge] allow_from, which setup, login --human and upgrade write for you when no policy exists. With no policy at all, addressed asks/tasks are refused and left unacked. |
--label | Label for the session the bridge registers ([bridge] label). Default <folder> · <git branch> · <harness>, e.g. descant · 16280-w1 · claude. Labels are not the key, so duplicates are harmless. |
--unattended-max-seconds | Overrides [bridge] unattended_max_seconds (900). The bridge resolves it once and forwards the answer to the connection it spawns. 0 or less is a usage error: it never disabled the bound. |
Which agent session is this? Resolved automatically, never interactively, and never from the folder: --session, else MA2H_SESSION_ID, else a known harness variable (MA2H_AGENT_SESSION_ID, then CLAUDE_CODE_SESSION_ID), else the long-lived program hosting this agent. Two agents in one checkout get two sessions and two addresses; one agent that changes directory — or whose worktree is deleted under it — keeps its own. When nothing is resolvable (a service manager's parent chain) the command exits 2 naming --session rather than guessing. Nothing is written into a project folder.
A flagless run adopts the running connection's settings instead of failing, and says on stderr when they differ from what it would have resolved. A flag you named that disagrees is exit 2. A changed policy or transport is applied by restarting the connection: oh-hai session close --keep-connection, then oh-hai bridge.
The foreground exits when the process that started it does (a shell, a harness task), with exit 0 and everything unread left for the next run — so nohup oh-hai bridge & stops when its shell exits. For a bridge that must outlive every shell, the background connection has a private, non-forking entrypoint for a service unit's ExecStart=; it is deliberately absent from --help and documented, with its unit file, in the supervision guide in the source repository (private — ask for access).
The connection is never the reader's child. Since 0.12.1 the reader double-forks on POSIX: a throwaway intermediate launches the connection and exits within the same call, so by the time the reader returns the connection has been reparented away — to init, or to the nearest child subreaper where one exists (a container init, some supervisors). On an ordinary host ps shows it with ppid 1 while the reader is still running. detached alone would not have done this — it changes the process group, not the parent — so a harness that ends a background task by walking parent pids (pkill -P, any tree walk from the task shell) used to reach the connection and a re-run then reported "consumer stopped" instead of adopting it. It cannot now. The pid the startup line names is the connection's, relayed back through a transient file in the state home. Windows keeps the single spawn: it never reparents, and a Windows harness ends a tree with a job object an extra hop would not escape. The unattended bound is untouched — an init-parented connection still stops renewing after 900 s with no attending reader.
Under --json (or --raw) each record is one NDJSON envelope { ok, command: "bridge", data: { kind, received_at, <payload>, signature } }, byte-verbatim — received_at rides alongside kind, never inside the signed payload. Human mode prints one summary line per record, and where the Hub recorded a sender name it rides in parentheses beside the attested actor — message/ask · agent:<id>#sess_… (lead-f-v4) — never in its place. That asymmetry with the mail surfaces is deliberate: this is the line on which one agent decides whether to trust another, a label is sender-chosen and not unique, and a bare name in the from slot would be attacker-chosen text standing where a Hub-attested string belongs. The name is omitted entirely when the Hub recorded none, or when it only repeats an id the actor already spells out. Transitions (a backoff, a refusal, a replaced session, a stop) are spooled as bridge.event records and replayed in order, so a run that reattaches after an hour still sees what happened while it was gone. The no-policy notice and a stale-pin warning go to stderr only, so a caller reading the first envelope never mistakes one for mail. Every fatal exit prints oh-hai bridge: FATAL (exit <n>, <code>) — … on stderr, in --json mode too, and the bridge exits with the connection's code when the connection is what died. A stand-down prints STOOD DOWN and exits 15.
Once there is a state home to read, every exit ends with the lease epilogue — one stderr line after the stop reason, read off this machine's state home with no Hub call, so a bridge exiting because the Hub is unreachable can still say when its address dies. It is armed as soon as the entry resolves, which is after flag parsing, identity resolution and state-home resolution: a run that fails before that (a usage error, an unresolvable credential, an unresolvable agent session) exits with its reason and no epilogue, so a supervisor must not treat the line as unconditional.
oh-hai bridge: lease — sess_abc expires 2026-09-18T14:33:07.000Z (Hub's clock; last renewed 2m 10s ago); consumer pid 777 on host-a.Two facts, deliberately never merged. The instant is the Hub's, printed verbatim and labelled as theirs; the staleness is ours, measured between two readings of our own clock. There is no "time remaining" anywhere, because producing one means subtracting our now from their instant — wrong by whatever the two clocks disagree by, and wrong in the direction that reports a dead address as live. The one expiry claim the bridge makes alone is certainly lapsed: more of our own time has passed since our last renewal than the whole lease, which is an elapsed duration against a stated duration with no Hub instant in it. oh-hai session status asks the Hub and remains the authority.
A lapsed lease, one inside its warning window (a third of the lease, capped at five minutes), or a dead, absent or unreadable connection adds a second ⚠️ line saying what happens next and what to do. No new exit code: nothing has failed on these exits, and the documented 0 → act → re-run shape is already the fix, because the re-run starts a connection. The warning fires on stale contact, never on a computed remaining time. oh-hai inbox watch prints the same epilogue minus the lease — the principal mailbox is addressed by agent id and has no session to lapse, so what stops working when nothing drains it is delivery, not the address.
oh-hai bridge --exit-codes prints the exit-code table with no credential and no Hub call, so a supervisor can read the contract before it has one. SIGTERM and SIGINT are clean stops (exit 0: the reader releases its lock and leaves the connection running); any other signal ends the process at its default disposition, which the shell reports as 128 + N (SIGHUP 129, SIGQUIT 131, SIGKILL 137) — killed from outside, and restartable.
Health is oh-hai doctor --self-test; the stand-down is oh-hai session close.
Removed and retired in 0.10.0
--once, --until-event, --stream and --attach-only are removed in 0.10.0. As of 0.12.3 each still warns once on stderr and is ignored; a later release makes them usage errors (exit 2). No date is promised — one was, 0.11.x passed without it, and a date the tree has already broken is worse than none. Drop them now. bridge consume became a private entrypoint (the old spelling warns and forwards). oh-hai bridge status and oh-hai bridge stop are retired: as of 0.12.3 each still works — one warning line on stderr, then it forwards and returns the forwarded command's exit code — and a later release makes both unknown subcommands (exit 2). Neither appears in --help. The mapping: bridge status → the bridge checks of oh-hai doctor --self-test (read-only; --session, --state-home and --json carry over, and --json is doctor's envelope); bridge stop → oh-hai session close (which also closes the session, and exits 4 with nothing to stand down); bridge stop --keep-session → oh-hai session close --keep-connection; bridge stop --purge → oh-hai session close, then this agent session's state entry is cleared.
The full model — what the bridge verifies, how the SSE hold and the long-poll work, and how to supervise it — is on Staying reachable.
session
oh-hai session start [--label <l>] [--label-kv <k>=<v>]... [--kind <k>] [--project <p>] [--run-id <r>] [--ttl <seconds>] [--quiet]
oh-hai session status [--id <sess_…>] [--state-home <abs path>]
oh-hai session close [--id <sess_…>] [--session <sess_…>|<name>] [--keep-connection] [--state-home <abs path>]A session is the addressing scope for one live run: Hub-minted (sess_…), lease-bound, and the thing --to agent:<id>#sess_… addresses. oh-hai bridge registers this run's session for you and your sends attach it automatically, so the reachable path needs neither session start nor an exported id; start is for a run that wants an address with no bridge.
close is the stand-down. With no argument it acts on this agent session: it stops the background connection, waits for its clean stop, closes the session at the Hub, and forgets the id locally so no later send presents it. The order matters — closing first is a race in which the connection sees a 410 it cannot tell from a lapsed lease and registers a replacement. A bridge that watched its own stand-down exits 15; a fresh oh-hai bridge afterwards registers a new session, and whoever held the old address must be given the new one. --keep-connection stops the connection and leaves the session open, which is how a settings change is applied (the running connection is the one enforcing the old values). --id and --session stand another session down from any directory. Nothing to stand down is exit 4, not a silent 0. On Windows no signal is sent; a request file is the mechanism on every platform.
start prints the id and the shell line that sets MA2H_SESSION_ID for later invocations; start --quiet prints only the id (id=$(oh-hai session start --quiet); with --json it is a usage error). The lease is renewed only by a command that presents the session through a lease-renewing endpoint: a session-scoped drain or ack (bridge), an SSE connect, or a notify / ask submit / task submit whose envelope carries agent.session. session status, whoami and ask await do not renew it — a run that only polls for an answer lets its session lapse unless a bridge is also running. --label is a free-text caption; --label-kv key=value (repeatable, at most 32 entries, each side at most 512 characters, no control characters, no repeated key) fills the labels map. --kind defaults to cli. --ttl is a request the Hub clamps into its advertised bounds (60–3600 s on the production Hub; default lease 900 s). status defaults to the attached session. Over the per-agent live cap (16), start fails with a 429 that names the way out.
status also answers whether anything is keeping this addressable: only a connection's drain renews the lease, so a live lease with nothing draining it is a countdown. It reads this machine's state root — pass --state-home for a bridge started under a non-default one (a relative path is a usage error before any Hub call), and it finds an entry whose connection has stopped, which is exactly the case worth reporting.
--json: start/status → { session } with id, agent_id, state (active, closed, expired), expires_at, closed_by_operator when a human closed it. status additionally carries:
| Field | Meaning |
|---|---|
lease | expires_at, expired, state, ttl_seconds, attested_at. expired is the Hub's own verdict, never local arithmetic, so it cannot contradict the resource it arrived in. attested_at dates the verdict, not the lease — status is a read and renews nothing. Poll it to keep the verdict fresh. |
consumer | state (alive / dead / absent / unreadable), attached, alive, pid, hostname, state_home. attached and alive are null, never false, when this machine cannot answer: a record it could not read is not evidence that nothing is running. |
closed_by_operator | A plain boolean, hoisted out of session because the wire field is true-only and the one state a supervisor must never restart through should not depend on knowing that. |
There is deliberately no seconds_remaining: it would mean subtracting this host's clock from the Hub's. ttl_seconds and attested_at do not combine into one either. The human print gains the same two lines.
close → { session, stopped, home, detail } (--keep-connection: { session: null, kept_open: true, stopped, detail }).
fleet
oh-hai fleet ls [--all] [--kind <k>] [--project <p>] [--label <substring>] [--stale <duration>] [--limit <n>] [--offset <n>]One row per session with the exact --to address to copy, plus who, state, age, idle, label, kind and project. Live sessions only by default; --all includes closed and expired ones the Hub still retains. --kind and --project match exactly; --label is a case-insensitive substring. Filters apply to the page the Hub served. The caption states the scope the Hub reported: own (your sessions) or account (every agent's sessions — only when the account has enabled Agent messaging in Settings). --json → { sessions, count, scope, filters, has_more, next_offset }.
IDLE is the time since last ack — the last delivery that session acknowledged — or never when it has acknowledged nothing. It is not a claim about whether the agent is working: the Hub writes it only where a delivery was acked, so a session nobody has mailed is idle by this measure and is not dead. A legend saying so prints beneath the table on stderr, keeping the table on stdout parsable. --stale <n>s|m|h|d keeps only rows whose last ack is older than that — a session that has acked nothing is not selected, because never almost always means a fresh run nobody has addressed rather than a stuck one. The unit suffix is required; --stale 30 is a usage error (exit 2) raised before any Hub call, since seconds and minutes are both plausible readings 60× apart.
A row whose session has bridge state on this host also carries its local lag and whether a reader is attending. The rest are captioned no local state here, which says nothing either way about a session running on another machine. Under --json that rides as local_bridge on the matching rows and is omitted from the others.
An absent record and an unreadable one are different answers, and the cell says which. The directory listing decides: a listing that does not name consumer.pid / reader.pid is a genuine absence and keeps its loud verdict — home found · consumer DEAD, no reader. A record that is there, or may be when its directory could not be listed, reads home found · consumer UNKNOWN and reader unknown, and under --json consumer.live, reader_attached and reader_attending are null rather than false. That distinction is the point: DEAD is the cell an operator relaunches a bridge over, and a relaunch against a live connection changes the session's address. The same care applies to the backlog — spool unreadable (spool_unreadable: true, unread: null) for a spool whose bytes could not be read, local state unreadable (local_scan_unreadable) for a walk that hit a directory it could not read, and 3 unread (1 unageable) (unageable_unread) when some unread records carry no usable received_at, so a young lag is never reported over a backlog whose real wait is unknown.
messages
oh-hai messages list [--limit <n>] [--offset <n>] [--status <s>] [--type <notify|ask|task>]
oh-hai messages get <id> | --id <id>The agent's own submitted messages, newest first — history, or dedupe before resubmitting. Bearer-scoped: never the human inbox. A pure index: each row shows status but not the answer body — read one with get. --limit defaults to 50 (Hub max 200); --offset up to 10 000. --status is one of open, delivered, queued, acknowledged, bounced, answered, declined, cancelled, expired, completed, dismissed. --json → { messages, count, has_more, next_offset }.
get reads one message this agent submitted, by id, under the agent's own token — to verify what a send actually stored, or to re-read an answer after ask await has returned. The id is the operand or --id (the flag ask await takes). It prints the id, type and status, title:, created:, the body, and for a resolved ask or task the same block ask await prints: resolution:, value: (asks only), comment:, actor: — plus edited: true when the human answered a select/confirm off-menu with --allow-edit, in which case read the value rather than matching it against your options. --json → the message exactly as the Hub returns it (the resolution under response.response), command: "messages.get". A 404 is exit 4: the id names nothing this agent submitted, and the Hub does not say whether that is because it is someone else's or does not exist. A human identity is refused before any Hub call (exit 2) — reading your account inbox as yourself is oh-hai mail read.
The FROM column names the run that sent each row, not the machine. On a fleet run from one host every invocation shares one agent.id, so this list interleaves them all — FROM is what tells your own send apart from a sibling's when you are checking whether a message left. It shows the name the Hub recorded at submit — the label that run registered (oh-hai bridge --label, oh-hai session start --label), else that agent's own label. With neither, the cell falls back through the message's project, then an MA2H_AGENT_TOKENS id verbatim (a deployment that names its own principals already typed a name), then a short run … or agent … id tail, and prints — only when the row carried nothing at all. A Hub-minted agnt_<uuid> is never printed bare. Long names are cut to 28 characters, by code point, so an emoji is never sliced in half. --json carries the exact agent.session if you need to match on it, and the recorded name under sender.
The STATUS column prints the protocol value as-is. When a page carries any of the addressed-delivery states, a short legend follows the table on stderr, so the table on stdout stays parsable: queued is acceptance, not delivery (accepted into the addressee's mailbox, not yet delivered), bounced means the destination went terminal before it was consumed, and acknowledged means the addressee consumed it.
mail
oh-hai mail list [--archived] [--limit <n>] [--offset <n>]
oh-hai mail read <id>
oh-hai mail watch [--all] [--once] [--raw] [--since <id|time>] [--auto-ack] [--attach-only]
oh-hai mail ack <id>...
oh-hai mail status
oh-hai mail stop [--purge]
oh-hai mail archive <id>...
oh-hai mail unarchive <id>...Your account inbox — the feed the web and iOS apps show — read with the account owner's identity. Requires oh-hai login --human. Under an agent identity every mail subcommand refuses with exit 2 before contacting the Hub. watch is two processes: a detached consumer holds the account SSE stream (GET /v1/stream) and records every message to a local spool, and the foreground tails that spool. Kill the foreground and the consumer keeps reading; re-run mail watch and it resumes at the first message you have not acked. Replay after a reconnect is at-least-once, deduped by id — including messages archived in the meantime — and a window it cannot read back far enough to close is printed as REPLAY_GAP from=<id> to=<id> rather than dropped. One consumer per identity and at most one --auto-ack tail (a second of either exits 10); plain tails are unlimited. mail ack records what you handled, mail status shows whether the consumer is running and how many messages are unacked, mail stop ends it and exits non-zero if it is still running — and those three never contact the Hub, so they answer while it is down. The spool holds full message bodies under $XDG_STATE_HOME/oh-hai/mail/; oh-hai logout and mail stop --purge remove it. list and read name the sender (FROM, and a from: line on the single read) — the label the sending run registered, else that agent's label, then the same fallbacks messages list uses, never a bare Hub-minted id. watch does not: its tail lines stay as they were. A name is sender-chosen, so read it as a nickname — Concepts → Sender name. --json: list → { messages, count, view, has_more, next_offset }; read → { message }; archive/unarchive → { results, count, failed } with a per-id outcome; watch → one { event, message } per line.
agents
oh-hai agents list
oh-hai agents create --label <label>
oh-hai agents revoke --target-agent <id> [--yes]Account identity management. create mints the agent, then a token for it, and stores that token in the keychain under the new id — it is not printed. revoke deletes the agent on the Hub and removes the matching local credential; it confirms interactively unless --yes (required under --json). Both need a human identity (oh-hai login --human) and have no fallback: reading a list of ids is not an argument for letting an agent mint or destroy a credential. GET /v1/agents stays human-only by design, and POST / DELETE with it.
list also works under an agent identity (0.12.2). A stored human login is always preferred and still shows created / last-used; with only an agent token the command reads the Hub's account agent directory (GET /v1/account/agents — { id, label, revoked, source } for the caller's own account and nothing else: no tokens, no token metadata, no actor, no counts, no timestamps). It is feature-detected, gated on the Hub advertising agents.directory_url in its capability document rather than on trying the path and reading a 404, and an advertised URL on a different origin than the configured Hub is refused before the bearer is sent. A Hub that offers no directory reports "not authenticated" as it always did, naming oh-hai login --human.
The --json envelope keeps one shape either way: on the directory path created_at and last_used_at are null, a line on stderr says why, and the table renders them as an em dash. A failed directory read exits with the class its cause deserves — 3 for a rejected bearer, 5 unreachable, 7 timeout, 6 only for a Hub that misbehaved — the same codes the human route gives, so a wrapping script can branch without knowing which credential resolved.
--json: list → { agents: [{ id, label, created_at, last_used_at, revoked }] }; create → { agent_id, label, storage }; revoke → { agent_id, revoked: true }. list does not ask for config-token (MA2H_AGENT_TOKENS) identities: its table reports every unrevoked row as active, and a config identity has no label, no created_at and no manageable record, so it would sit there beside a revoke that can only 404. setup and upgrade do ask for them, because a sender policy is a different question.
doctor
oh-hai doctor [--test-notify] [--self-test [--session <sess_…>|<name>] [--state-home <abs path>]] [--no-scan | --scan-only]The health check. Checks, each pass / warn / fail: config (URL scheme, account resolved), token (present, and from where), keychain (advisory when the token came from env or the file fallback), connectivity (a side-effect-free GET), update (advisory; asked of this install's own release channel — npm for a global npm install, https://ohhai.app/dist/latest for a standalone binary — and omitted when that channel is unreachable), and with --test-notify a real low-priority notify. A token the Hub rejects is a failure: a 401/403 on the connectivity probe fails doctor with exit 3 (oh-hai login) — the code whoami --check returns for the same answer — so a revoked credential never passes the setup check. An unreachable Hub or a 5xx stays a warning. A failed required check exits non-zero even though the --json envelope reports ok: true — branch on the exit code or per-check status. --json → { checks: [{ name, status, detail }], scan? }.
--self-test proves the loop end to end and is described on the Quickstart; it needs Settings → Agent messaging on, because its self-addressed notify is refused (403 not_authorized) while the account's inter-agent leg is off. It sends real traffic and is opt-in. After the round trip it reads this agent session's bridge (or the one --session / --state-home names), read-only and sending nothing into its session, as four checks: bridge consumer (the connection is live), bridge session (active at the Hub), bridge reader (a reader attending, or still inside the unattended bound) and bridge unread (nothing unread past that bound). A failure fails the self-test and names the check; no bridge at all passes with no bridge for this agent session. A record that exists and could not be read is reported as unknown and is a warn, not a fail — a fail is what a health script relaunches on, and relaunching over a live connection changes the address. The reader check takes the same care with its clock: it measures from the reader's heartbeat or the connection's start, whichever is later, so an unreadable connection record leaves any interval an upper bound rather than a measurement, and that is an unknown too. "Nobody is reading and nothing will ever stop it" needs no clock, so a direct connection with no reader stays loud however unreadable its record. This is what the retired bridge status forwards to.
doctor also scans this machine for oh-hai guidance that has gone stale: the instruction files oh-hai teach writes, agent memory notes, scripts that call oh-hai under [doctor] scan_roots (user config only; else this project and your bin directories), crontab / launchd / systemd user jobs — including a bridge unit that restarts on every failure — bridges running another build, and pre-0.10 per-directory bridge homes nothing will adopt. Each finding names the file, the line, the stale text and the exact fix. The CLI rewrites only what it owns (a managed teach block); everything else is reported and left byte-identical. The scan states what it could not inspect rather than reporting a clean result over it: a bridge root, a sessions/ or principals/ directory, a consumer record or a session-owner record that exists and could not be read each adds a limits[] entry naming the path — otherwise an EACCES hides a live connection on an old build behind an empty listing. Findings are advisory and never change the exit code. --no-scan skips it; --scan-only runs just the scan, offline.
teach
oh-hai teach [--global] [--dry-run] [--yes]Writes the OH HAI snippet into the agent-instruction files this machine's agents read — the send verbs, how to stay reachable, and, since 0.11.1, what a webhook source is and oh-hai ingest add <label> --to agent:<id>. Described on the Quickstart. Every file it writes is recorded (a receipt under $XDG_STATE_HOME/oh-hai/teach), so the first command after a later upgrade can rewrite exactly those blocks and no others. A run that reaches its write step also backfills a receipt for any managed block it finds without one — including a target whose block was already current — so one oh-hai teach brings a block written before receipts existed under management for good. --dry-run, a declined prompt and the non-interactive hint write neither file nor receipt. --json → { files: [{ path, action }], skipped?, dry_run, wrote }.
upgrade
oh-hai upgrade [--check]Checks for a newer version on this install's own release channel and acts by install method. The two channels do not advance together: npm publishes on the cli-v* tag and lands first, while CI moves the mirror's dist/latest pointer last, so a client resolving latest never sees a tag whose assets are missing. A standalone binary therefore reads https://ohhai.app/dist/latest — the same plain-text pointer install.sh uses, first line only, cli-v prefix stripped — and every other install method asks npm for dist-tags.latest of @oh-hai/cli. Then: an npm-global install runs npm i -g @oh-hai/cli@latest (interactive sessions only; a pipe, CI or --json prints the command instead); a standalone binary downloads the matching release asset, verifies its SHA-256 against SHA256SUMS, and swaps itself in place (interactive only); a source checkout gets guidance. --check reports without changing anything. oh-hai update is an alias. --json → { current, latest, update_available, method, action } with method one of npm-global | binary | dev | unknown and action one of up-to-date | check | npm-upgrade | print-command | binary-updated | binary-guidance | dev-guidance | unknown-guidance.
Every run except --check also writes the [bridge] allow_from sender policy when none exists (see setup) — including on an already-latest machine, because oh-hai upgrade is the fix the bridge names when it refuses an agent minted after the list was written. Since 0.12.2 an agent-only machine can do this unattended, via the account agent directory. --check is the no-mutation mode and writes nothing. It does not refresh a policy this tool generated earlier — see the limits under setup.
You do not have to run it to find out. Once a day, when a newer version is out on this install's channel, two lines go to stderr ahead of whatever you typed — the notice, then a parenthetical saying it is said at most once a day and naming the opt-out: update available — v… → v…. Run \oh-hai upgrade`.It is read from a 24 h disk cache at$XDG_STATE_HOME/oh-hai/update/check.jsonand never the network, so it cannot slow or fail a command; it is silent on every failure path; it never touches stdout, so--jsonstill carries exactly one envelope and the bridge's NDJSON stays byte-clean; and it is rate-limited, so abridge re-run loop is told once a day rather than once a second. The cache is refreshed only by commands that were making the request anyway (doctor, upgrade) plus one bounded check at oh-hai bridgestart. A source checkout, or an installoh-hai upgradecannot update, is never nagged. **SetNO_UPDATE_NOTIFIER=1` to turn it off** — which stops the probe, not merely the printing.
The first run of a new version migrates this machine — once, loudly, on its own. No installer hook is involved (curl | sh, npm i -g and a binary swap all skip them), so the first oh-hai command after the version changes does the work and prints a summary on stderr (a --json command still prints exactly one envelope on stdout); every command after that does nothing. It rewrites the managed instruction blocks this CLI wrote — those it holds a receipt for, plus, since 0.12.1, any block it finds by its <!-- oh-hai:teach start --> markers in the fixed global targets teach --global writes (~/.codex/AGENTS.md, ~/.claude/CLAUDE.md, ~/.gemini/GEMINI.md), which is how a block written before receipts existed stops being skipped by every upgrade; a global file with no block is never written. Project blocks stay receipt-only — they are unbounded and a migration must not crawl a filesystem — so a block in the current directory with no receipt is reported, not written, and the summary names it and tells you to run oh-hai teach there. It also moves a setting only a running bridge knew about (its unattended bound) into config; writes the sender policy under the same never-widen rules; stops background connections still running the old version, so the bridge reading them respawns them on the new binary with the same session and spool (one nobody is reading is left alone and reported); and refreshes the oh-hai skills through the owning tool's own updater (claude plugin update oh-hai-skills), printing the command when that tool is not on PATH. The migration is fail-open — you typed something unrelated. oh-hai upgrade always runs it, forced, and is the one place a failed step is a non-zero exit; upgrade --check migrates nothing.
Offline behaviour
logout, whoami (without --check), use, teach, bridge --exit-codes and doctor --scan-only work offline. doctor runs its local checks and reports network checks as warn. Everything else needs the Hub and fails fast with exit 5 (network) or 7 (timeout); nothing is left half-written.