# Eventos (SSE)

> Catálogo de los eventos del stream de una conversación — started, chunk, tool, permission, artifact, done — y cómo escribir un cliente que no pierda nada.

URL: https://www.ghosty.studio/docs/api/eventos

`GET /api/v2/me/agents/:agentId/conversations/:sessionId/events` es un stream **Server-Sent Events** con eventos con nombre (`event: chunk`), y siempre con `data` (aunque sea `{}`).

## Tres reglas que sorprenden

1. **Es re-suscribible.** Al conectarte recibes primero todo lo que el turno en curso ya emitió, y luego el directo. Un F5, o volver mañana, no pierde nada. Desconectarte **no cancela** el turno: el trabajo es del servidor.
2. **En reposo llega un `done` de entrada**, marcado `reposo: true`, para que distingas «no hay turno» de «acabó un turno».
3. **Los permisos abiertos se reenvían en cada suscripción.** El servidor no sabe qué pantallas siguen vivas. Retíralos con `permission-resolved`, que además cubre que conteste otro cliente.

Hay un latido cada 25 s para que ningún proxy corte un turno largo por silencio.

## Catálogo

| Evento | `data` | Cuándo |
|---|---|---|
| `started` | `{}` | Conexión establecida. |
| `caps` | `{ image: boolean }` | Capacidades del agente (si ve imágenes). |
| `status` | `{ phase: "waking" \| "session" }` | La máquina está despertando / el turno está en sesión. |
| `chunk` | `{ text, turnId }` | Un pedazo de la respuesta. Concaténalos por `turnId`. |
| `thought` | `{ text }` | Razonamiento visible del modelo, si el motor lo expone. |
| `tool` | `{ id, title?, kind?, status, path?, detalle? }` | Ciclo de vida de una herramienta: `pending → in_progress → completed \| failed`. Mismo `id` en cada cambio. |
| `title` | `{ title }` | La conversación fue bautizada. |
| `models` | `{ options: [{value,name}], current }` | Modelos disponibles y el actual. |
| `usage` | `{ used, size, cost, input?, output? }` | Tokens acumulados de la sesión. `used` puede superar `size` tras una compactación: no es un medidor de contexto. |
| `permission` | `{ id, title, tool?, options: [{optionId,name,kind?}] }` | El agente pide confirmación. Contesta con `POST …/permission { id, optionId }`. Diez minutos sin respuesta = denegado. |
| `permission-resolved` | `{ id }` | Ese pedido ya se cerró (por ti, por otro cliente o por plazo). |
| `artifact` | `{ … }` | El agente entregó algo: archivo, documento, hoja. Lleva `url` firmada y tipo. |
| `done` | `{ turnId }` o `{ reposo: true }` | Terminó el turno / no hay turno. |
| `error` | `{ message }` | El turno falló. El texto parcial recibido sigue siendo válido. |

## Cliente mínimo en Node

:::tabs
```typescript tab=TypeScript
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" } });
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
let buf = "";
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += value;
  let i;
  while ((i = buf.indexOf("\n\n")) !== -1) {
    const frame = buf.slice(0, i); buf = buf.slice(i + 2);
    const event = /^event: (.*)$/m.exec(frame)?.[1];
    const data = /^data: (.*)$/m.exec(frame)?.[1];
    if (!event || data == null) continue;
    handle(event, JSON.parse(data));
  }
}
```
```python tab=Python
import json, requests
with requests.get(url, headers={"Authorization": f"Bearer {token}"}, stream=True) as r:
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if line.startswith("event: "): event = line[7:]
        elif line.startswith("data: ") and event:
            handle(event, json.loads(line[6:])); event = None
```
:::
## Reintentos

Si la conexión se corta, vuelve a abrirla: recibes el replay y sigues. Para saber si el turno terminó mientras estabas fuera, mira `ultimoTurno` en `GET …/conversations/:sessionId`.
