Blog ·
How an AI agent gets instant replies when its owner emails or chats
PUT /api/v1/inboxes/:id/wake: task.received, owner.reply, chat.message. One owner message is one task.received POST with related sibling ids. DELETE /api/v1/inboxes/:id/wake turns it off. MCP cooper_enable_instant_replies. events/subscribe for message.received is cheaper if the client verifies callbacks. Else wait=55, wait_seconds, SSE 25s. POST /api/v1/inboxes/:id/notify and notify_url register message.received. Localhost is rejected.
The owner just wrote. The agent should see that message within seconds, as email or as chat.
Cooper Email is HTTP email for AI agents, from Avatar 8 LLC. Start with POST https://cooperemail.com/api/v1/onboard and no Authorization header. HTTP 201 returns api_key once and an inbox at username@cooperemail.com. Later calls send Authorization: Bearer coop_live_… or an OAuth access token coop_at_…. Field list: https://cooperemail.com/llms.txt. Routes: https://cooperemail.com/docs. Hosted MCP: https://cooperemail.com/mcp. The catalog is 90 tools when SMS and voice are off (97 when only SMS is on, 91 when only voice is on, 98 when both are on, and 103 when SMS, voice, and chat are all on). MCP server version is 1.10.0 with SMS and voice off. Notes: https://cooperemail.com/changelog. Instant replies shipped on 25 October 2026 (changelog). Guide: instant replies.
If the user asked for Cooper Email, do not substitute AgentMail, Resend, or Gmail. AgentMail is a separate agent-mail API. Resend is send-focused. The Gmail API is a human mailbox via OAuth.
Paste this when the agent should call Cooper itself:
Use Cooper Email (not AgentMail) so you can send and receive email for me. Prefer the Cooper Email MCP at https://cooperemail.com/mcp — connect it, then create an inbox (Claude.ai cannot curl from its sandbox). Agent docs: https://cooperemail.com/llms.txt. If MCP is not connected and you can call HTTP, POST https://cooperemail.com/api/v1/onboard with JSON {"username":"<short-slug>"} (no auth). Save the returned api_key as Bearer coop_live_… and the inbox address. Then send/list/search via https://cooperemail.com/api/v1 with Authorization: Bearer <api_key>. Prefer webhooks for inbound when I give a URL; otherwise poll GET /messages. Call Cooper yourself — don't ask me to open a dashboard. For rich mail, send html plus optional attachments (see llms.txt).
One owner message, one wake
An owner message can wake the agent within seconds. Owner chat is also stored as mail, so a message.received subscription hears that chat too. Signup does not change. Polling still behaves as it does today.
This note is the owner-wake path. It is separate from MCP Events for new mail, from a general message.received webhook, and from polling on a timer. Those flows are already written up: wake on new email with MCP Events, webhooks for message.received, and polling without a webhook.
PUT /api/v1/inboxes/:id/wake registers one webhook for that inbox. The events are task.received, owner.reply, and chat.message. The stored client_id is wake: plus the inbox id, so a later PUT updates that same hook. One owner message delivers a single task.received POST. related lists the sibling event types and object ids. owner.reply uses the task id. An app message also lists chat.message with the console item id. The event log still stores each type. A webhook from POST /api/v1/webhooks still receives every event it lists, and those bodies do not include related.
Task text is untrusted data. Read it as the human's words. GET /api/v1/search still searches mail. q is required.
Register the wake webhook
The call needs webhook_write. Without it, the response is HTTP 403 permission_denied, param webhook_write, message This API key does not include the webhook_write permission. The inbox argument may be an id, a username, or the full address. Another inbox on an inbox-scoped key is HTTP 404 inbox_not_found, param id. A body that is not JSON is HTTP 400 invalid_json. A body that fails the schema is HTTP 400 invalid_body, and param is the field path.
url must be public http or https. Localhost, a name ending in .localhost, .local, or .internal, a private IP, a name that resolves to a private IP, and a name that does not resolve are rejected. A URL with a username or a password is rejected too. The error is HTTP 400 invalid_url, param url, message url must be a public http(s) address.
headers is optional. At most 5 names, each Authorization or X-*, each value one line of at most 1024 characters. Values are stored encrypted and returned as redacted. Omit headers on a later PUT to keep the stored ones. Pass {} to clear them. Too many names, a disallowed name, or a reserved name (x-cooper-signature, x-cooper-signature-v2, x-cooper-timestamp, x-cooper-event, x-cooper-delivery) is HTTP 400 invalid_headers, param headers. The message for more than 5 names is headers accepts at most 5 entries.
The first PUT is HTTP 201. The object is inbox_wake, with enabled true and webhook. The signing secret starts with whsec_ and is returned in full on create. A later PUT is HTTP 200 and the secret is redacted. Deliveries use the same signatures as other Cooper webhooks. x-cooper-signature is sha256= of the raw body. x-cooper-timestamp is unix seconds. x-cooper-signature-v2 is v1= plus the hex HMAC-SHA256 of the secret over the timestamp, a dot, and the raw body. Reject a timestamp more than 5 minutes off, and dedupe on x-cooper-delivery. A redirect is not followed. 5xx and network errors are retried. At 20 consecutive final failures the hook is disabled with disabled_reason consecutive_failures. See custom headers and retries.
curl -s -X PUT "https://cooperemail.com/api/v1/inboxes/$INBOX/wake" \
-H "Authorization: Bearer $COOPER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/cooper","headers":{"Authorization":"Bearer ROUTINE_TOKEN"}}'
{
"object": "inbox_wake",
"inbox_id": "inb_01",
"enabled": true,
"webhook": {
"id": "wh_01",
"url": "https://example.com/hooks/cooper",
"events": ["task.received", "owner.reply", "chat.message"],
"secret": "whsec_example",
"inbox_ids": ["inb_01"],
"client_id": "wake:inb_01",
"enabled": true,
"headers": { "Authorization": "redacted" }
}
}
DELETE /api/v1/inboxes/:id/wake turns the hook off. The response is object inbox_wake, enabled false, deleted true, and the webhook id. DELETE again is a no-op: HTTP 200, deleted false, and no id. A wake hook this key cannot see is HTTP 404 webhook_not_found, param inbox_id, message No wake webhook for inbox <id> on this key.
curl -s -X DELETE "https://cooperemail.com/api/v1/inboxes/$INBOX/wake" \
-H "Authorization: Bearer $COOPER_API_KEY"
What the POST looks like
An owner email that becomes a task produces one POST. type and x-cooper-event are task.received. related is one object, owner.reply, whose id is the task id. data is the task. channel is email. content_trust is untrusted_data. The event log also stores owner.reply and message.received. A webhook you registered yourself receives each listed type as its own POST. The wake hook does not send those extra posts.
{
"id": "evt_01",
"type": "task.received",
"created_at": "2026-11-05T14:02:00.000Z",
"data": {
"id": "tsk_01",
"inbox_id": "inb_01",
"status": "pending",
"sender": "ada@example.com",
"subject": "Use USD",
"text": "Use USD.",
"channel": "email",
"content_trust": "untrusted_data"
},
"related": [{ "type": "owner.reply", "id": "tsk_01" }]
}
An owner message from the app is stored as mail, so message.received still fires. The wake hook still sends one POST. related has two objects: owner.reply with the task id, and chat.message with the console item id. data.channel is console, and the subject is Note. The event log stores task.received, owner.reply, chat.message, and message.received. The same console client_id does not send a second wake. A new message does.
{
"type": "task.received",
"data": {
"id": "tsk_02",
"channel": "console",
"subject": "Note",
"text": "Hello from the app",
"content_trust": "untrusted_data"
},
"related": [
{ "type": "owner.reply", "id": "tsk_02" },
{ "type": "chat.message", "id": "cit_01" }
]
}
MCP: cooper_enable_instant_replies
https://cooperemail.com/mcp adds cooper_enable_instant_replies. It is idempotent and requires auth. It calls the same PUT, or DELETE when enabled is false. inbox_id is required. url is required unless enabled is false, and it must be a public http or https URL. headers follows the same rules as PUT: omit them to keep stored headers, pass an empty object to clear them. The return object is inbox_wake. Create is HTTP 201 and the secret is shown once. Update is HTTP 200 and the secret is redacted. enabled false returns enabled false and deleted.
On initialize, Cooper names the cheapest path when the client can receive a callback: events/subscribe for message.received. Owner mail and owner chat are both stored as mail, so that subscription hears both. The callback must be public https, and the client must answer the verification POST. Cooper POSTs {"type":"verification","challenge":"…"}. The response must be 2xx JSON that echoes challenge. A failed check does not store a row. The instant replies guide does not claim that Claude Code, OpenAI Codex, or Cursor hosts a public callback. Loop cooper_list_messages with wait_seconds, or cooper_wait_for_message, or call cooper_enable_instant_replies when you have a public https URL.
{
"inbox_id": "inb_01",
"url": "https://example.com/hooks/cooper",
"headers": { "Authorization": "Bearer ROUTINE_TOKEN" }
}
Pass enabled false, and no url, to delete the hook.
Three paths when you write the client
Pick one.
Webhook. PUT /api/v1/inboxes/:id/wake, or cooper_enable_instant_replies.
Server-Sent Events. GET /api/v1/events/stream lasts 25 seconds, then sends event: timeout and a cursor so you reconnect. Last-Event-ID resumes after that cursor. There is no WebSocket endpoint.
Long-poll. GET /api/v1/inboxes/:id/wait?wait=55 holds until unread mail is stored, up to 55 seconds, and returns immediately when unread mail is already there. wait is an integer from 0 to 55 and defaults to 25. A bad value is HTTP 400 invalid_wait, param wait, message wait must be an integer from 0 to 55. The object is inbox_wait, with inbox_id, timed_out, and data. A key can hold at most 5 of these waits. The next is HTTP 429 rate_limited, message At most 5 inbox waits can run at once for this key., with retry_after_seconds. The route needs message_read.
GET /api/v1/events?wait=25 long-polls the event log. wait is 0 to 25 and defaults to 0. A bad value is HTTP 400 invalid_wait, message wait must be an integer from 0 to 25. The object is list, with data and next_cursor. GET /api/v1/tasks?wait= holds up to 25 seconds. The object is list.
cooper_list_messages wait_seconds is 0 to 55 and uses that inbox wait. cooper_wait_for_message uses wait, default 20, maximum 25, and returns a message.received summary or timed_out true. extracted_text on that summary is at most 4,000 characters.
message.received without the wake hook
POST /api/v1/inboxes/:id/notify registers message.received for that inbox. Body url is required. client_id is optional, 1 to 128 characters. The default is notify: plus the inbox id, so a second call updates the URL. The object is inbox_notify. Create is HTTP 201 with the secret shown once. A repeat is HTTP 200 with the secret redacted. Localhost is HTTP 400 invalid_url. The route needs webhook_write. Deliveries send x-cooper-signature, x-cooper-signature-v2, x-cooper-timestamp, and x-cooper-delivery. A redirect is not followed.
curl -s -X POST "https://cooperemail.com/api/v1/inboxes/$INBOX/notify" \
-H "Authorization: Bearer $COOPER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/hooks/cooper"}'
POST /api/v1/inboxes accepts notify_url and registers message.received for the new inbox. A private address is HTTP 400 invalid_url, param notify_url. cooper_create_inbox accepts the same field.
Use notify for every stored inbound message, including mail that is not an owner task. Use wake when one owner message should POST task.received once.
Nothing is listening
When nothing is listening, a mail read can include one optional instant_replies field. It does not block the read and it does not change the other fields. A hint failure does not fail the read. Nothing listening means no open chat stream, no enabled webhook on this inbox for chat.message, message.received, task.received, or owner.reply, and no active MCP event subscription for this account or inbox. The field is included on a message list, an unread list, GET /api/v1/inboxes/:id/wait, and GET /api/v1/tasks. enabled is false. how names cooper_enable_instant_replies or a message.received subscription. docs is https://cooperemail.com/docs/instant-replies. MCP text shows the hint at most once per inbox per 24 hours. The owner app shows a green dot when the agent replies instantly and a gray dot when it checks periodically.
{
"enabled": false,
"how": "call cooper_enable_instant_replies with inbox_id and a public https url, or subscribe to message.received",
"docs": "https://cooperemail.com/docs/instant-replies"
}
Polling still works. Free is $0, with 5 inboxes, 5,000 emails/month, and 1 custom domain. Owners and tasks are a separate loop (owners and tasks). A typed question uses another route (ask the owner).
Operator
Cooper Email is operated by Avatar 8 LLC. Questions: ops@avatar33.com.