How-to guide

Voice agents in code

Define a voice agent in one TypeScript file, run it locally against the dev environment, and deploy it with zero dropped calls.

What you'll build

A voice agent defined in code with @voicelayer/sdk: its prompt, models and tools in one file. VoiceLayer runs the call (speech, turn-taking, telephony, the guard). Your worker runs your code: it opens one outbound connection to VoiceLayer and answers when the call needs one of your functions. You need no LiveKit project and no provider keys in your process.

Prefer clicks? The flow builder builds the same kind of agent without code.

Prerequisites

  • Node.js 22 or later.
  • A secret API key in VOICELAYER_API_KEY, for the command line. It uploads and publishes your agent.
  • For production only: a worker key (vlw_…) for one environment. Create it in the dashboard under Developers → API keys, choosing "The workers that run your agent code". A worker key can only open a session to VoiceLayer; it can call no API.
The 1.0 API is on the @voicelayer/sdk/v1 subpath from version 0.8, and becomes the package root at 1.0. Running a 0.x worker? See Moving from 0.x to 1.0.

Step 1: Install

npm install @voicelayer/sdk

Step 2: Define the agent

An agent is a manifest — the prompt, the models, and the tools it declares, as plain data that is uploaded and versioned — plus handlers: the functions inside it. defineAgent returns the agent and starts nothing.

// agent.ts
import { defineAgent, defineTool, httpTool, deepgram, openai } from '@voicelayer/sdk/v1';

export default defineAgent({
  name: 'front-desk',
  prompt: 'You answer the phone for Harbor Dental. Be brief and friendly.',
  greeting: 'Harbor Dental, how can I help?',
  capabilities: ['system.say', 'system.transfer_call'], // what your handlers may do to the call
  toolBindings: [], // dashboard tools this agent uses; [] means none

  models: {
    stt: deepgram.stt({ model: 'nova-3' }),
    llm: openai.llm({ model: 'gpt-4o' }),
    tts: deepgram.tts({ model: 'aura-2-harmonia-en' })
  },

  tools: {
    // code your worker runs: it can reach your private network
    ...defineTool({
      name: 'next_free_slot',
      description: 'Find the next free appointment slot',
      input: { day: 'string' },
      run: async ({ day }) => ({ day, time: '10:30' })
    }),
    // a declaration VoiceLayer runs: a fixed endpoint, no code
    ...httpTool({
      name: 'open_ticket',
      description: 'Open a support ticket',
      input: { summary: 'string' },
      url: 'https://example.com/api/tickets'
    })
  }
});

Provider keys (OpenAI, Deepgram, …) never live in your process. Add them in the dashboard under Settings → Provider accounts.

Step 3: Run it against the dev environment

voicelayer dev uploads the agent to your dev environment, serves its handlers and publishes. It prints how to reach the agent: the dashboard Playground, or a test number.

export VOICELAYER_API_KEY=vl_...
npx voicelayer dev ./agent.ts

You see the uploaded version and a Playground link. Editing a handler restarts the runner; editing the manifest uploads a new dev version.

Step 4: Use the call from a handler

Every handler gets ctx, the call context:

CallWhat it does
ctx.say(text), ctx.respond(instructions?)Speak a line, or let the model write the next reply
ctx.ask(question)Ask and wait for the caller's answer (voice only)
ctx.handoff(to, opts)Transfer to a person (see Transfers)
ctx.endCall(opts), ctx.sendDtmf(digits)Hang up, or send keypad digits
ctx.memory.get / ctx.memory.setMemory about this caller that survives across calls
ctx.metadataCall state: what one handler writes, the next one reads
ctx.recordEvent(event)Add an entry to the call's timeline in the dashboard

A handler keeps no call state in memory between invocations, because two invocations of one call can land on two processes. Keep call state in ctx.metadata or ctx.memory.

Each ctx action is checked against the system.* capabilities you declare; an undeclared one is refused. Handlers get the caller's phone number and words as placeholders (for example <PII_PHONE_1>) unless a tool sets sendRealValues: true.

Handlers that the call waits on have deadlines, so a slow or crashed worker never hangs a call: a tool has 10 seconds, after which the model gets a failed result.

Step 5: Deploy to production

In production, a worker serves the build that your deploy recorded in voicelayer.lock.json:

// worker.ts
import agent from './agent.js';

await agent.start(); // serves the build in voicelayer.lock.json; SIGTERM drains
export VOICELAYER_WORKER_KEY=vlw_...   # a worker key for ONE environment
node worker.js

A deploy is a new, immutable version. Roll it out in this order, so no call drops:

Upload

npx voicelayer deploy ./agent.ts --env prd --no-publish stores the build and writes voicelayer.lock.json. Ship that file with your code.

Start the new workers beside the old ones

They register the new build. A worker that starts before its upload lands retries for up to 15 minutes.

Publish

npx voicelayer publish --env prd --min-workers 2 waits until two workers of the new build are connected, then publishes. New calls use it.

Let the old build drain

Calls already on the old build finish on it. When its last call ends, its workers exit.

SIGTERM drains, it does not exit: set your host's kill timeout to your longest call plus two minutes, or calls still on the old version are cut off.

Several agents can share one process: startAgents([a, b, c]).

Test it

Test a handler as a plain function with @voicelayer/sdk/testing, with no network:

import { assertAgent, invoke, testContext } from '@voicelayer/sdk/testing';
import agent from './agent.js';

assertAgent(agent); // the manifest builds and passes the upload's checks
const slot = await invoke(agent, 'tool.next_free_slot', { day: 'Monday' }, testContext());
console.log(slot); // { day: 'Monday', time: '10:30' }

End to end, run voicelayer dev and talk to the agent in the Playground. See Test an agent.

Command line

CommandWhat it does
voicelayer dev <entry>Serve against the dev environment and publish; watches the file
voicelayer manifest <entry> --checkBuild the manifest offline and run the upload's checks
voicelayer deploy <entry> --env prdUpload, wait for workers, publish
voicelayer deploy … --no-publishUpload only: roll your workers, then publish
voicelayer publish --env prd --min-workers 2Publish an uploaded build once its workers are connected; publishing an older build is a rollback
voicelayer versions --env prdPublished versions and uploaded builds, with their connected workers

Common errors

ProblemFix
The worker stops with a message about the lock fileIt has no build for its key's environment. Run voicelayer deploy for that environment and ship the new voicelayer.lock.json.
The worker refuses to start: a handler is missingYour code lacks a function the build declares. Deploy the code that matches the lock file.
The agent shows OfflineNo worker is running for it. Start one with the worker key for that environment.
A tool says "refused"Add the system.* capability the handler uses to capabilities.

Next steps

On this page