# Direct mode

> Answer every incoming message with one TypeScript handler that resumes safely after restarts.

Source: https://docs.vlayers.ai/developers/sdks/direct

Kind: How-to guide

## What you'll build

A small Node.js process that answers people who write to you. You write one handler; `serveDirect` from `@voicelayer/sdk/direct` reads the [event stream](https://docs.vlayers.ai/developers/receive/event-stream), calls your handler once per incoming message, and sends your replies.

Direct mode handles the conversations your code controls: those whose `controller` is `api` (see [Core concepts](https://docs.vlayers.ai/get-started/concepts)). VoiceLayer still runs every check on what you send, including the output guard.

## Prerequisites

- Node.js 22 or later.
- A secret API key in `VOICELAYER_API_KEY`. Secret keys carry the scopes direct mode needs: `messages:send`, `messages:read`, `events:read` and `conversations:control`. Use a test key (`vl_test_…`) while you build.
- No phone number needed: with a test key, your workspace's free [test sender](https://docs.vlayers.ai/developers/test-mode#your-free-test-sender) stands in for one.

## Step 1: Install

```bash
npm install @voicelayer/sdk
```

## Step 2: Write the handler

Your handler gets the message and a context. `ctx.send` replies in the same conversation. `ctx.setController` hands the conversation to your agent (`agent`) or to a person (`human`).

```ts
import { serveDirect } from '@voicelayer/sdk/direct';

const runner = await serveDirect(async (message, ctx) => {
  if (!message.text) return;

  if (/refund|person|human/i.test(message.text)) {
    await ctx.send({ text: 'Let me get someone from the team for you.' });
    await ctx.setController('human', { reason: 'The customer asked for a person.' });
    return;
  }

  await ctx.send({ text: `Thanks! You said: ${message.text}` });
});

process.on('SIGTERM', () => void runner.stop());
```

The key comes from `VOICELAYER_API_KEY`. Pass `channels: ['sms']` to handle only some channels.

What your handler gets:

| Field | What it is |
|---|---|
| `message.text` | What the person wrote, or `null` for media only |
| `message.media` | Attachments, each with `url` and `contentType` |
| `message.from` | The person's address on the channel, when the channel shows it |
| `message.channel`, `message.conversationId` | Where it arrived |
| `message.window` | On WhatsApp, whether the 24-hour reply window is open and when it closes |
| `ctx.conversation` | The conversation, as the API returns it |
| `ctx.client` | A ready `@voicelayer/api` client, for anything else your key may do |

## Step 3: Run it

```bash
export VOICELAYER_API_KEY=vl_test_...
npm pkg set type=module
npx tsx bot.ts
```

## Step 4: Keep your place across restarts

Without a cursor store, a restart starts from now and skips messages that arrived while you were down. Give the runner somewhere to keep its place: a file, a database row or a key in your cache.

```ts
import { readFile, writeFile } from 'node:fs/promises';
import { serveDirect } from '@voicelayer/sdk/direct';

const cursorStore = {
  load: async () => (await readFile('cursor.txt', 'utf8').catch(() => '')) || null,
  save: (cursor: string) => writeFile('cursor.txt', cursor)
};

await serveDirect(async (message, ctx) => {
  await ctx.send({ text: 'Got it.' });
}, { cursorStore });
```

Delivery is at least once. After a restart the runner may hand you a message again, but replies are idempotent per message: the same reply is never sent twice.

## Test it

Simulate a person texting you with a test key. Your handler receives it within a second and answers through the simulated carrier, at no cost.

cURL:

```bash
curl -X POST https://api.vlayers.ai/v1/test/inbound \
  -H "Authorization: Bearer $VOICELAYER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "sms",
    "from": "+15550123",
    "text": "Do you have a table for two tonight?"
  }'
```

TypeScript:

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

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

const { data } = await createTestInbound({
  client,
  body: {
    channel: 'sms',
    from: '+15550123',
    text: 'Do you have a table for two tonight?'
  }
});
console.log(data.conversationId);
```

Python:

```python
import os
import voicelayer

config = voicelayer.Configuration(access_token=os.environ["VOICELAYER_API_KEY"])
with voicelayer.ApiClient(config) as client:
    api = voicelayer.TestApi(client)
    result = api.create_test_inbound(
        create_test_inbound_request=voicelayer.CreateTestInboundRequest.from_dict({
            "channel": "sms",
            "from": "+15550123",
            "text": "Do you have a table for two tonight?",
        }),
    )
    print(result.conversation_id)
```

Without a number, your workspace's free test sender receives it, and the conversation is already your code's (`api`). If you name a number whose agent answers texts in `to`, the conversation belongs to the agent: hand it to your code first, with the printed id saved as `CONVERSATION_ID`:

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)
```

## Common errors

| Error | Status | How to fix |
|---|---|---|
| [`unauthorized`](https://docs.vlayers.ai/errors/unauthorized) | 401 | Send `Authorization: Bearer <key>` with a secret key from the dashboard (Developers → API keys). Test keys start with `vl_test_`. |
| [`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. |
| [`output_refused`](https://docs.vlayers.ai/errors/output_refused) | 422 | Change the text. `details.reason` says what was refused. |
| [`window_closed`](https://docs.vlayers.ai/errors/window_closed) | 409 | Wait for the person to write again. Template sends, the way to reach someone outside the window, are not available yet (`operation_not_supported`). |
| [`controller_conflict`](https://docs.vlayers.ai/errors/controller_conflict) | 409 | Read the conversation again (`details.current`) and decide whether to change it from there. |
| [`cursor_expired`](https://docs.vlayers.ai/errors/cursor_expired) | 410 | Page through `GET /v1/events` from the same cursor (the pull reads the whole retention), then reconnect the stream with its last cursor. |

If the output guard refuses your text, `ctx.send` throws `OutputRefusedError` and nothing is sent. Catch it and send a different reply.

## Next steps

- [Hand off a conversation](https://docs.vlayers.ai/developers/handoff): when to use `agent`, `api` and `human`.
- [Framework packages](https://docs.vlayers.ai/developers/frameworks/claude): answer with a Claude, OpenAI or LangChain agent instead of your own code.
- [TypeScript SDK](https://docs.vlayers.ai/developers/sdks/typescript): every other call.
