How-to guide

Direct mode

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

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, 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). 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 stands in for one.

Step 1: Install

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

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:

FieldWhat it is
message.textWhat the person wrote, or null for media only
message.mediaAttachments, each with url and contentType
message.fromThe person's address on the channel, when the channel shows it
message.channel, message.conversationIdWhere it arrived
message.windowOn WhatsApp, whether the 24-hour reply window is open and when it closes
ctx.conversationThe conversation, as the API returns it
ctx.clientA ready @voicelayer/api client, for anything else your key may do

Step 3: Run it

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.

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 -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?"
  }'

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

Common errors

ErrorStatusHow to fix
unauthorized401Send Authorization: Bearer <key> with a secret key from the dashboard (Developers → API keys). Test keys start with vl_test_.
insufficient_scope403Use a key with the scope named in details.required, or one whose restriction covers this channel or Binding.
output_refused422Change the text. details.reason says what was refused.
window_closed409Wait for the person to write again. Template sends, the way to reach someone outside the window, are not available yet (operation_not_supported).
controller_conflict409Read the conversation again (details.current) and decide whether to change it from there.
cursor_expired410Page 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

On this page