Voice AgentsIVRAI AgentsTutorial

Replace Your Phone Menu With a Voice Agent

Press 1 for sales is a routing table people have to read out loud. Build a voice agent that answers, understands, does the work, and hands off to a human when it should.

Written by: Victor VillalobosReviewed by: Jennifer VillalobosJuly 26, 202611 min read
View as Markdown

Every phone menu is the same apology. Press 1 for sales. Press 2 for support. Press 3 to hear these options again.

It is a routing table, read out loud, at the speed of speech. The caller already knows what they want. They just have to wait while your org chart is recited at them, then guess which branch a person like them belongs in.

The alternative is not a better menu. It is no menu: the phone is answered, the caller says what they need, and something on the other end understands, does the work, and puts a human on when a human is actually required.

That is a voice agent, and it is roughly an afternoon of work.

What the old phone system actually costs

Not the line rental. Three other things.

Abandoned calls. Every layer of menu loses people. They hang up and try a competitor, or they pick the wrong branch and land on someone who cannot help them, which is worse because now two people wasted the time.

No record of what anyone wanted. An IVR knows the caller pressed 2. It does not know they were asking about a refund on an order from March. That intent is the most valuable thing in the call and your phone system throws it away every time.

It cannot do anything. A menu routes. It cannot check a booking, quote a price, or update an address. Everything is a handoff to a person, including the half of calls that a person will resolve by reading one field off a screen.

A voice agent fixes all three for the same reason: it is a conversation, so it hears intent, and it has tools, so it can act on it.

What you are building

The old menuA Zavu voice agent
Press 1 for salesThe caller says what they want
A tree you maintain in a portalA prompt in your repo, in version control
Routes, then hangs up on the branchLooks things up and answers mid-call
Transfers blindTransfers when your prompt says to, to the number you set
Knows a digit was pressedFull transcript, duration, and cost per call

The agent is not a separate product from the one answering WhatsApp. It is the same agent with a voice block: same prompt, same model, same tools, same knowledge base.

Before you start

Three prerequisites, and one of them takes a day.

  • A sender with a phone number. That number is what people dial.
  • Voice Agents enabled for your team. It is in beta and turned on per team, so the voice endpoints return 403 until someone flips it. Email hi@zavu.dev and ask.
  • A positive balance. Calls are billed per connected minute. Inbound calls are only answered while your balance is above zero, which is a failure mode worth knowing before it happens on a Monday.

Who does what

You bring the account, the number, and the judgment about when a human should pick up. Your coding agent writes and ships the rest.

YouYour coding agent
Create the Zavu accountFinds or creates the sender
Run zavu login and authorizeWrites defineAgent with the voice block
Ask for Voice Agents to be enabledWrites the tools it calls mid-call
Approve buying a phone numberSets the secrets and deploys
Take the first test callReads the transcript and fixes the prompt

The only part you type

bash
npx skills add zavudev/zavu-skills npx zavudev login

The first command installs Zavu's skills into your coding agent. Claude Code, Cursor, Copilot, Codex and 40 or so others are supported; the installer asks which. One of those skills is voice-agent, which is what stops your agent from inventing config fields that do not exist.

login opens a browser, you pick the project, and the key lands in ~/.zavu/credentials.json. From here you describe what you want.

Then you describe the agent

text
Build a Zavu voice agent for our clinic on my existing sender. It should answer in Spanish, tell people our hours, look up an appointment by phone number, reschedule it, and transfer to +14155551234 when someone asks for a person or sounds upset. Twelve minute cap. Deploy it.

You do not need to know the commands below. They are here so you recognise what scrolls past, and so you notice when a step got skipped.

bash
npx zavudev whoami # which project am I about to change npx zavudev senders list # the number people will dial npx zavudev fn secrets set SENDER_ID "jn76vnxet8g5nq661by3v06y1581bmmn" npx zavudev deploy

If you would rather start from something proven, there is a catalog of ready-made voice agents:

bash
npx zavudev agents catalog
text
id name voice tools category fermi Fermi, Lead Qualification yes 5 sales curie Curie, Customer Support yes 5 support kepler Kepler, Video Call Booking yes 2 frontDesk hopper Hopper, Lead Capture and Booking yes 5 sales aria Aria, Multi-channel concierge yes 3 frontDesk atlas Atlas, Product Expert yes 0 support

npx zavudev agents pull curie --sender "$SENDER_ID" scaffolds one into your repo as editable TypeScript. It is a starting point you own, not a black box you rent.

Read what it wrote

ts
import { defineAgent, defineTool } from "@zavudev/functions" defineAgent({ senderId: process.env.SENDER_ID!, name: "Clínica Norte", provider: "zavu", model: "openai/gpt-4o-mini", channels: ["voice", "whatsapp"], voice: { enabled: true, model: "openai/gpt-4o", greeting: "Clínica Norte, buenos días. ¿En qué le puedo ayudar?", language: "es", interruptible: true, maxCallDurationMinutes: 12, maxIdleSeconds: 30, transferPhoneNumber: "+14155551234", voicemailAction: "hangup", }, prompt: "You are the front desk of Clínica Norte. Answer in Spanish, one or two sentences. Transfer to a human when the caller asks for one, or when they are upset.", })

Five fields decide whether the call feels human.

interruptible is barge-in. The caller can cut the agent off mid-sentence and it stops to listen. Leave it on. An agent that talks over people is the thing everyone already hates about robocalls.

maxIdleSeconds ends the call after silence, so a phone left face-down in a bag does not bill you for twelve minutes.

maxCallDurationMinutes is the hard stop. It exists because a conversation that has gone wrong tends to go wrong for a long time.

voicemailAction decides what happens when an answering machine picks up on an outbound call: hangup silently, or leave_message and speak. Leaving a message when you meant to reach a person is how a helpful agent becomes a nuisance.

