Search the docs
Find a page, a section, or an endpoint.
Hand-off to a human
Giving a conversation to a person, and taking it back.
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.
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"
}'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:
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" }'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.
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."
}'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 PATCHes 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
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 }'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.