Docs

Find a conversation

Search runs against stored rows in one inbox, including results well beyond the page you have loaded. It is a metadata search, not a full-body search, and knowing exactly where that boundary sits saves a lot of confused debugging.

These endpoints are how an agent navigates an inbox it has been given access to. They are read-only and need threads:read or messages:read; nothing here can send anything.

What the index actually covers

Four fields per message are searchable:

FieldContains
SubjectThe full subject line.
Preview The first 240 characters of the message's text body, whitespace-collapsed. This is the only part of the body that is searchable.
Sender addressThe normalised from address.
Sender display nameThe display name, when one was supplied.

Which means, plainly:

  • Complete message bodies are not searched. A word that appears only on the fifth paragraph of a long email will not be found. Retrieve the body when you need its full text.
  • Attachment contents are not searched, and neither are filenames.
  • Recipients are not searched. You cannot find a thread by who it was addressed to; search is scoped to one inbox, which is usually the question you were really asking.
  • Deleted messages are excluded from both result sets.

How a query is matched

Matching is Postgres full-text search with the simple configuration and a plain query parser. That has four consequences worth designing around:

BehaviourExample
All terms must match. Multiple words are combined with AND, not OR. q=invoice receipt finds only rows containing both.
No stemming. The simple configuration lowercases and nothing else. q=invoice does not match invoices, and q=running does not match run.
No prefix, wildcard or fuzzy matching. q=invo matches nothing. Whole words only.
Operators are not interpreted. The query is read as plain text. q=invoice OR receipt searches for all three tokens, including or.

An empty or whitespace-only q is treated as no filter and returns everything in the inbox. The query is capped at 200 characters and must be printable: a control character anywhere in it is rejected with invalid_request rather than silently stripped.

GET /v1/projects/{project_id}/inboxes/{inbox_id}/threads?q=invoice&limit=25
GET /v1/projects/{project_id}/inboxes/{inbox_id}/messages?q=invoice&limit=25

The three list endpoints

EndpointDefaultOrderSupports q
Threads in an inbox 25 Newest activity first, by last message Yes — a thread matches if any of its live messages does
Messages in an inbox 25 Newest first Yes
Messages in one thread 50 Chronological, ascending by position in the thread No — a thread is read in order

All three accept limit from 1 to 100 and return data, next_cursor and has_more. Thread rows also carry the preview of their most recent message, so a list view needs no second request per row. Requesting a thread that does not exist in this inbox returns not_found rather than an empty page — an empty page would be indistinguishable from a thread you cannot see.

How cursors work

A cursor is not an offset. It is a signed token carrying the sort position of the last row you were given — a timestamp or thread position plus that row's ID — so pages do not shift under you when new mail arrives mid-scroll, and there is no scanning cost as you go deeper.

# First page
GET .../messages?q=invoice&limit=25

# Next page: same q, cursor from the previous response
GET .../messages?q=invoice&limit=25&cursor=eyJ2IjoxLCJiaW5kaW5n...

Four properties follow from how it is built, and all four are load-bearing:

  • It is signed. The token is a payload plus an HMAC. A hand-edited cursor is rejected, so a cursor can never be used to page into rows you were not given.
  • It is bound to the exact filter. The signature covers the workspace, the project, the inbox, which list you were reading and the query string. Reusing a thread cursor on the message list, moving one between inboxes, or changing q and keeping the cursor all fail. Changing the query starts a new result set — by design.
  • It expires after 24 hours. An overnight pagination job must restart, not resume.
  • It only appears when there is more. next_cursor is null on the last page; has_more says the same thing, and is the field to branch on.

limit may change between pages; it is not part of the signature. For tracking integration progress over time, use the events cursor instead: it is durable, ordered and not scoped to a single inbox.

Cursor and query errors

CodeHTTPCause
invalid_cursor 400 Malformed, tampered with, more than 24 hours old, or belongs to a different inbox, endpoint or query. Drop it and start from the first page.
invalid_request 400 limit outside 1–100 or not an integer, or q over 200 characters or containing a control character.
not_found 404 The thread does not exist in this inbox, or is not one you are authorised to read.
permission_denied 403 The credential lacks threads:read or messages:read, or the inbox is outside its grant.

Reading a message body

Lists give you metadata and a preview. Full content is a separate request, so a list of fifty messages is not a transfer of fifty bodies:

GET /v1/projects/{project_id}/inboxes/{inbox_id}/messages/{message_id}/body

{
  "text": "Could you resend the receipt?",
  "html": "",
  "original_raw_available": false,
  "body_available": true
}

html is sanitised before it is stored and again on read, so it is never the sender's original markup. original_raw_available is false: the original MIME source is not exposed through this endpoint. body_available: false means no stored content manifest exists for that message and you are looking at the preview only.

Everything returned here is untrusted input. Subjects, previews, display names and bodies are written by whoever sent the mail. An agent that reads them must treat them as data, never as instructions, and must apply its own permissions before acting on anything they say.

Live updates in the console

While an inbox is open and the tab is visible, the console re-fetches the first page every five seconds and merges it with the pages you have already loaded, so older pages stay put and new mail appears at the top. Two consequences:

  • A partially loaded list is still partial. Scrolling through it is not an archive; use an export for that.
  • Polling pauses when the tab is hidden, so a background tab can be several minutes stale when you return to it.

Two recipes

Find a conversation and read it

  1. GET .../threads?q=<term>&limit=25. Match on the subject or the preview; keep next_cursor if you need to look further.
  2. GET .../threads/{thread_id}/messages?limit=50 for the conversation in order.
  3. GET .../messages/{message_id}/body for the one message you need in full.
  4. To reply in place, send with that message's ID as reply_to_message_id. See the quickstart send step.

Walk an entire inbox once

  1. Omit q. Start at limit=100, the maximum.
  2. Follow next_cursor while has_more is true, and finish inside 24 hours or be prepared to restart.
  3. For an ongoing feed rather than a one-off walk, switch to events: it tells you what changed instead of making you re-read what did not.