Two ways to build an agent in code
Let VoiceLayer run the agent loop with tools written like Vercel AI SDK tools, or run the loop yourself and bring it as the brain of a VoiceLayer agent.
An agent in code is a prompt, a model and tools. The one real choice is who runs the loop: the part that sends the conversation to the model, reads the tool calls it asks for, runs them and goes round again. VoiceLayer runs every call and text in both cases, so speech, telephony, turn-taking and the guard on what the agent says stay with VoiceLayer.
| Hosted | Bring your own brain | |
|---|---|---|
| Who runs the loop | VoiceLayer | You |
| What you write | defineAgent with tools, a model and actions | A framework agent (Claude Agent SDK, OpenAI Agents SDK, Vercel AI SDK, LangGraph) or a function |
| Where your code runs | Your worker runs each tool when the loop calls it | Your worker runs the whole agent |
| The model and its key | Chosen in code, the key saved under Connections | Yours, on your provider account |
| Tools | Your own, plus VoiceLayer's and your connected apps' | Whatever your framework has, plus VoiceLayer's tools |
| Package | @voicelayer/sdk | @voicelayer/sdk and @voicelayer/agents |
| Pick it when | You want the shortest path, and tools in your own code are enough | You already have an agent, or you need a loop VoiceLayer does not run |
You can use both in one workspace, one agent each.
Hosted: tools written like Vercel AI SDK tools
defineAgent reads tools in the shape the Vercel AI SDK uses: a description, an inputSchema and an execute. The execute runs in your worker, on your network, so it can reach a private service. VoiceLayer's loop decides when to call it.
import { defineAgent, tool } from '@voicelayer/sdk';
import { z } from 'zod';
export default defineAgent({
name: 'order-desk',
prompt: 'You answer for Acme. Look an order up before you talk about it.',
capabilities: ['system.say'],
model: { provider: 'anthropic', id: 'claude-haiku-4-5' },
tools: {
lookup_order: tool({
description: 'Find an order by its number and return its status.',
inputSchema: z.object({ orderId: z.string() }),
execute: async ({ orderId }) => db.orders.find(orderId),
}),
refund_order: tool({
description: 'Refund an order in full.',
inputSchema: z.object({ orderId: z.string() }),
needsApproval: true,
execute: async ({ orderId }) => db.orders.refund(orderId),
}),
},
actions: ['vl_check_availability', { name: 'vl_send_text', confirm: true }],
});@voicelayer/sdk does not depend on the ai package. A tool from ai's own tool() works in the same place, and so does the tool exported here, which needs nothing but zod.
Tools
inputSchemais azodschema (version 3 or 4) or the AI SDK'sjsonSchema(...). The arguments are checked against it beforeexecuteruns.executereceives the arguments and the AI SDK's options. Its abort signal is the invocation's, andexperimental_contextis the VoiceLayer context (ctx.say,ctx.metadata, and the rest). Return anything JSON can hold; throw an error with a message that is safe to say aloud, and the agent can apologise with it.needsApproval(orapproval) means ask the person first. The agent reads back what it is about to do and runsexecuteonly after a yes. A function is read as always ask. On a realtime voice model (models.realtime) the tool does not ask first: it runs without asking, and the build warns you when an agent sets both.- Every run of your tools is recorded with the conversation, with its outcome. The arguments are kept as a digest, never as values.
Models
model is a plain description: { provider: 'anthropic', id: 'claude-haiku-4-5' }, with optional options for temperature, maxOutputTokens and topP. The providers are openai, anthropic and google. An AI SDK model such as anthropic('claude-haiku-4-5') is also accepted: only its provider and model id are read, and the object never leaves your machine. A key or base URL is refused in code. Save the provider key under Connections in the dashboard and VoiceLayer uses it for this workspace.
Actions
actions names VoiceLayer's own tools (vl_book_meeting, vl_check_availability, vl_send_text, vl_send_email) and your connected apps' tools (int_hubspot_contacts_create). Each entry is a name, or an object with confirm, sendRealValues and the connection to use.
What you declare is what the agent has. It does not run until an Admin of your workspace switches it on, so a deploy can never quietly widen what an agent may do. If you change a declaration (a different connection, confirm turned on), it waits for the Admin again. Until then the agent is told it cannot do that thing and does not promise it. An Admin can still set argument rules and limits, and can switch an action off.
Bring your own brain
Keep the loop you already have. Your agent becomes the brain of a VoiceLayer agent: VoiceLayer does the listening and speaking, sends each turn to your worker, and says what comes back.
import { Agent } from '@openai/agents';
import { defineAgent } from '@voicelayer/sdk';
import { openaiBrain } from '@voicelayer/agents/openai';
export default defineAgent({
name: 'front-desk',
prompt: 'You are the front desk of Harbor Dental.',
capabilities: ['system.say', 'system.transfer_call', 'system.end_call'],
brain: openaiBrain(new Agent({ name: 'front-desk', model: 'gpt-4o-mini', instructions: 'Short, natural sentences.' })),
});The adapters are openaiBrain, claudeBrain, aiSdkBrain and langchainBrain, in @voicelayer/agents. With the Vercel AI SDK that is your own streamText or ToolLoopAgent loop. VoiceLayer's tools reach your framework as its own tools, and every call goes back through VoiceLayer's checks. Your model usage is on your provider account.
For texts, serveDirect answers the conversations your code controls with the same agent. See Bring your agent for each framework and Brains in code for the details of a voice brain.
Which one
- You are starting fresh, and your tools are functions: hosted.
- You have an agent that already works and you want it on the phone and on text channels: bring your own brain.
- You want the agent to use a tool VoiceLayer runs (booking, texting, a connected app) without writing the integration: either. Hosted names it in
actions; a brain reaches it as a tool.
Common errors
| Problem | Fix |
|---|---|
The build says a tool needs execute | A hosted tool is an inputSchema and an execute. A tool with no function to run has nothing for your worker to serve. |
The build refuses model | Use { provider, id } with openai, anthropic or google. Keys belong under Connections, not in code. |
| An action never runs | It is waiting for an Admin to switch it on. Ask one, and check you did not change the declaration since. |
Next steps
- Bring your agent: a page for each framework.
- Brains in code: voice brains in detail.
- Tools: HTTP tools you register once and share between agents.