Un agente de IA propio que corre un modelo open source localmente, capaz de programar, buscar en internet y usar herramientas vía MCP — sin depender de APIs pagas.
Estado: las fases 0 a 6 andan de punta a punta en local — agente con herramientas, sandbox WASM, RAG híbrido, MCP, endpoint compatible con OpenAI, multiusuario con JWT y un CLI interactivo. 480 tests. El despliegue (Fase 7) está pausado a propósito: correr Ollama 24/7 en la nube cuesta ~$180/mes de RAM y el proyecto todavía no lo necesita.
Python · FastAPI · LangGraph · Ollama · PostgreSQL + pgvector · MCP · AG-UI · Pyodide/WASM · Docker
web / CLI → FastAPI (runs + SSE con eventos AG-UI) → agente LangGraph (checkpointer en Postgres) → herramientas: búsqueda web (Tavily), sandbox WASM (Pyodide), RAG híbrido (pgvector), MCP (n8n y otros).
- Plan de acción y arquitectura — visión, stack, estructura, ERD, flujo del agente, fases, decisiones y bugs de diseño detectados
- Contrato de la API — endpoints, runs, eventos AG-UI, documentos, sandbox
- Seguridad: modelo de amenazas y checklist — OWASP LLM 2025 + Agentic 2026
- Identidad visual y prompts de Canva
- Diagramas — el sistema completo y las conexiones con n8n, como páginas que se abren en el navegador
Un módulo por carpeta; cada carpeta tiene su README explicando qué va ahí.
api/ agent/ tools/ sandbox/ mcp_client/ rag/ models/ db/ web/ cli/ n8n/ docker/ tests/ evals/ perfil/ docs/
Hace falta uv y Ollama (local o en Docker).
# 1. Dependencias (crea el venv con Python 3.12 desde uv.lock)
uv sync
# 2. Configuración
cp .env.example .env
# Editar .env: como mínimo BYTE_API_KEY y BYTE_SECRET_KEY
# openssl rand -hex 32 (una para cada una)
# 3. El modelo (gratis, en tu PC)
ollama pull qwen2.5-coder:7b
# 4. El sandbox de ejecución de código (en otra terminal)
cd sandbox && npm install
SANDBOX_TOKEN=$(openssl rand -hex 32) npm start # el mismo token va en .env
cd ..
# 5. Postgres con pgvector (opcional; sin él todo queda en memoria)
# El --env-file es necesario: con -f, Compose busca el .env junto al compose
# (en docker/), no en la raíz, y la interpolación de SANDBOX_TOKEN falla.
docker compose --env-file .env -f docker/docker-compose.yml up -d postgres ollama
# 6. Levantar la API
uv run uvicorn api.main:app --reload- Chat mínimo: http://localhost:8000 (pide la API key y la canjea por una cookie)
- API y OpenAPI: http://localhost:8000/docs
- Salud:
curl localhost:8000/api/v1/health
En CPU, un 7B hace unos 5-15 tokens por segundo y un run son varias llamadas al
modelo: por eso BYTE_RUN_TIMEOUT_S viene en 600. Si ves runs que se cortan
solos, ese es el primer lugar donde mirar.
Sin DATABASE_URL, Byte arranca en memoria: sirve para probar, pero las
conversaciones se pierden al reiniciar (lo avisa en el log). Con BYTE_ENV=prod
directamente no arranca sin Postgres. Sin TAVILY_API_KEY el agente funciona
igual pero sin búsqueda web, y sin SANDBOX_URL/SANDBOX_TOKEN, sin ejecutar
código.
Lo más rápido es el CLI, que abre una ventana de chat y se queda:
uv run python -m cli.byte_cli # o `byte`, si se instaló el alias❯ ¿qué hace el archivo agent/runner.py?
✻ Worked for 12s · read_file
❯ calculá el Sharpe anualizado de [0.01, 0.012, -0.003, 0.008, 0.005]
✻ Worked for 9s · code_exec
❯ /model # cambiar de modelo sin perder la conversación
❯ /safe # pedir aprobación antes de ejecutar código
Y por HTTP, sin navegador:
KEY=tu-api-key
CONV=$(curl -s -X POST localhost:8000/api/v1/conversations \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' -d '{}' | jq -r .id)
# ?wait=true espera la respuesta completa en un solo JSON
curl -s -X POST "localhost:8000/api/v1/conversations/$CONV/messages?wait=true" \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"content":"buscá la última versión de FastAPI"}' | jquv run pytest -q # no necesita Ollama, Tavily ni Postgres
# Con Postgres levantado, se suman los tests del repositorio y el checkpointer
# contra la base real (en CI esto corre siempre)
BYTE_TEST_DATABASE_URL=postgresql://byte:byte@localhost:5432/byte uv run pytest -q
uv run ruff check .
uv run ruff format .
uv run pre-commit install # ruff + gitleaks antes de cada commit
cd sandbox && npm test # incluye la suite de escape del sandbox-
POST /auth/register,POST /auth/login,GET /me: contraseñas con argon2id y un JWT de 30 minutos. El token va enAuthorization: Bearer, el mismo header que la API key —se prueba primero el JWT y después la clave—, así que los clientes de OpenAI y n8n siguen funcionando sin cambios -
El login no dice si un email existe: mismo mensaje y mismo tiempo ante un email desconocido y una contraseña incorrecta, para que no se pueda enumerar quién está registrado
-
El filtro por dueño está en las tres capas (documentos, conversaciones y runs), con tests de aislamiento que corren también contra Postgres
-
Cada usuario ve lo suyo: ver, borrar, renombrar o escribir en una conversación ajena responde 404 —no 403, que confirmaría que ese id existe— y los listados no se mezclan. Con API key el dueño es
None, así que lo que había antes del multi-usuario sigue siendo accesible con ella -
Refresh token de 14 días, rotado en cada uso: el que se manda deja de valer y vuelve otro. Si aparece uno ya canjeado se revoca la sesión entera — hay dos copias dando vueltas y no se puede saber cuál es la del dueño
list_files, read_file y grep: el agente recorre un proyecto, abre el
archivo que importa y cita la línea. Es la diferencia entre un asistente que
habla de código y uno que lo mira.
Opt-in con BYTE_PROJECT_ROOT, y confinado a esa raíz. No es una precaución
de manual: el modelo elige las rutas a partir de lo que leyó, y lo que leyó
puede ser un README con instrucciones metidas adentro. Las rutas se comparan
después de resolve(), que sigue los symlinks — el caso que una comparación de
texto deja pasar, porque la ruta parece inocente y el destino real está afuera.
/model en el chat lista los configurados y cambia entre ellos. Sirve porque
ningún modelo local gana en todo: evals/COMPARACION.md mide cinco contra las
tareas que el agente hace de verdad, y granite4.1:8b saca 19/21 contra 16/21 de
qwen3:8b —acierta el Sharpe que el otro erra— pero tarda 60% más.
El cambio es explícito y no automático por una razón medida: en 16 GB no entran dos modelos a la vez, así que Ollama desaloja uno para cargar el otro y alternar cuesta ~27 s. Un clasificador que dudara pagaría ese precio cada vez que cambiara de opinión.
El criterio para evaluar cualquier modelo nuevo está ahí también: primero
verificar el tool calling, después la inteligencia. qwen2.5-coder:7b saca 2/21
no por tonto sino porque escribe la llamada como texto JSON en vez de emitirla
por el canal de herramientas — y el catálogo de Ollama lo declara tools igual.
byte ask, byte chat, byte search, byte run, byte docs, y
byte login / logout / whoami para entrar como usuario — sin login usa la
API key, que identifica a la instancia. El token se guarda en ~/.config/byte
con permisos 0600 y se renueva solo. Detalles en cli/.
Es el CLI definitivo: la reescritura en Go que el plan preveía quedó fuera de alcance, porque cambiaría código probado por código nuevo sin ganar nada para quien lo usa.
- Langfuse para las trazas del agente: por qué decidió usar una herramienta,
cuántas vueltas dio, qué devolvió cada una. El
langfuse_trace_idqueda en el mensaje, así que desde una respuesta se salta a su traza - Bugsink (self-hosted, un contenedor) para los errores
Las dos están apagadas y hay que encenderlas a mano. Byte corre local, y
Langfuse Cloud recibiría tus prompts y respuestas: sin LANGFUSE_PUBLIC_KEY no
sale nada de tu máquina. Bugsink es self-hosted, así que sus datos no salen de
tu red — y va con PHONEHOME=false, porque lo que se instala local no tiene por
qué avisarle a nadie.
Lo que sí sale, sale redactado: claves de API, JWT, URLs con credenciales,
emails y tarjetas se reemplazan por una marca ([API_KEY], [EMAIL]) antes
de enviarse. La redacción va enganchada en el cliente, no en cada punto de
instrumentación, para que no dependa de acordarse.
uv sync --extra observabilidad # los SDK no vienen por defecto
cd docker && docker compose --profile observabilidad up -d bugsink-
Cliente MCP (
mcp_client/): los servidores se declaran enBYTE_MCP_SERVERScomonombre=urly sus herramientas entran al registro del agente junto a las nativas, consource: "mcp:<nombre>". Un servidor caído no impide arrancar, igual que Byte arranca sin Tavily o sin sandbox -
Lista blanca: el modelo no elige a qué host se conecta Byte. Solo http(s):
stdioimplicaría lanzar procesos, que es otra superficie de ataque -
Contra el tool poisoning: los argumentos se validan contra el esquema que declara el servidor antes de ejecutar; el resultado entra al prompt marcado como contenido no confiable; la descripción se sanea y se atribuye (
[servidor MCP 'x'] …); y una herramienta externa no puede tapar a una nativa —code_execsigue siendo el sandbox de Byte -
La descripción no se envuelve en los delimitadores, a diferencia del resultado: medido contra qwen3:8b, envolverla rompe el tool calling (0 de 3 llamadas contra 3 de 3). El porqué está en
mcp_client/README.md -
Compatible con OpenAI:
POST /v1/chat/completions(con y sin streaming) yGET /v1/models. Open WebUI, Continue.dev o cualquier SDK de OpenAI usan a Byte como backend apuntando ahttp://localhost:8000/v1con laBYTE_API_KEYcomo clave. Elmodelse valida contra una lista blanca: un nombre arbitrario nunca llega a Ollamafrom openai import OpenAI c = OpenAI(base_url="http://localhost:8000/v1", api_key="<BYTE_API_KEY>") c.chat.completions.create(model="byte", messages=[{"role": "user", "content": "hola"}])
Lo que entra por ahí es el agente completo, con sus herramientas: una pregunta que necesite buscar en la web la busca. Las conversaciones quedan guardadas y se ven en la web y en
byte conversations. El modo seguro es la excepción — no hay forma de pedir una aprobación humana en ese formato, así que un run que la necesite se corta y lo dice -
n8n (opcional,
n8n/): tres workflows listos para importar — ingesta automática de documentos al RAG, un canal de email para preguntarle a Byte desde el correo, y n8n como servidor MCP para que Byte use sus herramientas. Va en un perfil aparte del compose (docker compose --profile n8n up -d n8n), así que no pesa si no se usa. Los JSON no llevan credenciales: cada nodo dice en sus notas cuál necesita
- Documentos:
POST /documentssube PDF, TXT o Markdown (máx. 20 MB, tipo validado por magic bytes), responde202e indexa en segundo plano. El estado se sigue conGET /documents/{id}:processing→indexed|error - Búsqueda híbrida sobre
pgvector: similitud de vector (embeddings denomic-embed-text, índice HNSW) combinada con coincidencia léxica (tsvector+ GIN). Expuesta enPOST /searchy como herramientadoc_searchdel agente - Citas: los fragmentos que usó el agente quedan en
MESSAGES.metadatacon sudocument_idychunk_id. Los chunks vienen de archivos de terceros, así que entran al prompt marcados como contenido no confiable, y leer un documento y querer ejecutar código en el mismo run dispara el modo seguro - Compactación: cuando el historial pasa el ~60% del contexto, los mensajes
viejos se resumen en
CONVERSATIONS.summaryen vez de descartarse, y el resumen se inyecta en cada turno. Los originales no se borran: la UI los sigue mostrando.POST /conversations/{id}/compactla fuerza a mano
- Servicio
sandbox/(Node + Pyodide): corre el código que escribe el agente dentro de WebAssembly, con un intérprete nuevo por ejecución. Cada capa de aislamiento se verificó contra un Pyodide sin endurecer, donde el vector funcionaba — el detalle y la tabla completa están ensandbox/README.md POST /executepara ejecutar directo (lo que va a usarbyte run), ycode_execcomo segunda herramienta del agente- Modo seguro (HITL): si en la conversación entró contenido externo —una
búsqueda web o un documento del RAG— y el agente quiere ejecutar código, el run
se detiene y espera confirmación humana, aunque nadie lo haya pedido. La
combinación "contenido de terceros + ejecutar código" es justo la que permite
que una inyección indirecta llegue a correr algo. Cuenta la conversación
entera, no el run: si mirara solo el run, partir el ataque en dos mensajes lo
evadiría. Se modela como estado AG-UI (
awaiting_approval) y se retoma conPOST /runs/{id}/resume, en el mismo run, desde el checkpoint evals/con 10 tareas para detectar regresiones del agente
Implementado:
- API según el contrato:
/health,/health/details, conversaciones (CRUD con paginación por cursor),POST /conversations/{id}/messages→GET /runs/{id}/events,POST /runs/{id}/cancel,GET /runs/{id},GET /messages/{id},GET /tools - Streaming SSE con eventos AG-UI (no nombres propios):
RUN_STARTED,STEP_*,TOOL_CALL_*,TEXT_MESSAGE_*,STATE_SNAPSHOT/STATE_DELTA,RUN_FINISHED,RUN_ERROR; cada evento conid:para reconectar conLast-Event-ID - Agente LangGraph con
StateGraphpropio:retrieve_context→agent→should_continue→tools→finalize, checkpointer conthread_id = conversation_id - Búsqueda web (Tavily) como única herramienta, con argumentos validados por Pydantic
- Logging estructurado con
structlogyrequest_iden logs y errores - Seguridad de Fase 0: API key hasheada comparada en tiempo constante, cookie
httpOnly+SameSite=Strictpara la web,events_tokenfirmado de 60 s de un solo uso ligado al run, rate limiting por credencial, tope de runs concurrentes, timeout y tope de iteraciones por run, resultados de herramientas delimitados como datos no confiables, CSP/HSTS/nosniff, CORS restringido,gitleaksypip-auditen CI - Un run por conversación (
409): dos a la vez compartirían el hilo del checkpointer y se pisarían el estado. Borrar una conversación corta sus runs en vuelo BYTE_ENV=prodno arranca sin Postgres: el checkpointer nunca queda en memoria en producción, como pide el plan- Página HTML mínima que consume el SSE (sin diseño: la identidad Byte llega en la Fase 5)
- 158 tests de Python + 25 del sandbox, con dobles de Ollama, Tavily y el sandbox. Los del repositorio y el checkpointer corren contra las dos implementaciones: en memoria siempre, y contra Postgres cuando hay uno (se saltean si no)
Pendiente de Fase 0:
- Correr el agente contra un Ollama real. El código y los tests están, pero los
tests usan un modelo falso: todavía no se ejecutó una conversación contra Ollama.
Primer paso:
qwen2.5-coder:7b, medir RAM connum_ctxexplícito; después el 30B-A3B - Deploy de prueba en Railway con la RAM mínima y medir el costo real
- Límite de gasto y alertas en Railway (se configura en el dashboard)
Decisiones que se corrieron de fase, a propósito:
POST /runs/{id}/resumey el modo seguro (HITL) van con el sandbox (Fase 1): hoysafe_modese acepta y se reporta, pero no hay herramienta peligrosa que aprobarIdempotency-Keyqueda para la Fase 4, junto con el resto del endurecimiento de la API- Markdown sanitizado con
nh3: la página mínima pinta contextContent, así que no hay HTML que sanear hasta la Fase 5
MIT