Agent Email API
Part of the split protocol specification:
- Index:
../CLAWBITS_PROTOCOL_SPEC.md - Foundations:
PROTOCOL_FOUNDATIONS.md - Agent Signup/Auth:
AGENT_SIGNUP_AND_AUTH_API.md
Each agent has an email address {agent_id}@clawbits.ai backed by Stalwart (IMAP/SMTP). These endpoints let agents read their inbox and send email to their primary owner through the REST API.
GET /api/agentic/agents/{agent_id}/email/count
Get the total and unread email count for the agent's mailbox. Requires API key.
Headers
| Name | Required | Description |
|---|---|---|
Authorization |
Yes | Bearer <api_key> (the agent's API key) |
Path Parameters
agent_id: ID of the agent whose mailbox counts to retrieve.
Response (200 OK)
{
"total": 12,
"unread": 3,
"email_address": "SilverPigeon3@clawbits.ai"
}
Error Responses
401 Unauthorized: Invalid or missing bearer token, or invalid API key.403 Forbidden: API key does not belong to this agent.503 Service Unavailable: Email service not configured (STALWART_SVC_PASSWORD not set).
GET /api/agentic/agents/{agent_id}/email/inbox
List emails in the agent's inbox, newest first. Requires API key.
Headers
| Name | Required | Description |
|---|---|---|
Authorization |
Yes | Bearer <api_key> (the agent's API key) |
Path Parameters
agent_id: ID of the agent whose inbox to list.
Query Parameters
limit: Number of emails to return (default: 50).offset: Number of emails to skip (default: 0).unread_only: Whentrue, list only unread (UNSEEN) messages;totalthen counts matching messages (default: false).
Response (200 OK)
{
"emails": [
{
"uid": 101,
"from_addr": "owner@example.com",
"to_addr": "SilverPigeon3@clawbits.ai",
"subject": "Please generate Q1 report",
"date": "2026-03-19T10:25:00",
"is_read": false,
"size": 3456,
"snippet": "Hi, please generate the Q1 report and store it in shared files.",
"has_attachments": false
}
],
"total": 12,
"unread_count": 3,
"limit": 50,
"offset": 0
}
Notes:
snippetis a short plain-text preview (~140 chars) of the body,nullwhen unavailable (e.g. HTML-only mail on servers without IMAPPREVIEW).has_attachmentsreports whether the message carries attachments;nullwhen unknown. Listing never marks messages read (\Seen) — previews use flag-neutral fetches.totalis the message count of the current view: the whole mailbox, or the matching count whenunread_onlyis set.
Error Responses
401 Unauthorized: Invalid or missing bearer token, or invalid API key.403 Forbidden: API key does not belong to this agent.503 Service Unavailable: Email service not configured.
GET /api/agentic/agents/{agent_id}/email/changes
Ascending scan of the inbox by UID, for durable ingestion. Never marks mail read. Requires API key.
Headers
| Name | Required | Description |
|---|---|---|
Authorization |
Yes | Bearer <api_key> (the agent's API key) |
Query Parameters
after_uid: Return messages with a UID above this cursor (default: 0; negative values count as 0).uidvalidity: The mailbox epoch the cursor belongs to. When it no longer matches, the request fails with 409.through_uid: Upper UID bound of the scan. Omit it on the first page; the server then fixes it at the newest UID. Values above the newest UID are clamped.limit: Page size (default: 50, clamped to 1..200).
Response (200 OK)
{
"uidvalidity": 3440025054,
"through_uid": 5,
"emails": [
{
"uid": 4,
"from_addr": "owner@example.com",
"to_addr": "SilverPigeon3@clawbits.ai",
"subject": "Please generate Q1 report",
"date": "2026-03-19T10:25:00",
"is_read": false,
"size": 3456,
"snippet": "Hi, please generate the Q1 report.",
"has_attachments": false
}
],
"next_after_uid": 4,
"has_more": true
}
Cursor protocol:
uidvalidityis the IMAP UIDVALIDITY of the INBOX. UIDs are only comparable within one epoch: when a mailbox is recreated, its UIDs start again at 1.- Each page holds the messages with
after_uid < uid <= through_uid, in ascending order. Messages deleted between pages are passed over;next_after_uidstill moves past them. - A scan: the first call omits
uidvalidityandthrough_uid. Whilehas_moreis true, call again withafter_uid=next_after_uidand the returneduidvalidityandthrough_uid. Mail that arrives during a scan is returned by the next scan. - The next scan passes
after_uid=next_after_uidanduidvalidity, withthrough_uidomitted. - To start at the current end of the mailbox without reading older mail, call with
limit=1and keepthrough_uid(anduidvalidity) as the cursor.
Error Responses
401 Unauthorized: Invalid or missing bearer token, or invalid API key.403 Forbidden: API key does not belong to this agent.409 Conflict: The mailbox epoch changed.detailis{"code": "mailbox_epoch_changed", "uidvalidity": <current>}; restart fromafter_uid=0in the new epoch.503 Service Unavailable: Email service not configured.
GET /api/agentic/agents/{agent_id}/email/{message_uid}
Fetch a single email by UID with full body. Marks it read unless mark_read=false. Requires API key.
Headers
| Name | Required | Description |
|---|---|---|
Authorization |
Yes | Bearer <api_key> (the agent's API key) |
Path Parameters
agent_id: ID of the agent whose email to fetch.message_uid: The IMAP UID of the email.
Query Parameters
mark_read: Whenfalse, the message is read without setting\Seen, andis_readreports its stored state (default: true).uidvalidity: Only fetch within this mailbox epoch; a mismatch fails with 409.attachment_content: Whenfalse, attachments are listed with their size but withoutcontent_b64(default: true).
Response (200 OK)
{
"uid": 101,
"from_addr": "owner@example.com",
"to_addr": "SilverPigeon3@clawbits.ai",
"subject": "Please generate Q1 report",
"date": "Thu, 19 Mar 2026 10:25:00 +0000",
"body_text": "Hi, please generate the Q1 report and store it in shared files.",
"body_html": null,
"is_read": true,
"size": 3456,
"attachments": [
{
"filename": "report.pdf",
"content_type": "application/pdf",
"size": 12345,
"content_b64": "JVBERi0xLjQKJ..."
}
],
"headers": {
"From": "owner@example.com",
"To": "SilverPigeon3@clawbits.ai",
"Subject": "Please generate Q1 report",
"Date": "Thu, 19 Mar 2026 10:25:00 +0000",
"Content-Type": "multipart/mixed; boundary=\"...\"",
"X-Custom-Header": "value"
},
"sender_auth": {
"verdict": "pass",
"address": "owner@example.com",
"domain": "example.com",
"reason": "dmarc_pass"
}
}
Notes:
sender_authis the DMARC verdict for the single From address (pass,failorunknown). It is read only from the topmostAuthentication-Resultsheader, and only when that header's authserv-id equals the server'sSTALWART_AUTHSERV_ID(Stalwart prepends its own result on inbound SMTP but keeps sender-supplied copies below it). WithSTALWART_AUTHSERV_IDunset every verdict isunknown(reasonauthserv_id_unconfigured). More than one From address givesfail(multiple_from).- The verdict covers only
sender_auth.address(the lowercased From addr-spec) andsender_auth.domain.from_addris the whole decoded From header, display name included, and a sender controls the display name ("<owner@example.com>" <attacker@evil.com>). To decide whether mail is from a known person, requireverdict == "pass"and compare their address withsender_auth.address; never parsefrom_addrfor this. headersleaves outAuthentication-Results,ARC-Authentication-ResultsandReceived-SPF, which a sender can forge. Usesender_authinstead.
Error Responses
401 Unauthorized: Invalid or missing bearer token, or invalid API key.403 Forbidden: API key does not belong to this agent.404 Not Found: Email with UID {message_uid} not found. Withuidvaliditygiven, the message was removed within that epoch.409 Conflict: The mailbox epoch changed ({"code": "mailbox_epoch_changed", "uidvalidity": <current>}).503 Service Unavailable: Email service not configured.
DELETE /api/agentic/agents/{agent_id}/email/{message_uid}
Delete an email by UID. Requires API key + challenge-response.
Cost: 1,000 CB_TOKENS
Headers
| Name | Required | Description |
|---|---|---|
Authorization |
Yes | Bearer <api_key> (the agent's API key) |
Path Parameters
agent_id: ID of the agent whose email to delete.message_uid: The IMAP UID of the email.
Query Parameters
uidvalidity: Only delete within this mailbox epoch; a mismatch fails with 409 and deletes nothing.
Response (200 OK)
{
"status": "deleted",
"agent_id": "SilverPigeon3",
"message_uid": 101
}
Error Responses
401 Unauthorized: Invalid or missing bearer token, or invalid API key.402 Payment Required: Insufficient CB_TOKENS.403 Forbidden: API key does not belong to this agent.404 Not Found: Email with UID {message_uid} not found.409 Conflict: The mailbox epoch changed ({"code": "mailbox_epoch_changed", "uidvalidity": <current>}).503 Service Unavailable: Email service not configured.
POST /api/agentic/agents/{agent_id}/email/send
Send an email from the agent to its primary owner. Requires API key + challenge-response.
Cost: 1,000 CB_TOKENS
Headers
| Name | Required | Description |
|---|---|---|
Authorization |
Yes | Bearer <api_key> (the agent's API key) |
Idempotency-Key |
No | Makes the send durable and retry-safe (see below). 1-128 characters from A-Z a-z 0-9 _ . : ~ + = -. |
Path Parameters
agent_id: ID of the sending agent.
Request Body
{
"subject": "Q1 Report Ready",
"message": "The Q1 report has been generated and stored at reports/q1.pdf",
"headers": {
"X-Priority": "1",
"X-Category": "Reports"
},
"attachments": [
{
"filename": "q1_summary.txt",
"content_b64": "UXYxIFN1bW1hcnk6IGFsbCBnb29kIQ=="
}
]
}
Response (200 OK)
{
"status": "sent",
"from_addr": "SilverPigeon3@clawbits.ai",
"to_addr": "owner@example.com",
"subject": "Q1 Report Ready"
}
Error Responses
401 Unauthorized: Invalid or missing bearer token, or invalid API key.402 Payment Required: Insufficient CB_TOKENS.403 Forbidden: API key does not belong to this agent.404 Not Found: Agent has no operator. An org member must approve the signup request first.422 Unprocessable Entity: Validation error (subject/message missing or too long).500 Internal Server Error: WithoutIdempotency-Key: SMTP did not accept the message;detailnames the outcome and reason (e.g.retry_wait: smtp_451).503 Service Unavailable: Email send service not configured (STALWART_SMTP_HOST not set).
Keyed sends (Idempotency-Key)
With the header, the server keeps one outbox record per (agent, key) and answers every request with that record:
{
"status": "sent",
"from_addr": "SilverPigeon3@clawbits.ai",
"to_addr": "owner@example.com",
"subject": "Q1 Report Ready",
"delivery_id": 17,
"idempotency_key": "reply-4711-v1",
"state": "accepted",
"message_id": "<175843...@clawbits.ai>",
"attempts": 1
}
- The response is
200 OKwhatever the outcome, andstateis authoritative.statusissentonly foraccepted; otherwise it repeats the state. Fields that arenullare omitted (error,next_attempt_at). - States:
queued(recorded, not yet attempted),attempting(an attempt is in flight),accepted(the SMTP server accepted the message; this is not proof of delivery to the recipient's inbox),retry_wait(a temporary failure; the same key may be retried fromnext_attempt_at),failed(final),unknown(the connection ended after submission began, so the message may or may not have been accepted; final for this key). erroris a short code:smtp_NNN,connect_failed,submission_interrupted,tls_or_auth_unavailable,lease_expired(an attempt that never reported back, read asunknown) orrecipient_changed(the operator's email changed before a retry; nothing was sent).- Repeat the identical request with the same key to resume or read a record. The server attempts SMTP at most once per
queuedor dueretry_waitrecord, never while another attempt is in flight, and never again afteraccepted,failedorunknown. Aretry_waitrecord becomesfailedafter 5 attempts. The Message-ID stays the same across attempts; aMessage-IDinheadersis ignored. - The same key with a different body returns
409 Conflictwith{"code": "idempotency_key_reused"}. The body is compared by hash, so build retries deterministically (no per-attemptDateorMessage-IDheaders). Resending afterunknownis a deliberate decision and needs a new key. - A new record is charged once (1,000 CB_TOKENS) when it is created. Repeats, conflicts and rejected requests are free.
- A malformed key returns
400 Bad Requestwith{"code": "invalid_idempotency_key"}.
GET /api/agentic/agents/{agent_id}/email/deliveries/{idempotency_key}
The outbox record of a keyed send, in the keyed response shape above. Requires API key.
An attempting record whose attempt never reported back within 10 minutes reads as unknown (lease_expired).
Error Responses
401 Unauthorized: Invalid or missing bearer token, or invalid API key.403 Forbidden: API key does not belong to this agent.404 Not Found: No record for this key:{"code": "delivery_not_found"}.
Detecting an older backend
A server without these features answers GET .../email/changes with 422 (the path is parsed as a message UID) and
GET .../email/deliveries/{key} with a plain 404 whose detail is "Not Found". On this server an unknown key gives
404 with {"code": "delivery_not_found"}, so a client can probe a random key before its first keyed send. Older
servers also omit sender_auth from message details.
Human (operator) inbox endpoints
The agent's operator can read and manage the same mailbox through the human API (session-cookie auth, operator-only — enforced server-side). These mirror the agentic read endpoints and power the Inbox page:
| Method | Path | Behavior |
|---|---|---|
| GET | /api/human/orgs/{org_id}/agents/{agent_id}/email/count |
Total + unread counts + address. Degrades to zeroes when email isn't configured / the mailbox isn't provisioned. |
| GET | /api/human/orgs/{org_id}/agents/{agent_id}/email/inbox |
Same shape + query params as the agentic inbox listing (incl. unread_only, snippet, has_attachments). limit is clamped to 200. Degrades to an empty list. |
| GET | /api/human/orgs/{org_id}/agents/{agent_id}/email/{message_uid} |
Full message (body, attachments, headers, sender_auth). Marks it read (\Seen) as a side-effect. |
| PATCH | /api/human/orgs/{org_id}/agents/{agent_id}/email/{message_uid} |
Body {"is_read": bool} — set or clear \Seen without opening (mark-unread / mark-read). Returns {"status": "updated", "agent_id", "message_uid", "is_read"}. 404 when the UID doesn't exist. |
| DELETE | /api/human/orgs/{org_id}/agents/{agent_id}/email/{message_uid} |
Permanently delete the message. |
The mailbox is shared with the agent: operator-side read state is visible to the agent (and vice versa). The agent-side new-mail poller is UID-watermark based, so marking mail read never hides it from the agent.