La idea en una frase
Cada usuario corre con su propia cuenta de Anthropic, y nada se cruza
Un solo backend atiende a muchos usuarios. Por cada mensaje se lanza un proceso claude -p. Queremos que ese proceso (a) facture a la cuenta del usuario dueño del chat, y (b) no comparta nada con el de otro usuario: ni credenciales, ni archivos, ni sesiones.
Eso se logra con dos mitades: almacenamiento (la key vive cifrada en la DB) e inyección + aislamiento (en runtime se descifra a memoria y se inyecta en un proceso con su propio entorno y sus propios directorios).
Cómo leer este documento
Leyenda
en reposo, en la DB
solo en memoria, durante un run
cada uno tiene el suyo
Tres términos
| instance_id | el identificador canónico del usuario (su teléfono normalizado). Todo se keyea por acá. |
| holder | el dueño de la sesión: la conversación standalone, o el grupo (cuando varios chats se vinculan con /vincular y comparten contexto). |
| subprocess | el proceso claude -p que se lanza por mensaje. Su entorno (env) es cómo se le pasan los datos del usuario. |
Proceso A · puntual, lo hace el admin
Alta y asignación de la key
Anthropic no permite crear keys por API, así que el primer paso es siempre manual. A partir de ahí, el sistema valida, cifra y guarda.
- El admin crea la API key en la Console de Anthropic.Manual, fuera del sistema. La copia una sola vez.
- La asigna a un usuario vía panel / endpoint / CLI de admin.PUT /api/admin/users/{id}/anthropic-key · solo is_admin.
- El sistema la valida contra Anthropic antes de guardarla.Si es inválida o sin saldo → se rechaza y el admin reintenta.
- Se cifra y se persiste asociada al usuario. cifradaencrypt_str(key) (Fernet ⟵ bridge_secret) → omzg_user_anthropic_key.api_key_encrypted. Se guarda además key_hint (últimos 4, para la UI). Nunca en claro.
Proceso B · en cada mensaje del usuario
Ejecución: de un mensaje a una respuesta
- Llega el mensaje (WhatsApp / Telegram) y se resuelve el instance_id del usuario.
- Se busca su key en la DB y se aplica la precedencia:1.º key propia (asignada y activa) → 2.º key compartida → fail-closed (aborta + alerta). El login OAuth del server nunca se usa (normativa).
- Se descifra a memoria. en claro · efímerodecrypt_str(api_key_encrypted) → un string, vivo solo durante este run.
- Se arma el entorno del subprocess, con su key y sus directorios propios. aisladoVer la sección Aislamiento: HOME / CLAUDE_CONFIG_DIR / cwd por instance + ANTHROPIC_API_KEY del usuario.
- Se ejecuta claude -p con ese entorno.La key inyectada gana sobre el login OAuth del server (verificado: el CLI lo dice explícito).
- Anthropic factura a la cuenta dueña de esa key y la respuesta vuelve por el mismo canal.El puntero de sesión se guarda de nuevo en la DB para el próximo mensaje.
El punto central
Aislamiento: tres capas, ninguna fuga
El aislamiento no es una sola cosa: son tres capas que tapan fugas distintas. Juntas hacen que ni las credenciales, ni los datos, ni los archivos o sesiones de un usuario se crucen con los de otro.
CLAUDE_CONFIG_DIR = …/A/cfg
cwd = …/A/work
tokens Google / Notion = de A
CLAUDE_CONFIG_DIR = …/B/cfg
cwd = …/B/work
tokens Google / Notion = de B
Por qué se hace así
Un entorno por subprocess
Un backend único atiende a muchos usuarios en paralelo, y el env es el único canal para inyectarle a cada claude -p las credenciales de ese usuario. Hacerlo por subprocess (un dict fresco por mensaje) evita el “race” entre runs concurrentes y garantiza que la key/los datos de un usuario nunca lleguen al proceso de otro.
| Si fuera un env global | Con un env por subprocess |
|---|---|
| roto A setea KEY_A → B (concurrente) setea KEY_B → el run de A factura a B. Race y fuga. | ok env_A={…KEY_A} → proceso A · env_B={…KEY_B} → proceso B. Dos dicts, dos procesos, cero cruce. |
Además es efímero: el env vive solo mientras corre ese proceso y muere con él; la key descifrada no queda en ningún lado.
Continuidad de la conversación
Sesiones: puntero (DB) + transcript (disco)
Una sesión de claude -p son dos cosas en dos lugares. La fuente de verdad es la DB, no el disco. (Verificado con claude -p real + el código del orchestrator.)
| Pieza | Qué es | Dónde | Aislamiento |
|---|---|---|---|
| session_id (puntero) | un UUID, el “nombre” de la sesión | DB · claude_session_id | por holder |
| historial (fuente de verdad) | los mensajes de la conversación | DB · omzg_messages | por conversación |
| transcript (fast-path) | el .jsonl que el CLI usa para --resume | disco · CLAUDE_CONFIG_DIR/projects/<cwd>/<id>.jsonl | por instance |
Validación
La idea, validada de punta a punta
Probado con dos API keys reales y el modelo más barato. Las dos mitades (almacenamiento cifrado e inyección con aislamiento) quedaron demostradas.
| Qué se probó | Resultado |
|---|---|
| Las 2 keys funcionan contra la API y con claude -p | ✓ respuesta correcta |
| La key inyectada gana sobre el login OAuth del server | ✓ el CLI lo confirma; key inválida → 401 |
| Aislamiento total entre 2 instancias en paralelo | ✓ 6/6: cada agente leyó solo su secreto, sin fuga |
| Cifrado/descifrado de la key (round-trip) | ✓ reversible, decrypt == original |
Roadmap
Mejoras priorizadas
Tres mejoras decididas para construir sobre la base. No bloquean nada; suman valor operativo y de seguridad, ordenadas por retorno.
- Ledger de uso por usuario.
claude -p ya devuelve total_cost_usd + tokens en cada run. Persistirlos por instance_id da costo y consumo por usuario sin depender de la Console, y habilita límites y alertas. La data ya está; solo falta guardarla.
- Health-check de keys + revocación real.
Un job diario re-valida las keys activas y avisa antes de que una muerta sorprenda al usuario a mitad de conversación. Y al revocar, se desactiva la key en Anthropic vía Admin API; borrar la fila no la mataba (seguía viva en la cuenta).
- Cifrado versionado.
Guardar la versión del secret junto al cifrado permite rotar la llave maestra de forma incremental, sin invalidar todas las keys de golpe. Barato ahora, caro después de tener muchos usuarios.