# Hand-off to a human

> Hand a conversation from an agent to a person and take it back, with the thread events that tell whatever was driving it that it no longer is.

A conversation has an owner, and the owner can change. This page is that,
end to end, against your own addresses: software answers, gets stuck, gives
the conversation to a person, and takes it back. Five minutes, four requests,
and two things you do in a browser.

## An approval queue is not a hand-over

The distinction is the whole of it, and it is easy to skim past.

An **approval queue** says the software wrote something and somebody should
look at it before it goes. The software is still answering; a person is
checking its work.

A **hand-over** says a person has the conversation now. The software has
stepped back, it is not drafting anything, and everything watching has been
told — because the owner is a field on the thread, not a state in your
process. The other party keeps replying to the same address and is never
informed that anything changed.

| Owner        | Means                            |
| ------------ | -------------------------------- |
| `human`      | A person is answering.           |
| `automation` | Deterministic code is answering. |
| `agent`      | A model is answering.            |

## What you need

An **agent address** — `type: "agent"` — a key, and a webhook subscribed to
`thread.*`. If you have not made an address yet, do that first; the whole of
the setup is one request.

```bash
curl https://pidgeon.ai/api/v1/identities -H "Authorization: Bearer $PIDGEON_API_KEY" -H "Content-Type: application/json" -d '{
  "domain_id": "your-domain-id",
  "local_part": "scout",
  "type": "agent"
}'
```

```ts
const { data: scout } = await identitiesCreate({
body: {
  domain_id: 'your-domain-id',
  local_part: 'scout',
  type: 'agent',
},
});
```

```python
scout = httpx.post(
  "https://pidgeon.ai/api/v1/identities",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
  json={
      "domain_id": "your-domain-id",
      "local_part": "scout",
      "type": "agent",
  },
).json()["data"]
```

**Any address with an inbox can be handed over** — there has to be somewhere
for the person to read it, which is the whole of the requirement. What
`type: "agent"` changes is where a conversation *starts*: mail arriving at an
`agent` address opens a thread already owned by `agent`, so the queue your
software works is the one it finds by asking for its own threads. At a
`human` or `team` address the conversation starts as a person's, and
software would have to take it first.

That is why the walkthrough uses one, and why the hand back below returns the
thread to `agent` rather than to `automation`.

## 1 · Mail arrives

Send something to your new address from anywhere — your own mail client is
fine. You get `message.received`, carrying `thread_id`.

Everything after this is that one id.

## 2 · Your software already owns it

**Mail arriving at an `agent` address opens a conversation the agent owns**,
so for the common case there is nothing to do here: ask for the threads you
own and the new one is in the list.

That is the point of the type. An address exists to be answered by something,
and which something is a fact about the address rather than a claim each
arrival has to make. It used to be the other way — every conversation began as
a person's, whatever it arrived at — which meant an agent following this page
asked for the threads it owned and got none.

A `human` or `team` address starts its conversations as a person's, because
that is who reads it. Claiming one there, or taking a conversation back after
a person has had it, is the same call either way:

```bash
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": "agent", "status": "open" }'
```

```ts
await threadsUpdate({
path: { id: threadId },
body: {
  owner_type: 'agent',
  status: 'open',
},
});
```

```python
httpx.patch(
  f"https://pidgeon.ai/api/v1/threads/{thread_id}",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
  json={"owner_type": "agent", "status": "open"},
)
```

`thread.updated` fires, carrying `previous_owner_type` alongside `owner_type`.
Both sides, every time — which is what lets you tell a change of owner from a
change of status without keeping a copy of the last one you saw.

Open the address in the web client and the conversation says **An agent is
answering this**.

`thread.created` fires when a conversation begins — including one your own
send opened, not only one that arrived. If you are driving outreach, that is
the event that tells you a new conversation exists at all.

## 3 · It gets stuck, and gives the conversation back

One call, because it is one intention.

```bash
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."
}'
```

```ts
await threadsUpdate({
path: { id: threadId },
body: {
  owner_type: 'human',
  status: 'human_review',
  reason: 'They want a refund above what I can approve.',
},
});
```

