Claude Code vive en la terminal. Se renderiza con React e Ink, un framework que dibuja componentes de interfaz en stdout mediante códigos de escape ANSI y un motor de layout Yoga compilado a WASM. Es hermoso como lo es un edificio brutalista: funcional, con opiniones firmes y nada interesado en tus preferencias.
Queríamos llevarlo al navegador. Una interfaz gráfica de verdad. Burbujas de chat, tarjetas de herramientas, solicitudes de permiso, un indicador de pensamiento que no desaparezca a mitad de una idea. Lo que vino después fueron seis días depurando flags sin documentar, peleando con el comportamiento de spawn propio de cada plataforma, viendo al mismo bug volver de entre los muertos tres veces y aprendiendo que la distancia entre “prototipo que funciona” y “funciona de verdad” se mide en groserías.
Día 1: la investigación, o cómo descubrimos que esto era una mala idea
La primera pregunta era obvia: ¿se le puede poner un skin a Claude Code? La respuesta era no. La interfaz de la terminal no es una capa de temas que puedas cambiar por otra. Es la aplicación. Componentes de React que se renderizan en una instancia de Ink que escribe directamente en el búfer de tu terminal. No existe el “solo cambia el CSS”. No hay CSS.
Lanzamos seis agentes de investigación en paralelo. Diseccionaron las entrañas de Claude Code, analizaron el protocolo de streaming del Agent SDK y revisaron nueve wrappers de interfaz gráfica de la comunidad que ya habían intentado alguna versión de esto: Claudia (Tauri, respaldado por YC), CodePilot (Electron/Next.js), Claudex (FastAPI/React) y otros seis. Cada uno había tomado un camino distinto. Ninguno había resuelto de forma limpia el problema de la ida y vuelta de los permisos.
Surgieron tres opciones. Crear desde cero una aplicación nueva con Tauri o Electron. Hacer un fork de Claude Code y arrancarle el renderizador de Ink. O ampliar nuestra Neural Interface, que ya tenía un servidor Express, infraestructura de WebSocket, sesiones de terminal y un explorador de archivos funcionando en el puerto 3344.
Elegimos la opción tres. No porque fuera la mejor arquitectura. Porque era la única en la que no teníamos que empezar de cero.
Día 2: “Solo tiene que lanzar un proceso”
El plan era simple. El usuario envía un mensaje por un WebSocket. El servidor lanza claude como subproceso con --output-format stream-json. Se analizan los eventos NDJSON. Se reenvían al navegador. Se renderizan burbujas de chat.
El proceso no se lanzó.
En Windows, claude no es un binario. Es un wrapper .cmd que el spawn() de Node no puede ejecutar sin shell: true. El error era ENOENT. Ni “archivo no encontrado”. Ni “plataforma incorrecta”. Simplemente... nada. El subproceso fallaba en silencio y el WebSocket se quedaba ahí, esperando eventos que nunca iban a llegar.
Añadimos shell: true. Entonces descubrimos el problema de las variables de entorno. Claude Code revisa las variables VSCODE_* y TERM_PROGRAM para detectar si se está ejecutando dentro de un IDE. Nuestro proceso del servidor las heredaba todas. La instancia anidada de Claude creía que estaba dentro de VS Code, y eso cambiaba su comportamiento de maneras que no podíamos predecir. Tuvimos que quitar todas las variables de entorno que interferían antes de lanzar el proceso, replicando el mismo patrón de filtrado que ya usaba nuestro lanzador de terminales.
Después el subproceso se quedó colgado. Se lanzaba, se conectaba, cargaba la configuración de sus servidores MCP. Y ahí se quedaba sin hacer nada, porque --strict-mcp-config no estaba definido y la inicialización del servidor MCP bloqueaba el proceso. Otro fallo silencioso. Otra hora perdida.
También descubrimos, por accidente, que claude -p --output-format stream-json exige en silencio el flag --verbose si quieres recibir los eventos de llamadas a herramientas. Sin él, obtienes el resultado final y nada de lo que pasa en medio. Esto no está documentado en ninguna parte. Lo encontramos leyendo el código fuente.
Día 3: que parezca algo
Cuando los eventos por fin empezaron a fluir, había que renderizarlos. El formato stream-json emite eventos como system.init, assistant (con bloques de contenido de texto y tool_use), tool_result y result. Cada uno necesitaba su propio renderizador.
La primera versión parecía una terminal haciéndose pasar por una aplicación de chat. Todo en monoespaciado, sin jerarquía visual, las llamadas a herramientas volcadas como JSON en crudo. Así que reescribimos el CSS desde cero. Un diseño al estilo GPT: columna centrada, max-width de 780px, avatar más contenido en los mensajes del asistente, burbujas de usuario en forma de píldora. Acento dorado, porque estábamos construyendo SynaBun y el dorado es el color de la marca.
El usuario lo odió.
No solo el dorado. Toda la paleta. Los degradados dorados del botón de enviar, la barra de desplazamiento con tinte dorado, los bordes dorados de las tarjetas de herramientas, el box-shadow dorado al enfocar el campo de entrada. Todo. Luego probamos con acentos azules. También los odió.
Quita el color. Que sea neutro. El panel debe integrarse con la Neural Interface, no gritar su propia identidad.
Así que arrancamos todos los colores de acento y reconectamos el panel entero al sistema de variables CSS de la propia Neural Interface. --t-faint, --s-subtle, --b-hover. Grises neutros por todas partes. El único elemento con color que quedó fue el icono de la “S” de la marca: un solo degradado dorado en un mar monocromo. Fue la decisión correcta. Hicieron falta tres reescrituras completas de la paleta para llegar ahí.
Día 4: el binario que se comió el flujo JSON
Alguien adjuntó un JPEG con el botón del clip.
El sistema de archivos adjuntos usaba file.text() para leer el contenido del archivo y lo inyectaba en el prompt mediante etiquetas <file>. Eso funciona bien con archivos de código, markdown y texto plano. Con un JPEG, file.text() devuelve basura binaria ilegible que se concatena en la cadena del prompt, se envía por stdin y corrompe de inmediato el flujo NDJSON. El subproceso se atraganta. El WebSocket muere. La interfaz se congela.
La solución fue una lista de permitidos. Unas 70 extensiones de archivos de texto y de código que es seguro leer como texto. Todo lo demás se rechaza con un mensaje de estado. También añadimos un límite de tamaño de 100KB y pusimos el contenido del archivo antes del texto del usuario en el prompt, para que Claude vea primero el contexto. Nueve pruebas nuevas. El tipo de bug que te hace preguntarte cómo pudo funcionar alguna vez, hasta que caes en que nadie había intentado adjuntar una imagen todavía.
Día 5: el fantasma del flag --print
Este merece su propia sección porque pasó tres veces.
Claude Code tiene un flag --print (forma corta: -p). Ejecuta Claude en modo no interactivo: envías un prompt, recibes una respuesta y termina. Además, aprueba automáticamente todas las llamadas a herramientas. Sin solicitudes de permiso. Sin eventos control_request. La CLI simplemente hace lo que quiere.
Para un skin que necesita mostrar tarjetas de permiso “Permitir / Denegar / Siempre”, esto es fatal. Si --print está entre los argumentos del spawn, la interfaz de permisos es código muerto. Totalmente implementada, totalmente estilizada, jamás activada.
11 de marzo: Notamos que las solicitudes de permiso no aparecían. Encontramos -p en el array de argumentos. Lo quitamos. Los permisos funcionaron. Celebramos.
12 de marzo: Los permisos dejaron de funcionar otra vez. Alguien había vuelto a añadir --print con un comentario que decía // --print is REQUIRED for stream-json. El comentario estaba equivocado. --input-format stream-json se encarga por sí solo de la entrada por stdin. El comentario estaba escrito con absoluta seguridad y era absolutamente incorrecto.
Ese “alguien” era la IA. En otra sesión, trabajando con otro plan, había concluido que --print era necesario porque la documentación de la CLI decía que stream-json “solo funciona con --print”. Esa documentación describía otro caso de uso. La IA no lo verificó. Simplemente añadió el flag y escribió un comentario convincente.
13 de marzo: El mismo bug. Tercera vez. La solución definitiva fue quitar --print y añadir un comentario tan agresivo que fuera imposible malinterpretarlo:
NO añadas --print / -p aquí. Fuerza el modo no interactivo, que aprueba automáticamente TODOS los usos de herramientas y suprime los eventos control_request. Este bug se corrigió 3 veces. Consulta el CHANGELOG.
Tres sesiones. Tres planes. Dos de ellos se contradecían. Uno decía “añade --print, es obligatorio”. El otro decía “quita --print, rompe los permisos”. Los dos los escribió la misma IA, con días de diferencia y con la misma seguridad. Así es como se ve de verdad construir con IA. No la demo pulida. La parte en la que tu propia herramienta te hace gaslighting de una sesión a otra.
La guerra del formato de control_response
Incluso después de arreglar lo del flag --print, las respuestas de permiso no funcionaban. El usuario hacía clic en “Permitir”. No pasaba nada. Claude se quedaba esperando.
El servidor normalizaba el mensaje control_response a un formato plano: {"type": "control_response", "request_id": "...", "response": {"behavior": "allow"}}. Limpio. Lógico. Incorrecto.
La CLI lee q.response.request_id y q.response.response. Espera un formato anidado en el que todo vive dentro de un wrapper response con un campo subtype: "success". Con el formato plano, q.response.request_id daba undefined, la búsqueda de la solicitud pendiente fallaba y la respuesta se descartaba en silencio.
Lo encontramos leyendo el código fuente real del SDK. Línea 13436 de cli.js. No la documentación. No los ejemplos. El código minificado. Ese es el nivel de arqueología que exigió este proyecto.
Día 6: pensar sobre el pensar
Claude Code tiene pensamiento extendido. Puedes definir niveles de esfuerzo: low, medium, high, max. Creamos un botón en la barra de herramientas para alternarlos. Un pequeño rayo que recorre los niveles con puntos que se van encendiendo progresivamente. La primera versión usaba un emoji de rayo con trucos de CSS a base de filter: saturate(). Feo. Lo reemplazamos por un SVG que hereda currentColor, como un icono civilizado.
Los colores estaban demasiado saturados. Brillo morado en el nivel máximo. Animación de pulso. Transformaciones de escala en el icono. Bastó una mirada para tenerlo claro: había que bajarle el tono. Así que apagamos todo. Quitamos el brillo, el pulso y la escala. De un azul suave a un violeta apagado a lo largo de los niveles. Discreto.
Después descubrimos que los bloques de pensamiento eran invisibles. La función renderAssistant() solo extraía los bloques de contenido text y tool_use. Los bloques de pensamiento se descartaban sin avisar. Con el esfuerzo en alto o máximo, Claude pensaba largo y tendido y el usuario no veía nada.
Añadimos la extracción de los bloques de pensamiento y los mostramos como elementos <details> plegables. El primer diseño usaba un borde izquierdo morado como acento. Se veía mal. Lo rediseñamos para que coincidiera exactamente con el patrón existente de las tarjetas de herramientas: los mismos tokens de CSS, el mismo border-radius, las mismas transiciones al pasar el cursor, la misma rotación de la flecha. Si parece que pertenece ahí, pertenece ahí.
El indicador de pensamiento tenía su propio problema. hideThinking() se llamaba con el primer evento de streaming del asistente, lo que quitaba los puntos de actividad mientras Claude seguía trabajando. En la CLI real, el spinner se mantiene durante todo el turno. Tuvimos que inventar un patrón repositionThinking() que mueve el indicador debajo del contenido nuevo a medida que llegan los mensajes, con un temporizador persistente que cuenta los segundos. Solo finish() lo elimina.
El marcador real
Seis días. Catorce archivos modificados en dos interfaces completas. Dos reescrituras totales del CSS. Tres paletas de colores rechazadas. Un sistema de archivos adjuntos que tuvo que aprender qué es un archivo binario. Un flujo de permisos que se rompió y se arregló tres veces distintas porque la IA no dejaba de contradecir sus propios arreglos anteriores. Un interruptor de pensamiento que pasó por cuatro iteraciones visuales. Un formato de control_response que nos obligó a leer el código minificado del SDK para acertar. Un subproceso que fallaba en silencio al lanzarse en Windows por tres motivos diferentes.
El skin final tiene: una página de chat independiente con selectores de proyecto y de branch, un panel lateral deslizante para la vista 3D, cambio de modelo, seguimiento de costos, archivos adjuntos con validación, tarjetas de solicitud de permisos con Permitir/Denegar/Siempre, pensamiento extendido con control de esfuerzo, compatibilidad con comandos de barra, streaming de mensajes parciales, atajos de teclado y un diseño redimensionable. Usa cero frameworks. JS puro, sin paso de build, sin bundler. Cada línea de CSS se escribió a mano y se discutió.
Lo que aprendimos de verdad
El comportamiento sin documentar es la norma, no la excepción. La obligación de usar el flag --verbose, el cambio en el ciclo de vida de --print, el formato de respuesta anidado, el wrapper .cmd de Windows, el bloqueo por la configuración de MCP. Nada de esto aparecía en ninguna guía. Lo descubrimos todo lanzando el proceso y viéndolo fallar.
Los planes generados por IA se contradicen entre una sesión y otra. El mismo modelo escribe dos planes para el mismo problema que recomiendan soluciones opuestas, ambos con total seguridad. Si construyes con IA, necesitas un sistema de memoria que registre las decisiones y sus razones. Nosotros teníamos uno. Aun así, no bastó para evitar que el flag --print se volviera a añadir dos veces.
El color es un campo minado. Lo que se ve bien aislado se ve mal en contexto. Lo que se ve bien con un panel de 420px de ancho se ve mal al 22%. Lo que se ve bien con un mensaje se ve mal con veinte. Cada decisión visual se tomó, se deshizo y se volvió a tomar en el navegador con contenido real.
Y la distancia entre «la función existe en el código» y «la función de verdad funciona de principio a fin» es donde se va la mayor parte del tiempo. La interfaz de permisos estaba completamente implementada el día 3. No funcionó de verdad hasta el día 6. Cada componente era correcto por separado. Todo se venía abajo en la integración.
El skin de Claude Code viene incluido en la Neural Interface de SynaBun. No es un fork, ni un wrapper, ni una aplicación aparte. Es el mismo servidor Express, el mismo WebSocket, el mismo puerto. Solo otra forma de mirar la misma conversación. Si eso valió seis días y un deterioro medible de la salud mental es una pregunta que todavía no estamos listos para responder.