Build a restaurant reservation phone agent
Create a policy-complete, tool-allowlisted agent and prove a scripted mock call through its final transcript.
This catalog 1.1 tutorial creates an immutable agent revision, starts an asynchronous restaurant call, and proves that only the allowlisted calendar and email tools run. It assumes Mockhouse and an executor configured with DevVoiceSessionRuntime are listening; use the mocks quickstart and voice worker guide for that source-workspace setup.
Canonical tools
| Lifecycle | Tools |
|---|---|
| Agent revisions | create_voice_agent, get_voice_agent, list_voice_agents, update_voice_agent, delete_voice_agent |
| Phone numbers | buy_number, list_numbers, attach_agent_to_number, detach_number, release_number |
| Session activation | start_agent_call, create_web_session |
| Sessions | get_agent_session, list_agent_sessions, get_session_transcript, send_session_message, stop_agent_session |
Provider matrix
| Toolkit | Role in this tutorial | Honest boundary |
|---|---|---|
| Voice Agents | Immutable definitions, policy, sessions, allowlists, transcript | Native eyeball control plane |
| Pipecat | Scripted caller and ordered runtime events | Deterministic mock/runtime adapter, not a hosted SaaS API |
| Twilio | PSTN transport | Mocked here; live mode needs a connected account |
| LiveKit | WebRTC rooms and short-lived participant grants | Mocked here; live mode needs a connected account |
| Deepgram / ElevenLabs | STT / TTS references | Mocked here; live worker integration is deployment-owned |
| Google Calendar / Gmail | Allowlisted child actions | Execute through their normal canonical adapters |
Start a browser or app session
Create an agent revision with transport: "webrtc:livekit", then execute
voice-agents.create_web_session asynchronously with the agent ID, optional revision, a
LiveKit transportConnectionId, and the end user's participantIdentity. The eventual
output contains the normal pinned session, its transcriptArtifactId, and this join grant:
const joinGrant = {
roomUrl: "https://livekit.example.test",
participantToken: "<short-lived room token>",
expiresAt: "2026-07-19T02:00:00.000Z",
};The grant is minted by the LiveKit adapter for that room and participant. It expires after
one hour by default and never contains the LiveKit API secret. The voice worker joins under a
different identity, and the resulting session uses the same ordered events, transcript,
polling, and stop_agent_session lifecycle as a phone call.
Own, bind, and reassign a number
Use voice-agents.buy_number to acquire a Twilio number and list_numbers to inspect both
provider inventory and Eyeball binding state. Bind the unbound number with
attach_agent_to_number. A number already attached to a different agent or revision returns
invalid_input; the binding is never silently replaced.
Reassignment is an explicit two-step operation: call detach_number, then
attach_agent_to_number for the new agent revision. Detaching retains the owned number.
release_number returns the number to Twilio, is marked destructive, and rejects a bound
number until it is detached. Deleting an agent does not implicitly detach or release its
number.
When start_agent_call omits both from and transportConnectionId, exactly one attached
number becomes the telephony default. Multiple bindings are ambiguous and return
invalid_input. With no binding, only the in-process development runtime may choose its fake
transport; a remote worker requires explicit transport fields or one attached number.
Run the scripted call
import { Eyeball } from "@eyeball/sdk";
const eyeball = new Eyeball({
apiKey: process.env.EYEBALL_API_KEY!,
baseUrl: process.env.EYEBALL_EXECUTOR_URL!,
});
const executorUrl = process.env.EYEBALL_EXECUTOR_URL!.replace(/\/$/u, "");
const userId = "diner_restaurant_demo";
const object = (value: unknown): Record<string, unknown> => {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("Expected an object");
}
return value as Record<string, unknown>;
};
const created = object(
await eyeball.tools.run(
"voice-agents.create_voice_agent",
{
agent: {
name: "Table Host",
systemPrompt:
"Confirm date, time, party size, name, and email before booking.",
llm: { model: "model:fixture:restaurant-concierge" },
voice: {
tts: { provider: "elevenlabs", voiceId: "voice_fixture_warm_host" },
stt: { provider: "deepgram", model: "nova-3", language: "en" },
},
transport: "pstn:twilio",
tools: ["google-calendar.create_event", "gmail.send_email"],
guardrails: {
maxDurationSeconds: 600,
handoffToHuman: { enabled: false },
},
webhooks: {
endpointIds: [],
transcript: true,
events: ["session.lifecycle", "turn.transcript", "tool_call", "tool_result"],
},
recordingPolicy: {
mode: "disabled",
consent: "external",
retentionDays: 0,
redactDtmf: true,
},
},
},
{ userId, idempotencyKey: "restaurant:create-agent" },
),
);
const agent = object(created.agent);
const pending = await eyeball.tools.execute("voice-agents.start_agent_call", {
userId,
mode: "async",
idempotencyKey: "restaurant:start-call:1",
input: {
agentId: String(agent.id),
revision: Number(agent.revision),
to: "+966500000111",
from: "+966500000222",
transportConnectionId: "conn_twilio_restaurant_demo",
script: [
{ caller: "Tomorrow at 7, a table for four under Sam." },
{
expect_tool_call: "google-calendar.create_event",
input: {
calendarId: "primary",
title: "Table for four — Sam",
startTime: "2026-07-18T16:00:00.000Z",
endTime: "2026-07-18T17:30:00.000Z",
timeZone: "Asia/Riyadh",
},
},
{ caller: "Email sam@example.com with the confirmation." },
{
expect_tool_call: "gmail.send_email",
input: {
to: ["sam@example.com"],
subject: "Your table is confirmed",
body: "Tomorrow at 7 PM for four guests.",
},
},
],
},
});
const started = await eyeball.executions.wait(pending.executionId);
if (started.status !== "succeeded") throw new Error("Call failed to start");
const sessionId = String(object(object(started.output).session).id);
let afterSequence = 0;
let state = "created";
while (!["completed", "failed", "abandoned"].includes(state)) {
const advanced = await fetch(
`${executorUrl}/v1/dev/voice-sessions/${sessionId}/advance`,
{
method: "POST",
headers: {
authorization: `Bearer ${process.env.EYEBALL_API_KEY!}`,
"content-type": "application/json",
},
body: JSON.stringify({ userId, milliseconds: 1_000 }),
},
);
if (!advanced.ok) throw new Error(`Voice worker returned ${advanced.status}`);
const page = object(
await eyeball.tools.run(
"voice-agents.get_agent_session",
{ sessionId, afterSequence, eventLimit: 100 },
{ userId },
),
);
const session = object(page.session);
state = String(session.state);
afterSequence = Number(page.nextSequence);
console.log(page.events);
}
const transcript = object(
await eyeball.tools.run(
"voice-agents.get_session_transcript",
{ sessionId },
{ userId },
),
);
const artifact = object(transcript.artifact);
const turns = Array.isArray(artifact.turns) ? artifact.turns.map(object) : [];
const toolTurns = turns.filter((turn) => turn.speaker === "tool");
if (
toolTurns.length !== 2 ||
toolTurns.some((turn) => typeof turn.executionId !== "string")
) {
throw new Error("Expected two child execution IDs in the transcript");
}
console.log(toolTurns.map((turn) => turn.executionId));The script is runtime-only Pipecat mock input; it never enters VoiceAgentDefinition. The development worker advances deterministic time, emits gap-free ordered session events, and dispatches child executions through the same executor as any other tool. This path uses no Twilio, Deepgram, ElevenLabs, Gmail, or Google Calendar account.
Expected transcript excerpt
Caller: Tomorrow at 7, a table for four under Sam.
Table Host: I’ll reserve the table and send your confirmation.
google-calendar.create_event succeeded · exe_…
Caller: Email sam@example.com with the confirmation.
gmail.send_email succeeded · exe_…
Table Host: Your table is confirmed and the email is on its way.
The transcript artifact surfaces tool calls and child execution IDs; it does not flatten them into untraceable prose.
Prepare a real-provider certification run
Keep the agent draft, policy, allowlist, and canonical tool inputs. For a certification deployment, configure apps/voice-worker, set EYEBALL_VOICE_WORKER_URL and the shared control token on the executor, give the worker a user-pinned executor key, connect Twilio, Deepgram, ElevenLabs, Anthropic, Google Calendar, and Gmail for the same userId, remove script, and stop calling the development-only /v1/dev/voice-sessions/:id/advance route. The mock path requires zero third-party accounts; the voice-worker guide describes the deployment variables and recovery boundary.
The dashboard's Voice Agents screen tests PSTN and chat agents against the dev-stack fixtures, creates LiveKit web sessions for webrtc:livekit agents (surfacing the create-time-only join grant), and manages the owned-number inventory — buy, attach, detach, release — with detach-before-release enforced by the executor.
See Voice Agents toolkit for all 17 tools and schemas.