Cómo está hecho

No es un chat sobre una persona. Es un agente de voz de dominio cerrado con recuperación trazable, evals en CI y control de costos; la persona es el primer tenant. Esta página es para quien llega desde el CV y quiere ver las decisiones, no el demo.

Flujo

Navegador, servidor en Vercel, Gemini y datos, con el flujo de una sesiónNavegadorVercel · serverlessDatosUI Next.jsestados · tarjetas · textoAudioWorklet16 kHz ↑ · 24 kHz ↓ · flushWeb Speechúltimo escalónGemini Livespeech-to-speech/api/sessionTurnstile · rate limit · presupuesto/api/tools · /api/chattools en el servidor, nunca en el clientecore/prompt · BM25 · registry · guard · storageGemini Flash-Literuta de texto · evals · jueztenants/<id>/agent.yaml · identity.mdcorpus/*.mdPostgres (Neon)turns · messagesusage · settings① token② audio ↕③ toolCall
  1. El navegador pide un token efímero de Gemini. El servidor lo emite con la configuración del tenant bloqueada dentro del token (system prompt, tools, voz): el cliente no puede cambiarla y la API key nunca sale del servidor.
  2. El audio va directo del navegador a Gemini Live. Speech-to-speech nativo: ~300 ms al primer audio, interrupción incluida.
  3. Cuando el modelo necesita un dato, emite un toolCall. El navegador solo lo transporta: el tool corre en el servidor, contra el corpus. El corpus nunca toca el cliente.
  4. El resultado vuelve al modelo, que responde citando los documentos que lo respaldan.

Speech-to-speech, no pipeline

Un pipeline STT → LLM → TTS suma tres latencias en serie (alrededor de un segundo) y la interrupción hay que construirla a mano. Gemini Live devuelve audio a ~300 ms y trae la interrupción de fábrica. La reproducción usa un AudioWorklet con flush() en lugar de <audio>: interrumpir exige vaciar lo que aún no sonó. El plan original proponía un relay WebSocket propio; Vercel serverless no lo mantiene, y un token efímero con configuración bloqueada cumple el objetivo (la key no sale del servidor) sin un segundo servicio.

BM25, no vectores

El corpus son unos 25 documentos Markdown con frontmatter; cabe entero en el contexto. Un índice vectorial añadiría un servicio, un modelo de embeddings y un coste para un problema que un BM25 de 80 líneas resuelve con un test determinista: “esta consulta debe recuperar este documento”. Las coincidencias con tags, tecnologías o empresa refuerzan la puntuación; no filtran (un filtro excluyente dejaba fuera al documento correcto cuando un token casual era tag de otro). La interfaz Retriever.search(query, limit) no cambia: migrar a vectores es cambiar el cuerpo de una clase.

Los tools corren en el servidor

El navegador ve nombres y argumentos, nunca la implementación ni los datos. Los argumentos se validan con Zod, y el mismo schema genera la declaración que ve el modelo: una sola fuente de verdad. Argumentos inválidos vuelven como ok: false con el detalle, para que el modelo corrija, no como un 500.

Un solo tool con efecto

dejar_mensaje es el único tool que escribe algo. Sin tools destructivos, el radio de daño de un jailbreak es cero: lo peor que puede hacer un visitante hostil es dejar un mensaje, y ese tool tiene rate limit por IP, sanitización e idempotencia (el modelo a veces vuelve a llamarlo tras un “sí, guárdalo”). La contención es quitar capacidades, no acumular filtros.

Anti-alucinación

Datos y evals, no solo prompt. Capa 0: una ficha de ~2k tokens fija al inicio del prompt. Capa 1: el corpus, con un documento por cada pregunta predecible sin respuesta obvia (renta, por qué dejó cada trabajo, tecnologías que no ha usado): una respuesta redactada vale más que cualquier guardrail. Y una suite de evals que corre contra la ruta de texto con reglas deterministas y un modelo distinto como juez, con umbral: si un rechazo falla, el build falla.

Casos
76/76
último run 2026-09-20 23:41 UTC
Rechazos correctos
18/18
umbral: 100 %
Juez
gemini-3.1-flash-lite
distinto al modelo que responde

Métricas de producción

Sesiones
14
últimos 30 días
Turnos
53
Tasa de rechazo
6 %
turnos en que dijo que no tenía el dato
Primer audio p50
7105 ms
desde el click hasta oír al agente
Primer audio p95
70513 ms
Duración media
0 s
mediana por sesión

Contención y privacidad

Turnstile invisible antes de emitir tokens de voz; rate limit por IP y día; presupuesto diario por tenant que cierra el canal solo y avisa por correo al 50 % y al 100 %; kill-switch desde el panel, sin redeploy. Cascada de degradación: Gemini Live → voz del navegador (con aviso) → texto; nunca un error. Los turnos se guardan sin audio y con correos y teléfonos redactados, 30 días. El micrófono solo se activa tras consentimiento explícito.

Multi-tenant

Nada de la persona vive fuera de tenants/<id>/: configuración, ficha y corpus. El motor programa contra las interfaces Retriever, Tool y VoiceProvider. Un segundo tenant es una carpeta, no un fork.

Código

Todo es público: el repositorio, con el plan de trabajo, siete registros de decisión (ADR) que explican dónde y por qué la implementación se apartó del plan, y un historial de commits que cuenta el porqué de cada cambio.