# Email threading for AI agents

> How does an AI agent keep an email conversation in one thread, and what happens when a person steps in?

## The short answer

An agent keeps a conversation in one thread by answering with the right headers: `In-Reply-To` set to the `Message-ID` of the message it answers, and `References` carrying the chain before it. With Pidgeon the agent calls `POST /v1/messages/{id}/reply` and the headers, recipients and subject are taken from the message it is answering. Incoming mail is threaded on arrival, and each thread records whether the agent or a person is answering it.

## How email knows what belongs together

Every email carries a `Message-ID`, a unique string its sender's server stamps on it. A reply names its parent in `In-Reply-To` and lists the conversation so far in `References`. Mail clients read those two headers to draw a thread. When they are missing, most fall back to the subject, stripped of `Re:` and `Fwd:`.

An agent that writes its own MIME, or calls a send API with a subject of `Re: Your order` and no headers, breaks this. In its own records the reply may look threaded. In the customer's client it can arrive as a new conversation, beside the one it answers, and the customer has two places to reply.

There is a second trap. Some sending services replace the `Message-ID` you set with one of their own. Amazon SES, which Pidgeon sends through, does: the recipient sees an id ending in `amazonses.com`, not the one stored at send time. A reply that quotes the stored id names a message the recipient never received. Pidgeon puts the id the recipient actually saw into `In-Reply-To` and `References` when it answers its own sent mail, and matches incoming replies against both.

## How Pidgeon threads incoming mail

A thread is a row with an id, and every message and event names it as `thread_id`. When mail arrives:

1. **The headers decide.** `In-Reply-To` and `References` are matched against messages at the same address. A match puts the message in that thread.
2. **Otherwise the subject.** A normalised subject, without reply prefixes and list tags, joins a conversation from the last 30 days. Two unrelated "Invoice" emails a year apart stay two threads.
3. **Otherwise a new thread**, and `thread.created` fires.

A message your agent composes from scratch always starts a thread of its own. Five identical requests to five suppliers are five conversations, not one with five people in it.

## Replying over the API

The reply route reads everything but the words off the message being answered:

```sh
curl https://pidgeon.ai/api/v1/messages/$MESSAGE_ID/reply \
  -H "Authorization: Bearer $PIDGEON_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: reply-$MESSAGE_ID" \
  -d '{ "text": "Thanks, that is the right invoice. Paying it today." }'
```

`/reply-all` keeps everybody on the message and drops your own addresses, so the agent never mails itself. If you build the message yourself with `POST /v1/messages`, pass `in_reply_to` with the Pidgeon id of the message you are answering, and Pidgeon sets `In-Reply-To` and `References` for you. Drafts take the same field.

To read the conversation before answering, `GET /v1/messages?thread_id=…` returns it oldest first, 50 at a time by default. Each message carries `reply_text` beside the full `text`: the new words, with the quoted history cut off. Give the model `reply_text` for each message and it reads the conversation once, not once per quote. A forward is not cut, because what it carries is the point.

Over MCP the same steps are `list_messages` with the `thread_id`, then `reply_to_message`.

## Who is answering

Threading tells you which messages belong together. An agent in a real inbox also needs to know whether it is still the one answering. Each Pidgeon thread carries an `owner_type`, `agent`, `automation` or `human`, and a `status`: `open`, `waiting`, `resolved` or `human_review`.

Mail arriving at an address created with `type: "agent"` opens a thread the agent owns. When the agent is out of its depth, it hands the thread to a person in one call:

```sh
curl https://pidgeon.ai/api/v1/threads/$THREAD_ID \
  -X PATCH \
  -H "Authorization: Bearer $PIDGEON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "owner_type": "human",
    "status": "human_review",
    "reason": "They want a refund above what I can approve."
  }'
```

The people who can answer as the address get a notification with the reason. The customer keeps writing to the same address and sees nothing change.

## A person taking a thread over

A person can also step in without being asked. When somebody replies in an agent's thread from the Pidgeon inbox or from their own mail client, the thread becomes theirs: `owner_type` turns `human`, they are named on it, and `thread.updated` and `thread.assigned` fire. The reply goes out from the agent's address with the same `References`, so for the customer it is the next message in the conversation.

While a person has the thread, an API key's reply, reply-all, forward or scheduled send in it is refused with `403 taken_over`. So is a key trying to set the owner back. The agent can still save drafts there, words for the person to send. Handing back is the person's decision, made with **Hand back** in the inbox. Nothing hands a conversation back on a timer.

Your agent learns all of this from the events it already reads. Check `owner_type` on `message.received` before answering, and treat `taken_over` as the answer "a person has this", not as an error to retry.

## Limits

- **Threads are per address.** The same conversation copied to two of your addresses is two threads, one in each inbox.
- **The subject fallback is a guess.** A sender whose client drops the headers and changes the subject starts a new thread. One who reuses a subject within 30 days with no headers joins the old one.
- **Pidgeon ships no agent loop.** Threads, owners and events are what you build one on; the model, the prompt and the decision to answer are yours.

## Start

[Sign up](https://pidgeon.ai/signup) and create an address with `type: "agent"`. [Threads](https://pidgeon.ai/docs/threads.md) has the owner and status model, [the hand-off guide](https://pidgeon.ai/docs/handover.md) walks a conversation from the agent to a person and back, and [sending](https://pidgeon.ai/docs/sending.md) covers `in_reply_to`. The [human-in-the-loop post](https://pidgeon.ai/blog/human-in-the-loop-email-agents) has the reasoning behind the hand-off.

## Questions

### Which headers keep a reply in the same thread?

In-Reply-To, set to the Message-ID of the message being answered, and References, carrying the chain before it. Most mail clients fall back to the subject only when those are missing.

### How does an agent reply in the same thread with Pidgeon?

POST /v1/messages/{id}/reply. The recipients, subject and threading headers are taken from the message being answered; the agent brings the text.

### How does Pidgeon decide which thread incoming mail joins?

The In-Reply-To and References headers first, matched within the address. Otherwise a normalised subject from the last 30 days, and otherwise a new thread.

### What happens when a person replies in the agent's thread?

The thread becomes theirs: owner_type turns human and thread.updated fires. Until they hand it back, an API key's reply in that thread is refused with 403 taken_over.
