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

LifecycleTools
Agent revisionscreate_voice_agent, get_voice_agent, list_voice_agents, update_voice_agent, delete_voice_agent
Phone numbersbuy_number, list_numbers, attach_agent_to_number, detach_number, release_number
Session activationstart_agent_call, create_web_session
Sessionsget_agent_session, list_agent_sessions, get_session_transcript, send_session_message, stop_agent_session

Provider matrix

ToolkitRole in this tutorialHonest boundary
Voice AgentsImmutable definitions, policy, sessions, allowlists, transcriptNative eyeball control plane
PipecatScripted caller and ordered runtime eventsDeterministic mock/runtime adapter, not a hosted SaaS API
TwilioPSTN transportMocked here; live mode needs a connected account
LiveKitWebRTC rooms and short-lived participant grantsMocked here; live mode needs a connected account
Deepgram / ElevenLabsSTT / TTS referencesMocked here; live worker integration is deployment-owned
Google Calendar / GmailAllowlisted child actionsExecute 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:

ts
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

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