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.
@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/sdkStep 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.tsYou 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:
| Call | What 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.set | Memory about this caller that survives across calls |
ctx.metadata | Call 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 drainsexport VOICELAYER_WORKER_KEY=vlw_... # a worker key for ONE environment
node worker.jsA 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.
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
| Command | What it does |
|---|---|
voicelayer dev <entry> | Serve against the dev environment and publish; watches the file |
voicelayer manifest <entry> --check | Build the manifest offline and run the upload's checks |
voicelayer deploy <entry> --env prd | Upload, wait for workers, publish |
voicelayer deploy … --no-publish | Upload only: roll your workers, then publish |
voicelayer publish --env prd --min-workers 2 | Publish an uploaded build once its workers are connected; publishing an older build is a rollback |
voicelayer versions --env prd | Published versions and uploaded builds, with their connected workers |
Common errors
| Problem | Fix |
|---|---|
| The worker stops with a message about the lock file | It 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 missing | Your code lacks a function the build declares. Deploy the code that matches the lock file. |
| The agent shows Offline | No 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
- Brains in code: use your own model or agent framework as the call's brain.
- How an agent runs: flow agents and code agents side by side.
- Transfers: send a caller to a person.