How-to guide

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:

ControllerWhat happens to incoming messages
agentYour VoiceLayer agent answers them on its own.
apiNothing answers automatically. You get the message.received event and reply from your code.
humanA 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) and messages:read. See Authentication and API keys.
  • A conversation id, from a message.received event or GET /v1/conversations.
  • curl, Node.js 18 or later with @voicelayer/api, or Python 3.9 or later with voicelayer.
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"
Response · 200
{
  "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."
  }'
Response · 200
{
  "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."
  }'
Give a conversation to 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

ErrorStatusHow to fix
controller_conflict409Read the conversation again (details.current) and decide whether to change it from there.
controller_unsupported409Keep the conversation with its current controller, or with the API or a person when its agent is a code agent.
not_found404Check the id, and that the key belongs to the workspace (and covers the channel) that owns it.
insufficient_scope403Use 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

On this page