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:readandconversations: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/sdkStep 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:
| 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
export VOICELAYER_API_KEY=vl_test_...
npm pkg set type=module
npx tsx bot.tsStep 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
| Error | Status | How to fix |
|---|---|---|
unauthorized | 401 | Send Authorization: Bearer <key> with a secret key from the dashboard (Developers → API keys). Test keys start with vl_test_. |
insufficient_scope | 403 | Use a key with the scope named in details.required, or one whose restriction covers this channel or Binding. |
output_refused | 422 | Change the text. details.reason says what was refused. |
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 | 409 | Read the conversation again (details.current) and decide whether to change it from there. |
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: when to use
agent,apiandhuman. - Framework packages: answer with a Claude, OpenAI or LangChain agent instead of your own code.
- TypeScript SDK: every other call.