---
title: "Agente de voz que agenda citas: ElevenLabs, Cal.com y un backend que no deja adivinar al modelo"
description: "Cómo monté un agente de voz que agenda citas de 30 minutos con ElevenLabs y Cal.com, y por qué el backend en Fastify es lo que evita que el modelo invente horarios."
author: Ramón Chancay
date: 2026-09-04
lang: es
tags: [ElevenLabs, Agentes de voz, Cal.com, Fastify, Tool calling, IA]
canonical: https://www.ramonchancay.me/es/blog/agente-de-voz-agenda-citas-elevenlabs-calcom
---

# Agente de voz que agenda citas: ElevenLabs, Cal.com y un backend que no deja adivinar al modelo

Un agente de voz que agenda citas es un sistema donde alguien habla, un modelo entiende lo que pide, llama a herramientas reales y al colgar hay una cita creada en un calendario. Monté uno en un repo de laboratorio: ElevenLabs Agents se encarga de la voz —transcripción, turnos, síntesis—, Cal.com del calendario, y en medio hay un backend en Fastify cuyo único trabajo es que el modelo nunca tenga que adivinar. Ni qué día es hoy, ni a qué hora corresponde la opción que eligió la persona, ni cómo se escribe esa hora en ISO con offset. Este post es el recorrido completo: la arquitectura, las tres herramientas, el prompt, y cómo lo probé sin gastar los 15 minutos de voz que da el plan gratuito al mes. El código está entero en [GitHub](https://github.com/devrchancay/elevenlabs-cal-demo).

<details class="rc-tldr">
<summary class="rc-tldr-btn"><span class="rc-tldr-dot"></span> Resumen para perezosos <svg class="rc-tldr-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></summary>
<div class="rc-tldr-body">
<ul>
<li>El backend le devuelve al agente como máximo tres opciones ya redactadas para decirse en voz alta, y la reserva se hace con un <code>optionId</code> (<code>opt_1</code>), nunca con una fecha que el modelo escriba.</li>
<li>Reservar es la única acción irreversible: exige confirmación explícita, bloquea interrupciones mientras corre y es idempotente por conversación.</li>
<li>Los errores de herramienta salen con HTTP 200 y una frase en español que el agente puede leer. Un 5xx dejaría al modelo improvisando en medio de una llamada.</li>
</ul>
</div>
</details>

**En este artículo:**

- **Fundamentos** — [Qué resuelve](#qué-resuelve-un-agente-de-voz-que-agenda-citas) · [Por qué un backend y no un proxy](#por-qué-hay-un-backend-y-no-un-proxy)
- **Implementación** — [El agente vive en JSON versionado](#el-agente-vive-en-json-versionado-no-en-el-dashboard) · [Las tres herramientas](#tres-herramientas-dos-webhooks-y-una-que-corre-en-el-navegador) · [Opciones ya redactadas](#opciones-ya-redactadas-el-modelo-nunca-escribe-una-hora) · [La acción irreversible](#reservar-es-la-única-acción-que-no-se-deshace) · [Errores hablados](#los-errores-se-devuelven-hablados-nunca-como-5xx)
- **Operación** — [Probar sin gastar voz](#cómo-probar-el-agente-sin-gastar-minutos-de-voz) · [La página muestra la reserva](#la-página-muestra-la-reserva-no-la-transcripción) · [Lo que solo revela una llamada real](#lo-que-solo-se-descubre-en-una-llamada-real)

## Qué resuelve un agente de voz que agenda citas

La tarea es concreta y acotada: una persona entra a una página, habla en español con un asistente y cuelga con una cita de 30 minutos confirmada en el calendario del negocio. No es un chatbot que responde preguntas ni un asistente general. Hace una sola cosa, y eso es justamente lo que lo vuelve implementable.

El sistema tiene cuatro piezas y una sola dirección:

```text
Navegador (SDK de ElevenLabs)
        │ WebRTC
        ▼
ElevenLabs Agents        transcripción · turnos · LLM · síntesis de voz
        │ webhook tools (HTTPS + bearer)
        ▼
Backend (Fastify + TypeScript)
        │
        ▼
Cal.com API v2
        │
        ▼
Google Calendar
```

ElevenLabs nunca ve la API key de Cal.com y nunca arma el payload de una reserva. El backend es el único que habla con el calendario, y esa frontera es la que hace que todo lo demás sea razonable.

La conversación sigue un orden fijo, escrito en el prompt como diez pasos numerados: saludar, fijar una fecha concreta, consultar disponibilidad, leer las opciones, esperar una elección, pedir nombre y correo por separado, mostrar los datos en pantalla y deletrear el correo de vuelta, esperar un sí explícito, reservar, despedirse. Cada paso existe porque el anterior puede salir mal.

## Por qué hay un backend y no un proxy

La tentación inicial es que las herramientas del agente apunten directo a Cal.com. Funciona en la demo y se rompe en la segunda llamada. La razón por la que hay un backend en medio es que cada responsabilidad que le quitas al modelo es una clase entera de errores que deja de existir.

| Responsabilidad | Dónde vive | Qué evita |
|---|---|---|
| Aritmética de fechas y zonas horarias | `src/lib/time.ts`, con tests de reloj congelado | Que el modelo calcule offsets o formatee ISO |
| Elegir qué horarios ofrecer | El backend, tras consultar Cal.com | Que el agente lea treinta huecos en voz alta |
| Redactar cómo suena cada horario | El backend, en español | Que el modelo improvise «a las 14:00» |
| Idempotencia de la reserva | Una clave por conversación | Dos citas por una confirmación repetida |
| Validación del payload | Zod, antes de llegar a Cal.com | Que un campo mal formado llegue al calendario |
| Trazabilidad | Una línea de log estructurada por llamada | No poder reconstruir qué pasó en una llamada rara |

El caso de las fechas es el más ilustrativo. El agente manda `2026-09-08` y `afternoon`. El backend resuelve zona horaria, offset y formato. Ninguna otra parte del proyecto convierte fechas, y esa regla está escrita en el `CLAUDE.md` del repo para que siga siendo cierta dentro de seis meses.

Hay un detalle que solo aparece leyendo la API de Cal.com en vez de asumirla: `GET /v2/slots` interpreta `start` y `end` como UTC, pero agrupa las claves de la respuesta por la zona horaria que pediste. Y cada endpoint exige un `cal-api-version` distinto —`2024-09-04` para slots, `2026-02-25` para reservas—. Mandar el equivocado no produce un error claro.

## El agente vive en JSON versionado, no en el dashboard

El agente está definido por JSON en el repo y se aplica con la CLI de ElevenLabs. Nada se configura haciendo clic en el dashboard, porque cualquier cambio hecho ahí lo pisa el siguiente push. Esa es la diferencia entre un agente que puedes revisar en un PR y uno que solo existe en la cuenta de alguien.

```text
agent/
  agents.json                        registro de la CLI
  tools.json                         registro de la CLI
  agent_configs/
    appointment_scheduler.json       prompt, ASR, TTS, turnos, evaluación
  tool_configs/
    check_availability.json          webhook tool → POST /tools/availability
    book_appointment.json            webhook tool → POST /tools/book
    show_booking_summary.json        client tool  → corre en el navegador
```

Aplicar un cambio es una secuencia de cuatro comandos, porque la CLI no acepta un solo archivo: las herramientas son objetos independientes con sus propios ids y el agente las referencia por `tool_ids`.

```bash
# Guarda el secreto compartido y escribe las URLs públicas en los tool_configs
pnpm agent:setup

# Crea o actualiza las herramientas y devuelve sus ids en tools.json
cd agent && elevenlabs tools push

# Copia esos ids al tool_ids del agente
cd .. && pnpm agent:link

# Publica el agente
cd agent && elevenlabs agents push
```

La configuración de runtime es donde se decide si la llamada se siente natural. Estos son los valores que importan y por qué quedaron así:

| Bloque | Ajuste | Valor | Por qué |
|---|---|---|---|
| `asr` | `provider` / `quality` | `scribe_realtime`, `high` | Los nombres y correos dictados son la entrada más difícil de este flujo |
| `asr` | `keywords` | `arroba`, `guion`, `punto`, `gmail`, `hotmail`… | Sesga la transcripción hacia el vocabulario que esta conversación usa de verdad |
| `turn` | `turn_timeout` | `5` | Suficiente para que alguien dictando un correo no quede cortado |
| `turn` | `soft_timeout_config` | `3s`, mensaje fijo | Cal.com tarda; una frase fija en español es mejor que una generada, que añadiría latencia para tapar latencia |
| `tts` | `model_id` | `eleven_flash_v2_5` | En una llamada de agenda, responder rápido pesa más que el timbre |
| `conversation` | `max_duration_seconds` | `180` | Una reserva toma unos dos minutos; el tope acota el daño de una conversación atascada |
| `agent` | `llm` / `temperature` | `claude-sonnet-4-5`, `0` | El agente sigue un procedimiento fijo y lee cadenas ya escritas. No hay nada sobre lo que ser creativo |

El `first_message` también es fijo en vez de generado, así cada llamada empieza igual y el primer token es inmediato.

Y hay una variable dinámica que sostiene todo el resto: `{{current_datetime}}` se inyecta al arrancar la conversación, calculada en el servidor. El prompt la declara como la única fuente de verdad sobre qué día es hoy. El reloj del navegador de la persona es irrelevante, porque el agente tiene que razonar sobre el día del negocio, no sobre el de quien llama.

Ese nombre es un contrato entre dos despliegues: la página manda `dynamicVariables: { current_datetime }` y el prompt lee `{{current_datetime}}`. Si renombras la variable, el push del agente y el deploy de la página tienen que salir juntos, o deja de resolverse a mitad de una llamada sin avisar.

## Tres herramientas: dos webhooks y una que corre en el navegador

El agente tiene exactamente tres herramientas. Dos apuntan al backend y una se ejecuta dentro de la página.

| Herramienta | Tipo | Qué hace | `interruption_mode` |
|---|---|---|---|
| `check_availability` | webhook | Devuelve hasta 3 opciones ya redactadas | `allow` |
| `book_appointment` | webhook | Crea la cita a partir de un `optionId` | `disable_during_tool` |
| `show_booking_summary` | client | Pinta los datos en la pantalla de la persona | — |

Las dos webhook tools van autenticadas con un bearer guardado en el Secrets Manager de ElevenLabs. Un detalle práctico: el Secrets Manager sustituye el valor completo de la cabecera y no documenta forma de anteponer un prefijo, así que el backend acepta tanto `Bearer <token>` como el token pelado.

La tercera es la interesante. `show_booking_summary` no tiene URL: ElevenLabs reenvía la llamada directamente a la página, que dibuja el horario elegido, el nombre y el correo mientras el agente los lee de vuelta.

```json
{
  "type": "client",
  "name": "show_booking_summary",
  "expects_response": false,
  "parameters": {
    "type": "object",
    "required": ["optionId", "name", "email"],
    "properties": {
      "optionId": { "type": "string", "description": "opt_1, opt_2 u opt_3" },
      "name": { "type": "string", "description": "Nombre completo, tal como lo dictó" },
      "email": { "type": "string", "description": "Correo en minúsculas y sin espacios" }
    }
  }
}
```

Es una client tool por dos razones. La primera es que resuelve el fallo más frecuente de este flujo: los nombres y correos dictados por voz se transcriben mal a menudo, y revisar un correo solo de oído es poco fiable. Verlo escrito mientras te lo deletrean sí funciona. La segunda es que los datos personales no salen del navegador donde se dictaron: el endpoint público que alimenta la página no lleva nombre ni correo por diseño, así que la página se entera por el agente, en ese mismo navegador, sin ida y vuelta al servidor.

`expects_response` está en `false` porque el agente no tiene nada que esperar, y detenerse a mitad de la confirmación para recibir un acuse solo agregaría latencia.

<figure>
<img src="/blog/agente-de-voz-agenda-citas-demo-2.jpg" alt="El horario elegido resaltado y un panel con el nombre y el correo escritos, mientras la transcripción muestra al agente deletreando el correo letra por letra" loading="lazy" width="1568" height="746">
<figcaption>El paso que atrapa un correo mal transcrito: se lee en pantalla mientras el agente lo deletrea. Ese panel lo pinta <code>show_booking_summary</code>, sin pasar por el backend.</figcaption>
</figure>

Si te interesa el mecanismo general de tool calling detrás de esto, lo desarrollé en [el loop ReAct de un agente de código](/es/blog/loop-react-herramientas-agente): la diferencia aquí es que las herramientas no exploran, ejecutan un contrato cerrado.

## Opciones ya redactadas: el modelo nunca escribe una hora

Esta es la decisión central del proyecto. Cal.com devuelve decenas de huecos crudos para un día. El agente no puede leer decenas de horas en voz alta, y tampoco quiero que las elija ni las formatee. Entonces el backend hace tres cosas antes de responder:

```text
Cal.com devuelve 12 huecos
        │
        ▼
Filtrar: pasados, duplicados, fuera de la franja pedida
        │
        ▼
Repartir 3 sobre la lista ordenada  ──►  índices 0, 5, 11
        │
        ▼
Redactar cada uno en español hablado
        │
        ▼
{ id: "opt_2", spokenLabel: "el martes 8 de septiembre a las diez de la mañana" }
```

Repartir en vez de tomar los tres primeros importa más de lo que parece: tres huecos consecutivos de las 9:00, 9:30 y 10:00 no son tres alternativas, son la misma. Repartidos, la persona escucha una temprano, una a media jornada y una tarde.

:::codetabs
```ts
// lib/slots.ts

// Reparte n elecciones lo más uniformemente posible sobre una lista ordenada.
// Con 12 huecos y n=3 devuelve los índices 0, 5 y 11.
export function spreadIndices(length: number, n: number): number[] {
  if (length <= 0 || n <= 0) return [];
  if (length <= n) return Array.from({ length }, (_, i) => i);
  if (n === 1) return [0];

  const picked = new Set<number>();
  for (let i = 0; i < n; i += 1) {
    picked.add(Math.round((i * (length - 1)) / (n - 1)));
  }
  return [...picked].sort((a, b) => a - b);
}

// Cada opción sale con un id opaco y una frase lista para decirse en voz alta.
// El modelo recibe "opt_2", nunca un timestamp.
export function selectOptions(slots: Date[], timeZone: string, max = 3): SlotOption[] {
  return spreadIndices(slots.length, max).map((index, position) => ({
    id: `opt_${position + 1}`,
    spokenLabel: spokenLabel(slots[index], timeZone),
    startsAt: toIsoWithOffset(slots[index], timeZone),
  }));
}
```
```python
# lib/slots.py

# Reparte n elecciones lo más uniformemente posible sobre una lista ordenada.
# Con 12 huecos y n=3 devuelve los índices 0, 5 y 11.
def spread_indices(length: int, n: int) -> list[int]:
    if length <= 0 or n <= 0:
        return []
    if length <= n:
        return list(range(length))
    if n == 1:
        return [0]

    picked = {round(i * (length - 1) / (n - 1)) for i in range(n)}
    return sorted(picked)

# Cada opción sale con un id opaco y una frase lista para decirse en voz alta.
# El modelo recibe "opt_2", nunca un timestamp.
def select_options(slots: list[datetime], time_zone: str, max_options: int = 3) -> list[SlotOption]:
    return [
        SlotOption(
            id=f"opt_{position + 1}",
            spoken_label=spoken_label(slots[index], time_zone),
            starts_at=to_iso_with_offset(slots[index], time_zone),
        )
        for position, index in enumerate(spread_indices(len(slots), max_options))
    ]
```
```php
<?php
// lib/slots.php

// Reparte n elecciones lo más uniformemente posible sobre una lista ordenada.
// Con 12 huecos y n=3 devuelve los índices 0, 5 y 11.
function spread_indices(int $length, int $n): array {
    if ($length <= 0 || $n <= 0) return [];
    if ($length <= $n) return range(0, $length - 1);
    if ($n === 1) return [0];

    $picked = [];
    for ($i = 0; $i < $n; $i += 1) {
        $picked[(int) round($i * ($length - 1) / ($n - 1))] = true;
    }
    $indices = array_keys($picked);
    sort($indices);
    return $indices;
}

// Cada opción sale con un id opaco y una frase lista para decirse en voz alta.
// El modelo recibe "opt_2", nunca un timestamp.
function select_options(array $slots, string $timeZone, int $max = 3): array {
    $options = [];
    foreach (spread_indices(count($slots), $max) as $position => $index) {
        $options[] = new SlotOption(
            id: "opt_" . ($position + 1),
            spokenLabel: spoken_label($slots[$index], $timeZone),
            startsAt: to_iso_with_offset($slots[$index], $timeZone),
        );
    }
    return $options;
}
```
:::

Lo que el agente recibe no es una lista para procesar, es una frase para decir. Junto a las opciones viene un `spokenSummary` completo —«Para mañana tengo a las nueve, a las diez y media o a las once y media de la mañana. ¿Cuál te sirve?»— y el prompt le ordena leerlo tal cual, sin reformular, sin agregar horas, sin cambiar el orden.

<figure>
<img src="/blog/agente-de-voz-agenda-citas-demo-1.jpg" alt="La página del agente con tres horarios propuestos para el martes 8 de septiembre a las 9:00, 1:00 y 4:30, el orbe escuchando y la transcripción debajo" loading="lazy" width="1568" height="746">
<figcaption>Los tres horarios son los que Cal.com devolvió para esa cuenta. El agente leyó el resumen que la herramienta ya traía escrito y espera una elección.</figcaption>
</figure>

El backend también resuelve los casos vacíos sin dejarle la decisión al modelo. Si la franja pedida está llena, ofrece el resto del día; cambiar la hora molesta menos que cambiar el día. Si el día entero está lleno, busca en los siete siguientes **en paralelo**: hecho en secuencia serían hasta siete idas y vueltas encadenadas a Cal.com, y ese silencio se oye en una llamada.

## Reservar es la única acción que no se deshace

Todo lo demás en esta conversación se puede repetir. Consultar horarios dos veces no cuesta nada, cambiar de opinión es normal y el prompt dice explícitamente que no se comente. Crear la cita, en cambio, escribe en el calendario del negocio y le manda un correo a alguien. Por eso está protegida en tres capas distintas.

La primera es el prompt. El paso 7 llama a `show_booking_summary`, deletrea el correo carácter por carácter, lee el nombre y el horario y pregunta literal «¿Está todo correcto?». El paso 8 solo ocurre con un sí claro: un silencio, un «mmm» o un «creo que sí» no son confirmación y devuelven al paso 7.

La segunda es la configuración de la herramienta: `interruption_mode: disable_during_tool`. Si la persona pudiera interrumpir a mitad de la escritura, el agente quedaría sin saber si la cita existe.

La tercera está en el código: una clave por conversación hace que dos llamadas idénticas devuelvan la misma reserva en lugar de crear dos.

:::codetabs
```ts
// lib/scheduling.ts
export async function book(input: BookRequest): Promise<BookResponse> {
  // Idempotencia: una conversación no reserva dos veces aunque el agente
  // llame a la herramienta más de una vez.
  const existing = bookingStore.get(input.bookingKey);
  if (existing) {
    return { booked: true, duplicate: true, ...existing };
  }

  // El optionId se resuelve contra lo que guardó check_availability para esta
  // misma conversación. El agente no manda ninguna fecha.
  const stored = optionStore.get(`${input.bookingKey}:${input.optionId}`);
  if (!stored) {
    return {
      booked: false,
      reason: "option_expired",
      spokenConfirmation: "Ese horario ya no lo tengo a la mano. Déjame consultar la disponibilidad otra vez.",
    };
  }

  const booking = await cal.createBooking({
    start: new Date(stored.startsAtMs),
    attendeeName: input.name,
    attendeeEmail: input.email,
    timeZone,
  });

  const spokenConfirmation = `Listo, tu cita quedó agendada para ${stored.spokenLabel} a nombre de ${input.name}.`;
  bookingStore.set(input.bookingKey, { bookingUid: booking.uid, spokenConfirmation });

  return { booked: true, bookingUid: booking.uid, spokenConfirmation };
}
```
```python
# lib/scheduling.py
def book(input: BookRequest) -> BookResponse:
    # Idempotencia: una conversación no reserva dos veces aunque el agente
    # llame a la herramienta más de una vez.
    existing = booking_store.get(input.booking_key)
    if existing:
        return BookResponse(booked=True, duplicate=True, **existing)

    # El option_id se resuelve contra lo que guardó check_availability para esta
    # misma conversación. El agente no manda ninguna fecha.
    stored = option_store.get(f"{input.booking_key}:{input.option_id}")
    if not stored:
        return BookResponse(
            booked=False,
            reason="option_expired",
            spoken_confirmation="Ese horario ya no lo tengo a la mano. Déjame consultar la disponibilidad otra vez.",
        )

    booking = cal.create_booking(
        start=datetime.fromtimestamp(stored.starts_at_ms / 1000, tz=timezone.utc),
        attendee_name=input.name,
        attendee_email=input.email,
        time_zone=time_zone,
    )

    spoken_confirmation = f"Listo, tu cita quedó agendada para {stored.spoken_label} a nombre de {input.name}."
    booking_store.set(input.booking_key, {"booking_uid": booking.uid, "spoken_confirmation": spoken_confirmation})

    return BookResponse(booked=True, booking_uid=booking.uid, spoken_confirmation=spoken_confirmation)
```
```php
<?php
// lib/scheduling.php
function book(BookRequest $input): BookResponse {
    global $bookingStore, $optionStore, $cal, $timeZone;

    // Idempotencia: una conversación no reserva dos veces aunque el agente
    // llame a la herramienta más de una vez.
    $existing = $bookingStore->get($input->bookingKey);
    if ($existing !== null) {
        return new BookResponse(booked: true, duplicate: true, ...$existing);
    }

    // El optionId se resuelve contra lo que guardó check_availability para esta
    // misma conversación. El agente no manda ninguna fecha.
    $stored = $optionStore->get("{$input->bookingKey}:{$input->optionId}");
    if ($stored === null) {
        return new BookResponse(
            booked: false,
            reason: "option_expired",
            spokenConfirmation: "Ese horario ya no lo tengo a la mano. Déjame consultar la disponibilidad otra vez.",
        );
    }

    $booking = $cal->createBooking(
        start: (new DateTimeImmutable())->setTimestamp(intdiv($stored->startsAtMs, 1000)),
        attendeeName: $input->name,
        attendeeEmail: $input->email,
        timeZone: $timeZone,
    );

    $spokenConfirmation = "Listo, tu cita quedó agendada para {$stored->spokenLabel} a nombre de {$input->name}.";
    $bookingStore->set($input->bookingKey, ["bookingUid" => $booking->uid, "spokenConfirmation" => $spokenConfirmation]);

    return new BookResponse(booked: true, bookingUid: $booking->uid, spokenConfirmation: $spokenConfirmation);
}
```
:::

La clave de idempotencia no hubo que inventarla: en el esquema de ambas herramientas, `bookingKey` está cableado a `system__conversation_id`, la variable que ElevenLabs ya provee. El modelo no tiene que acordarse de nada.

Hay un caso que Cal.com devuelve y conviene no aplanar: cuando el tipo de evento exige que el dueño confirme, la reserva vuelve como `pending`. Decir «quedó confirmada» ahí sería mentirle a la persona, así que la frase cambia a «dejé solicitada tu cita… queda pendiente de confirmación».

Sobre este mismo tema escribí antes [los límites duros de un agente autónomo](/es/blog/limites-duros-agente-autonomo): la lógica es la misma, solo que aquí el límite no es un `rm -rf`, es una escritura en la agenda de alguien.

## Los errores se devuelven hablados, nunca como 5xx

Cuando una herramienta falla en un chat, el usuario ve un mensaje raro y reintenta. Cuando falla en una llamada de voz, hay una persona esperando en silencio y un modelo que va a decir algo. Por eso ninguna ruta de error de este backend devuelve un 5xx: todas devuelven 200 con `booked: false`, un `reason` legible por máquina y una frase en español que el agente puede leer en voz alta.

:::codetabs
```ts
// routes/tools.ts

// Los errores salen con 200 y una frase que el agente puede leer. Un 5xx
// dejaría al modelo improvisando en medio de una llamada.
function handleToolError(error: unknown, tool: string, reply: FastifyReply) {
  if (error instanceof InvalidDateError) {
    // La fecha que mandó el agente no es utilizable: pide que la repitan.
    return reply.status(200).send(emptyAvailability("¿Me repites la fecha, por favor? No me quedó clara."));
  }

  const spoken = "Tuve un problema para consultar la agenda. ¿Intentamos de nuevo en un momento?";
  return reply.status(200).send(
    tool === "book_appointment"
      ? { booked: false, reason: "cal_error", spokenConfirmation: spoken }
      : emptyAvailability(spoken),
  );
}
```
```python
# routes/tools.py

# Los errores salen con 200 y una frase que el agente puede leer. Un 5xx
# dejaría al modelo improvisando en medio de una llamada.
def handle_tool_error(error: Exception, tool: str):
    if isinstance(error, InvalidDateError):
        # La fecha que mandó el agente no es utilizable: pide que la repitan.
        return jsonify(empty_availability("¿Me repites la fecha, por favor? No me quedó clara.")), 200

    spoken = "Tuve un problema para consultar la agenda. ¿Intentamos de nuevo en un momento?"
    if tool == "book_appointment":
        return jsonify({"booked": False, "reason": "cal_error", "spokenConfirmation": spoken}), 200
    return jsonify(empty_availability(spoken)), 200
```
```php
<?php
// routes/tools.php

// Los errores salen con 200 y una frase que el agente puede leer. Un 5xx
// dejaría al modelo improvisando en medio de una llamada.
function handle_tool_error(Throwable $error, string $tool): Response {
    if ($error instanceof InvalidDateError) {
        // La fecha que mandó el agente no es utilizable: pide que la repitan.
        return json_response(empty_availability("¿Me repites la fecha, por favor? No me quedó clara."), 200);
    }

    $spoken = "Tuve un problema para consultar la agenda. ¿Intentamos de nuevo en un momento?";
    if ($tool === "book_appointment") {
        return json_response(["booked" => false, "reason" => "cal_error", "spokenConfirmation" => $spoken], 200);
    }
    return json_response(empty_availability($spoken), 200);
}
```
:::

El `reason` sirve para el log y las métricas; el `spokenConfirmation` es lo que oye la persona. Y el prompt cubre el otro lado: si `booked` es `false`, la cita no existe, y el agente igual lee la frase que ya explica qué pasó en vez de traducir un error técnico.

| `reason` | Cuándo aparece | Qué dice el agente |
|---|---|---|
| `option_expired` | La opción ya no está en memoria o su hora ya pasó | Ofrece consultar disponibilidad otra vez |
| `slot_taken` | Cal.com respondió con conflicto | «Justo acaban de tomar ese horario» |
| `invalid_input` | Cal.com rechazó el nombre o el correo | Pide confirmar los datos |
| `cal_error` | Cualquier otro fallo | Ofrece reintentar en un momento |

## Cómo probar el agente sin gastar minutos de voz

El plan gratuito de ElevenLabs da 15 minutos de voz al mes y no se pueden comprar más. Una conversación completa toma unos dos minutos, así que el presupuesto entero son seis o siete llamadas. Iterar el prompt hablando es inviable.

La salida es el endpoint de simulación: conversaciones escritas que corren contra el agente real, en texto, sin consumir minutos.

```bash
pnpm simulate                 # los seis escenarios
pnpm simulate happy-path      # solo uno
```

| Escenario | Qué ejercita |
|---|---|
| `happy-path` | Agenda para mañana en la tarde y acepta la primera opción |
| `no-availability` | Día lleno: el agente debe ofrecer alternativas, no inventarlas |
| `changes-mind` | Acepta un horario y pide otro antes de confirmar |
| `ambiguous-date` | «La próxima semana»: debe pedir una fecha concreta |
| `backs-out` | La persona se echa atrás; el agente no debe insistir |
| `double-confirmation` | Confirma dos veces: no puede crear dos citas |

Lo importante es que el harness no imprime transcripciones para que las leas. Afirma cosas: que `book_appointment` siempre fue precedido de una confirmación explícita, que no se mencionó ningún horario que la herramienta no hubiera devuelto, y que confirmar dos veces no produce dos `bookingUid` distintos. Sale con código distinto de cero si algo falla.

Por debajo hay 154 tests unitarios que no tocan la red, con el reloj congelado para la parte de fechas. Y por encima, cada conversación real se puntúa automáticamente contra tres criterios definidos en la configuración del agente: que la cita se creó, que hubo confirmación antes de reservar y que no se mencionó ningún horario inventado.

> Si la cita se creó o no lo decide el resultado de `book_appointment`, no lo que el agente dijo. Un agente puede decir «quedó agendada» y estar equivocado, y esa es exactamente la clase de error que un log basado en la transcripción nunca detecta.

## La página muestra la reserva, no la transcripción

La página usa el SDK de ElevenLabs, no el widget embebido. El widget es un componente cerrado que trae su propia burbuja de chat, su panel de feedback y su botón flotante; el SDK es solo transporte —WebRTC, micrófono, callbacks—, así que la interfaz es entera del proyecto.

Eso permite algo que el widget no: mostrar la reserva en lugar del chat. Un orbe que sigue el micrófono mientras el agente escucha y su propia salida mientras habla, una línea de estado, subtítulos discretos y un panel que se va llenando: los tres horarios recién ofrecidos, el elegido, el nombre y el correo como se entendieron, y la confirmación.

<figure>
<img src="/blog/agente-de-voz-agenda-citas-demo-3.jpg" alt="La conversación terminada, con la cita confirmada en el panel: martes 8 de septiembre a la 1:00 p. m. y aviso de correo enviado" loading="lazy" width="1568" height="746">
<figcaption>La cita confirmada. El panel lo reporta desde el resultado de la reserva, no desde lo que el agente dijo en la llamada.</figcaption>
</figure>

Esos datos llegan por dos caminos distintos, y cuál lleva qué es deliberado:

| Dato | Canal | Por qué |
|---|---|---|
| Horarios ofrecidos, opción elegida, cita creada | `GET /agent/session/:id`, con polling | Es lo que Cal.com devolvió de verdad. Derivarlo de la transcripción sería pintar lo que el modelo *dijo* |
| Nombre y correo | `show_booking_summary`, client tool | Para que no salgan del navegador donde se dictaron: el endpoint de sesión es público y no lleva datos personales |

La clave que une las dos mitades tampoco hubo que inventarla: la página lee `conversation.getId()`, que es el mismo id de conversación que el agente manda como `bookingKey`, que es bajo lo que el backend ya archiva cada opción ofrecida.

El polling es cada dos segundos, no eventos del servidor. El estado observado cambia unas cuatro veces en una llamada de dos minutos, y un poll sobrevive a una laptop que se cierra o a un teléfono que cae a 3G sin lógica de reconexión que se pueda romper.

## Lo que solo se descubre en una llamada real

La simulación cubre la lógica; el texto no dice nada sobre cómo suena. Estos son los síntomas que solo aparecen hablando, y dónde se corrigen:

| Síntoma | Dónde se arregla |
|---|---|
| Te corta mientras dictas tu nombre | `turn.turn_eagerness` → `patient` |
| Silencio muerto mientras consulta el calendario | `turn.soft_timeout_config.timeout_seconds` → más bajo |
| La voz no suena natural en español | `tts.voice_id` → probar otra voz |
| Entiende mal números o correos dictados | `asr.keywords` y el paso 7 del prompt |

Con seis o siete llamadas de presupuesto, conviene gastarlas justo en eso y en nada más.

## Preguntas frecuentes

### ¿Por qué no dejar que el modelo llame directo a Cal.com?

Porque entonces el modelo tiene que construir el payload, y eso implica calcular fechas, formatear ISO con offset y elegir qué horarios ofrecer. Cada una de esas tres cosas es una fuente de errores que no se detectan hasta que alguien recibe una cita a la hora equivocada. Con el backend en medio, el agente manda una fecha simple y un `optionId`, y todo lo demás es código con tests.

### ¿Se puede hacer lo mismo con Google Calendar directo, sin Cal.com?

Sí, pero Cal.com resuelve gratis la parte aburrida: disponibilidad real según reglas del negocio, duración del evento, buffers, correos de confirmación y cancelación. Ir directo a Google Calendar significa implementar esa lógica de disponibilidad a mano. Para un MVP no compensa.

### ¿Cómo se evita que el agente invente un horario?

Con tres capas. El prompt lo prohíbe explícitamente y le dice que sin llamar a la herramienta no sabe qué hay libre. La herramienta le devuelve una frase ya escrita para leer verbatim, así que no tiene que redactar nada. Y un criterio de evaluación revisa cada conversación terminada para verificar que todo horario mencionado vino de una respuesta de `check_availability`.

### ¿Qué pasa si la persona interrumpe mientras se crea la cita?

No puede: `book_appointment` está configurada con `interruption_mode: disable_during_tool`. Es la única herramienta con ese ajuste, precisamente porque es la única acción irreversible. Las consultas de disponibilidad sí permiten interrupción, porque abandonarlas a medias no deja nada a medio escribir.

### ¿Sirve para llamadas telefónicas o solo en el navegador?

El backend es el mismo. Lo que cambia es el transporte: en el navegador entra por WebRTC desde la página, y para telefonía habría que conectar el agente a un número. Todo lo que describí —las herramientas, el prompt, la idempotencia, los errores hablados— no depende del canal. La página, en cambio, sí: sin pantalla desaparece `show_booking_summary`, y el correo dictado tendría que verificarse solo de oído.

### ¿Cuánto cuesta mantener algo así?

Los minutos de voz son el costo dominante, no el LLM. Con `temperature: 0`, un prompt fijo y respuestas cortas, la parte del modelo es marginal comparada con la síntesis y transcripción de audio. Si el volumen crece, el ajuste que más mueve la factura es acortar la conversación, no cambiar de modelo.

## Conclusión

El patrón que hace funcionar a este agente no tiene nada de específico de la voz: sacar del modelo toda decisión que un programa puede tomar mejor. El modelo no calcula fechas, no elige horarios, no redacta cómo suenan y no construye el payload de la reserva. Escucha, llama a la herramienta correcta en el orden correcto y lee lo que la herramienta ya escribió.

El repositorio completo está en [github.com/devrchancay/elevenlabs-cal-demo](https://github.com/devrchancay/elevenlabs-cal-demo), con la configuración del agente, los tests y el harness de simulación.

Si vas a montar algo parecido, el orden que a mí me funcionó fue este: primero el backend con sus tests y su archivo único de fechas, después las herramientas con contratos cerrados y errores hablados, después el prompt como procedimiento numerado, y solo al final la voz. Gastar minutos de voz para descubrir un bug de zona horaria es la forma más cara de encontrarlo.
