# Agenda y reservas

La agenda de Met es una sola: la que ve el negocio en el panel, la que usa el Met cuando agenda por WhatsApp y la que lees y escribes por la API. Una cita que creas aquí aparece en la pantalla de Agenda, se lleva al calendario conectado (Google u Outlook) y, si la ficha del contacto tiene correo, le llega la invitación.

El recorrido es siempre el mismo: **qué se puede agendar** (servicios) → **cuándo hay espacio** (huecos libres) → **la cita**.

## Antes de empezar

| Scope | Para qué | Con una key de test |
|---|---|---|
| `agenda:read` | Servicios, recursos, huecos libres y las citas (lista y detalle). También los eventos `booking.*`. | Sí |
| `agenda:write` | Crear, reprogramar y cancelar citas. | No: responde `403` `test_mode_restricted`, porque escribe en el calendario conectado y le manda la invitación al contacto. |

Las rutas llevan el workspace: `/workspaces/{workspaceId}/agenda/…`. Con el SDK lo pasas una vez al crear el cliente. Los servicios, los recursos y el horario de atención se configuran en el panel (Agenda); por la API se leen.

```ts
import { Met } from '@meteor.ia/sdk';

const met = new Met(process.env.METEOR_API_KEY!, { workspaceId: 42 });
```
```python
import os
from meteor_ia import Met

met = Met(os.environ["METEOR_API_KEY"], workspace_id=42)
```

## 1. Qué se puede agendar

```ts
const { data: servicios } = await met.agenda.eventTypes();
// [{ id: 3, object: 'event_type', name: 'Consulta inicial', duration_min: 30, modality: 'both', price: 80000, resource_ids: [5] }]

const { data: recursos } = await met.agenda.resources();
// [{ id: 5, object: 'agenda_resource', name: 'Dra. Ruiz', kind: 'staff', timezone: 'America/Bogota' }]
```
```python
servicios = met.agenda.event_types()["data"]
recursos = met.agenda.resources()["data"]
```

Un **servicio** (`event_type`) es lo que se reserva: dura `duration_min` minutos, se atiende `in_person`, `virtual` o `both`, y lo atienden los recursos de `resource_ids`. Un **recurso** es quien o donde se atiende: una persona (`staff`), una sede (`location`) o un equipo (`asset`). Solo salen los activos, hasta 200 de cada uno.

## 2. Los huecos libres

Pide los huecos de un servicio entre dos **fechas locales del negocio** (`YYYY-MM-DD`), hasta 31 días por llamada:

```ts
const { data: huecos, has_more } = await met.agenda.slots({
  eventTypeId: 3,
  from: '2026-11-03',
  to: '2026-11-07',
});
// [{ object: 'slot', resource_id: 5, resource_name: 'Dra. Ruiz', starts_at: '2026-11-03T14:00:00.000Z',
//    ends_at: '2026-11-03T14:30:00.000Z', label: 'martes 3 de noviembre de 2026, 09:00 a. m.' }]
```
```python
huecos = met.agenda.slots(event_type_id=3, from_="2026-11-03", to="2026-11-07")["data"]
```
```bash
curl "https://api.met.meteor.com.co/api/v1/workspaces/42/agenda/slots?event_type_id=3&from=2026-11-03&to=2026-11-07" \
  -H "Authorization: Bearer $METEOR_API_KEY"
```

Un hueco ya descuenta el horario de atención, las excepciones, los bloqueos, las citas tomadas y lo ocupado en el calendario conectado. `label` es la fecha escrita para mostrarle a una persona. Llegan hasta 200: si `has_more` es `true`, pide un rango más corto. Con `resourceId` (`resource_id`) ves solo los de un recurso.

En Python el parámetro se llama `from_` porque `from` es palabra reservada; por la red viaja como `from`.

## 3. Agendar la cita

Pasa el `starts_at` y el `resource_id` de un hueco **tal cual** llegaron:

