# Conversations and handoff

> Decide who answers each conversation — your AI agent, your code or a person — and switch between them safely.

Source: https://docs.vlayers.ai/developers/handoff

Kind: How-to guide

## 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](https://docs.vlayers.ai/get-started/concepts#conversation) has a [controller](https://docs.vlayers.ai/get-started/concepts#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) and `messages:read`. See [Authentication and API keys](https://docs.vlayers.ai/get-started/authentication).
- 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`.

```bash
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:

```bash
curl "https://api.vlayers.ai/v1/conversations/$CONVERSATION_ID" \
  -H "Authorization: Bearer $VOICELAYER_API_KEY"
```

TypeScript:

```ts
import { createVoiceLayerClient, getConversation } from '@voicelayer/api';

const client = createVoiceLayerClient({ apiKey: process.env.VOICELAYER_API_KEY!, throwOnError: true });

const { data } = await getConversation({
  client,
  path: {
    id: process.env.CONVERSATION_ID!
  }
});
console.log(data.conversation);
```

Python:

```python
import os
import voicelayer

config = voicelayer.Configuration(access_token=os.environ["VOICELAYER_API_KEY"])
with voicelayer.ApiClient(config) as client:
    api = voicelayer.ConversationsApi(client)
    result = api.get_conversation(
        id=os.environ["CONVERSATION_ID"],
    )
    print(result.conversation)
```

Response `200`:

```json
{
  "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:

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

TypeScript:

```ts
import { createVoiceLayerClient, updateConversation } from '@voicelayer/api';

const client = createVoiceLayerClient({ apiKey: process.env.VOICELAYER_API_KEY!, throwOnError: true });

const { data } = await updateConversation({
  client,
  path: {
    id: process.env.CONVERSATION_ID!
  },
  body: {
    controller: 'api',
    expectedController: 'agent'
  }
});
console.log(data.conversation.controller);
```

Python:

```python
import os
import voicelayer

config = voicelayer.Configuration(access_token=os.environ["VOICELAYER_API_KEY"])
with voicelayer.ApiClient(config) as client:
    api = voicelayer.ConversationsApi(client)
    result = api.update_conversation(
        id=os.environ["CONVERSATION_ID"],
        update_conversation_request=voicelayer.UpdateConversationRequest.from_dict({
            "controller": "api",
            "expectedController": "agent",
        }),
    )
    print(result.conversation.controller)
```

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:

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

TypeScript:

```ts
import { createVoiceLayerClient, updateConversation } from '@voicelayer/api';

const client = createVoiceLayerClient({ apiKey: process.env.VOICELAYER_API_KEY!, throwOnError: true });

const { data } = await updateConversation({
  client,
  path: {
    id: process.env.CONVERSATION_ID!
  },
  body: {
    controller: 'human',
    reason: 'The customer asked for a refund.'
  }
});
console.log(data.conversation.controller);
```

Python:

```python
import os
import voicelayer

config = voicelayer.Configuration(access_token=os.environ["VOICELAYER_API_KEY"])
with voicelayer.ApiClient(config) as client:
    api = voicelayer.ConversationsApi(client)
    result = api.update_conversation(
        id=os.environ["CONVERSATION_ID"],
        update_conversation_request=voicelayer.UpdateConversationRequest.from_dict({
            "controller": "human",
            "reason": "The customer asked for a refund.",
        }),
    )
    print(result.conversation.controller)
```

Response `200`:

```json
{
  "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:

```bash
curl "https://api.vlayers.ai/v1/conversations?controller=human&limit=20" \
  -H "Authorization: Bearer $VOICELAYER_API_KEY"
```

TypeScript:

```ts
import { createVoiceLayerClient, listConversations } from '@voicelayer/api';

const client = createVoiceLayerClient({ apiKey: process.env.VOICELAYER_API_KEY!, throwOnError: true });

const { data } = await listConversations({
  client,
  query: {
    controller: 'human',
    limit: 20
  }
});
console.log(data.conversations);
```

Python:

```python
import os
import voicelayer

config = voicelayer.Configuration(access_token=os.environ["VOICELAYER_API_KEY"])
with voicelayer.ApiClient(config) as client:
    api = voicelayer.ConversationsApi(client)
    result = api.list_conversations(
        controller="human",
        limit=20,
    )
    print(result.conversations)
```

## Step 5: Give it back to the agent

cURL:

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

TypeScript:

```ts
import { createVoiceLayerClient, updateConversation } from '@voicelayer/api';

const client = createVoiceLayerClient({ apiKey: process.env.VOICELAYER_API_KEY!, throwOnError: true });

const { data } = await updateConversation({
  client,
  path: {
    id: process.env.CONVERSATION_ID!
  },
  body: {
    controller: 'agent',
    reason: 'Back to the assistant after the refund.'
  }
});
console.log(data.conversation.controller);
```

Python:

```python
import os
import voicelayer

config = voicelayer.Configuration(access_token=os.environ["VOICELAYER_API_KEY"])
with voicelayer.ApiClient(config) as client:
    api = voicelayer.ConversationsApi(client)
    result = api.update_conversation(
        id=os.environ["CONVERSATION_ID"],
        update_conversation_request=voicelayer.UpdateConversationRequest.from_dict({
            "controller": "agent",
            "reason": "Back to the assistant after the refund.",
        }),
    )
    print(result.conversation.controller)
```

> **Warning:** 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](https://docs.vlayers.ai/developers/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](https://docs.vlayers.ai/developers/receive/webhooks) or the [event stream](https://docs.vlayers.ai/developers/receive/event-stream).

## Common errors

| Error | Status | How to fix |
|---|---|---|
| [`controller_conflict`](https://docs.vlayers.ai/errors/controller_conflict) | 409 | Read the conversation again (`details.current`) and decide whether to change it from there. |
| [`controller_unsupported`](https://docs.vlayers.ai/errors/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`](https://docs.vlayers.ai/errors/not_found) | 404 | Check the id, and that the key belongs to the workspace (and covers the channel) that owns it. |
| [`insufficient_scope`](https://docs.vlayers.ai/errors/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](https://docs.vlayers.ai/developers/receive/webhooks): react to `conversation.controller_changed`.
- [Connect an AI agent over MCP](https://docs.vlayers.ai/developers/mcp): your agent can hand conversations over too.
- [Core concepts](https://docs.vlayers.ai/get-started/concepts).
