Skip to content

REST API reference

Base URL: http://localhost:3100 (the compose stack binds the API to localhost by default — see security.md for reaching it remotely).

All endpoints except GET /healthz and the public discovery routes under /.well-known/ (agent card, domain-control proof, OAuth Protected Resource Metadata, and OAuth Authorization Server Metadata) require a bearer token. GET /authorize is the browser authorization entry — consent is approved by an admin Dashboard session at /ui/oauth/authorize (owner), not by any OAuth protocol credential. POST /oauth/token and POST /oauth/revoke use protocol credentials (code + PKCE, or the token value plus bound client_id) — they do not take an admin / identity Bearer.

Authorization: Bearer <admin key, oa_… identity token, or OAuth access token>

Credential kinds (details in security.md):

  • Admin key (from the API_KEYS env) — full access, every endpoint below.
  • Identity token (oa_… from POST /v1/identities) — scoped to one address: messages / wait / send / participant tasks / own notify routes (human alerts need canNotifyUser), and GET /v1/identities/:address/push-tier for that same address. Creating or listing identities, rotating tokens, deleting identities, and PUT …/push-tier stay admin-only. Anything outside scope returns 403.
  • OAuth access token — identity-scoped only (never admin); issued by the authorize flow. Revoke the grant via POST /oauth/revoke or Dashboard /ui/oauth/grants (does not touch oa_…); deleting the identity cascades and kills its OAuth grants too.