```ts
const cita = await met.agenda.bookings.create({
  eventTypeId: 3,
  contactId: 4821,
  startsAt: huecos[0].starts_at,
  resourceId: huecos[0].resource_id,
  modality: 'virtual',
  notes: 'Primera consulta, viene por recomendación.',
});
// { id: 91, object: 'booking', status: 'confirmed', video_call_url: 'https://meet.google.com/…', calendar_synced: true, … }
```
```python
cita = met.agenda.bookings.create(
    event_type_id=3,
    contact_id=4821,
    starts_at=huecos[0]["starts_at"],
    resource_id=huecos[0]["resource_id"],
)
```
```bash
curl -X POST https://api.met.meteor.com.co/api/v1/workspaces/42/agenda/bookings \
  -H "Authorization: Bearer $METEOR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"event_type_id":3,"contact_id":4821,"starts_at":"2026-11-03T14:00:00.000Z","resource_id":5}'
```

- `contact_id` es un contacto del CRM de este workspace (ver [CRM y contactos](contactos.html)). Uno que no existe o que es de otro workspace responde `404` `contact_not_found`.
- Sin `resource_id`, la cita va al primer recurso libre a esa hora.
- `modality` solo hace falta si el servicio es `both`; si el servicio es solo virtual o solo presencial, mandar la otra responde `400` `validation_error`.
- Antes de escribir, Met recalcula los huecos de ese día. Si alguien tomó el horario mientras tanto, responde `409` `slot_unavailable` y **no crea nada**: vuelve a pedir los huecos.
- Si el contacto ya tiene una cita confirmada de ese servicio a esa hora, responde `409` `booking_exists` con el `booking_id` de la que ya existe.
- El SDK manda `Idempotency-Key` solo: un reintento de red no duplica la cita. Con cURL, mándalo tú.

`calendar_synced` dice si la cita quedó en el calendario conectado. `video_call_url` trae el enlace de la videollamada cuando el calendario lo genera.

## Leer las citas

```ts
// Las próximas de un contacto
const { data, has_more } = await met.agenda.bookings.list({
  from: new Date().toISOString(),
  contactId: 4821,
  status: 'confirmed',
});

// Todas las de noviembre, sin manejar cursores
for await (const c of met.agenda.bookings.iterate({ from: '2026-11-01T00:00:00-05:00', to: '2026-11-30T23:59:59-05:00' })) {
  console.log(c.id, c.starts_at, c.status);
}

const una = await met.agenda.bookings.retrieve(91);
```
```python
from datetime import datetime, timezone

proximas = met.agenda.bookings.list(from_=datetime.now(timezone.utc).isoformat(), contact_id=4821)
for c in met.agenda.bookings.iterate(from_="2026-11-01T00:00:00-05:00", to="2026-11-30T23:59:59-05:00"):
    print(c["id"], c["starts_at"], c["status"])
```

La lista va **de la más antigua a la más reciente** por hora de inicio. Sin `from` empieza por las que ya pasaron: para ver solo las próximas, pasa `from` con la hora actual. `from` y `to` son instantes ISO 8601 con zona (a diferencia de los huecos, que usan fechas locales). Filtra también por `resourceId` y por `status`: `confirmed`, `cancelled`, `rescheduled`, `no_show` o `completed`. Pagina con `limit` (1–100, 20 por defecto) y `startingAfter` = el `id` de la última cita de la página.

## Reprogramar y cancelar

```ts
await met.agenda.bookings.reschedule(91, { startsAt: '2026-11-04T15:00:00.000Z' });
await met.agenda.bookings.cancel(91, { reason: 'El cliente viaja esa semana' });
```
```python
met.agenda.bookings.reschedule(91, starts_at="2026-11-04T15:00:00.000Z")
met.agenda.bookings.cancel(91, reason="El cliente viaja esa semana")
```

