# 19 · El stream del turno

El turno del agente se lee **mientras se genera**, por `GET /v1/agent/streams/{job_id}`, como
Server-Sent Events. Es el tercer camino para seguir una operación asíncrona, además del webhook y de la
consulta del trabajo ([página 17](./17-jobs.md)), y el único que entrega la respuesta antes de que termine.

## Cómo se abre

La ruta **no se compone a mano**: el acuse de `POST /v1/agent/turns` la trae en `stream_url`
([página 15](./15-api-reference.md)). El `stream_id` de ese acuse es el mismo `job_id` del sobre, así que la
respuesta, el trabajo y su cobro se citan con un solo identificador.

```
GET /v1/agent/streams/{job_id}
Authorization: Bearer ak_live_…
Accept: text/event-stream
```

Lo autoriza **`jobs:read`**, el mismo permiso que la consulta del trabajo, y no el `agent:converse` con el que
abrió el turno: quien paga la respuesta puede leerla. Sin él, un turno se paga y no se puede leer.

La respuesta es `200` con `Content-Type: text/event-stream`, sin caché y sin transformación por el camino. La
primera línea que recibe es un comentario, `: connected`.

Un trabajo que no existe y uno de otra cuenta responden **lo mismo**, `404 resource_not_found`. Distinguirlos
convertiría esta ruta en un oráculo de identificadores ajenos. Una clave `ak_test_` tampoco lee un stream de
producción: para ella ese trabajo no existe.

## Eventos

Tres, y ninguno más. El nombre de la línea `event:` es **el mismo valor** que el campo `event_type` del
`data`, así que puede programar contra cualquiera de los dos sin traducir nada.

| Evento | Qué es | ¿Cierra? |
|---|---|---|
| `response_chunk` | Un tramo de la respuesta del agente. Concaténelos en orden | No |
| `response_end` | La generación terminó bien | **Sí** |
| `error` | La generación se interrumpió. `content` trae el motivo | **Sí** |

Hoy el turno emite **un solo `response_chunk`** con la respuesta entera, y después su `response_end`. No
programe contra esa cantidad: concatenar tramos es correcto con uno y con cincuenta.

`response_chunk` describe una generación viva; los otros dos son **terminales**.

## Forma de `data`

Cada trama lleva un objeto JSON en `data`, con los nombres en `snake_case` igual que el resto de `/v1`.

| Campo | Tipo | Qué es |
|---|---|---|
| `job_id` | texto | El trabajo que genera la respuesta. Es el mismo de la ruta y del sobre |
| `conversation_id` | texto | La conversación a la que pertenece el turno. Puede venir vacío |
| `event_type` | texto | Uno de los tres eventos. Coincide con la línea `event:` |
| `content` | texto | El tramo de respuesta; en el `error`, el motivo |
| `sequence` | entero | Posición del tramo. **Puede faltar** |
| `timestamp` | texto | Momento del tramo, en ISO 8601 |

```
: connected

id: 0
event: response_chunk
data: {"job_id":"cd5e91f3-371e-4ffc-b168-a0beb22f31a5","conversation_id":"a3f1…","event_type":"response_chunk","content":"Su pedido salió ayer.","sequence":0,"timestamp":"2026-08-30T00:44:01.120Z"}

id: 1
event: response_end
data: {"job_id":"cd5e91f3-371e-4ffc-b168-a0beb22f31a5","conversation_id":"a3f1…","event_type":"response_end","content":"","sequence":1,"timestamp":"2026-08-30T00:44:01.354Z"}
```

🔴 **`sequence` puede faltar, y la clave se omite en vez de viajar en `null`.** Cuando falta, la trama tampoco
trae línea `id:`: no hay posición que reanudar, y fabricarle un número haría que dos lectores del mismo stream
numeraran distinto. Le llega así el `error` que emite el propio transporte, y puede llegarle cualquier evento
sin posición durante un despliegue nuestro. Su cliente concatena igual; lo que no puede hacer es usar esa
trama como punto de reanudación.

`job_id` y `request_id` **no son lo mismo** y los dos viajan en el sobre: `request_id` correlaciona una
petición concreta, `job_id` identifica el trabajo. En el stream va el `job_id`.

## Tras el terminal, cerramos

Al emitir `response_end` o `error`, el servidor **cierra la conexión**. No hay nada más que llegue por ella.

Eso importa porque `EventSource` reconecta solo: si no cierra el suyo al recibir el terminal, reabrirá el
stream de un turno ya terminado y recibirá otra vez lo que quede en ventana, o un `409` cuando ya no quede
nada. No le cuesta créditos, pero es tráfico que no le aporta nada.

