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

Source: https://docs.vlayers.ai/developers/sdks/voice-agents

Kind: How-to guide

## 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](https://docs.vlayers.ai/build/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.

> **Note:** 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](https://docs.vlayers.ai/developers/sdks/migrating).

## Step 1: Install

```bash
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.

```ts
// 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.

```bash
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:

| 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](https://docs.vlayers.ai/build/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`:

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

await agent.start(); // serves the build in voicelayer.lock.json; SIGTERM drains
```

```bash
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.

> **Warning:** 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:

```ts
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](https://docs.vlayers.ai/build/testing).

## 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](https://docs.vlayers.ai/developers/sdks/brains): use your own model or agent framework as the call's brain.
- [How an agent runs](https://docs.vlayers.ai/build/deploy): flow agents and code agents side by side.
- [Transfers](https://docs.vlayers.ai/build/transfers): send a caller to a person.