- **Reprogramar** mueve una cita confirmada a otra hora (y, si pasas `resourceId`, a otro recurso que atienda el servicio), dentro del horario de atención y sin pisar otra cita. El evento del calendario conectado se mueve con ella.
- **Cancelar** cancela la cita y su evento en el calendario. El motivo queda en las notas. Una cita que no está confirmada (ya cancelada, atendida o ausente) responde `409` `booking_not_confirmed`.

## Enterarte de cada cambio

Las citas cambian desde cuatro lugares: el Met en una conversación, una persona en el panel, la app operativa del negocio y tu integración. Los cuatro emiten los mismos eventos (ver [Webhooks](webhooks.html)):

| Evento | Cuándo |
|---|---|
| `booking.created` | Se agendó una cita. |
| `booking.updated` | Se reprogramó o se editó su detalle (modalidad, duración, sede, notas). |
| `booking.cancelled` | Se canceló. |

```ts
await met.webhooks.subscriptions.create({
  url: 'https://tu-sistema.com/met/eventos',
  enabled_events: ['booking.created', 'booking.updated', 'booking.cancelled'],
});
```

El `data` de los tres es la cita, con la misma forma de `bookings.retrieve()`, más el `app_url` que abre la conversación del contacto en Met. `booking.updated` trae además `previous_attributes`: **solo** los campos que cambiaron, con su valor de antes.

```json
{
  "type": "booking.updated",
  "data": {
    "id": 91, "object": "booking", "status": "confirmed", "contact_id": 4821,
    "starts_at": "2026-11-04T15:00:00.000Z", "ends_at": "2026-11-04T15:30:00.000Z",
    "previous_attributes": { "starts_at": "2026-11-03T14:00:00.000Z", "ends_at": "2026-11-03T14:30:00.000Z" },
    "app_url": "https://met.meteor.com.co/abrir/42/contacto/4821"
  }
}
```

Guardar una cita sin cambiar nada no emite evento, y cancelar una que ya estaba cancelada tampoco. El evento sale con `livemode: false` solo cuando la cita la agendó un Met dentro de un Run de una key de test.

## Desde un asistente (MCP)

El servidor MCP de Meteor tiene `met_list_event_types`, `met_find_slots` y `met_list_bookings`, que piden `agenda:read`. Las que escriben —`met_create_booking`, `met_reschedule_booking` y `met_cancel_booking`— piden `agenda:write`, que un cliente MCP no recibe por defecto: tiene que pedirlo a propósito.

## Errores comunes

| Código | Qué pasó |
|---|---|
| `400` `validation_error` | Una fecha mal escrita, `to` antes que `from`, más de 31 días de huecos o una modalidad que el servicio no tiene. Al reprogramar: un horario que ya pasó, fuera del horario de atención, o una cita que no está confirmada. |
| `403` `missing_scope` | La key no trae `agenda:read` o `agenda:write`. Las keys emitidas antes de que existiera el scope no lo traen. |
| `403` `test_mode_restricted` | Una key `met_test_` intentó escribir en la agenda. |
| `403` `workspace_mismatch` | El `workspaceId` de la ruta no es el de la key. |
| `404` `event_type_not_found` | El servicio no existe, es de otro workspace o está inactivo. |
| `404` `contact_not_found` | El contacto no es de este workspace. |
| `404` `booking_not_found` | La cita no existe en este workspace. Al reprogramar llega como `not_found`. |
| `404` `resource_not_found` | Al reprogramar, el recurso no atiende ese servicio en este workspace. |
| `409` `slot_unavailable` | Al agendar, el horario ya no está libre (o ya pasó, o no coincide con un hueco). Vuelve a pedir los huecos. |
| `409` `booking_exists` | El contacto ya tiene esa cita; el error trae su `booking_id`. |
| `409` `booking_not_confirmed` | Se intentó cancelar una cita que no está confirmada. |
| `409` `conflict` | Al reprogramar, el horario nuevo choca con otra cita. |