Una interrupción **después** del `200` no puede llegarle como código HTTP —las cabeceras ya están escritas—,
así que viaja dentro del stream: recibe un evento `error` con el motivo en `content`, y después el cierre. Son
dos motivos, y distinguen dónde falló:

| `content` | Qué pasó |
|---|---|
| `turn_failed` | La generación no llegó a producir respuesta |
| `stream_interrupted` | La generación siguió; lo que se cortó fue esta entrega |

Con `stream_interrupted` merece la pena reabrir el stream con `Last-Event-ID`; con `turn_failed` no hay nada
más que llegue, y el estado del trabajo lo cuenta `GET /v1/jobs/{job_id}`.

## Comentarios: `: connected` y `: ping`

Toda línea que empieza por `:` es un comentario del protocolo y su cliente **debe ignorarla**. Hay dos:

- `: connected`, en cuanto la conexión se establece.
- `: ping`, cada 20 segundos mientras el stream está callado, para que ningún intermediario cierre una
  conexión que parece inactiva.

No son eventos: no traen `event:`, no traen `data:` y no cuentan para la secuencia. Las bibliotecas de SSE ya
las descartan; si lee la respuesta a mano, descártelas usted.

## Reanudar con `Last-Event-ID`

Cada trama con posición sale con `id: <sequence>`, un entero que empieza en 0. Para empalmar una conexión
cortada, reabra la misma ruta declarando el último que vio:

```
GET /v1/agent/streams/{job_id}
Last-Event-ID: 3
```

Recibe lo publicado **después** de esa posición y sigue en directo. `EventSource` lo manda solo. Un valor que
no sea un entero no negativo se ignora, y entonces la reanudación empieza desde el principio de lo que quede
guardado — así que una conexión nueva sin cabecera también recibe lo que se publicó antes de conectar.

**La ventana de reanudación son 2 minutos** y cubre hasta 200 tramos. No es una copia del resultado: es el
tramo que permite empalmar una reconexión. Cuando lo que falta ya venció, abrir el stream responde
**`409 result_not_replayable`** —lo reciba con `Last-Event-ID` o sin él— porque el hueco no se disimula: el
trabajo existe y su cobro también, y lo que no existe es una copia de su salida. Entregarle solo el tramo que
queda dejaría una respuesta corrupta con aspecto de completa.

Cuando eso pasa, la copia durable sigue donde la promete el contrato: el resultado del trabajo, 24 h, en
`GET /v1/jobs/{job_id}` ([página 17](./17-jobs.md)). Si necesita la respuesta generada otra vez, pida el turno
con una clave de idempotencia **nueva**.

## Streams simultáneos

**50 por cuenta y entorno.** Cuando el techo está lleno, abrir otro responde
`429 concurrency_limit_reached` con `Retry-After` en segundos.

Es un techo **propio**, distinto del de peticiones simultáneas ([página 18](./18-api-limits.md)) aunque
comparta el código de error: leer una respuesta que ya pagó no puede competir con originar trabajo nuevo. La
ranura la ocupa el **trabajo** que se lee, no la conexión, así que reconectar sobre el mismo `job_id` no
consume otra.

Mantener la conexión abierta no gasta ritmo de su clave. Lo que lo gasta es **abrir** el turno.

## Leer no cuesta nada

El turno se cobra **una vez**, por respuesta generada ([página 12](./12-pricing.md)). Leer el stream no
cuesta nada, ni la primera vez ni al reanudar: el cobro sigue a la generación, no a la entrega. Si su cliente
se desconecta a mitad, el turno cuesta lo mismo, y cortar el stream no cancela la generación.

## Los rechazos que sí son código HTTP

Todo lo que se decide **antes** del `200` llega como error normal de la superficie, con la forma de la
[página 16](./16-api-errors.md).

| Código | HTTP | Qué pasó |
|---|---|---|
| `authentication_required` | 401 | Falta la credencial o no identifica una cuenta |
| `insufficient_scope` | 403 | Su clave no tiene `jobs:read` |
| `resource_not_found` | 404 | El trabajo no existe, es de otra cuenta o es de otro entorno |
| `result_not_replayable` | 409 | La ventana de reanudación de ese stream venció |
| `concurrency_limit_reached` | 429 | Su techo de streams simultáneos está lleno. Trae `Retry-After` |

## Qué leer después

| Para | Vaya a |
|---|---|
| Abrir el turno y qué trae su acuse | [página 15](./15-api-reference.md) |
| El sobre, la clave de idempotencia y la autenticación | [página 14](./14-api-quickstart.md) |
| Consultar el trabajo cuando pierde el stream | [página 17](./17-jobs.md) |
