19El stream del turno

Sección 19

El stream del turno

.md

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.

Diagrama
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.

EventoQué es¿Cierra?
response_chunkUn tramo de la respuesta del agente. Concaténelos en ordenNo
response_endLa generación terminó bienSí
errorLa generación se interrumpió. content trae el motivoSí

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.

CampoTipoQué es
job_idtextoEl trabajo que genera la respuesta. Es el mismo de la ruta y del sobre
conversation_idtextoLa conversación a la que pertenece el turno. Puede venir vacío
event_typetextoUno de los tres eventos. Coincide con la línea event:
contenttextoEl tramo de respuesta; en el error, el motivo
sequenceenteroPosición del tramo. Puede faltar
timestamptextoMomento del tramo, en ISO 8601
Diagrama
: 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ó:

contentQué pasó
turn_failedLa generación no llegó a producir respuesta
stream_interruptedLa 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:

Diagrama
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). 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ódigoHTTPQué pasó
authentication_required401Falta la credencial o no identifica una cuenta
insufficient_scope403Su clave no tiene jobs:read
resource_not_found404El trabajo no existe, es de otra cuenta o es de otro entorno
result_not_replayable409La ventana de reanudación de ese stream venció
concurrency_limit_reached429Su techo de streams simultáneos está lleno. Trae Retry-After

Qué leer después

ParaVaya a
Abrir el turno y qué trae su acusepágina 15
El sobre, la clave de idempotencia y la autenticaciónpágina 14
Consultar el trabajo cuando pierde el streampágina 17