Skip to content

Blog ·

How an AI agent finds out why its Cooper Email API call was refused

GET /api/v1/request-log is the agent email API error log for refused calls, newest first. A row is stored only after a live key or OAuth token was accepted and the response was an HTTP 4xx ApiError. Unauthenticated 401 responses, HTTP 2xx, and HTTP 5xx are not stored. Filters: status, code, api_key_id, inbox_id, since, limit (1–100, default 25), cursor. A full-access account key sees the account. A restricted or inbox-scoped key sees only its own key. MCP: cooper_list_request_refusals.

The tool result said the call failed, then the transcript moved on. When you need to answer why did my agent's email API call fail, read the error Cooper just returned, then the agent email API error log of recent refusals.

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 88 tools when SMS and voice are off (95 when only SMS is on, 89 when only voice is on, 96 when both are on, and 101 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. This log shipped on 2 November 2026: https://cooperemail.com/changelog#2026-11-02-see-why-your-agent-s-api-calls-were-refused. Reference: https://cooperemail.com/docs/reference/request-log.

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

Read the live 4xx before you search the log

Every Cooper error is JSON. Branch on code. The HTTP status is the response status. The body always has type, code, message, and docs_url. param is included when the failure names a field or a permission, and omitted when it does not. docs_url is https://cooperemail.com/docs# plus the code.

{
  "error": {
    "type": "authentication_error",
    "code": "permission_denied",
    "message": "This API key does not include the inbox_create permission.",
    "param": "inbox_create",
    "docs_url": "https://cooperemail.com/docs#permission_denied"
  }
}

type matches the status: invalid_request (400), authentication_error (401 and 403), payment_required (402), not_found (404), conflict (409), rate_limit (429), unsupported_media (415), or internal_error (500, 502, and 503). A 403 permission_denied is authentication_error.

HTTP 429 rate_limited, and an HTTP 503 that sends Retry-After, also include retry_after_seconds and resets_at. retry_after_seconds is that header's integer, at least 1. resets_at is that many seconds later, in UTC. Both are omitted when no retry time is known. An inbox, email, or custom-domain cap is HTTP 402 plan_limit_exceeded and adds upgrade_url, plan, resource, limit, used, and next_plan on the live JSON. The log keeps type, code, param, and message only. Keep the live body for retry_after_seconds, resets_at, or upgrade_url.

A body that fails schema validation is HTTP 400 invalid_body. param is the field path, dots between segments. That JSON is returned and is not written to the log. An HTTP 4xx thrown after the key was accepted, such as invalid_status or permission_denied, is recorded.

Open the refused-request log

GET /api/v1/request-log lists refused calls for the signed-in account, newest first by created_at then id. Any live key can call it, including a restricted or inbox-scoped key. A missing or unrecognized key is HTTP 401 unauthorized. A revoked key is HTTP 401 api_key_revoked. An expired key is HTTP 401 api_key_expired. Those 401 responses are not stored. The MCP tool with the same list is cooper_list_request_refusals.

A row is stored only when a live API key or OAuth token was accepted and the response was an HTTP 4xx ApiError (status 400 through 499, including 400, 402, 403, 404, 409, and 429). HTTP 2xx is not stored. HTTP 5xx is not stored, including 503. Recording is best-effort and does not change the HTTP response. A failed write still returns the original 4xx, and that refusal can be missing from the list.

Each account keeps at most 1000 rows per UTC day. Further refusals that day are dropped. The 4xx is still returned. Rows older than 7 days are deleted when that account next writes or lists. Another account gets HTTP 200 and an empty data array.

The row stores method, path, status, and error type, code, param, and message. The message is cut at 300 characters. An email address, a coop_live_ or coop_test_ token, or a Bearer token in the message becomes [redacted] before that cut. The path keeps ids and drops the query string. A path segment that contains @ or a key token is stored as :id. inbox_id is set from /inboxes/{id} when that segment is an inbox id. batch, send-mode, and delete are not inbox ids. The row also stores api_key_id or oauth_client_id, source (rest or mcp), and mcp_tool (the tool name, at most 80 characters) for an MCP call. mcp_tool is null on REST. Request bodies, query strings, Authorization headers, API keys, tokens, email content, and recipient addresses are not stored.

A full-access account key (permissions null, and not pinned to an inbox) sees the account. A restricted key, or any inbox-scoped key, sees only its own api_key_id. An OAuth client sees only its own oauth_client_id, and those rows have api_key_id null. Debug a sub-agent with a full-access account key or with that sub-agent's own key.

curl -s "https://cooperemail.com/api/v1/request-log?status=403&code=permission_denied&limit=25" \
  -H "Authorization: Bearer $COOPER_API_KEY"
{
  "object": "list",
  "data": [
    {
      "object": "request_refusal",
      "id": "rrf_01",
      "created_at": "2026-11-02T14:00:00.000Z",
      "method": "POST",
      "route": "/api/v1/inboxes",
      "status": 403,
      "error": {
        "type": "authentication_error",
        "code": "permission_denied",
        "param": "inbox_create",
        "message": "This API key does not include the inbox_create permission."
      },
      "api_key_id": "key_01",
      "oauth_client_id": null,
      "inbox_id": null,
      "source": "mcp",
      "mcp_tool": "cooper_create_inbox"
    }
  ],
  "has_more": false,
  "next_cursor": null
}

The list object is list. Each row's object is request_refusal. has_more is true when another row sits past this page, and only then is next_cursor set. Otherwise it is null. Pass it back as cursor. A cursor that does not decode is HTTP 400 invalid_cursor with param cursor.

Filters

status must be an HTTP code from 400 to 499, or the call is HTTP 400 invalid_status with param status. code is 1 to 64 characters of letters, digits, and underscore (HTTP 400 invalid_code, param code). api_key_id is 1 to 80 of those same characters (HTTP 400 invalid_api_key_id). inbox_id is an inbox id, 1 to 128 characters of letters, digits, ., _, and -, and no @ (HTTP 400 invalid_inbox, param inbox_id). since must parse as a timestamp. Cooper stores that instant in UTC. A bad value is HTTP 400 invalid_date with param since. limit is an integer from 1 to 100 and defaults to 25 (HTTP 400 invalid_limit, param limit). A restricted key that also passes another key's api_key_id still sees only its own rows.

curl -s "https://cooperemail.com/api/v1/request-log?api_key_id=$KEY_ID&inbox_id=$INBOX_ID&since=2026-11-02T00:00:00.000Z&limit=100" \
  -H "Authorization: Bearer $COOPER_API_KEY"

MCP tool call refused

https://cooperemail.com/mcp exposes the same list as cooper_list_request_refusals. It is read-only. Inputs match the query string: status, code, api_key_id, inbox_id, since, limit, and cursor. The tool calls GET /api/v1/request-log and returns that JSON. A failed Cooper tool comes back with isError true and that error JSON in the text.

An MCP refusal is stored with source mcp and mcp_tool set to the tool name. method and route are the HTTP call the tool made. cooper_create_inbox without inbox_create is POST /api/v1/inboxes, status 403, mcp_tool cooper_create_inbox. The route is not /mcp. The API key is not in the tool text or the row.

{
  "status": 403,
  "code": "permission_denied",
  "api_key_id": "key_01",
  "inbox_id": "inb_01",
  "since": "2026-11-02T00:00:00.000Z",
  "limit": 25
}

Pass those fields to cooper_list_request_refusals. Omit a field to skip that filter. Call again with cursor set to next_cursor while has_more is true.

Fix the common refusals

Bad scope. permission_denied means the key was accepted, then rejected. param is the missing permission, or permissions when the route needs a full-access key. A missing grant says This API key does not include the <name> permission. GET /api/v1/stats needs full access: the message is This route requires a full-access API key. and param is permissions. An inbox-scoped key on an account-wide route, including POST /api/v1/onboard and POST /api/v1/inboxes, says An inbox-scoped API key cannot call this route. param is the route permission (inbox_create for those two).

permissions null is full access inside the inbox scope. An empty array is not. OAuth is full access: GET /api/v1/me and cooper_whoami return auth_type oauth and key null. On an API key, key has id, name, prefix, inbox_id, permissions, and expires_at. inbox_id null means the key is not pinned. Compare those with param. Accepted names are inbox_read, inbox_create, inbox_update, inbox_delete, message_read, message_send, message_update, message_delete, draft_write, draft_send, webhook_read, webhook_write, domain_read, domain_write, list_read, list_write, owner_manage, task_read, task_reply, key_read, key_write, workspace_manage, billing_read, and billing_write. An unknown name is HTTP 400 invalid_permission with param permissions. Mint a wider key over HTTP with POST /api/v1/keys or POST /api/v1/inboxes/:id/keys. The MCP server does not mint keys. See scoped API keys.

A scoped key that names another inbox is HTTP 404 inbox_not_found, param id, message No inbox matching "<id>" for this API key. That 404 is stored. It is not permission_denied. Use this key's inbox id, username, or address.

Invalid params. Change the field named by param. On GET /api/v1/inboxes, invalid_status means status was not active or paused. On this log, invalid_status means the filter was not an HTTP 4xx code. invalid_limit, invalid_cursor, invalid_date, invalid_code, invalid_api_key_id, and invalid_inbox are the filters above. invalid_body names a body field and is not in the log, so keep that live response. Call the original route again. A 400 is not a retry on a timer.

Rate limits. HTTP 429 rate_limited is stored. One key can hold at most 5 inbox waits at once, shared by GET /api/v1/inboxes/:id/wait and wait-for-code. The next call says At most 5 inbox waits can run at once for this key. and sends Retry-After 1. POST /api/v1/domains/:id/verify runs at most once every 60 seconds. The message is Domain verification can run at most once every 60 seconds. Wait until resets_at on the live error. The log row has status 429 and code rate_limited and does not include retry_after_seconds. See wait for a code.

Plan limits. HTTP 402 plan_limit_exceeded is stored. The live error includes upgrade_url. The stored message names the plan, the cap, and the usage. A sub-account past its inbox quota is HTTP 402 workspace_quota_exceeded, with resource, limit, used, and workspace_id on the live error. See billing.

What the log will not explain

A successful send, an HTTP 5xx, and a call Cooper could not tie to an account are not rows. GET /api/v1/search searches mail, not this log. q is required. An empty q is HTTP 400 missing_query. Prefer a message.received webhook when you have a public HTTPS URL.

Related

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 -X POST 'https://cooperemail.com/api/v1/onboard?ref=blog' \
  -H 'X-Client: curl'
# optional username. Omit it and Cooper picks box plus 8 hex characters:
# 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.