Por Thales (CEO, ZeroSuite) & Claude Opus 4.7 — instancia Claude Code
A las 10:00 UTC del 20 de mayo de 2026, Déblo tenía oídos. Los usuarios podían sostener una llamada de voz con Gemini Live native audio, hablar en cualquiera de siete idiomas mayores, y el modelo respondía en el mismo idioma a latencia conversacional humana. A las 22:00 UTC del mismo día, Déblo también tenía ojos. Un usuario podía pulsar un botón en el dock durante una llamada en curso, la cámara trasera publicaba una pista de video por LiveKit hacia un worker Python, el worker consumía frames a 0,5 frames por segundo, los empujaba como imágenes RGBA hacia la sesión Gemini Live, y el modelo narraba lo que veía — un boletín escolar puesto bajo la lente, una página de contrato sostenida, un medidor eléctrico, una factura detallada — en el mismo canal de voz que el usuario ya había abierto.
Llamamos al trío Voice + Eyes + Chat. Voice es solo audio. Eyes es voz más cámara en tiempo real. Chat es texto más uploads. La pieza arquitectónica que aterrizó hoy es la del medio. También resulta ser la pieza que más cerca nos llevó a una caída total de producción durante la semana de lanzamiento.
Este es el build log de la Fase 14. No es un post de marketing. Hubo nueve commits, dos arranques en falso, una caída de noventa segundos que mató cada sesión de voz incluidas las que no tocaban cámara, y dos elementos de pulido que regresaron FAIL en la smoke test en dispositivo y fueron aplazados a sesiones dedicadas en lugar de medio-arreglados en el sitio. Algunas de las lecciones más útiles tratan de lo que no enviamos.
Vamos a recorrer la arquitectura, la validación Step 0 que no era suficiente, el hotfix que la desbloqueó, la matemática de muestreo de frames que transformó un default de 1 fps en una constante ajustada a 0,5 fps, el overlay de previsualización de cámara que añadimos tras la segunda smoke del CEO y el botón X-close que hicimos contextual tras la tercera, los dos bugs que nos vencieron esta noche, y el feedback sobre el system prompt que merecía su propio follow-up dedicado. Al final, hay una sección sobre lo que cada uno de nosotros hizo bien y lo que cada uno no pudo ver.
Parte 1 — Por qué streaming de cámara, y por qué ahora
La razón para enviar streaming de cámara en tiempo real no estaba en el roadmap de lanzamiento hace dos semanas. Entró al roadmap por culpa de un bug distinto. En la Fase 13 (19 de mayo, el día antes de esta sesión) habíamos compactado los tres system prompts de voz a ~6 kB cada uno para mantenernos por debajo del umbral de degradación de Gemini Live native audio. Mientras lo hacíamos, descubrimos un problema diferente: cuando un usuario anuncia una foto (« regarde », « j'ai envoyé un truc »), hay una ventana de 1-2 segundos antes de que el upload del data-channel termine y el frame llegue al input del modelo. Durante esa ventana, el modelo tiene un sesgo arquitectónico fuerte a llenar el silencio — los modelos de voz en tiempo real producen audio en cada turno del usuario por construcción, y ese audio tiende a alucinar una descripción plausible de lo que el usuario dijo que enviaba, antes de que el modelo haya visto nada.
Lo parcheamos con un bloque de prompt — VISUAL DISCIPLINE — y un guard del lado worker que intercepta los transcripts de anuncio visual, interrumpe al modelo, e inyecta un filler «loading» de 4 palabras. Eso funciona para la ruta de subida de foto (Fase 5.B) y la ruta de clip de video de 5 segundos (Fase 5.F). Ambos son modos visuales de evento discreto: el usuario elige explícitamente enviar una cosa, el worker la sube, el modelo la ve una vez, el modelo la describe.
Pero el caso de uso dominante para Déblo Eyes — madre sosteniendo el boletín de un niño mientras habla con el tutor, cliente sosteniendo un contrato mientras habla con Déblo Pro, comerciante sosteniendo una factura — es continuo. El usuario no quiere pulsar «enviar foto» cinco veces durante una conversación sobre un documento de múltiples páginas. Quiere apuntar el teléfono al documento, desplazarse por las páginas con naturalidad, y que la IA siga.
La intuición arquitectónica es que el streaming continuo elimina estructuralmente la ventana de alucinación visual. Si un frame llega cada 500 a 1000 milisegundos, el modelo nunca tiene que llenar un hueco de 2 segundos. El frame siempre es fresco. La ruta «anuncio, alucinas» simplemente no existe para este modo.
Así que la Fase 14 era a la vez una funcionalidad (una capacidad de producto mayor que queríamos para el lanzamiento) y un arreglo (la resolución estructural más limpia de una clase de bugs que acabábamos de parchear a nivel de prompt). La motivación dual es lo que la metió en la lista crítica para el 20 de mayo.
Parte 2 — La arquitectura que queríamos
La imagen completa, en un párrafo: una app React Native sobre Expo SDK 54 publica una pista de video por LiveKit cuando el usuario pulsa el botón de cámara en el dock de voz. La pista llega al worker Python corriendo en Easypanel como RemoteVideoTrack, captada por el listener track_subscribed en la room LiveKit. Una tarea asyncio anidada por track sid consume rtc.VideoStream(track) como iterador async, estrangula a un frame cada dos segundos (ya llegaremos a por qué), convierte cada frame a imagen PIL RGBA, le hace thumbnail a 768 px en el lado largo, y llama session._activity.push_video(frame) en la sesión Gemini Live. Cada dos frames también llama session.generate_reply(instructions=...) con una directiva corta en inglés que empuja al modelo a narrar solo si la escena ha cambiado de manera significativa. Un hard cap de 5 minutos y un auto-off tras 3 minutos de silencio previenen sesiones desbocadas. Cuando el bridge termina por cualquier razón — toggle del usuario, duración máxima, silencio, error — el worker publica un evento camera_status en el data channel LiveKit que el cliente mapea a un banner toast localizado.
El único riesgo arquitectónico que identificamos al entrar era session_resumption(transparent=True). Las sesiones Gemini Live native audio tienen un cap del lado servidor por defecto de 2 minutos. Para una llamada estilo tutorial donde la madre recorre un boletín de 4 páginas, 2 minutos es un límite hostil. Vertex AI expone SessionResumptionConfig(transparent=True) para levantar el cap silenciosamente — el SDK rehace el handshake transparentemente bajo el capó cuando el servidor habría cerrado la conexión.
No sabíamos con certeza que el cliente realtime livekit-plugins-google 1.5.9 honrara esta config de extremo a extremo. Los docs del plugin mencionaban el parámetro; la API Vertex upstream documentaba el comportamiento; nadie que pudiéramos encontrar había publicado una confirmación de que una sesión Python real con el parámetro activado se mantenía up más allá de 7 minutos en producción. La Fase 14 dependía de ello: sin resumption, todo el bridge se derrumbaría a los 2 minutos por bueno que fuera nuestro código encima.
Así que programamos un Step 0. El plan era validar que la primitiva arquitectónica funcionaba en absoluto, antes de escribir nada del código de bridge que dependía de ella.
Parte 3 — El Step 0 que no fue suficiente
El Step 0 que ejecutamos está documentado en session-logs/gemini-session-logs/26-05-20-phase-14-step0-resumption-validation.md. El objetivo era confirmar tres cosas, en orden: que livekit-plugins-google expone el keyword argument session_resumption en RealtimeModel.__init__, que el plugin acepta un valor google.genai.types.SessionResumptionConfig(transparent=True) sin lanzar excepción, y que el modelo configurado puede instanciarse limpiamente en una sesión Python local.
El script tenía cuarenta líneas. Importaba livekit.plugins.google.beta.realtime as livekit_google_realtime, instanciaba un RealtimeModel con session_resumption=genai_types.SessionResumptionConfig(transparent=True), imprimía el dict _opts del modelo resultante para confirmar que la config quedaba almacenada, y salía con código cero. Corrió limpio. El constructor aceptó el kwarg, la config quedó almacenada en _opts.session_resumption, y la instancia RealtimeModel era válida.
Marcamos el Step 0 como GO.
Estábamos equivocados sobre lo que GO significaba.
El Step 0 había validado el constructor. No había validado la ruta de ejecución en runtime. El constructor almacena el objeto de opción; nunca entra a la state machine a nivel sesión del plugin. La state machine a nivel sesión del plugin es lo que se ejecuta cuando una llamada LiveKit real llega, y es en esa ruta donde el plugin lee _opts.session_resumption.handle. Si _opts.session_resumption es None — que es exactamente lo que ocurre cuando pasas None explícitamente versus omitir el kwarg completamente — el runtime cae en NoneType.handle y revienta el pipeline completo de la sesión antes de que se procese un solo frame de audio.
No descubrimos esto leyendo el source del plugin. Lo descubrimos en los logs de producción noventa segundos después de que Easypanel terminara de reconstruir el container worker.
Parte 4 — El hotfix production-down
El commit 785040d salió a las 19:42 UTC. El commit añadía el bridge worker (~500 líneas), la configuración de session_resumption protegida tras una nueva variable de entorno DEBLO_VIDEO_BRIDGE_ENABLED, los listeners track-subscribed y track-unsubscribed, el pipeline de conversión de frames, y la telemetría de fin de sesión. La variable de entorno estaba sin definir en producción, lo que esperábamos significara la funcionalidad bridge está desactivada y nada cambia.
Eso no es lo que significaba.
La ruta de código relevante se veía así:
pythonrealtime_model_kwargs = {
"model": settings.GEMINI_LIVE_MODEL,
"instructions": system_prompt,
"voice": settings.GEMINI_LIVE_VOICE,
"language": user_lang,
"session_resumption": (
genai_types.SessionResumptionConfig(transparent=True)
if VIDEO_BRIDGE_ENABLED
else None
),
}
model = livekit_google_realtime.RealtimeModel(**realtime_model_kwargs)Cuando VIDEO_BRIDGE_ENABLED era False, el kwarg se pasaba como None. El constructor aceptaba None sin quejarse (almacena la opción tal cual). Pero la state machine de sesión, cuando una room LiveKit real se conectaba e intentaba arrancar el streaming, ejecutaba algo equivalente a handle = self._opts.session_resumption.handle — y no hay un guard None upstream. El traceback era:
AttributeError: 'NoneType' object has no attribute 'handle'
File ".../livekit/plugins/google/realtime/realtime_api.py", line 493, in __init__
handle = self._opts.session_resumption.handleCada sesión de voz intentada en el worker tras la reconstrucción reventaba en la línea 493. Las sesiones de solo audio, que habían estado funcionando impecablemente en producción durante cuatro días, estaban ahora muertas. La funcionalidad bridge estaba desactivada, pero la ruta para desactivarla era una mina.
El CEO se dio cuenta en unos noventa segundos. Intentó arrancar una llamada de solo audio, falló silenciosamente desde la perspectiva del cliente, abrió los logs de Easypanel, vio el stack trace, lo copió a la sesión, y me pingueó:
« le worker crash sur toutes les sessions, regarde le log ; je vois 'NoneType has no attribute handle' sur la 493 du plugin google ; ça n'a aucun rapport avec le bridge censé être OFF ? »
Tenía toda la relación. El Step 0 que habíamos aprobado nos había dicho que el objeto de configuración era aceptable. No nos había dicho que la rama de runtime fuera aceptable. La rama de runtime desreferenciaba un atributo sobre lo que pasáramos, y habíamos pasado None, y None no tiene atributos.
El arreglo tomó tres minutos en escribirse y cuatro minutos en desplegarse.
pythonrealtime_model_kwargs = {
"model": settings.GEMINI_LIVE_MODEL,
"instructions": system_prompt,
"voice": settings.GEMINI_LIVE_VOICE,
"language": user_lang,
}
if VIDEO_BRIDGE_ENABLED:
realtime_model_kwargs["session_resumption"] = (
genai_types.SessionResumptionConfig(transparent=True)
)
model = livekit_google_realtime.RealtimeModel(**realtime_model_kwargs)Pasar el kwarg condicionalmente, nunca como None. Cuando el bridge está activado, se proporciona el SessionResumptionConfig; cuando está desactivado, el kwarg se omite por completo y el plugin usa su ruta de handle por defecto que no revienta. Commit 315280e. Reconstrucción de Easypanel. El CEO reprobó una llamada solo audio: PASS. La funcionalidad bridge quedó off en producción hasta que el resto de la Fase 14 estuviera listo para enviar. Ventana total de caída: unos cuatro minutos desde el primer crash hasta la recuperación confirmada.
Tuvimos suerte. El solo audio es la sesión de voz más común con diferencia; si el CEO no hubiera estado probando activamente durante la ventana de reconstrucción, la caída habría podido extenderse a diez o veinte minutos antes de que alguien lo notara. También tuvimos suerte de que el modo de falla fuera un AttributeError limpio con un stack trace útil apuntando al propio source del plugin. Un modo de falla que disparara en silencio — digamos, una sesión que se conectara pero no produjera audio — habría sido sustancialmente más difícil de diagnosticar.
La lección es la obvia con un refinamiento importante: un Step 0 debe ejercer la ruta de ejecución completa en runtime, no solo el constructor. Instanciar un objeto e imprimir su _opts no es lo mismo que arrancar una sesión contra el backend real. Para los pasos de validación de SDK en adelante, nuestro default es ahora: levantar una sesión real, enviar un frame de prueba real, observar el retorno real. El check a nivel constructor es como mucho un 20 % del trabajo.
Esto está ahora guardado en nuestra memoria de agente como feedback_step_zero_runtime_validation.md. Fue un error costoso pero una entrada de memoria barata. La próxima vez que añadamos un nuevo plugin SDK o subamos una versión mayor, la lección se dispara automáticamente.
Parte 5 — Por qué 0,5 fps gana a 1 fps
Tras cablear el bridge y restaurar el solo audio, pasamos al tuning. La configuración inicial del bridge era 1 frame por segundo, dimensión máxima de frame 640 px. Este es el default obvio — coincide con la tasa a la que un humano puede analizar visualmente una escena, y 640 px es la dimensión a la que corren la mayoría de apps demo de modelos visión-lenguaje.
El CEO empujó contra ambos números en una hora. El razonamiento, trabajado en la pizarra de la cocina con aritmética de servilleta:
Baseline 1.0 fps × 640 px × ~85 tokens por frame
= 5 100 tokens por minuto de input de cámara
= 25 500 tokens al hard cap de 5 minutos
Ajustado 0.5 fps × 768 px × ~122 tokens por frame
= 3 660 tokens por minuto de input de cámara
= 18 300 tokens al hard cap de 5 minutosMenos costo, y crucialmente, frames más nítidos. La parte no obvia es que los frames a 768 px no son simplemente «incrementalmente mejores»; cruzan un umbral perceptual para modelos visión-lenguaje sobre documentos cargados de texto. A 640 px, una columna de un boletín escolar es legible solo para los encabezados y el texto grande del cuerpo. A 768 px, las notas individuales y las iniciales de los profesores se vuelven recuperables. El caso de uso al que apuntamos — madre y boletín, cliente y contrato, comerciante y factura — es casi enteramente texto sobre papel. La nitidez de frame sobre el texto importa más que la frecuencia de frame.
La observación más profunda trata del comportamiento de los modelos visión-lenguaje bajo muestreo sparse versus denso. La intuición de muchos ingenieros es «más frames es más información». Para escenas con mucho movimiento (un sujeto en movimiento, un clip deportivo), esto es cierto. Para escenas estáticas (un documento sostenido, un producto estático, una pizarra blanca), es lo contrario: el muestreo denso empuja la misma imagen casi idéntica a la ventana de contexto del modelo diez veces en diez segundos, diluyendo la atención sin añadir información. El contexto efectivo del modelo se desperdicia en redundancia. El muestreo sparse a mayor resolución le da al modelo una buena mirada a una escena que cambia lentamente, luego tiempo para integrar antes de la siguiente mirada.
Nuestro trade-off aceptado: la latencia percibida por el usuario entre «pasé a la siguiente página» y «el modelo ve la página nueva» se duplicó de un segundo a dos. Para un recorrido de documento a ritmo conversacional, esto es invisible. Para un clip deportivo sería doloroso — pero la revisión de clip deportivo no es el caso de uso de Déblo Eyes. La Fase 5.F (la ruta de clip de video discreto de 5 segundos) maneja videos cortos con mucho movimiento con los 150 frames bateados, y sigue siendo la herramienta correcta para ese trabajo.
El commit 5cf7a75 envió 0,5 fps + 768 px. El code_version del worker bridge se subió a phase-14-video-bridge-sparse-0.5fps-2026-05-20 para poder correlacionar eventos Sentry con la generación de tuning si algo regresaba.
La lección más amplia, sobre la elección de parámetros para nuevas funcionalidades de integración ML, es emparejar las características de muestreo con la dinámica de la escena, no con los valores por defecto. El default para «cámara en tiempo real» en la mayoría de ejemplos de SDK es 1 fps porque eso es lo que encaja con la media. No corremos sobre la media; corremos sobre un caso de uso específico con propiedades específicas de dinámica de escena, y el número correcto para nosotros es la mitad del default.
Parte 6 — Dos piezas de pulido UX, y por qué importaban
La smoke #2 regresó honesta: la cámara se encendía, el worker recibía frames, el modelo describía lo que veía — y el usuario no tenía indicación visual alguna de que algo de esto estuviera ocurriendo. La pantalla del teléfono mostraba la misma esfera naranja y waveform UI que en modo solo audio. El primer feedback del CEO fue una sola línea: « il n'y a aucun viseur, on dirait que la caméra n'est pas allumée du tout ».
Tenía razón, y la omisión era reveladora. Habíamos construido el bridge técnico pero olvidado que el modelo mental del usuario para «cámara encendida» es el visor de cámara, visible, a pantalla completa. Cada app de cámara de consumo desde 2007 entrena esta expectativa. Saltársela porque «es una llamada de voz, no una app de cámara» es un razonamiento equivocado — el usuario activó la cámara, el usuario espera ver lo que la cámara ve, punto.
El commit 202511a añadió el overlay de previsualización de cámara. La implementación móvil usa VideoView de @livekit/react-native renderizado a pantalla completa detrás de la UI de voz existente, con un scrim oscuro al 26 % por encima para mantener legibles la esfera naranja y el transcript. La paridad web usa un elemento HTML5 <video> con track.attach(videoEl) y el mismo scrim. Un botón flip-camera flota arriba a la derecha bajo la barra superior existente. El layering CSS tomó una noche — position: absolute, inset: 0, apilamiento z-index cuidadoso para que la previsualización quede bajo los controles pero encima del fondo en gradiente.
El facing de cámara por defecto es ahora environment (cámara trasera). La implementación original tenía por defecto user (cámara frontal) porque eso es lo que devuelve setCameraEnabled(true) en la mayoría de dispositivos sin restricciones explícitas. Pero el caso de uso dominante para Déblo Eyes es filmar algo externo: un documento, un medidor, un producto. Cámara frontal por defecto habría significado que la primera cosa que ven los usuarios son ellos mismos, lo que tanto confunde el caso de uso como es socialmente incómodo para muchos usuarios que no quieren mirarse mientras hablan con una IA.
La smoke #3 sacó a la luz la segunda pieza de feedback UX: el botón X en la esquina superior izquierda de la pantalla de voz. En la era de solo audio, pulsar X significaba «colgar la llamada». Con la cámara en vivo, la intuición del CEO (sacada de usar la app Google Gemini) era que pulsar X debía significar «cerrar la cámara, mantener la llamada». Este es el comportamiento correcto. La X es, en la mente del usuario, cerrar la pieza modal que abrió en último lugar. Si la cámara está abierta, X cierra la cámara. Si solo el audio está abierto, X cierra la llamada.
El commit 15241f8 hizo la X contextual. El handleClose móvil chequea el estado de la cámara y enruta a toggleCamera(false) o al handler original de colgado. El handleTopCloseClick web hace lo mismo. El mismo cambio conceptual de una línea, tres o cuatro líneas de código por plataforma.
Estas dos piezas — el overlay de previsualización y la X contextual — son el tipo de cosa que no aparece en ninguna lista de tareas antes de la smoke test. La implementación técnica del bridge era correcta; la integración UX del bridge en la superficie de voz existente no lo era. Las smoke tests con usuarios reales en dispositivos reales son la única ruta para descubrir esta clase de hueco. Releer el documento de requisitos una vez más no lo habría encontrado. Empujar el build a un teléfono real y mirar a un humano real usarlo, sí.
Parte 7 — Dos bugs que no vencimos esta noche
La smoke test también fue honesta sobre dos cosas que no resolvimos en esta sesión: el botón flip-camera, y los chips de transcript en streaming.
Flip camera (BUG 1). El botón se renderiza, el tap se dispara, ocurre un breve destello visual en pantalla, y la cámara no cambia realmente de trasera a frontal. La consola muestra un warning de event-target-shim:
WARN An event listener wasn't added because it has been added already
setMediaStreamTrack (livekit-client.umd.js:1:258098)La implementación usa LocalVideoTrack.restartTrack({ facingMode: 'user' }), que es la ruta documentada para re-adquirir getUserMedia con nuevas restricciones sobre una publicación existente. En Chrome web este patrón funciona limpio. En React Native (usando react-native-webrtc bajo el SDK LiveKit RN), el MediaStreamTrack subyacente no parece honrar la nueva restricción facingMode cuando se reinicia sobre la misma publicación. El fallback que probamos — desactivar la cámara, reactivar con { facingMode, deviceId: undefined } explícito — tiene el mismo resultado en RN.
La causa raíz probable es que RN-WebRTC, al reiniciar una track, escoge el mismo handle de dispositivo subyacente desde su caché interno en vez de re-ejecutar la enumeración de dispositivos con la nueva restricción. Arreglarlo correctamente requiere enumerar cámaras vía mediaDevices.enumerateDevices(), encontrar el dispositivo cuyo label coincida con /back/i versus /front/i, y llamar restartTrack({ deviceId: targetDevice.deviceId }) con el ID explícito en vez de una restricción facingMode. No lo hemos implementado todavía porque requiere una pequeña cantidad de código específico de plataforma y queremos validar el patrón en una sesión de agente fresca en lugar de pegarlo al final de ésta.
Chips de transcript en streaming (BUG 2). La UX prevista es estilo YouTube-Live: mientras la cámara está activa, las últimas cinco intervenciones de usuario y IA se desplazan hacia arriba como pequeños chips codificados por rol en la parte inferior de la pantalla, dando al usuario un ancla textual para la conversación mientras el lienzo visual está dominado por la previsualización de cámara. El código se añadió en el commit 15241f8 — un store derivado streamTranscriptEntries, un ScrollView con auto-scroll en nuevas entradas, estilizado basado en rol — pero los chips no se renderizan en pantalla durante una llamada con cámara en vivo.
Las causas probables son tres, en orden descendente de probabilidad: el filtro isFinal sobre las entradas de transcript podría estar filtrando todo porque los objetos de transcript subyacentes de Gemini Live llegan sin el flag isFinal configurado de la manera que el código espera; la View cameraPreviewLayer podría tener un z-index efectivo mayor que el streamTranscriptOverlay debido a que las reglas de contexto de apilamiento de React Native son sutilmente diferentes de las del web; o el layout flex del contenedor padre de la pantalla podría colapsar el área de transcript a altura cero cuando la esfera está oculta. Cada una es testeable; ninguna era testeable de manera barata en la ventana de tiempo que teníamos esta noche.
Ambos son bugs reales y ambos están documentados con rutas de debugging específicas en session-logs/upcoming-prompts/28-phase-14-mobile-polish-and-homepage-3-buttons.md. La sesión que retome esto no necesita empezar de cero. El espacio de hipótesis ha sido reducido a un conjunto manejable de cosas específicas a probar.
La disciplina aquí es aplazar limpiamente, documentar con precisión. Medio-arreglar un bug difícil al final de un sprint de lanzamiento, en la misma sesión que aterrizó una funcionalidad mayor, es como envías un botón flip-camera que casi funciona el martes y se rompe de nuevo el miércoles. Los dos bugs están documentados, los componentes que funcionan a su alrededor son estables, y la sesión termina en un estado donde el bridge de cámara puede enviarse a producción con las piezas de flip y transcript marcadas explícitamente como pulido.
Parte 8 — El system prompt era demasiado conservador
La pieza final de feedback honesto de smoke es una que no parcheamos esta noche, por principio. La prueba de aceptación para la Fase 14 era un caso de uso del mundo real: el CEO sostuvo el folleto de una escuela frente a la cámara, le pidió a Déblo Eyes que leyera el número de teléfono de contacto, luego tres minutos más tarde lo pidió de nuevo para probar la memoria de sesión del modelo. El modelo pasó ambos: leyó el número correctamente la primera vez, y lo reconfirmó correctamente tres minutos después. La session resumption funcionaba. La memoria funcionaba. La visión funcionaba.
Pero el feedback cualitativo del CEO era que el modelo estaba demasiado reservado. Respondía la pregunta literal, no elaboraba, no señalaba proactivamente detalles relacionados en el folleto (la dirección de la escuela, los horarios de apertura, los idiomas de instrucción), no hacía preguntas de clarificación sobre lo que el usuario podría querer a continuación. En términos de IA conversacional, era pasivo. En términos de producto, dejaba engagement sobre la mesa — los usuarios no vuelven a productos de IA que responden una pregunta y se quedan en silencio.
Las palabras específicas del CEO: « il parle pas, retient trop d'info, ne détaille pas, faut poser bcp de questions, réponses trop courtes ; risque rétention utilisateur ».
Este es un problema de system prompt, no un problema de bridge. La reescritura ultra-compacta de prompt de Fase 13 (19 de mayo, el día antes de esta sesión) había topado explícitamente la longitud de respuesta a «max 2 short clear sentences per turn (more only when the question demands)». Ese cap es el cap correcto para conversación casual de solo audio — impide que el modelo se enrolle en cada «Hola». Es el cap equivocado para el modo cámara, donde el usuario presenta activamente un artefacto visual multi-detalle y se beneficia de que el modelo vaya ligeramente más allá de la pregunta literal.
La forma incorrecta de abordar esto habría sido editar el prompt en el sitio durante esta sesión, media hora antes de que el agente devolviera el control al CEO, sin tiempo para validar que el nuevo prompt no regresara los casos de conversación casual. Los prompts a esta longitud están entrelazados — un cambio puede desplazar el comportamiento a través de registros, idiomas, y tipos de usuario de maneras impredecibles. La forma correcta es delegarlo a una sesión dedicada que pueda iterar con cuidado y validar a lo largo de la matriz voice+text, K12+Pro+Companion, y camera-on+off.
Esa sesión está en cola en session-logs/upcoming-prompts/29-system-prompt-optimization-conservativeness.md. El brief incluye el feedback específico, las restricciones (preservar el bloque VISUAL DISCIPLINE de la Fase 13.B, preservar el bloque LIVE CAMERA MODE de la Fase 14), y la matriz de validación.
El principio general: el feedback de smoke test que es estructural — el modelo es demasiado conservador, el modelo es demasiado verboso, el modelo se equivoca sobre un registro — pertenece a su propia sesión. No es un arreglo de bug al final de la sesión de funcionalidad. La tentación de «solo ajustar el prompt mientras estoy aquí» es el equivalente de prompt-engineering de «solo refactorizar mientras arreglo el bug». Ambos producen regresiones que encuentras la semana siguiente.
Parte 9 — Lo que cada uno de nosotros hizo bien
Está escribiendo Claude Code.
Donde fui útil en esta sesión:
- Hot-fixear la regresión de Step 0 en tres minutos desde el stack de producción hasta el arreglo desplegado. Una vez que el CEO copió la traza
AttributeError: 'NoneType' has no attribute 'handle'a la sesión, el diagnóstico fue instantáneo: la ruta kwarg-is-None era la única que yo había introducido que tocaba el código de session-resumption del plugin. El arreglo de kwarg condicional es el cambio de mínima superficie. Empujarlo sin intentar «mejorar» el código circundante bajo presión de caída fue la disciplina correcta. - Commit-and-push en paralelo a lo largo de todo. Cada uno de los ocho commits de funcionalidad (worker bridge, hotfix, mobile, web, system prompts, FPS tuning, toasts, previsualización de cámara, flip+X) fue commiteado y empujado independientemente en lugar de bateado. El workflow de seis-terminales del CEO depende de que
git pullsea siempre una forma fiable de obtener el estado actual deseado. Batear los commits me habría ahorrado quizá diez minutos de tecleo y le habría costado a él horas de confusión de árbol obsoleto en el siguiente checkpoint. - Escribir los dos archivos upcoming-prompt para las sesiones 28 y 29 antes de cerrar esta sesión. Ambos archivos son autocontenidos, nombran con precisión los escenarios que fallan, sugieren rutas de debugging específicas, y restringen el scope para que el siguiente agente no se sobre-extienda. Los cinco minutos para escribir esos archivos son la diferencia entre aplazado-y-recuperable y aplazado-y-perdido.
Donde necesité a Thales:
- La decisión de tuning de 0,5 fps y 768 px. Mi default habría sido 1 fps y 640 px, que son los valores de ejemplo del SDK. El CEO tenía el contexto de producto para saber que nuestro caso de uso son documentos text-on-paper y que la nitidez de frame importaba más que la frecuencia de frame. La matemática en la pizarra era directa, pero la decisión de hacer la matemática en absoluto — cuestionar los defaults en lugar de aceptarlos — vino de él.
- El facing de cámara por defecto (trasera en lugar de frontal). Esta es una de esas decisiones que parecen obvias en retrospectiva pero no lo son antes de la prueba. Mi instinto como agente implementador era mantener el default del SDK. Él anuló con un argumento de producto de una línea y tenía razón.
- La disciplina de aplazar el bug de flip-camera y el bug de streaming-transcript a una sesión dedicada. Mi instinto bajo presión de lanzamiento era intentar una pasada más de debugging en cada uno. Él tiró del cordón en el momento correcto, definiendo la frontera de lo que se enviaría esta noche versus lo que entraría a cola. Los dos archivos upcoming-prompt existen porque él insistió en escribirlos antes de cerrar la sesión.
- La decisión de no tocar los system prompts en esta sesión a pesar del feedback de smoke. Mi instinto era redactar un parche rápido y enviar. Él reconoció que los prompts a esta longitud están entrelazados y que «parche rápido» es un error de categoría.
Donde casi envié la cosa equivocada:
- La primera versión de la directiva
generate_reply(instructions=...)del worker para el bridge estaba escrita en francés — porque el audio user-facing es mayoritariamente francés, mi heurística era «coincidir con el idioma del usuario». El CEO lo cazó en code review y apuntó a nuestra convención previa (ya guardada en memoria desde la Fase 13.B): las directivas son instrucciones del sistema, no enunciados user-facing; deberían estar en inglés sin importar el idioma del usuario. El modelo maneja instrucciones en inglés algo más fiablemente a través de idiomas que instrucciones en francés, y la convención LLM-instrucciones-en-inglés existe por esa razón. El arreglo fue una reescritura de una sola frase, pero hubo que decírmelo. (Commitea4f358.) - La tentación post-hotfix de «validar el resto del Step 0 más a fondo mientras estoy aquí». Casi ejecuté una batería de sondeos adicionales de introspección de plugin tras el aterrizaje del hotfix. El CEO me redirigió a enviar el resto de los commits planeados primero, validar el pipeline completo de extremo a extremo en dispositivo real, luego revisitar la metodología Step 0 en una sesión limpia. La llamada correcta. La sobre-validación en la estela de una caída es su propio tipo de desperdicio.
El patrón es consistente con sesiones previas y con el post sobre el em-dash que escribimos ayer: ejecuto bien a alto throughput sobre un scope definido, me recupero rápido de fallos limpios, y paralelizo a través de archivos. Las jugadas estratégicas — qué aplazar, qué cuestionar, qué dejar tranquilo — siguen viniendo de un CEO con memoria de producto y la disciplina para anular los impulsos por defecto del agente. La Fase 14 se envió bien porque ambas mitades de ese par estaban haciendo su trabajo. El par es la unidad, no el agente.
Parte 10 — Lo que la Fase 14 significa para el lanzamiento
Código-completo en main no significa listo para usuarios. El bridge de Fase 14 está detrás de un feature flag (DEBLO_VIDEO_BRIDGE_ENABLED) que está actualmente activado en producción pero inactivo para end-users porque el build móvil que expone el botón de toggle de cámara todavía no está en TestFlight. Las próximas puertas son:
- Reconstrucción de EAS dev client para integrar el commit
15241f8. Sin esto, el dispositivo iOS con el que el CEO hace smoke-test no tiene el botón toggle de cámara, el overlay de previsualización de cámara, el botón flip, el scaffolding del transcript en streaming, o el toast auto-off. Estimado 20-25 minutos. - Completar smoke de los dos escenarios restantes que no corrieron esta noche: comportamiento de pantalla bloqueada en iOS (¿sobrevive el bridge de cámara al bloqueo y desbloqueo de la pantalla?) y la ruta de auto-off por silencio de 3 minutos (¿desmonta correctamente el worker el bridge tras 3 minutos de silencio del usuario con la cámara aún encendida?).
- Sesiones 28 y 29 entregadas. La sesión 28 arregla flip-camera y streaming-transcript y añade la entrada de tres botones de la página de inicio. La sesión 29 optimiza los system prompts para un comportamiento menos conservador en modo cámara mientras preserva los bloques de visual-discipline y live-camera.
- Monitor Sentry de 48 horas tras el siguiente checkpoint de estabilidad, vigilando picos de
video.frame.convert_fail, eventosvideo.bridge.shutdown_timeout, y cualquier categoría de error inesperada en la ruta de solo audio que pudiera regresar como efecto colateral. - Limpieza J+14 del código muerto de Fase 5.F (la ruta de clip de video discreto de 5 segundos) si el bridge de cámara resulta estable. La ruta del clip puede quedarse por ahora; arrastrar ambas durante una ventana de transición es barato.
Una vez que todo eso esté verde, la ruta camera-on es alcanzable para end-users. El documento maestro de lanzamiento se actualizó para referenciar Déblo Eyes como parte del trío Voice + Eyes + Chat (commit incluido en el sprint de Fase 14), y la copia de descripción de App Store se está actualizando en paralelo.
Lo que tenemos al final de hoy es una funcionalidad en la que estamos seguros arquitectónicamente — el bridge se sostiene, el costo está acotado, el modelo se integra correctamente, la resumption funciona más allá del cap de 2 minutos — y que sabemos inacabada en los bordes UX. La posición honesta es enviar la arquitectura, acordonar los bordes inacabados en sesiones de seguimiento nombradas, y resistir la tentación de declarar done antes de que done se alcance.
Conclusión
Déblo obtuvo ojos hoy. La arquitectura para streaming de cámara en tiempo real desde un cliente React Native hacia Gemini Live native audio a través de un worker Python LiveKit está ahora código-completa en main. El riesgo arquitectónico único (session_resumption(transparent=True) honrado en runtime más allá del cap de servidor de 2 minutos) está empíricamente validado por una prueba en vivo de 7 minutos. El momento único de production-down (cada sesión de voz reventando en None.handle tras un kwarg pasado condicionalmente como None) fue cazado en 90 segundos y hot-fixeado en cuatro minutos. El tuning de muestreo de frames (0,5 fps, 768 px, sparse alta calidad sobre denso baja calidad) está justificado tanto por la aritmética de costo como por las características perceptuales de los modelos visión-lenguaje sobre escenas estáticas cargadas de texto. Dos bugs de pulido UX y un problema de conservadurismo de system prompt están documentados, aplazados, y en cola para sesiones dedicadas en vez de medio-parcheados en el sitio.
La mayor lección del día no es sobre cámaras. Es sobre lo que «validación» significa. El Step 0 que ejecutamos nos dijo que el objeto de configuración era aceptable para el constructor del SDK. No nos dijo que la ruta de ejecución en runtime que consumía el objeto de configuración toleraría un None. El primero no nos dijo casi nada sobre el segundo. La disciplina en adelante, escrita en la memoria del agente y en nuestra guía interna de validación, es: un Step 0 que no ejerce el runtime no es un Step 0. Instanciar una clase e imprimir sus opciones es el primer 20 % del trabajo. El otro 80 % es arrancar una sesión real, ejercer la ruta que el SDK usa realmente en producción, y mirar lo que pasa. Si hubiéramos hecho eso esta mañana, la caída de producción de cuatro minutos a las 19:42 UTC esta noche no habría ocurrido, y la lección que estamos escribiendo ahora habría sido escrita por alguien más, en algún lugar más, contra un SDK diferente.
No lo hicimos, y la caída sí. La lección es la más barata de las dos — escrita, indexada, recuperada automáticamente por futuras sesiones de agente cuando aparezca un paso de validación de SDK. La próxima vez que añadamos un plugin mayor o subamos una versión mayor, el paso de validación en runtime estará en el plan desde el principio, no en el postmortem.
Déblo Eyes está enviado. El trío Voice + Eyes + Chat está estructuralmente completo. La ventana de lanzamiento está abierta.
Los ojos pueden ver lo que tú ves, en tiempo real, y recuerdan lo que vieron hace tres minutos.
Esta pieza fue escrita conjuntamente por Thales (CEO de ZeroSuite, construyendo Déblo y VeoStudio desde Abiyán, Costa de Marfil) y Claude Opus 4.7 — instancia Claude Code corriendo sobre macOS, ventana de contexto 1M. El sprint Fase 14 que describe fue ejecutado el 20 de mayo de 2026 por un agente Claude Code independiente desde un prompt autocontenido (session-logs/gemini-session-logs/phase-14-impl-prompt-agent.md), validado por Thales en dispositivo iOS real, y recapitulado al final del día en session-logs/gemini-session-logs/26-05-20-phase-14-session-master-recap.md. Los nueve commits descritos son, en orden: 785040d (worker bridge), ea4f358 (English instructions fix), 315280e (production-down hotfix), a0d07b0 (mobile UI), 156a23e (web parity), 590a284 (system prompts LIVE CAMERA MODE block), 5cf7a75 (sparse 0.5 fps + 768 px tuning), 6629761 (auto-off toasts and worker→client data-channel signaling), 202511a (camera preview overlay + flip button + default back camera), y 15241f8 (flip restartTrack pattern + context-aware X + streaming transcript scaffolding). Los dos items de pulido aplazados están seguidos en session-logs/upcoming-prompts/28-phase-14-mobile-polish-and-homepage-3-buttons.md; la optimización del system prompt está seguida en session-logs/upcoming-prompts/29-system-prompt-optimization-conservativeness.md. El documento de validación Step 0, incluyendo la anotación post-mortem sobre lo que falló en validar, se preserva en el repo en session-logs/gemini-session-logs/26-05-20-phase-14-step0-resumption-validation.md como registro de cómo se veía la validación pre-flight antes de que la caída reescribiera nuestra disciplina.