Skip to content
machinemail

auth.md registration

How an AI agent registers itself with MachineMail through auth.md - discovery, the anonymous, service_auth and identity_assertion registration types, tokens and scopes, sandbox limits, the token endpoint and revocation, with full HTTP examples.

An agent can get a working MachineMail address without a human creating a key first. It reads https://api.machinemail.dev/auth.md, registers, and gets a credential. A human claims it later to bind it to their organization.

MachineMail follows the auth.md protocol: standard OAuth discovery documents, an agent registration endpoint, a claim ceremony with a 6-digit code, and token exchange with RFC 7523 JWT bearer assertions. auth.md itself is a Markdown file written for agents. It is generated from the live configuration, so it always matches what the server accepts.

The flow is: discover → register → claim (if needed) → exchange for an access token → call the API → handle revocation.

Note

Examples use https://api.machinemail.dev. Always use the URLs the discovery documents and responses return; they are authoritative.

Step 1: discover

Any unauthenticated request to /v0 returns a 401 that points at the protected resource metadata:

http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://api.machinemail.dev/.well-known/oauth-protected-resource"

The MCP server’s 401 points at its own metadata instead, on its own host: resource_metadata="https://mcp.machinemail.dev/.well-known/oauth-protected-resource/mcp", whose resource is https://mcp.machinemail.dev/mcp (see MCP sign-in). The authorization server is the same, and the steps below work for both: a token for the root resource is good on /v0 and on the MCP server.

Protected resource metadata (RFC 9728)
GET /.well-known/oauth-protected-resource
json
{
  "resource": "https://api.machinemail.dev/",
  "resource_name": "MachineMail",
  "resource_logo_uri": "https://api.machinemail.dev/favicon.svg",
  "authorization_servers": ["https://api.machinemail.dev"],
  "scopes_supported": [
    "inboxes.read",
    "inboxes.write",
    "messages.read",
    "messages.send",
    "messages.write",
    "webhooks.read",
    "webhooks.write",
    "domains.read",
    "domains.write",
    "lists.read",
    "lists.write",
    "metrics.read",
    "pods.read",
    "pods.write",
    "api_keys.read",
    "api_keys.write"
  ],
  "bearer_methods_supported": ["header"],
  "resource_documentation": "https://api.machinemail.dev/auth.md"
}
Authorization server metadata (RFC 8414)
GET /.well-known/oauth-authorization-server
json
{
  "issuer": "https://api.machinemail.dev",
  "token_endpoint": "https://api.machinemail.dev/oauth2/token",
  "revocation_endpoint": "https://api.machinemail.dev/oauth2/revoke",
  "jwks_uri": "https://api.machinemail.dev/.well-known/jwks.json",
  "grant_types_supported": ["urn:ietf:params:oauth:grant-type:jwt-bearer", "urn:workos:agent-auth:grant-type:claim"],
  "token_endpoint_auth_methods_supported": ["none"],
  "revocation_endpoint_auth_methods_supported": ["none"],
  "response_types_supported": [],
  "resource": "https://api.machinemail.dev/",
  "authorization_servers": ["https://api.machinemail.dev"],
  "scopes_supported": ["inboxes.read", "..."],
  "bearer_methods_supported": ["header"],
  "agent_auth": {
    "skill": "https://api.machinemail.dev/auth.md",
    "identity_endpoint": "https://api.machinemail.dev/agent/identity",
    "claim_endpoint": "https://api.machinemail.dev/agent/identity/claim",
    "events_endpoint": "https://api.machinemail.dev/agent/event/notify",
    "identity_types_supported": ["anonymous", "service_auth"],
    "identity_assertion": { "assertion_types_supported": [] },
    "events_supported": []
  }
}

agent_auth.identity_types_supported lists the registration types this server accepts. The service’s public signing keys are at /.well-known/jwks.json.

Step 2: pick a registration type

TypeUse whenWhat you get at onceClaim
anonymousyou know nothing about your usera sandbox inbox and a working credentialoptional; unlocks the full API and keeps the inbox
service_authyou know your user’s email addressnothing usable yetrequired before anything works
identity_assertionyou can mint an ID-JAG from a trusted identity providerfull access in a new organizationonly if the email already has a MachineMail account

identity_assertion is not enabled

