Sección 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), 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). 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-streamLo 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: 3Recibe 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). 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) 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). 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.
| 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 |