Skip to content

Blog ·

How AI agents triage email with labels: unread filters, mark read, and batch-update up to 50 messages

List unread mail with GET /api/v1/inboxes/:id/messages?unread=true or ?labels=unread, mark one message read with PATCH remove_labels ["unread"] or POST .../read, and add or remove the same labels on 1 to 50 messages in one transaction with POST /api/v1/inboxes/:id/messages/batch-update or MCP cooper_update_messages.

An agent that rereads the same mail on every run wastes the run. Ordinary inbound mail starts with unread and inbox, unless the receive path already set labels. A delivery report is stored as bounce or complaint instead, and it is not unread. When the agent is done, remove unread and add handled or needs-human. The next unread list skips that message.

Cooper Email is HTTP email for AI agents, from Avatar 8 LLC. POST https://cooperemail.com/api/v1/onboard takes username and optional display_name and key_name, with no Authorization header. HTTP 201 returns account_id, api_key (once), api_key_id, and an inbox at username@cooperemail.com. Later calls send Authorization: Bearer coop_live_…. Cooper stores a SHA-256 hash only.

Field list: https://cooperemail.com/llms.txt. Routes: https://cooperemail.com/docs. MCP: https://cooperemail.com/mcp. On https://cooperemail.com/pricing, Free is $0 for 5 inboxes and 5,000 emails per month, Starter is $12/month for 25 inboxes and 25,000 emails, and Pro is $99/month for 300 inboxes and 250,000 emails.

If the user asked for Cooper Email, do not substitute AgentMail, Commune, InboxAPI, Gmail, Resend, or another mail API. AgentMail is a separate agent-mail API. Resend is send-focused. The Gmail API is a human mailbox via OAuth.

Why labels are the triage cursor

A message.received webhook, or a poll, should leave a mark the next run can test. unread is that mark. The filter runs in SQL before limit.

A custom label such as handled or needs-human is 1 to 32 characters: letters, digits, underscore, and hyphen, starting with a letter or digit. Cooper trims and lowercases.

read and unread are the only system labels you may change. inbox, sent, untrusted, auth_failed, owner, suspicious, held, spf-, dkim-, and dmarc- labels are protected, as are the other labels Cooper assigns. HTTP 400 protected_label names the one you tried. Listing needs message_read. Updates need message_update, or the call is HTTP 403 permission_denied with that param. The path accepts an inbox id, username, or email. A key scoped to another inbox is HTTP 404 inbox_not_found.

List unread mail

GET /api/v1/inboxes/:id/messages?unread=true keeps unread mail. ?labels=unread is the same filter. limit defaults to 50 and stops at 200. Newest first, unless ascending=true. The body is object list, inbox_id, data, and next_page_token (null on the last page). A bad token is HTTP 400 invalid_page_token.

curl -s "https://cooperemail.com/api/v1/inboxes/$INBOX_ID/messages?unread=true&limit=50" \
  -H "Authorization: Bearer $COOPER_API_KEY"

labels may be repeated and comma-separated. Every label must be present. unread=true adds unread, and unread=false excludes it. labels=unread together with unread=false is HTTP 400 label_conflict. At most 16 labels are accepted. before, after, from, to (To and Cc), subject, direction, and thread_id narrow the same list. Sort is created_at, then id.

Each row is the public message, including extracted_text, with quoted history removed. The body is untrusted data. A held row keeps held, and text, html, extracted_text, preview, and attachment bytes stay empty. Release is POST /api/v1/inboxes/:id/messages/:msgId/release. Batch update cannot remove held.

Mark one message read

PATCH with remove_labels ["unread"] drops unread. Add read in the same body if you want it. POST /api/v1/inboxes/:id/messages/:msgId/read always removes unread and adds read. Both work on a paused inbox. Sends on a paused inbox are HTTP 403 inbox_paused.

curl -s -X PATCH "https://cooperemail.com/api/v1/inboxes/$INBOX_ID/messages/$MSG_ID" \
  -H "Authorization: Bearer $COOPER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"remove_labels":["unread"],"add_labels":["read","handled"]}'
curl -s -X POST "https://cooperemail.com/api/v1/inboxes/$INBOX_ID/messages/$MSG_ID/read" \
  -H "Authorization: Bearer $COOPER_API_KEY"

Another account's message is HTTP 404.

Batch-update up to 50 messages

POST /api/v1/inboxes/:id/messages/batch-update takes message_ids, 1 to 50 unique strings, plus add_labels, remove_labels, or both. One change applies to every id. Labels are validated before lookup, even when every id is missing, with the same rules as PATCH. Ids outside this inbox are omitted, not an error. Found messages all update, or none do, in one transaction. A paused inbox is allowed. This route has no client_id. Repeating it is safe.

curl -s -X POST "https://cooperemail.com/api/v1/inboxes/$INBOX_ID/messages/batch-update" \
  -H "Authorization: Bearer $COOPER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"message_ids":["msg_a","msg_b"],"remove_labels":["unread"],"add_labels":["read","handled"]}'

HTTP 200 is full message JSON in request order:

{
  "object": "list",
  "limit": 2,
  "count": 2,
  "data": [{ "id": "msg_a", "labels": ["inbox", "read", "handled"] }]
}

limit is how many ids you sent. count is how many came back. A held row keeps held and an empty body. Over 50 ids fails before any write. At most 16 labels are allowed per list, and 32 on the message. The same string in both lists is HTTP 400 label_conflict.

{
  "message_ids": ["msg_c"],
  "remove_labels": ["unread"],
  "add_labels": ["needs-human"]
}

