A developer reads an API's documentation once, writes code, and that code calls the API a thousand times the same way. An agent works differently. It reads a tool description on every run, decides each call from what the previous response said, and has no chance to go back and read the docs when something unexpected comes back. Whatever the response body says is all it reads before its next call.

That changes what makes an API good to use, which is what Agent Experience (AX) means: design judged by how a tool-calling model gets on with it, alongside how a person does. For email, it comes down to six properties you can check. Below is each one, how the Lumbox email API handles it, and where Lumbox still falls short.

1. Waiting should be one call

The most common thing an agent does with email is wait for a message: a sign-up code, a reply, a confirmation. With a list endpoint, waiting means checking, getting nothing, and checking again, and every check is a tool call that costs a model turn and the tokens of an empty result.

GET /v1/inboxes/:id/wait turns that into one call. It holds the request open for up to timeout seconds (default 30, clamped to 1 through 120), checks for a matching message every second on the server, and returns the full parsed email as soon as one arrives. Filters for from, subject, category and has_otp=true narrow what counts as a match. GET /v1/inboxes/:id/otp goes further and returns only {code, all_codes, from, subject, expires, email_id}. For longer sessions, GET /v1/inboxes/:id/stream sends matching messages as Server-Sent Events for up to 900 seconds.

Two sharp edges remain. /wait only returns mail that arrived at or after since, which defaults to the moment the request started, so a code that landed one second before the call is missed unless the agent passes an earlier since. And a since value that does not parse as a date is treated as if it had not been passed, rather than rejected with a 400, so an agent sending a malformed timestamp gets a quiet default instead of an error.

2. The answer should be a field, not a body

An agent that has to find a six-digit code inside a marketing-styled HTML email spends tokens on the email and can still pick the wrong number. Every message Lumbox receives carries a parsed object with otp_codes, verification_links, magic_links, category, code_expiry and requires_browser, so the agent reads a field.

The extraction is regular expressions, not a model, which makes it deterministic and free, and also limited. On received mail it looks for 4 to 8 digit codes near words like "code" or "verification", or on a line by themselves, in the subject and plain-text body. An alphanumeric code falls through to the agent, which then has to read the text.

The MCP server applies the same idea to context size. Its read_email tool returns only the parsed fields unless the model sets include_body: true. When the body is returned, text fields are wrapped in a fence marking them as untrusted content, so a model can tell message text from instructions.

3. Errors should say what to do next

A model recovers from an error only if the error tells it how. Some of Lumbox's do that well:

401 {"error": "Missing API key. Send X-API-Key or Authorization: Bearer ak_<key>."}
402 {"error": "...", "code": "PLAN_LIMIT_EXCEEDED", "limit": 3, "current": 3}
403 {"error": "Failed to send: ...", "code": "INVALID_RECIPIENT",
     "invalid_recipients": [{"address": "...", "reason": "..."}]}
408 {"error": "Timeout: no matching email received", "timeout": 120}
429 {"error": "Rate limit exceeded. Try again later.", "retry_after_seconds": 41}
500 {"error": "Internal server error", "request_id": "..."}

The recipient error lists every bad address at once, so the agent can fix them in one pass. The 500 carries a request_id in place of a stack trace.

The shapes are not uniform, though. Most errors are a sentence in error, some add a machine-readable code, and the idempotency conflicts use a snake_case error with a separate message. An agent that branches on errors should check code first and fall back to the status. The 429 puts its hint in the body and sends no Retry-After header, so generic backoff libraries will not see it. The long-poll timeout uses 408, which many HTTP clients treat as retryable, so a client with automatic retries can turn one 120 second wait into several.

4. Retries should be safe

Agents retry: after a timeout, after a crash, or because the model called the same tool twice. Mutating calls under /v1/inboxes/* and /v1/send/* accept an Idempotency-Key header. The first successful response is stored for 24 hours and replayed on a repeat with Idempotent-Replayed: true. The same key with a different body returns 409 idempotency_key_reuse, and a repeat that arrives while the first is still running returns 409 idempotency_in_progress. A failed request releases its key so the retry can proceed. Routes outside those two prefixes, such as webhooks and domains, do not honour the header.

5. Limits should be visible before they bite

Responses on the authenticated routes carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds) for the general limit of 120 requests per minute per key. Send endpoints add X-Send-RateLimit-Limit and X-Send-RateLimit-Remaining, since sending has a separate per-plan limit (3 per minute on Free, 30 on Starter). GET /v1/plans returns every plan's inbox and monthly quotas with no key at all.

6. The API should explain itself

An agent meeting Lumbox for the first time can call GET /v1/meta without a key and get the base URL, both auth header forms, the rate limit and its headers, the MCP server URL and a short list of core endpoints. POST /v1/orgs with a name and no auth creates a sandbox organization and returns an API key once (limited to 3 per hour per IP), so an agent can get working credentials with no person in the loop. An OpenAPI file and llms.txt are published on lumbox.co. On the MCP server, initialize and tools/list answer without a key, tool calls need one, and MCP clients can use OAuth 2.1 with dynamic client registration and PKCE instead of pasting a key.

The cost of a large tool surface

The MCP server exposes more than 80 tools, covering inboxes, sending, threads, domains, webhooks, a credential vault and browser automation. Every tool's name, description and schema goes into the model's context, so a large list costs tokens on every turn and gives the model more wrong tools to pick. An agent that only needs to receive sign-up codes is better served by two HTTP calls wrapped as its own tools, POST /v1/inboxes and GET /v1/inboxes/:id/otp, than by the full server.

Related posts cover long-polling in more depth and why pagination is an agent experience problem. The for-agents docs page is written for a model to read directly.