identity_assertion is accepted only when trusted identity providers are configured, and appears in identity_types_supported only then. The flow has not yet been verified end to end against a real provider (issue #15). Prefer anonymous or service_auth.

Step 3: register

anonymous

http
POST /agent/identity
Content-Type: application/json

{ "type": "anonymous", "name": "Research agent" }

name is optional, up to 100 characters, and is shown to the human at claim time (labelled as self-described). client_name is accepted as an alias.

200 OK
{
  "registration_id": "reg_01m4fykg1y0000pkp9n2xbbzhg",
  "agent_id": "reg_01m4fykg1y0000pkp9n2xbbzhg",
  "registration_type": "anonymous",
  "identity_assertion": "eyJ0eXAiOiJvYXV0aC1pZC1qYWcrand0IiwiYWxnIjoiRVMyNTYi...",
  "assertion_expires": "2026-10-10T09:06:15.230Z",
  "pre_claim_scopes": ["inboxes.read", "messages.read", "messages.send", "messages.write"],
  "claim_url": "https://api.machinemail.dev/agent/identity/claim",
  "claim_token": "clm_Rs9tbVm4xFy8R1xK1Xs0dAfOswjQuByW",
  "claim_token_expires": "2026-10-10T09:06:15.230Z",
  "post_claim_scopes": [
    "inboxes.read",
    "inboxes.write",
    "messages.read",
    "messages.send",
    "messages.write",
    "webhooks.read",
    "webhooks.write",
    "domains.read",
    "domains.write",
    "lists.read",
    "lists.write",
    "metrics.read",
    "pods.read",
    "pods.write",
    "api_keys.read",
    "api_keys.write"
  ],
  "inbox": {
    "inbox_id": "agent-f5pvo2cei042@machinemail.dev",
    "organization_id": "org_01m4fykg200000hp1bk0mkazqn",
    "pod_id": "pod_01m4fykg210000x6z8b6wc2rdx",
    "email": "agent-f5pvo2cei042@machinemail.dev",
    "display_name": "Research agent",
    "status": "active",
    "metadata": { "agent_id": "reg_01m4fykg1y0000pkp9n2xbbzhg" },
    "ephemeral": true,
    "ttl_seconds": 86400,
    "expires_at": "2026-10-10T09:06:15.234Z",
    "created_by": "agent",
    "created_at": "2026-10-09T09:06:15.234Z",
    "updated_at": "2026-10-09T09:06:15.234Z"
  }
}
  • inbox is your sandbox address on the shared machinemail.dev domain. It is a MachineMail extension to the protocol.
  • identity_assertion is a service-signed JWT you exchange for access tokens in step 5.
  • claim_token is returned only once. Keep it: you need it to start the claim and to collect your post-claim token.
  • registration_id and agent_id are the same value.

service_auth

http
POST /agent/identity
Content-Type: application/json

{ "type": "service_auth", "login_hint": "ada@acme.dev", "name": "Ada's scheduling agent" }
200 OK
{
  "registration_id": "reg_01m4g0a2k70000c8r3yq5wz1vd",
  "agent_id": "reg_01m4g0a2k70000c8r3yq5wz1vd",
  "registration_type": "service_auth",
  "claim_url": "https://api.machinemail.dev/agent/identity/claim",
  "claim_token": "clm_...",
  "claim_token_expires": "2026-10-10T09:20:41.112Z",
  "post_claim_scopes": ["inboxes.read", "inboxes.write", "..."],
  "claim": {
    "user_code": "482913",
    "expires_in": 600,
    "verification_uri": "https://api.machinemail.dev/claim?claim_attempt_token=cat_...",
    "interval": 5
  }
}

There is no assertion and no inbox yet. Only the person signed in as login_hint can claim this registration. Go straight to step 4b with the claim block.

identity_assertion

Unverified against a real provider (see the callout above). For completeness: the agent posts an ID-JAG minted by a trusted provider, with typ: oauth-id-jag+jwt, aud set to the issuer, a fresh jti, exp about five minutes out, client_id, auth_time no older than an hour, and email_verified: true or phone_number_verified: true:

http
POST /agent/identity
Content-Type: application/json

{ "type": "identity_assertion", "assertion_type": "urn:ietf:params:oauth:token-type:id-jag", "assertion": "<ID-JAG>" }

A new identity gets its own organization and an assertion with every scope at once. If the verified email belongs to an existing MachineMail account, the response is 401 interaction_required carrying a claim_token and claim block, and the user confirms the link through the claim ceremony. A stale auth_time gets 401 login_required.

Step 4: the claim ceremony

4a. Start a claim (anonymous)

service_auth registrations already have a claim block. An anonymous agent starts one when it is ready to hand off to a human:

http
POST /agent/identity/claim
Content-Type: application/json

{ "claim_token": "clm_Rs9tbVm4xFy8R1xK1Xs0dAfOswjQuByW", "email": "ada@acme.dev" }

email is optional but recommended: with it, only the person signed in with that address can claim you. Instead of claim_token, you can send { "agent_id": "reg_..." } with your access token or identity assertion as Authorization: Bearer.

200 OK
{
  "registration_id": "reg_01m4fykg1y0000pkp9n2xbbzhg",
  "agent_id": "reg_01m4fykg1y0000pkp9n2xbbzhg",
  "claim_attempt_id": "cla_01m4fykpje0000fhmv60b53n9g",
  "status": "initiated",
  "expires_at": "2026-10-09T09:16:21.900Z",
  "claim_attempt": {
    "user_code": "186569",
    "expires_in": 600,
    "verification_uri": "https://api.machinemail.dev/claim?claim_attempt_token=cat_BtqhTnCOIk8hZZgGvvtCbpqlLkndzMIC",
    "interval": 5
  },
  "user_code": "186569",
  "expires_in": 600,
  "verification_uri": "https://api.machinemail.dev/claim?claim_attempt_token=cat_BtqhTnCOIk8hZZgGvvtCbpqlLkndzMIC",
  "interval": 5
}

The code fields appear both inside claim_attempt and at the top level. Calling this again issues a new code and invalidates the previous one.

4b. Hand off to the human

Show your human the code and the link, for example:

Open https://api.machinemail.dev/claim?claim_attempt_token=cat_…, sign in or sign up, and enter this code: 186569. It expires in 10 minutes.

The code goes into the MachineMail page, never back to you. Claiming an agent describes what the human sees.

4c. Poll for completion

http
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:workos:agent-auth:grant-type:claim&claim_token=clm_Rs9tbVm4xFy8R1xK1Xs0dAfOswjQuByW

Poll no faster than every interval seconds (5). While waiting:

400
{ "error": "authorization_pending", "error_description": "The user has not completed the claim yet", "message": "The user has not completed the claim yet" }
errorMeaningDo
authorization_pendingthe human hasn’t finishedwait interval seconds and poll again
slow_downyou polled faster than intervaladd 5 seconds to your interval
expired_tokenthe code lapsed, or the 24-hour claim window closedcall 4a for a new code; if the window closed, register again
access_deniedthe registration was revokedstop, register again if appropriate

Once claimed:

200 OK
{
  "access_token": "eyJ0eXAiOiJhdCtqd3Qi...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "inboxes.read inboxes.write messages.read messages.send messages.write webhooks.read webhooks.write domains.read domains.write lists.read lists.write metrics.read pods.read pods.write api_keys.read api_keys.write",
  "identity_assertion": "eyJ0eXAiOiJvYXV0aC1pZC1qYWcrand0Ii...",
  "assertion_expires": "2026-10-10T09:31:02.418Z"
}

Claiming revokes every pre-claim access token and the pre-claim identity assertion. Switch to the ones in this response.

Step 5: exchange the assertion for an access token

http
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=eyJ0eXAiOiJvYXV0aC1pZC1qYWcrand0Ii...&resource=https://api.machinemail.dev/
200 OK
{
  "access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "inboxes.read messages.read messages.send messages.write"
}

resource and client_id are optional. If you send them, resource must be the resource URL from the metadata and client_id must be your registration_id. The token endpoint also accepts a JSON body. Responses carry Cache-Control: no-store.

There is no refresh token. When the access token expires, exchange the same identity assertion again, until the assertion itself expires.

Step 6: call the API

The access token works on the REST API and on MCP:

http
GET /v0/inboxes
Authorization: Bearer eyJ0eXAiOiJhdCtqd3Qi...
Send from the sandbox inbox
curl https://api.machinemail.dev/v0/inboxes/agent-f5pvo2cei042@machinemail.dev/messages/send \
  -H "Authorization: Bearer $ACCESS_TOKEN" -H "content-type: application/json" \
  -d '{"to":["ada@acme.dev"],"subject":"Hello from your agent","text":"I have an inbox now."}'

For MCP, configure https://mcp.machinemail.dev/mcp with the same bearer token (see MCP server).

Every message you read is untrusted input. Check trust.level before acting on it (Using trust in agents).

Tokens

All tokens are JWTs signed by MachineMail. Verify them against /.well-known/jwks.json if you want to; you don’t need to.

TokentypLifetimeUse
identity assertionoauth-id-jag+jwt24 hours, and never past the end of an unclaimed registration’s claim windowexchange at /oauth2/token for access tokens
access tokenat+jwt1 hourAuthorization: Bearer on /v0 and /mcp
claim tokenopaque, clm_...until the claim window closes, 24 hours after registrationstart claims, poll for the post-claim token

The identity assertion’s sub and client_id are your registration_id, aud is the issuer, and it carries registration_type. The access token’s aud is the resource URL and scope lists your scopes. After claim, the assertion also carries the claiming user’s email with email_verified: true.

Both carry a generation counter. Claiming or revoking bumps it, which invalidates every token issued before.

Warning

A claimed agent can collect fresh tokens with the claim grant (4c) until its claim token expires, 24 hours after registration, and each post-claim identity assertion lasts 24 hours. After that there is no way to renew them. For an agent that should keep working, have the human create an API key for it in the dashboard after claiming.

Scopes

ScopeGrantsBefore claim
inboxes.readList and read inboxes.yes, sandbox inbox only
inboxes.writeCreate, update and delete inboxes.no
messages.readRead messages, threads, attachments and trust reports.yes, sandbox inbox only
messages.sendSend, reply to and forward messages; create and send drafts.yes, sandbox inbox only
messages.writeChange labels on and delete messages and threads.yes, sandbox inbox only
webhooks.readList webhooks and their deliveries.no
webhooks.writeCreate, update, test and delete webhooks.no
domains.readList domains and their DNS records.no
domains.writeAdd, verify, update and delete custom domains.no
lists.readRead allow/block lists and suppressions.no
lists.writeChange allow/block lists and suppressions.no
metrics.readRead metrics and the event log.no
pods.readList pods.no
pods.writeCreate, update and delete pods.no
api_keys.readList API keys.no
api_keys.writeCreate, update and revoke API keys.no

Scopes map to the same permissions as API keys. You don’t request scopes: an unclaimed anonymous agent gets the four pre-claim scopes, and a claimed agent gets all of them across the claiming organization.

Sandbox limits

Until it is claimed, an anonymous agent works in a sandbox:

  • One inbox. Its token reaches only its own sandbox inbox, an agent-...@machinemail.dev address. It cannot create inboxes, webhooks, keys or anything else: those calls fail with forbidden, and other inboxes return not_found.
  • Expiry. The inbox is ephemeral with a 24-hour TTL, extended by each inbound message. The registration expires 24 hours after it was created if nobody claims it, and its tokens stop working then. Expiry is checked when the registration is next used.
  • Sending. Every send (REST send, reply, reply-all, forward, drafts and scheduled drafts, and the MCP tools) counts against caps on messages and on different recipients per rolling 24 hours, and a message can have only a few recipients. With the deployment defaults that is 10 messages to 10 different addresses, at most 5 per message; writing again to an address already counted is free. Over a daily cap you get HTTP 429 with type: "rate_limited", Retry-After, and a message asking you to get claimed; too many recipients in one message is a 400 validation error.
  • Registration rate. Each IPv4 address or IPv6 /64 can make 3 anonymous or service_auth registrations per rolling hour and 10 per rolling 24 hours (deployment defaults), with a service-wide cap of 100 per hour. Over the limit you get 429 rate_limited with Retry-After.

MachineMail can change these numbers at any time, for example to stop abuse. The values in force are in auth.md and in each error message.

Claiming lifts all of these. The sandbox inbox moves into the human’s organization and stops expiring.

Errors

Protocol errors use the OAuth shape, with the description repeated as message:

json
{
  "error": "invalid_grant",
  "error_description": "The assertion is invalid or expired; register again at /agent/identity",
  "message": "The assertion is invalid or expired; register again at /agent/identity"
}
errorStatusWhereWhat to do
anonymous_not_enabled, service_auth_not_enabled, identity_assertion_not_enabled400/agent/identityPick another type.
invalid_request400allFix the request: missing type, a login_hint that is not an email, a missing grant_type or claim_token, and so on.
invalid_issuer, invalid_signature, expired, replay_detected, invalid_audience, invalid_client_id, missing_verified_email400/agent/identity (ID-JAG)Mint a fresh, correct ID-JAG or use another type.
interaction_required401/agent/identityShow the claim block to the user, then poll (4c).
login_required401/agent/identityRe-authenticate the user at your provider.
invalid_claim_token404 or 401/agent/identity/claimUnknown or revoked registration, or agent_id without a valid bearer. Register again.
claimed_or_in_flight409/agent/identity/claimAlready claimed; poll 4c for your token.
claim_expired410/agent/identity/claimRegister again.
invalid_grant400/oauth2/tokenAssertion expired, revoked or superseded by a claim. Use the newer one, or register again.
invalid_client401/oauth2/tokenclient_id must be your registration_id, or leave it out.
invalid_target400/oauth2/tokenresource must be the resource URL from the metadata.
unsupported_grant_type400/oauth2/tokenUse the JWT bearer or claim grant.
authorization_pending, slow_down, expired_token, access_denied400/oauth2/token (claim grant)See 4c.
rate_limited429registrationBack off for Retry-After seconds.

Revocation

  • Revoke an access token: POST /oauth2/revoke with token=<access_token>&token_type_hint=access_token. Always answers 200.
  • Revoke the whole registration: post your identity assertion instead (token_type_hint=identity_assertion). Every token stops working.
  • A human revokes you: from the dashboard’s Settings page, or with DELETE /v0/agent-registrations/{agent_id}. Your requests start failing with 401, and the token endpoint answers invalid_grant.
  • Your identity provider revokes you (identity_assertion only): it posts a Security Event Token to /agent/event/notify.

After any revocation, register again if your human still wants you to have email.