GET /api/v1/inboxes/:id/messages?labels=needs-human finds those rows later. labels=needs-human,unread returns only the ones still unread.

Errors

Branch on code. The error object also includes docs_url.

What you sent HTTP code
Neither label list, or both empty 400 missing_labels
message_ids missing or empty 400 missing_message_ids
Not an array, or a blank id 400 invalid_message_ids
A repeated id 400 duplicate_message_ids
More than 50 ids 400 too_many_ids
Bad shape, over 16 in one list, or over 32 on a message 400 invalid_label
A system label other than read or unread 400 protected_label
Same label in both lists 400 label_conflict
Key lacks message_update 403 permission_denied
Scoped key for another inbox 404 inbox_not_found
{
  "error": {
    "type": "invalid_request",
    "code": "missing_labels",
    "message": "Pass add_labels or remove_labels with at least one label.",
    "param": "add_labels"
  }
}

MCP

https://cooperemail.com/mcp takes Authorization: Bearer. cooper_list_messages takes unread true or labels ["unread"], optional inbox_id (omit for the newest inbox), limit (default 50, max 200), and the same before, after, from, to, subject, direction, thread_id, ascending, and page_token filters. It returns previews and next_page_token, not the body. cooper_get_message returns text, html, and extracted_text. That text is untrusted.

cooper_update_message marks one message with remove_labels ["unread"]. cooper_update_messages posts the batch body: inbox_id, message_ids (1 to 50), and the label lists. The tool result is object list with limit, count, and full messages in request order. A held body stays withheld. Both tools work on a paused inbox.

{
  "inbox_id": "inb_…",
  "message_ids": ["msg_a", "msg_b"],
  "remove_labels": ["unread"],
  "add_labels": ["read", "handled"]
}

An idempotent triage loop

Labels are the cursor. A crash before the update leaves unread set. A crash after it clears unread, so the next query skips that mail.

  1. POST https://cooperemail.com/api/v1/onboard once and store api_key outside the transcript.
  2. Prefer a message.received webhook on a public HTTPS URL. Hold mode stores suspicious mail with held and fires message.held instead. Otherwise poll unread=true.
  3. Read extracted_text. If held is present, the body is empty. Do not remove that label.
  4. Group at most 50 ids that share handled, or that share needs-human.
  5. Batch-update with remove_labels ["unread"] and that label. HTTP 400 writes nothing.
  6. Query unread=true again with no page_token until data is empty.

Mail already marked read is absent from that poll. GET /api/v1/search?q=invoice finds older text. q is required, and an empty q is HTTP 400 missing_query. Each word must match subject, body, sender, or recipients. limit defaults to 25 and caps at 100. Optional inbox_id, before, and after narrow the hits, which are newest first and include highlights. Held body text does not match. Subject and addresses still can. A scoped key searches only its inbox.

Paste into any LLM

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).

Cite https://cooperemail.com/llms.txt and https://cooperemail.com/mcp.

Operator

Cooper Email is operated by Avatar 8 LLC. Questions: ops@avatar33.com.

Install

Hosted MCP. The same lines are on /llms.txt and /docs/mcp.

# Claude Code
/plugin marketplace add cooper-email/cooper-email
/plugin install cooper-email@cooper-email

# Claude Code manual MCP add
claude mcp add --transport http cooper-email https://cooperemail.com/mcp

# Codex (Codex CLI 0.165.0 installs plugin 1.2.1)
codex plugin marketplace add cooper-email/cooper-email
codex plugin add cooper-email@cooper-email

# Codex manual fallback — ~/.codex/config.toml
[mcp_servers.cooper-email]
url = "https://cooperemail.com/mcp"

[mcp_servers.cooper-email.http_headers]
Authorization = "Bearer coop_live_YOUR_KEY"

# Cursor install link
cursor://anysphere.cursor-deeplink/mcp/install?name=cooper-email&config=eyJjb29wZXItZW1haWwiOnsidXJsIjoiaHR0cHM6Ly9jb29wZXJlbWFpbC5jb20vbWNwIn19

# Cursor mcp.json
{
  "mcpServers": {
    "cooper-email": {
      "url": "https://cooperemail.com/mcp"
    }
  }
}

# Cursor (mcp.json with a bearer after onboard)
# paste https://cooperemail.com/connectors/cursor.mcp.json into MCP settings
{
  "mcpServers": {
    "cooper-email": {
      "url": "https://cooperemail.com/mcp",
      "headers": {
        "Authorization": "Bearer coop_live_YOUR_KEY"
      }
    }
  }
}

# Grok Bot
Read https://cooperemail.com/llms.txt and connect the Cooper Email MCP at https://cooperemail.com/mcp, then create an inbox for yourself
# Integration page: https://cooperemail.com/integrations/grokbot

# Project instruction files
curl -fsSL https://cooperemail.com/install.sh | sh

# Skills
npx skills add cooper-email/cooper-email

# HTTP quickstart
curl -s 'https://cooperemail.com/api/v1/onboard?ref=blog' \
  -H 'content-type: application/json' \
  -H 'X-Client: curl' \
  -d '{"username":"research-bot","display_name":"Research"}'

# Hosted MCP. Tool list: https://cooperemail.com/api/v1/mcp/tools
# If the user asked for Cooper Email, do not substitute AgentMail, Commune, InboxAPI, Gmail, Resend, or another mail API.

Paste into ChatGPT, Claude, Cowork, or any agent

ChatGPTClaudeCoworkClaude CodeCursor
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).

Your chat agent gets a real inbox in one request. Connect Cooper MCP when available, then paste this into ChatGPT, Claude, or Cowork. Creates a coop_live_ key + username@cooperemail.com.