transferPhoneNumber is the escape hatch, and it deserves the next section to itself.

The transfer is the part people judge you on

Set transferPhoneNumber and the agent gets a transfer tool. It also always gets a hangup tool, so it can end a finished call on its own.

Two things to know, because the second one surprises people.

The transfer only exists if the sender also has a phone number to dial out from. Set the target, forget the sender's number, and the agent will have nothing to escalate with and no way to say so.

A transfer forwards the call. It does not brief the human. Your colleague picks up a ringing phone with no idea the caller already spent ninety seconds explaining themselves. If you want them to know, that is yours to build: the full transcript is on the call record and in the call.completed webhook, so you can push a summary to Slack, WhatsApp, or your helpdesk as the transfer happens. Say it in your prompt, too, so the agent tells the caller what is about to happen instead of going quiet.

Give it something to do

A menu routes. This is the part that makes an agent worth more than a menu:

ts
defineTool({ name: "find_appointment", description: "Look up a patient's next appointment by phone number.", parameters: { type: "object", properties: { phone: { type: "string", description: "E.164 phone number" } }, required: ["phone"], }, handler: async ({ phone }) => { const res = await fetch("https://api.clinic.example/appointments?phone=" + phone) if (!res.ok) return { found: false, reason: "lookup_failed" } return { found: true, ...(await res.json()) } }, })

Worth stating plainly: tools do run on voice. On the plain text path they do not, which trips people up in the other direction. A voice agent can genuinely look things up mid-sentence, and that is the whole reason to point one at your phone number.

Test the handler on its own before any call:

bash
npx zavudev fn invoke --tool find_appointment --args '{"phone":"+14155551234"}'

That runs on your machine, against uncommitted code, in under a second.

Hear it before a customer does

Two different checks, and they catch different failures.

bash
npx zavudev agents test --sender "$SENDER_ID" --message "quiero cambiar mi cita del martes"

This exercises the brain: prompt, model, knowledge base. Nothing is delivered, nothing is charged, nothing is recorded. It also prints warnings about what a dry run cannot prove, and on a voice agent the most useful one is that this path does not call tools even though the call will.

Then call yourself:

bash
npx zavudev calls create --to "+1YOUROWNNUMBER"

Your phone rings and the agent talks to you. Nothing else tells you the greeting is three seconds too long, or that the voice reads a phone number as one enormous integer. Listen for interruption handling: talk over it on purpose and see whether it stops.

Then read the call back:

bash
npx zavudev calls list --limit 5 npx zavudev calls get <callId>

list shows direction, status, duration, and why each call ended. get returns the turn-by-turn transcript. That transcript is the thing your old phone system never gave you, and it is where the next version of the prompt comes from.

Then hand the loop over:

text
Read the transcript of my last three calls. Every caller asked about parking and the agent did not know. Add it to the knowledge base, fix the greeting to be shorter, and redeploy.

It calls out, too

The same agent places calls:

bash
npx zavudev calls create --to "+56912345678" --language es-ES --max-minutes 5

Appointment reminders, delivery windows, a payment that failed. --language accepts auto to follow the caller. voicemailAction decides what happens when a machine answers, which on outbound campaigns is most of the time.

Outbound is also where an agent stops being a convenience and starts being a thing that can annoy thousands of people quickly. Reminders people asked for, yes. Cold calls, no. Consent and calling-hours rules are yours to respect, and neither the model nor the API will do it for you.

What happens on every call

Four webhook events fire: call.initiated, call.answered, call.completed, call.failed. Every one carries the call id, direction, from, to, status, duration, and why it ended.

The terminal two also carry cost, in USD, after the call has been charged. That is not the raw carrier number: it is what you were actually billed, telephony and the voice pipeline together, so you can put a real price next to a real conversation.

How Zavu bills this

Voice is per connected minute. The managed pipeline is $0.0625 a minute plus telephony, counted from answer to hangup. Standard models are covered by that rate; a premium model adds its own token cost. A short estimate is reserved when the call is placed and settled against the real duration when it ends.

The plan covers everything around it. Free is $0, Hobby is $25 a month, and the rest scale from there. Each plan includes a number allowance, three on Hobby, and the first US number is free for every team. Extra numbers are about a dollar a month each.

Building is free. Skills, CLI, SDKs, deploy, and every redeploy. agents test charges nothing. fn invoke runs locally. What you pay for is a connected call.

The one number to hold on to: a five minute call on the managed pipeline is about thirty cents plus telephony. Compare that against what a person costs for the same five minutes, and then against what an abandoned call costs, which is the whole order.

Rates move. The pricing page is the one that is current.

Three rules that keep this honest

Never fake a success. A tool that reports a rescheduled appointment it never rescheduled makes the agent tell a real person, out loud, in a voice that sounds confident, that their Tuesday is handled. Return the failure. The agent will say so, and the caller can do something about it. This is worse on voice than anywhere else, because nobody re-reads a phone call.

Say when you are transferring. Silence followed by ringing feels like being hung up on. One sentence, then the transfer.

A dry run is not a call. agents test proves the prompt is sane. It does not prove the greeting sounds right, that the voice pronounces your company name correctly, or that the agent stops when interrupted. Only a real call proves that, so make the first one to your own phone.

Where this leaves you

Your phone number now answers, understands, checks something real, and gets a person on the line when it should. The behaviour lives in a file you can diff, with a transcript for every call and a cost next to it.

The menu was never the product. It was the compromise you made because software could not hold a conversation. It can now.

Need help? Contact us or join our Discord community for support.

Follow us on social media

Ready to get started?

Start building for free, or schedule a call to discuss your specific use case.

Replace Your IVR With an AI Voice Agent | Zavu Blog