Conversations and handoff
Decide who answers each conversation — your AI agent, your code or a person — and switch between them safely.
What you'll build
A handoff: your code takes a conversation over from the agent, a person steps in when needed, and the agent gets it back afterwards.
Who answers a conversation
Every conversation has a controller:
| Controller | What happens to incoming messages |
|---|---|
agent | Your VoiceLayer agent answers them on its own. |
api | Nothing answers automatically. You get the message.received event and reply from your code. |
human | A person answers from the dashboard (Conversations). |
A new conversation starts with agent when its number's agent answers texts (the Agent answers texts switch on the number's page, Dashboard → Phone numbers, off by default; WhatsApp, Telegram and Discord have the same choice per connected account), and with api otherwise. Always read the current controller before you change it.
Prerequisites
- A secret API key with
conversations:control(to change the controller) andmessages:read. See Authentication and API keys. - A conversation id, from a
message.receivedevent orGET /v1/conversations. - curl, Node.js 18 or later with
@voicelayer/api, or Python 3.9 or later withvoicelayer.
export CONVERSATION_ID=<the conversation's id>Step 1: Check who answers it now
Read the conversation. Its controller tells you who has it.
curl "https://api.vlayers.ai/v1/conversations/$CONVERSATION_ID" \
-H "Authorization: Bearer $VOICELAYER_API_KEY"{
"conversation": {
"id": "0192f5b0-7c3e-7a51-9b2d-4e8f1a6c3d21",
"channel": "sms",
"lineId": "0192f5a8-1a2b-7c3d-8e4f-5a6b7c8d9e01",
"bindingId": null,
"peer": {
"address": "+15550123"
},
"controller": "api",
"status": "open",
"openedAt": "2026-10-20T15:04:05.000Z",
"lastActivityAt": "2026-10-20T15:04:05.000Z",
"test": true
}
}Step 2: Take the conversation over from the agent
If controller is agent, take it over. PATCH /v1/conversations/{id} changes the controller. Pass expectedController so the change only happens if the agent still has it: if someone else changed it first, you get 409 controller_conflict instead of silently overriding them.
curl -X PATCH "https://api.vlayers.ai/v1/conversations/$CONVERSATION_ID" \
-H "Authorization: Bearer $VOICELAYER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"controller": "api",
"expectedController": "agent"
}'From now on the agent stays quiet and your code replies with POST /v1/conversations/{id}/messages. If controller is already api, skip this step: the conversation is yours (this request would answer 409 controller_conflict, with the current controller in details).
Step 3: Escalate to a person
Hand the conversation to a person, with a reason they see when they pick it up:
curl -X PATCH "https://api.vlayers.ai/v1/conversations/$CONVERSATION_ID" \
-H "Authorization: Bearer $VOICELAYER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"controller": "human",
"reason": "The customer asked for a refund."
}'{
"conversation": {
"id": "0192f5b0-7c3e-7a51-9b2d-4e8f1a6c3d21",
"channel": "sms",
"lineId": "0192f5a8-1a2b-7c3d-8e4f-5a6b7c8d9e01",
"bindingId": null,
"peer": {
"address": "+15550123"
},
"controller": "human",
"status": "open",
"openedAt": "2026-10-20T15:04:05.000Z",
"lastActivityAt": "2026-10-20T15:04:05.000Z",
"test": true
},
"changed": true
}changed is false when the conversation already had that controller. Each real change emits one conversation.controller_changed event, with the previous and the new controller and the reason.
Step 4: Find conversations waiting for a person
GET /v1/conversations?controller=human lists the conversations a person should answer:
curl "https://api.vlayers.ai/v1/conversations?controller=human&limit=20" \
-H "Authorization: Bearer $VOICELAYER_API_KEY"Step 5: Give it back to the agent
curl -X PATCH "https://api.vlayers.ai/v1/conversations/$CONVERSATION_ID" \
-H "Authorization: Bearer $VOICELAYER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"controller": "agent",
"reason": "Back to the assistant after the refund."
}'agent only when its agent answers texts. Otherwise nobody replies: the API accepts the change, and incoming messages go unanswered.Test it
Simulate a message with a test key (see Test mode), save its conversationId as CONVERSATION_ID, and run the steps above. A test conversation on your free test sender, or on a number whose agent does not answer texts, starts with api: skip Step 2, and stop after Step 4 (Step 5 would leave it with an agent that never replies). Watch the conversation.controller_changed events on your webhook or the event stream.
Common errors
| Error | Status | How to fix |
|---|---|---|
controller_conflict | 409 | Read the conversation again (details.current) and decide whether to change it from there. |
controller_unsupported | 409 | Keep the conversation with its current controller, or with the API or a person when its agent is a code agent. |
not_found | 404 | Check the id, and that the key belongs to the workspace (and covers the channel) that owns it. |
insufficient_scope | 403 | Use a key with the scope named in details.required, or one whose restriction covers this channel or Binding. |
controller_unsupported means that conversation cannot change hands, for example a web chat, or an agent that runs as your own code.
Next steps
- Receive with webhooks: react to
conversation.controller_changed. - Connect an AI agent over MCP: your agent can hand conversations over too.
- Core concepts.