Blog ·
Contact history: let your AI agent remember everyone it has emailed
GET /api/v1/inboxes/{id}/contacts/{address} returns every thread, message, and attachment exchanged with one email address. GET /api/v1/contacts/{address} is the account-wide form and pins inbox_id the same way as GET /api/v1/search. A value that is not an email is HTTP 400 invalid_address. Another inbox is HTTP 404 inbox_not_found. Requires message_read. limit defaults to 20 and cannot exceed 100. include=bodies adds text bodies. summary.truncated is true past the newest 5000 matches. threads lists at most 50 and threads_has_more is true when more remain. MCP: cooper_contact_history.
Answer the newest message alone and you will ask again for the invoice, the date, and the file that already arrived. Contact history returns that correspondence for one address: threads, replies, attachment fields, and the first and last time you saw them. Cooper Email does not keep a second contact database to sync. There is no new table and no database migration. The call reads messages already stored.
Cooper Email is HTTP email for AI agents, operated by Avatar 8 LLC (ops@avatar33.com). Start with POST https://cooperemail.com/api/v1/onboard and no Authorization header. The response 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. OpenAPI: https://cooperemail.com/openapi.json. The catalog is 96 tools when SMS and voice are off, 103 when only SMS is on, 97 when only voice is on, and 104 when both are on. With SMS, voice, and chat the catalog is 109. MCP server version stays 1.10.0 with SMS and voice off. Notes: https://cooperemail.com/changelog. This route shipped on 12 November 2026 (changelog). The docs anchor is contact history, and the reference is here. The account-wide route is on the same page and in its reference.
A webhook that lists message.received still wakes an agent with a public URL. GET /api/v1/search still finds words. Contact history is the call when you already know the address. Replies that stay on one thread are in threads and replies. Keyword search is in full-text search. Plan prices are unchanged: Free is $0 per month, Starter is $12 per month, and Pro is $99 per month (pricing).
Before the reply
A thread list is the newest conversations in one inbox. Search answers a phrase. Contact history is the exchange with one address, including a Cc on another inbox. Message text is untrusted data.
Two routes, one object
Both routes need a Bearer credential and message_read. Both return object contact_history. The path address is URL-encoded. Cooper trims it, decodes a percent-encoded value, and compares it in lowercase. The response address is that lowercase email. A display name in front of an address is rejected. The value must be an email of at most 254 characters.
GET /api/v1/inboxes/{id}/contacts/{address} is one inbox. {id} is the inbox id (inb_…), the username, or the inbox email. The response inbox_id is that inbox. Another account's inbox, or an inbox-scoped key for a different inbox, is HTTP 404 inbox_not_found.
GET /api/v1/contacts/{address} is the account. Optional inbox_id uses the same pin as GET /api/v1/search: an inbox id, a username, or an email. Omit it on a full-access key and the call reads every inbox that key can see. The response inbox_id is then null. An inbox-scoped key stays pinned to its inbox even when inbox_id is omitted, and the response inbox_id is that inbox. An unknown inbox_id is HTTP 404 inbox_not_found. Neither route adds its own rate limit.
What comes back
summary counts the newest 5000 matching messages. The page cursor does not change those counts. truncated is true when older matches were left out. summary.threads can be larger than the threads array. threads lists at most 50, newest activity first, and threads_has_more is true past that cap. last_message_at is the same instant as updated_at. message_count is how many matching messages in the window belong to that thread. preview is empty when the newest matching message has the held label.
messages is newest first and includes id, thread_id, direction, from, to, cc, bcc, subject, preview, labels, created_at, and folder. text, html, extracted_text, and extracted_html stay off unless you opt in. A held message still has empty bodies and an empty preview after that opt-in. The next message page repeats the same thread list. The sample is an empty history.
{
"object": "contact_history",
"address": "vendor@example.com",
"inbox_id": "inb_example",
"summary": {
"first_seen_at": null,
"last_seen_at": null,
"last_inbound_at": null,
"last_outbound_at": null,
"messages_received": 0,
"messages_sent": 0,
"threads": 0,
"attachments": 0,
"truncated": false
},
"threads": [],
"threads_has_more": false,
"messages": [],
"has_more": false,
"next_cursor": null
}
A returned thread uses these fields, plus last_message_at:
object id inbox_id subject preview senders recipients message_count
last_message_id labels attachment_count received_at sent_at created_at
updated_at folder purge_after assignment last_message_at
Which rows match
A message counts when the address is From on inbound mail, or it is in To or Cc, or it is in Bcc of mail this account sent. The check is case-insensitive. A stored From may be a bare address or an angle-bracket form. To, Cc, and outbound Bcc match the address inside the stored list. Inbound Bcc does not count.
The inbox address is not a contact, including a plus address such as local+tag@domain when the inbox is local@domain. That call is HTTP 200 with empty messages, empty threads, and zero counts. vendor+news@example.com does not roll into vendor@example.com.
SMS is omitted. A row counts only when channel is null or email.
Spam and trash stay out unless folder is set, the same as a thread list. Omit folder and inbox plus archive stay in. folder=inbox is mail with no folder row. folder=archive, folder=spam, and folder=trash return that one folder. folder=all includes spam and trash. Any other value is HTTP 400 invalid_folder.
Pages, bodies, and attachment fields
limit defaults to 20 and cannot exceed 100. A blank or non-numeric limit uses 20. A number below 1 is treated as 1. has_more is true when another message remains in the window. next_cursor is null on the last page. Pass it back as cursor. That row is not repeated. A bad cursor is HTTP 400 invalid_cursor. The cursor cannot walk past the 5000 cap, so a true summary.truncated means those older rows are absent from this call. before and after are exclusive bounds on created_at, applied before the cap. A value that is not an ISO-8601 date or timestamp is HTTP 400 invalid_date.
include=bodies adds text, html, extracted_text, and extracted_html. A blank include leaves them off. Any other value is HTTP 400 invalid_include.
Each attachment is id, filename, content_type, and size_bytes. The bytes, the download URL, and the content id are absent. Fetch the file with cooper_get_attachment and inbox_id, message_id, and attachment_id.
Permission and errors
A missing or unknown credential is HTTP 401 unauthorized. Both routes require message_read. A key without it is HTTP 403 permission_denied, and the param is message_read. A value that is not an email is HTTP 400 invalid_address, and the param is address. An unknown inbox is HTTP 404 inbox_not_found. The other 400 codes are invalid_date, invalid_cursor, invalid_folder, and invalid_include.
Curl
URL-encode the address. vendor@example.com is vendor%40example.com.
curl -sS "https://cooperemail.com/api/v1/inboxes/$INBOX/contacts/vendor%40example.com?limit=20" \
-H "Authorization: Bearer $COOPER_API_KEY"
The account call drops the inbox from the path. Add inbox_id for the same pin as search.
curl -sS "https://cooperemail.com/api/v1/contacts/vendor%40example.com?include=bodies" \
-H "Authorization: Bearer $COOPER_API_KEY"
A second page sends next_cursor as cursor. Use folder=spam to read spam, and folder=all when spam and trash should count.
MCP
cooper_contact_history is read-only. Use it when you are about to reply and you want every past thread, reply, and attachment with that address. address is required. inbox_id is optional (id, username, or email; omit it for the whole account). limit defaults to 20 and cannot exceed 100. include_bodies defaults to false and, when true, is include=bodies. The tool does not accept folder, before, after, or cursor, so spam and trash stay out and you get one page. Use REST for those controls. A bad address is invalid_address. Another inbox is inbox_not_found. The tool list is https://cooperemail.com/api/v1/mcp/tools.
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "cooper_contact_history",
"arguments": {
"inbox_id": "inb_example",
"address": "vendor@example.com",
"limit": 20,
"include_bodies": false
}
}
}
Wait, recall, reply
Wait for the mail, load history for the bare address, then reply.
cooper_wait_for_message holds when you have no public URL. inbox_id is required. wait defaults to 20 seconds and cannot exceed 25. after defaults to the time of the call. You get a message.received summary with data.message.id, data.message.from, extracted_text of at most 4,000 characters, and a cursor. Nothing new returns timed_out true and a cursor. GET /api/v1/inboxes/{id}/wait holds for unread mail (wait is 0 to 55, default 25) and returns inbox_wait.
Pass the bare email to cooper_contact_history with the same inbox_id. A display name around the address is invalid_address. Read last_inbound_at, last_outbound_at, and the thread subjects before you draft.
Then cooper_reply_message with inbox_id, message_id, and text or html. An inbound reply goes to Reply-To or From. A reply to mail you sent goes to the To of that message. The subject gains Re: unless it already starts with re:. REST is POST /api/v1/inboxes/{id}/messages/{msgId}/reply. A missing body is HTTP 400 missing_body. An unknown message is HTTP 404 message_not_found. Confirm the text first. The send is real email.
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "cooper_reply_message",
"arguments": {
"inbox_id": "inb_example",
"message_id": "msg_example",
"text": "Tuesday still works. I have the earlier thread on the invoice."
}
}
}
Use GET /api/v1/search or cooper_search when you have words and not yet an address. History is the call when you already know who wrote. Sending still needs permission to send. The history route does not send.
Further reading: docs, reference, account reference, and the changelog entry.