```python
httpx.patch(
  f"https://pidgeon.ai/api/v1/threads/{thread_id}",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
  json={
      "owner_type": "human",
      "status": "human_review",
      "reason": "They want a refund above what I can approve.",
  },
)
```

**Somebody is told.** The people who can answer as the address get a push
notification and an email with the subject, who handed it over and your
`reason` — one sentence, up to 500 characters, also carried on
`thread.updated`. Who that is, in order: the address's owner and anybody
given send access by name, which for an agent includes whoever created it;
otherwise everybody, when the whole workspace can send as it; otherwise the
workspace's owners. Add `assignee_id` to the same request and only that
person is told.

`human_review` **with no assignee** is the state this whole primitive exists
for: software asked for a person, and nobody has come yet. It is not the same
as a conversation that merely has nobody's name on it, and the inbox spells
the two differently — **Waiting for a person** against *Unassigned*.

Splitting this into two `PATCH`es is how a conversation ends up owned by a
person and not marked for review, which is a thread nobody is looking for.

## 4 · A person takes it

This step is deliberately not an API call.

Open the inbox. The conversation is in it, under the same address, with
**Waiting for a person** under the subject and a **Take it** button beside it.
Press it.

`thread.assigned` fires, and `thread.updated` with it. Your software learns
who has the conversation without you building anything: it is watching the
same events it has been watching all along.

If the mailbox has a team, the strip above the folder filters to exactly this
— **Unassigned** is the queue of hand-overs nobody has picked up.

## 5 · They reply, as the address

From the web client, or from Apple Mail or any mail client set up with an
app password — no special step. The message goes out **from the
agent's own address**, in the same thread and with the same `References` — the
person on the other end sees a reply to their email and is told nothing about
any of this. There is no "escalated to a human" banner, because there was no
change of address for one to explain.

That is the part worth pausing on. A hand-over that changes the sending
address is not a hand-over, it is a transfer, and every one of those costs the
other party a new thread and a re-explanation.

**A person's reply takes the conversation.** If a person answers a
conversation your software still owns — they saw it in Apple Mail and
replied — it becomes theirs: `owner_type` turns `human`, they become the
assignee, and `thread.updated` (with `taken_by_reply`) and `thread.assigned`
fire. Stop answering when you see them, or the other party gets two replies.

Only a person does this. A send with your API key never takes a conversation,
and neither does a person sending a draft your software wrote: that is
signing it off, and the conversation stays yours. Nor does somebody who can
no longer send as the address.

**New mail reaches them.** While a person has the conversation, the
customer's next replies notify them — one notification per burst, so ten
replies in a few minutes are one email.

## 6 · And back again

```bash
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": "agent", "status": "open", "assignee_id": null }'
```

```ts
await threadsUpdate({
path: { id: threadId },
body: {
  owner_type: 'agent',
  status: 'open',
  assignee_id: null,
},
});
```

```python
httpx.patch(
  f"https://pidgeon.ai/api/v1/threads/{thread_id}",
  headers={"Authorization": f"Bearer {os.environ['PIDGEON_API_KEY']}"},
  json={"owner_type": "agent", "status": "open", "assignee_id": None},
)
```

The person can do the same thing from the inbox — **Hand back**, beside the
line that says they have it.

**There is no automatic hand back.** A person who has been given a
conversation keeps it until somebody sets the owner again, and that is
deliberate: software deciding on its own that a person has finished is exactly
the failure this primitive exists to prevent.

## What fired, and what you can watch

| Event               | Fires when                                                              |
| ------------------- | ----------------------------------------------------------------------- |
| `message.received`  | Mail arrives at the agent’s address. Step 1.                            |
| `thread.created`    | The first message in a conversation makes one.                          |
| `thread.updated`    | The owner changed. Carries the old owner and the new. Steps 2, 3 and 5. |
| `thread.assigned`   | Somebody has taken it. Step 4.                                          |
| `thread.unassigned` | Nobody has it any more — a hand back clears the name.                   |

Every one of them is subscribable, so a queue running through Pidgeon can post
to Slack, open a ticket, or wake your process when a conversation changes
hands. Nothing here polls.

## What Pidgeon does not do

There is no prompt, no model and no loop in any of this. The API and the
events are the substrate you build an agent on; the agent is yours. Saying
otherwise would be the wrong kind of promise.