For /v1/*, failures return 401 {"error":"unauthorized"} for bad tokens. POST /mcp uses a WWW-Authenticate challenge instead (see below). Examples below assume:

Terminal window
export API=http://localhost:3100
export KEY=your-admin-key

Liveness probe. No auth.

Terminal window
curl $API/healthz
# → 200 {"ok":true}

Create an identity. With no localpart, a random one like fox-k7d2 is generated. The address is always on the DOMAIN the server was configured with.

The response includes the identity’s scoped token, shown exactly once — hand this one to your agent, not the admin key.

Terminal window
# Feed the bearer header through curl config on stdin so the token stays off argv.
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl -X POST $API/v1/identities \
-H "Content-Type: application/json" \
--config - \
-d '{"name":"signup-bot"}'
# → 201 {"address":"fox-k7d2@example.com","name":"signup-bot","pushContentTier":1,"token":"oa_…"}
FieldTypeNotes
namestring?Free-form label for the identity
localpartstring?Force a specific address, e.g. billingbilling@example.com
canNotifyUserboolean?Admin-granted permission for this identity to call notify_user and notify_verify

Response always includes resolved pushContentTier (default 1). Tier 3 adds pushContentTierWarning on list/public identity shapes.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl $API/v1/identities --config -
# → 200 {"identities":[{"address":"fox-k7d2@example.com","name":"signup-bot",
# "createdAt":"2026-07-26T00:00:00.000Z","pushContentTier":1}]}

Token hashes are never included in responses.

POST /v1/identities/:address/token — admin only

Section titled “POST /v1/identities/:address/token — admin only”

Rotate an identity’s token. The old token stops working immediately; the new plaintext is returned once.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl -X POST $API/v1/identities/fox-k7d2@example.com/token \
--config -
# → 200 {"address":"fox-k7d2@example.com","token":"oa_…"}

DELETE /v1/identities/:address — admin only

Section titled “DELETE /v1/identities/:address — admin only”

Delete an identity (and invalidate its token). Its mail stays in the catch-all mailbox until the retention sweeper removes it.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl -X DELETE $API/v1/identities/fox-k7d2@example.com --config -
# → 200 {"deleted":true}

Read the mail-arrival push content tier for one identity. Admin keys may read any address. An identity token may read only its own address (otherwise 403).

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl $API/v1/identities/fox-k7d2@example.com/push-tier \
--config -
# → 200 {"address":"fox-k7d2@example.com","pushContentTier":1}
FieldTypeNotes
addressstringLowercased identity address
pushContentTier1 | 2 | 3How much content mail-arrival user pushes include (default 1)
warningstring?Present only when tier is 3 — body/OTP leave this server via ntfy

Tier semantics (mail-arrival pushes to the human topics only):

TierContent in the push
1 (default)Interrupt only — address + whether the mail looks OTP/link-bearing. No sender, subject, preview, or codes.
2Tier 1 plus masked From / Subject
3Interrupt line plus unmasked From / Subject, body preview, and extracted OTP codes/links (sensitive)

PUT /v1/identities/:address/push-tier — admin only

Section titled “PUT /v1/identities/:address/push-tier — admin only”

Set the push content tier. Admin key required — identity tokens get 403 {"error":"forbidden: admin key required"}.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl -X PUT $API/v1/identities/fox-k7d2@example.com/push-tier \
-H "Content-Type: application/json" \
--config - \
-d '{"pushContentTier":2}'
# → 200 {"address":"fox-k7d2@example.com","pushContentTier":2}
FieldTypeNotes
pushContentTier1 | 2 | 3Required
confirm_riskboolean?Required as true when setting tier 3

Tier 3 ships body previews and OTP codes/links off-box through the ntfy channel. Without "confirm_risk": true the API refuses with:

400 {"error":"confirm_risk_required","message":"Tier 3 includes message body previews and OTP codes/links in push notifications. That content leaves this server for the ntfy channel."}

A successful tier-3 response also includes the same text in warning. Create and list responses always include resolved pushContentTier; list / public identity shapes also add pushContentTierWarning when the tier is 3.

List an identity’s inbox, newest first. limit defaults to 50 (max 200). Identity tokens may only list their own address.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl "$API/v1/messages?address=fox-k7d2@example.com&limit=10" \
--config -
# → 200 {"messages":[{"id":"42","from":"noreply@github.com","to":"fox-k7d2@example.com",
# "subject":"Verify your email","date":"2026-07-26T00:01:00.000Z","seen":false,
# "snippet":"Confirm your address by clicking…","hasOtp":true,"source":"external"}]}

Each summary includes:

FieldTypeNotes
hasOtpbooleantrue when OTP extraction found any code or verification-looking link
source"internal" | "external"HMAC mail-stamp classification — fail-closed (see below)

Full message, including extracted OTP codes and links, plus source.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl "$API/v1/messages/42?address=fox-k7d2@example.com" \
--config -
# → 200 {"id":"42","from":"noreply@github.com","to":"fox-k7d2@example.com",
# "subject":"Verify your email","date":"2026-07-26T00:01:00.000Z",
# "text":"Your code is 482913 …","html":"<p>Your code is …</p>",
# "otp":{"codes":["482913"],"links":["https://github.com/verify?token=…"]},
# "links":["https://github.com/verify?token=…"],"source":"external"}

otp.codes holds short numeric/alphanumeric verification codes found in the body; otp.links holds URLs that look like verification/confirmation links. Both are best-effort extraction — the raw text/html are always there as fallback. source uses the same fail-closed stamp check as the list endpoint. Server-stamped task mail may also include taskId / taskState.

source also appears on POST /v1/messages/wait (same detail shape). The POST /v1/messages/:id/seen response is only {id, seen} — no source.

Mark a message read ("seen":true) or unread ("seen":false). Reading a message never changes the flag by itself — agents call this after processing a message, so the unseen count means “not yet handled”. Returns 404 when the message is not addressed to address.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl -X POST $API/v1/messages/42/seen \
-H "Content-Type: application/json" \
--config - \
-d '{"address":"fox-k7d2@example.com","seen":true}'
# → 200 {"id":"42","seen":true}

Long-poll until a matching message arrives. This is the workhorse for automated signups: create the identity, trigger the signup, then wait.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl -X POST $API/v1/messages/wait \
-H "Content-Type: application/json" \
--config - \
-d '{"address":"fox-k7d2@example.com","subjectContains":"verify","timeoutSec":180}'
FieldTypeNotes
addressstringIdentity to watch (required)
fromContainsstring?Case-insensitive substring match on the sender
subjectContainsstring?Case-insensitive substring match on the subject
timeoutSecnumber?Default 60 (clamped by MCP_MAX_WAIT_SECONDS, range 1–600)

Success returns the same shape as GET /v1/messages/:id (including otp and source). On expiry:

408 {"error":"timeout"}

Set your HTTP client timeout comfortably above timeoutSec.

Send from an existing identity. from must be an identity you created — otherwise 403 {"error":"from is not a known identity"}. Identity tokens may only send as themselves.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl -X POST $API/v1/send \
-H "Content-Type: application/json" \
--config - \
-d '{"from":"fox-k7d2@example.com","to":"friend@example.org",
"subject":"hello from an agent","text":"sent via openagent.email"}'
# → 200 {"queued":true,"messageId":"<…@example.com>"}
FieldTypeNotes
fromstringAn existing identity (required)
tostringRecipient (required)
subjectstringRequired
textstringPlain-text body (required)
htmlstring?Optional HTML alternative

Each identity is limited to SEND_RATE_LIMIT messages per rolling hour (default 20). Over the limit:

429 {"error":"rate_limited","limit":20,"retryAfterSec":1234}

queued:true means the mailserver accepted it — not that the recipient’s provider did. Deliverability is your infrastructure’s job; see deliverability.md.

Create a task between two managed identities. The API sends an email with a private X-OA-Task UUID and X-OA-Task-State: submitted, then wakes the recipient’s server-side agent route. Task mail is exempt from the ordinary SEND_RATE_LIMIT.

With an identity token, omit from and the server uses that identity. Admin keys must include from explicitly.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$IDENTITY_TOKEN" | \
curl -X POST $API/v1/tasks \
-H "Content-Type: application/json" \
--config - \
-d '{"to":"worker@example.com","subject":"Check staging","body":"Run the smoke test.","wait":true}'
FieldTypeNotes
fromstring?Required only with an admin key; must be a known identity
tostringA different known identity on this server
subjectstringRequired task subject
bodystringRequired plain-text instructions
waitboolean?Wait up to 600 seconds for completed or failed before returning — clamped by MCP_MAX_WAIT_SECONDS (default 60)

Returns 201 with a task object. A wait may return a non-terminal task after the clamped timeout; use GET /v1/tasks/:id?wait=true again, or poll without wait.

List task threads. Identity tokens see only threads where they are one of the two participants. Admin keys see all task threads. Optional state is one of submitted, working, input-required, completed, or failed.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$IDENTITY_TOKEN" | \
curl "$API/v1/tasks?state=working" --config -

Read one task thread, including the email-backed state history and latest JSON result when present. Only a participant or an admin key may read it. Add wait=true to hold the request for up to 600 seconds until a terminal state appears — clamped by MCP_MAX_WAIT_SECONDS (default 60); a long-lived client can repeat this call with the same task ID.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$IDENTITY_TOKEN" | \
curl "$API/v1/tasks/0fdc3207-056e-47c1-a65c-b29d39f66b83?wait=true" \
--config -

Advance a task. The API, not the caller, writes the task state headers onto a new reply in the email thread. completed and failed are terminal; later updates return 409 {"error":"task_already_terminal"}. Concurrent non-terminal updates use last-writer-wins mailbox order.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$WORKER_TOKEN" | \
curl -X POST $API/v1/tasks/0fdc3207-056e-47c1-a65c-b29d39f66b83/state \
-H "Content-Type: application/json" \
--config - \
-d '{"state":"completed","body":"Smoke test passed.","result":{"version":"0.4.0","checks":["login","send"]}}'
FieldTypeNotes
fromstring?Required only with an admin key; identity tokens derive it from themselves
statestringRequired: submitted, working, input-required, completed, or failed
bodystring?Optional human-readable update
resultJSON?Optional structured result, written as a JSON block in the reply body

The caller must be one of the task participants. A token for another managed identity receives 403 even if it knows the UUID.

Ordinary mail-client replies do not reliably retain X-OA-Task-* headers, so they do not advance state and may not appear in this thread view. v0.4 does not fall back to References/In-Reply-To and does not expose Message-ID values. Attachments are not task output in v0.4; use the result block instead.

Outbound mail the API sends may carry an HMAC header X-OA-Mail-Stamp. On read, the API recomputes the stamp over the same field contract (from, to, subject, date, body hash) and sets message source:

sourceMeaning
"internal"Stamp present and verifies
"external"Missing header, bad/mismatched HMAC, missing fields, unparseable mail, or any other uncertainty

This is fail-closed: anything not proven internal is external. The stamp binds envelope fields plus a body digest, so copying a legitimate stamp onto altered text fails verification.

Why stamps are not written for every send: the signing key may fall back to the SMTP password (MAIL_PASSWORD in Compose). An external recipient who receives a stamped header gets a known-input + HMAC tag pair; when that key is the SMTP password, the pair enables an offline dictionary attack on the password. The API therefore writes X-OA-Mail-Stamp only when every To address is on this server’s domain. Mixed or external recipients get no stamp; when that mail is read back locally it classifies as external, which is intentional.

Treat source as a hygiene signal for agents (see Reading untrusted mail), not as a cryptographic security boundary against a hostile MTA.

Stateless remote MCP transport (MCP 2026-07-28 / SDK v2). Same 15 tools as the stdio package; no Mcp-Session-Id. POST only — other methods return 405 with Allow: POST.

Requires Authorization: Bearer <admin key, oa_… identity token, or OAuth access token>. Missing or invalid credentials return 401 plus a WWW-Authenticate challenge that includes a resource_metadata= URL pointing at the PRM document below (unlike /v1/*, which returns bare JSON without that header).

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl -X POST $API/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
--config - \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

Off loopback, serve this over https — the Bearer token is sent on every request. Env MCP_PUBLIC_URL sets the canonical public origin for the PRM document, OAuth issuer / audience, and the 401 resource_metadata= URL (required for public ingress; see Exposing MCP publicly). Client type: http setup: MCP client setup — Remote HTTP.

RFC 9728 Protected Resource Metadata for the MCP resource. No auth. The path-aware twin GET /.well-known/oauth-protected-resource/mcp returns the same document. authorization_servers lists the AS issuer; clients continue to RFC 8414 metadata.

Terminal window
curl $API/.well-known/oauth-protected-resource
# → 200 {"resource":"http://localhost:3100/mcp","authorization_servers":[…],
# "scopes_supported":["mcp"],"resource_name":"openagentemail", …}

GET /.well-known/oauth-authorization-server

Section titled “GET /.well-known/oauth-authorization-server”

RFC 8414 Authorization Server Metadata. No auth. Advertises authorization_endpoint (/authorize), token_endpoint (/oauth/token), revocation_endpoint (/oauth/revoke), code_challenge_methods_supported: ["S256"], authorization_response_iss_parameter_supported: true, and client_id_metadata_document_supported: true (CIMD; no DCR).

Terminal window
curl $API/.well-known/oauth-authorization-server
# → 200 {"issuer":"…","authorization_endpoint":"…/authorize",
# "token_endpoint":"…/oauth/token","revocation_endpoint":"…/oauth/revoke",
# "code_challenge_methods_supported":["S256"], …}

Loopback / tailnet remains the default. Public AS + /mcp ingress is supported when you set MCP_PUBLIC_URL and OAE_PUBLIC_EDGE=true — see Exposing MCP publicly. Web-agent wiring: MCP client setup — OAuth web authorization.

OAuth 2.1 authorization entry. Redirects (302) to /ui/oauth/authorize (Dashboard cookie path /ui). Consent is admin-session only: the owner approves or denies, choosing an existing identity or creating one. Successful and error redirects back to the client include iss (RFC 9207). Clients must send PKCE S256 and the RFC 8707 resource parameter ({base}/mcp).

Token endpoint. Supports:

grant_typeNotes
authorization_codeRequires PKCE S256 verifier, redirect_uri, client_id, and resource
refresh_tokenRotating refresh — previous refresh token is invalidated; access TTL 1h, refresh TTL 30d

Successful responses include expires_in=3600 (RFC 6749 §4.2.2). This service does not offer RFC 7662 introspection — treat 401 from POST /mcp as the expiry / revocation signal.

RFC 7009 token revocation. The caller must present the token value being revoked and a client_id bound to the issuing grant — mismatch skips the delete but still returns 200 (no anonymous revoke-by-guess). This endpoint revokes one token value only; with rotating refresh, later descendants are not traced — for a leak, revoke the whole grant at Dashboard /ui/oauth/grants (see security.md).

Public discovery card using A2A v1.0 vocabulary: a fixed capabilities object, free-form task support in skills, and the email entrance in services. It is a discovery shape only, not a claim of A2A wire-protocol compatibility. Add an already-known managed address as ?address=worker@example.com to put its mailto: endpoint into the card without enumerating identities.

Matching HTTP well-known domain-control proof. No auth. Shape:

{
"version": "1.0",
"domain": "example.com",
"agentCard": "https://api.example.com/.well-known/agent-card.json",
"proof": {
"type": "http-well-known-domain-control",
"domain": "example.com"
}
}

Publish a server-side ntfy notification. Agents never provide an ntfy topic or credential. target is user or agent:<identity-localpart>.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl -X POST $API/v1/notify \
-H "Content-Type: application/json" \
--config - \
-d '{"target":"user","title":"Approval needed","message":"Please review the draft","level":"urgent"}'
# → 200 {"target":"user","title":"Approval needed","level":"urgent"}

level is urgent, normal (default), or low; optional tags has at most five strings. Admin keys may alert the user. An identity token needs its admin-created canNotifyUser grant and is subject to NOTIFY_RATE_LIMIT.

Read cached notification history. topic is a logical route: self, user-alerts, user-low, or agent:<identity-localpart>. Identity tokens may only pass self (or their exact own agent route); they cannot read user or other-agent history.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$IDENTITY_TOKEN" | \
curl "$API/v1/notify/messages?topic=self&since=1h" \
--config -
# → 200 {"messages":[{"id":"…","time":…,"title":"…","message":"…","priority":3,"tags":[]}]}

Publish a harmless notification check and poll the ntfy cache for it. This is the same self-check used by ./deploy/doctor.sh. It has the same permission rule and independent rate limit as target:"user" notifications.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$KEY" | \
curl -X POST $API/v1/notify/verify --config -
# → 200 {"ok":true}

Create a new dedicated read-only ntfy account for one phone. This is an admin-only setup action; it is not exposed through MCP. The request’s public URL must exactly match the active NOTIFY_PUBLIC_URL, which means the HTTPS reverse proxy and a full stack restart must happen first.

Terminal window
printf 'header = "Authorization: Bearer %s"\n' "$ADMIN_KEY" | \
curl -X POST $API/v1/notify/devices \
-H "Content-Type: application/json" \
--config - \
-d '{"publicUrl":"https://ntfy.example.com"}'
# → 201 {"serverUrl":"https://ntfy.example.com","username":"phone-…",
# "password":"…","topics":{"userAlerts":"user-alerts-x7k2","userLow":"user-low-x7k2"}}

Save the returned password privately. Subscribe the ntfy app to both returned topics using this one account; it has no access to any agent topic. The phone notification guide has the public proxy and iOS/Android steps.

CodeMeaning
200 / 201Success
401Missing/invalid bearer token
403Valid token, disallowed action (identity token outside its scope, non-identity from, non-admin managing identities)
408wait timed out
429Send rate limit hit — back off retryAfterSec
5xxMailserver unreachable or internal error — check docker compose logs api