Construir un asistente de IA específico por proyecto en Telegram
Un asistente de IA acotado a un único mandato de cliente es estructuralmente diferente de una herramienta de IA de toda la organización. Diferentes límites, diferente modelo de memoria, diferentes secretos, diferente ciclo de vida de despliegue. Esta guía describe el patrón de asistente de IA específico por proyecto que usamos en tva: un bot de Telegram por mandato de consultoría, cada uno respaldado por su propia instancia de Claude Code CLI con OAuth persistente, su propio espacio de trabajo, y su propia lista de participantes permitidos. El patrón es clonable entre mandatos como plantilla, y toda la pila corre en un único contenedor Docker.
Esta guía está redactada para que un desarrollador (o un LLM) pueda leerla de forma lineal y reproducir la configuración desde cero. Cada versión está fijada. Cada decisión de configuración está justificada. Cada decisión de arquitectura lista las alternativas que descartamos y el motivo.
Qué necesitarás
- Un servidor Linux con Docker Engine 29.x y Docker Compose v2 (nosotros ejecutamos en un VPS de Hetzner Cloud; cualquier host con soporte para contenedores funciona)
- Una suscripción Pro o Max de Anthropic (esta guía usa autenticación OAuth de suscripción, no facturación por clave API)
- Un bot de Telegram registrado a través de
@BotFathercon su token en mano - Los IDs de usuario de Telegram de todas las personas que quieres admitir en el asistente (a través de
@userinfobot) - Un navegador en tu portátil para el flujo OAuth de una sola vez
- Una idea clara del mandato de consultoría al que servirá esta instancia
Qué construye esto
- Un único contenedor Docker que combina un bot de Telegram en Python y Claude Code CLI en un solo runtime
- OAuth contra una suscripción Claude Max, con credenciales persistidas entre reinicios del contenedor
- Un middleware de lista de permitidos que descarta silenciosamente los mensajes de quienes no están en la lista, tanto en DMs como en grupos
- Un espacio de trabajo persistente donde el asistente mantiene sus propios archivos CLAUDE.md y notes/ a lo largo del tiempo
- Ingesta de archivos adjuntos de Telegram (PDFs, fotos, notas de voz, etc.) en
/workspace/incoming/ - Registros de conversación en Markdown, legibles por humanos, disponibles para el asistente a través de la herramienta Read
- Una capa de identidad (
HERMES_PROJECT_NAME,HERMES_INSTANCE_ID) que hace la pila clonable a otros mandatos con solo cambiar dos variables de entorno
El modelo de instancia por proyecto: por qué supera al modelo de toda la organización
El instinto por defecto al construir una herramienta de IA interna es hacerla para toda la organización: un bot, un espacio de trabajo, acceso para todos los miembros del equipo. Esto funciona para casos de uso de bajo riesgo (un bot de Slack que resume documentos, una herramienta interna de preguntas y respuestas). Se desintegra rápidamente en el trabajo de consultoría, donde cada mandato tiene su propio límite de confidencialidad, sus propias partes interesadas, su propia base de conocimiento y sus propios plazos.
El modelo de instancia por proyecto invierte esto. Un bot por mandato. Un espacio de trabajo por mandato. Una lista de permitidos por mandato. La memoria del asistente está acotada al proyecto, no al operador. Cuando el mandato termina, la instancia puede archivarse o destruirse sin afectar nada más.
En concreto:
- El bot de Telegram tiene un nombre de usuario específico del proyecto (por ejemplo,
@some_project_assistant_bot) registrado por separado en BotFather - El contenedor Docker tiene un nombre específico del proyecto (por ejemplo,
some-project-assistant), ejecutándose desde un directorio específico del proyecto (/opt/some-project-assistant/) - La sesión OAuth está acotada a esta instancia — idealmente una cuenta de Anthropic separada si ejecutas múltiples instancias, para evitar conflictos de rotación de refresh-token
- El espacio de trabajo en
/workspace/CLAUDE.mdcontiene únicamente el briefing de este mandato específico - La lista de permitidos contiene únicamente a los participantes de este mandato específico
Dos variables de entorno hacen la pila clonable como plantilla: HERMES_PROJECT_NAME (el nombre de visualización, usado en el prompt del sistema y en la salida de /help) y HERMES_INSTANCE_ID (el slug usado en las rutas de directorio y el identificador de sesión de Claude). Para clonar la pila para un nuevo cliente, cambias dos variables de entorno, registras un nuevo bot en BotFather, realizas un login OAuth nuevo, rellenas la plantilla del espacio de trabajo, y todo el código fuente permanece bit a bit idéntico.
Siete decisiones de arquitectura que tomar antes de escribir código
La razón por la que esta pila es pequeña y predecible es que tomamos siete decisiones deliberadas antes de escribir la primera línea de código. Cada decisión tiene alternativas, y las alternativas importan. Si tu contexto es diferente al nuestro, elegir la otra rama en cualquiera de estas te da una pila diferente (y posiblemente mejor). Listamos las decisiones, las alternativas que consideramos y los compromisos que guiaron nuestra elección.
Decisión 1: Granularidad de la memoria
La elección es entre una memoria global del asistente (una sesión de Claude para todos los chats, el asistente recuerda todo a través de DMs y grupos) versus una memoria por chat (cada chat tiene su propia sesión aislada, con estrictos límites de privacidad entre DMs y conversaciones de grupo).
Elegimos global. El razonamiento: un asistente de consultoría se beneficia de poder conectar información entre conversaciones. Lo discutido en un DM sobre la evaluación de un proveedor alimenta la conversación de grupo sobre el contrato de ese proveedor. La memoria por chat obligaría al operador a repetir el contexto, y el asistente se percibiría desconectado.
El costo es real: no hay límite de privacidad entre DMs y grupos. Cualquier cosa mencionada en un DM es potencialmente recuperable en una respuesta de grupo. Esta es una elección explícita y documentada, no un efecto secundario. Para un caso de uso diferente (por ejemplo, un bot de RRHH donde las revelaciones personales deben permanecer privadas), la memoria por chat sería la respuesta correcta.
Implementación: la opción --continue de Claude Code CLI con un directorio de trabajo fijo. El archivo de sesión vive en ~/.claude/projects/-workspace/sessions/<auto-id>.jsonl, persistido mediante bind-mount, y se reanuda en cada invocación posterior de claude desde el mismo directorio de trabajo.
Decisión 2: OAuth de suscripción vs clave API
Puedes manejar Claude Code CLI de dos maneras: con la suscripción Pro/Max del operador (basada en OAuth, sin facturación por llamada) o con una clave API de Anthropic (pago por token). El modo predeterminado es la suscripción. La trampa es que varias variables de entorno cambian silenciosamente a facturación por clave API si están definidas en el entorno padre.
Según la documentación de autenticación de Anthropic, el orden de resolución es: primero las opciones de proveedor cloud Bedrock/Vertex/Foundry, luego ANTHROPIC_AUTH_TOKEN, luego ANTHROPIC_API_KEY, luego apiKeyHelper, luego CLAUDE_CODE_OAUTH_TOKEN, y finalmente el OAuth de suscripción desde /login. Si cualquiera de las opciones de mayor precedencia está definida — incluso con una cadena vacía en algunos shells — el CLI no llegará al auth de suscripción.
Para garantizar operación solo con OAuth, define las seis variables de entorno explícitamente como cadenas vacías en el bloque environment: del archivo compose. Es defensivo pero tiene un costo negligible.
environment:
ANTHROPIC_API_KEY: ""
ANTHROPIC_AUTH_TOKEN: ""
ANTHROPIC_BASE_URL: ""
CLAUDE_CODE_USE_BEDROCK: ""
CLAUDE_CODE_USE_VERTEX: ""
CLAUDE_CODE_USE_FOUNDRY: ""
El compromiso: el auth de suscripción tiene una rotación de refresh-token de un solo uso que genera conflictos si la misma cuenta de Anthropic es usada tanto por el contenedor como por el portátil del operador simultáneamente. Si los usas en paralelo, tendrás cierres de sesión aleatorios. Para un asistente dedicado, una cuenta de Anthropic dedicada es la solución más limpia. Para contexto comparativo sobre diferentes herramientas de IA y sus modelos de autenticación, consulta nuestra comparación honesta de Claude Code, Cursor y otros agentes CLI.
Decisión 3: Un contenedor o dos
El bot necesita el framework de Telegram (Python, aiogram). El motor de Claude necesita Node y el CLI @anthropic-ai/claude-code. Puedes ejecutarlos como dos contenedores (por ejemplo, el bot en un contenedor Python, claude en un contenedor Node, con IPC entre ellos) o fusionarlos en uno.
El enfoque de dos contenedores es estructuralmente más limpio, pero introduce un problema de IPC. El bot necesita invocar claude como un subprocess, lo que significa que necesita acceso al socket Docker del otro contenedor (un riesgo de escalada de privilegios) o una capa IPC personalizada basada en archivos (latencia adicional y más código). Ninguna opción resulta atractiva.
El enfoque de contenedor único sacrifica la pureza de contenedor por la simplicidad operacional. Una imagen, una sesión OAuth, un conjunto de variables de entorno, un esquema de bind-mount. La imagen ocupa ~700 MB en lugar de ~120 MB, pero el disco rara vez es el cuello de botella.
Elegimos contenedor único. El Dockerfile instala tanto la pila Python (base slim + pip) como la pila Node (keyring de NodeSource + claude-code) en secuencia, expone un único entrypoint, y el bot invoca claude mediante asyncio.create_subprocess_exec. Sin IPC, sin socket proxy, sin redes entre contenedores.
Decisión 4: Bootstrap del espacio de trabajo
El asistente necesita una base de conocimiento. La elección es si sembrarla con contexto del proyecto (para que el operador no tenga que proporcionar cada dato a través del chat) o empezar vacía (el asistente aprende puramente de las interacciones).
Nosotros sembramos. Una plantilla de espacio de trabajo en templates/workspace-CLAUDE.md.template contiene secciones de marcadores de posición para: el perfil del operador, los participantes y sus roles, el contexto del mandato, las convenciones de lenguaje, e instrucciones sobre cómo el asistente debe mantener notas a lo largo del tiempo. Cuando se arranca una nueva instancia, la plantilla se copia en data/workspace/CLAUDE.md y se rellenan los marcadores.
El asistente luego mantiene el archivo él mismo a través de las herramientas Write y Edit. Cuando lo corriges («así no es como este cliente usa ese término»), puede actualizar el archivo del espacio de trabajo para que la corrección persista en sesiones futuras. Combinado con la memoria global de sesión, esto le da al asistente dos capas de estado: a corto plazo en la sesión de Claude, y a largo plazo en los archivos del espacio de trabajo. Ambas persisten entre reinicios del contenedor mediante bind-mounts.
Decisión 5: Comportamiento en grupos y modo privacidad
Los bots de Telegram en grupos tienen una configuración de modo privacidad: por defecto, un bot solo ve los mensajes dirigidos directamente a él (comandos, @menciones, respuestas). Los demás mensajes del grupo no se entregan al bot en absoluto. Puedes deshabilitar esto en BotFather (/setprivacy → Disable), con lo que el bot ve todos los mensajes en cada grupo del que forma parte.
Para un asistente de consultoría que debe aprender de las discusiones del grupo, deshabilitar es la configuración correcta. Pero esto plantea una pregunta adicional: ¿cómo decide el bot a cuáles mensajes responder y cuáles simplemente registrar?
Nuestro modelo de activación: en un DM, cada mensaje recibe una respuesta. En un grupo, el bot solo responde a (a) @menciones explícitas del bot, (b) respuestas a sus propios mensajes anteriores, o (c) mensajes que contienen la palabra «Hermes» como palabra independiente (sin distinguir mayúsculas, con límite de palabra). Todos los demás mensajes se registran en /workspace/conversations/chat-<id>.md pero no desencadenan una llamada a Claude.
Esto significa que el asistente tiene acceso de lectura al contexto del grupo (puede consultar el archivo de registro mediante la herramienta Read cuando necesita contexto) pero no genera ruido. El registro de conversación también es un artefacto humano útil — el operador puede leerlo con cat para ver el historial del proyecto.
Decisión 6: UX de respuesta
Las respuestas de Claude pueden tardar entre 5 y 30 segundos cuando se involucra el uso de herramientas (fetches web, lecturas de archivos, razonamiento en múltiples pasos). Las opciones son: en buffer (esperar la respuesta completa, enviar un mensaje) o en streaming (editar un único mensaje progresivamente a medida que llegan los tokens).
El streaming es más elegante pero más complejo: la tasa de edición por mensaje de Telegram está limitada, pero el techo exacto no está documentado como un único número. La tasa general de envío (los límites publicados por Telegram: aproximadamente 30 mensajes por segundo a nivel global, no más de uno por segundo por chat, 20 por minuto por grupo) ofrece un límite superior aproximado. Un stream de edición token a token naíf se limita rápidamente. La implementación requiere agregación de fragmentos, manejo de mensajes parciales desde la salida stream-json de Claude, y degradación elegante cuando las ediciones se limitan.
Elegimos buffered. Mientras se genera la respuesta, el bot envía una acción de chat typing cada 5 segundos para que el usuario vea «escribiendo...» en su cliente de Telegram. Cuando la respuesta está lista, se envía como uno o más mensajes. Las respuestas de más de 4.000 caracteres se dividen automáticamente en el último límite de párrafo antes del límite, con una pausa de 0,3 segundos entre mensajes para mantenerse dentro de la tasa de envío de Telegram.
Decisión 7: Lista estricta de permitidos como límite exterior
Un bot de Telegram es accesible por cualquier persona que encuentre su nombre de usuario. Si no filtras, usuarios aleatorios descubrirán el bot e intentarán comandos. Para un asistente de consultoría, esto es inaceptable: el asistente tiene las credenciales OAuth del operador, tiene acceso al espacio de trabajo, puede realizar llamadas a herramientas. No quieres a desconocidos en este circuito.
La lista de permitidos está implementada como un outer-middleware de aiogram a nivel de actualización — el punto de interceptación más alto, antes de la resolución de filtros y la búsqueda de handlers. La verificación se realiza sobre event_from_user.id (el ID numérico de usuario de Telegram, que es estable por usuario incluso cuando cambia su nombre de usuario). Los miembros de la lista de permitidos se configuran mediante una variable de entorno CSV (HERMES_ALLOWED_USERS). Si un remitente no está en el conjunto, el middleware devuelve None sin invocar el handler: ninguna entrada de registro más allá de un evento de descarte a nivel debug, ninguna escritura en el registro de conversación, ninguna llamada a Claude, ninguna respuesta.
Este también es el lugar correcto para la validación de que el operador debe estar permitido: el cargador de configuración (usando pydantic-settings) verifica que HERMES_OPERATOR_ID esté contenido en HERMES_ALLOWED_USERS al inicio. Las configuraciones incorrectas hacen que el contenedor falle inmediatamente en lugar de silenciosamente dejar fuera al operador.
La pila de contenedores
Con las siete decisiones tomadas, la pila surge naturalmente. Aquí está el docker-compose.yml completo:
services:
hermes:
build: ./hermes
container_name: project-assistant
restart: unless-stopped
init: true
stop_grace_period: 15s
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
pids_limit: 512
env_file:
- .env
environment:
ANTHROPIC_API_KEY: ""
ANTHROPIC_AUTH_TOKEN: ""
ANTHROPIC_BASE_URL: ""
CLAUDE_CODE_USE_BEDROCK: ""
CLAUDE_CODE_USE_VERTEX: ""
CLAUDE_CODE_USE_FOUNDRY: ""
DISABLE_AUTOUPDATER: "1"
PYTHONDONTWRITEBYTECODE: "1"
PYTHONUNBUFFERED: "1"
TERM: xterm-256color
volumes:
- ./data/claude:/home/hermes/.claude
- ./data/claude.json:/home/hermes/.claude.json
- ./data/bot:/data
- ./data/workspace:/workspace
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Opciones clave que no son evidentes a primera vista:
init: true— ejecuta tini como PID 1, de modo que el proceso Python recibe SIGTERM limpiamente. Sin esto,docker compose stopespera 10 segundos y luego envía SIGKILL, dejando la sesión del bot sin cerrar.stop_grace_period: 15s— ligeramente más largo que elpolling_timeoutpredeterminado de aiogram de 10 segundos. Esto le da tiempo al hook de apagado para cerrar limpiamente la sesión de Telegram, lo que evitaTelegramConflictError: terminated by other getUpdates requestcuando el contenedor se reinicia más rápido de lo que Telegram libera la conexión de long-poll anterior.cap_drop: ALLyno-new-privileges:true— el contenedor no necesita capacidades Linux ni escalada de privilegios. Ambas se restringen por defecto.- Sin
read_only: true— Claude Code escribe en~/.claude/,~/.npm/, y ocasionalmente en/tmp/para actualizaciones automáticas. Un sistema de archivos raíz de solo lectura requeriría extensos montajes tmpfs para compensar. No vale la pena por la ganancia de seguridad. - Los dos montajes de archivo —
./data/claude.json:/home/hermes/.claude.jsonmonta un archivo específico (no un directorio). Este archivo debe existir en el host antes decompose up, inicializado con{}, o Claude Code lanza un error de parseo JSON al inicio.
El Dockerfile combina los dos runtimes:
FROM python:3.13-slim-trixie
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
DEBIAN_FRONTEND=noninteractive
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl file git gnupg locales poppler-utils tmux \
&& locale-gen en_US.UTF-8 \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN userdel -r ubuntu 2>/dev/null || true \
&& useradd -m -u 1000 -s /bin/bash hermes \
&& mkdir -p /data /workspace \
&& chown -R hermes:hermes /data /workspace /app
COPY --chown=hermes:hermes src/ ./src/
USER hermes
WORKDIR /app
CMD ["python", "-u", "-m", "src.main"]
Las versiones fijadas que debes tener en cuenta:
python:3.13-slim-trixie— base Debian 13, estable actualaiogram>=3.28,<4.0— el framework del bot (fijamos en 3.28.x al momento de escribir; la última versión actual es 3.28.2)pydantic-settings>=2.14,<3.0— cargador de configuraciónstructlog>=25.4,<26.0— registro en JSON@anthropic-ai/[email protected]— instalado vía npm global desde NodeSource Node 20poppler-utils— para pdftotext como alternativa a la herramienta Read cuando el parseo de PDF de Claude no se adapta al archivo
Dos cosas que vigilar en este Dockerfile:
- La línea
userdel -r ubuntu. La base Python slim en Debian 13 no incluye un usuario predeterminado con UID 1000, pero si cambias a una imagen base que sí lo tiene (algunas derivadas de Ubuntu), el comandouseradd -u 1000fallará. Siempre elimina primero el usuario con UID 1000 existente. - El patrón de keyring de NodeSource. Evitamos el script de instalación
curl | bash; está obsoleto y no es reproducible. El enfoque de keyring + nombre de códigonodistroes reproducible y auditable.
Para una visión más profunda de cómo el endurecimiento de contenedores encaja en una pila de producción con múltiples servicios, consulta nuestro recorrido sobre ejecutar más de cien contenedores Docker en producción.
La pila Python: cinco módulos que hacen el trabajo
El código del bot está dividido en módulos enfocados. Ninguno es grande; el más extenso tiene alrededor de 200 líneas.
settings.py—BaseSettingsde pydantic-settings con unBeforeValidatorpersonalizado para la lista de permitidos separada por comas. El validador maneja el caso límite donde pydantic-settings decodifica en JSON un entero simple (una lista de permitidos de un solo elemento comoHERMES_ALLOWED_USERS=12345se convierte enint, no enlist[int]) y lo convierte en una lista de un solo elemento.middleware.py—AllowListMiddlewarecomodp.update.outer_middleware. La verificación sobredata.get("event_from_user")usa la extracción de contexto de usuario incorporada de aiogram.trigger.py—is_trigger(message, bot_id, bot_username). Devuelve(True, "DM" | "@mention" | "text_mention" | "reply" | "keyword")o(False, None). La coincidencia de palabra clave usa una expresión regular con límite de palabra (\bhermes\b) para que las subcadenas no activen el trigger.conversation_log.py— registros Markdown de solo adición en/workspace/conversations/chat-<id>.md. Tanto los mensajes entrantes como los salientes se registran.file_intake.py— maneja ocho tipos de adjunto de Telegram (document, photo, video, audio, voice, animation, sticker, video_note). Descarga en/workspace/incoming/chat-<id>/<timestamp>-<name>con un límite estricto de 20 MB (el tope de descarga de la bot API de Telegram).hermes_engine.py— envuelve el subprocess del CLI de Claude Code. Usaasyncio.create_subprocess_execconcwd=/workspacey--continue(después de la primera llamada) para mantener la sesión global.response_pipeline.py— combina la actualización del indicador de escritura, la división automática y el retraso entre mensajes.handlers.py— tres handlers de comandos (/ping,/status,/help) y un handler de mensaje por defecto que ejecuta la verificación de trigger y despacha al motor.
La invocación del CLI de Claude es la pieza central. Aquí está la llamada completa al subprocess:
cmd = [
"claude",
"--print",
"--add-dir", "/workspace",
"--dangerously-skip-permissions",
"--append-system-prompt", _build_system_prompt(),
]
if SESSION_MARKER.exists():
cmd.append("--continue")
cmd.append(_build_user_prompt(text, ctx))
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd="/workspace",
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
SESSION_MARKER.touch(exist_ok=True)
return stdout.decode("utf-8").strip()
El prompt del sistema establece la persona del asistente, la disciplina de validación (no afirmar, validar primero a través de herramientas), el ciclo de aprendizaje (escribir nuevos hechos en /workspace/CLAUDE.md o notes/), el formato de salida para Telegram (texto plano, sin Markdown — el modo predeterminado de Telegram no renderiza Markdown de forma fiable), y una nota sobre los registros de conversación disponibles para lectura bajo demanda.
El prompt de usuario envuelve el mensaje real con metadatos de contexto: fuente del chat (DM con X, o grupo Y con los miembros A, B, C), identidad del remitente, marca de tiempo, motivo de activación, y la ruta del archivo del registro de conversación para este chat. Esto permite al asistente decidir si consultar el contexto del grupo antes de responder.
Paso a paso: arrancar una nueva instancia
Asumiendo que el código está en un repositorio Git y tienes un servidor con Docker instalado, aquí está el bootstrap completo. Sustituye tus propios valores por <instance-id> y <project-name>.
Paso 1: Registra el bot con BotFather.
- En Telegram, envía un mensaje a
@BotFather /newbot→ establece nombre y nombre de usuario (por ejemplo,some_project_assistant_bot)- Guarda el token que devuelve BotFather
/setprivacy→ selecciona tu bot → Disable (para que el bot vea todos los mensajes del grupo, no solo comandos y menciones)- Opcional:
/setcommandsconping,status,help
Paso 2: Clona el repositorio y prepara los directorios en el servidor.
sudo mkdir -p /opt/<instance-id>
sudo chown $USER /opt/<instance-id>
git clone <repo-url> /opt/<instance-id>
cd /opt/<instance-id>
mkdir -p data/claude data/bot data/workspace/{notes,conversations,incoming}
touch data/claude.json
sudo chown -R 1000:1000 data/
El paso chown es crítico y fácil de omitir. Docker crea los directorios fuente de bind-mount faltantes como propiedad de root; si omites el chown, el usuario del contenedor (UID 1000) no puede escribir en ellos y el login OAuth falla silenciosamente. Verifica con stat -c "%a %u:%g" data/claude — quieres ver 1000:1000, no 0:0.
Paso 3: Inicializa el espacio de trabajo.
cp templates/workspace-CLAUDE.md.template data/workspace/CLAUDE.md
# Abre el archivo y reemplaza los marcadores:
# {{HERMES_PROJECT_NAME}}, {{OPERATOR_NAME}}, {{CLIENT_LEGAL_ENTITY}}, etc.
Paso 4: Configura los secretos.
cp .env.example .env
chmod 600 .env
sudo chown 1000:1000 .env
# Edita .env: TELEGRAM_BOT_TOKEN, HERMES_ALLOWED_USERS,
# HERMES_OPERATOR_ID, HERMES_PROJECT_NAME, HERMES_INSTANCE_ID
Paso 5: Condición previa — envía /start al bot.
Telegram no permite que un bot envíe un DM a un usuario hasta que el usuario haya iniciado el chat al menos una vez. Antes del primer inicio del contenedor, el operador debe abrir el bot en Telegram y enviar /start. El bot no responderá (no hay handler registrado para /start), pero Telegram abre el canal de DM internamente. Sin este paso, el primer DM de incorporación lanza TelegramForbiddenError.
Paso 6: Construye e inicia el contenedor.
docker compose build hermes
docker compose up -d hermes
sleep 5
docker compose logs --tail=20 hermes
Deberías ver una secuencia de registros JSON: startup (con nombre del proyecto y recuento de allowed_users), polling_initialized (con drop_pending=true), onboarding_sent (el primer DM al operador), luego los eventos de inicio de polling de aiogram.
Paso 7: Login OAuth.
Este es un paso interactivo de una sola vez. En una terminal suficientemente ancha para mostrar la URL de OAuth en una sola línea (más de 250 caracteres — de lo contrario la URL se corta y Cloudflare rechaza el auth con Unknown scope: us):
docker exec -it project-assistant tmux new-session -s claude
# Dentro del contenedor:
claude
# Dentro del REPL de claude:
/login
# Elige: "Claude Pro or Max subscription"
# Copia la URL mostrada, ábrela en un navegador, inicia sesión, pega el
# código de autorización de vuelta en la terminal.
/status # debe mostrar "Subscription", no "API key"
/quit
# Desconecta tmux con Ctrl-b d, luego sal del docker exec.
Paso 8: Prueba de humo en Telegram.
- DM al bot:
/ping→pong - DM al bot:
/status→ salida diagnóstica (recuento de lista de permitidos, mensajes de sesión, recuentos de registros) - DM al bot: una pregunta natural — el asistente debe responder usando el motor de Claude
- Envía un PDF pequeño o una imagen con un pie de foto — el asistente debe hacer referencia a su contenido
- Envía dos mensajes seguidos, donde el segundo referencia al primero — la memoria multiturn debe mantenerse
Paso 9: Agrega participantes adicionales (cuando estés listo).
- Cada participante obtiene su ID de usuario de Telegram desde
@userinfobot - Actualiza
.env:HERMES_ALLOWED_USERS=<operator_id>,<member1_id>,<member2_id> docker compose restart hermes- Crea un grupo de Telegram, invita a todos los miembros y al bot, prueba con
/ping@some_project_assistant_bot
Notas operacionales y riesgos conocidos
La pila ha estado funcionando de manera estable, pero vale la pena tener en cuenta varios problemas.
WAF de Cloudflare en la renovación de OAuth. El endpoint de autenticación de Anthropic está detrás de Cloudflare. Existe un problema conocido (abierto en el repositorio anthropics/claude-code) donde Cloudflare clasifica ciertas IPs de servidor como Linux headless y bloquea la renovación de OAuth permanentemente. Se han reportado bloqueos que duran semanas. La vía de recuperación es volver a autenticarse desde una IP diferente (residencial, VPN). La mitigación es evitar user-agents personalizados y bucles de reintento agresivos, y considerar claude setup-token (un token de un año generado desde una máquina autenticada, conectado al contenedor mediante CLAUDE_CODE_OAUTH_TOKEN) como alternativa si operas en un rango de IP de alto riesgo.
Rotación de uso único del refresh-token. Los refresh-tokens de OAuth de Anthropic son de un solo uso. Si dos clientes (por ejemplo, tu portátil y tu contenedor) comparten la misma cuenta y ambos renuevan en paralelo, la primera renovación invalida al otro lado. El consejo práctico: una cuenta de Anthropic dedicada por instancia de asistente. Si no puedes, acepta el re-login ocasional y no ejecutes Claude Code en tu portátil y en el asistente simultáneamente.
La trampa del claude.json vacío. Si data/claude.json es un archivo de cero bytes al primer inicio del contenedor, Claude Code lanza Configuration Error: invalid JSON, Unexpected EOF. Inicialízalo con echo "{}" > data/claude.json, no con touch. El error es recuperable en el REPL («Reset with default configuration»), pero es mejor evitar la fricción.
El bug de corte de línea en la URL de OAuth (nuestra observación). La URL de OAuth tiene aproximadamente 530 caracteres. En una terminal de ancho normal, se corta en múltiples líneas. Cuando copias la salida cortada, los saltos de línea van incluidos, y tras la codificación URL, el parámetro scope parece user:inference us\ner:profile. Cloudflare entonces ve us como un scope y rechaza con Invalid OAuth Request — Unknown scope: us. Esto no está registrado como un problema upstream al momento de escribir esto — lo encontramos nosotros mismos. Ampliar la terminal a más de 250 caracteres antes de lanzar claude lo evita, o puedes eliminar manualmente el corte de línea en la barra de direcciones de tu navegador después de pegar.
El conflicto de UID 1000 con Ubuntu. Algunas imágenes base modernas de Ubuntu — notablemente ubuntu:24.04 (Noble) tras el rebase OCI de Canonical — incluyen un usuario ubuntu predeterminado con UID 1000. Si cambias la base del Dockerfile de python:3.13-slim-trixie (que no incluye un usuario predeterminado con UID 1000) a una que sí lo haga, useradd -u 1000 hermes falla con UID 1000 is not unique. El Dockerfile incluye un userdel -r ubuntu 2>/dev/null || true defensivo antes de useradd por esta razón.
El registro de conversación puede crecer. Los registros de solo adición en /workspace/conversations/ crecen con cada mensaje. Tras meses de uso activo, los archivos individuales pueden alcanzar megabytes. No hay rotación incorporada. Si te importa, agrega una tarea de tipo cron para archivar registros más antiguos de N días, o divide por mes.
Para preocupaciones operacionales más amplias sobre servicios autoalojados y qué sucede cuando fallan, consulta nuestro artículo sobre recuperación ante desastres para servicios autoalojados.
Qué está deliberadamente fuera del alcance
La tentación al construir una herramienta de IA interna es agregar funcionalidades. Mantenemos esta pila pequeña. Los siguientes aspectos están explícitamente fuera del alcance de la versión descrita aquí, y hemos tomado una decisión activa de no agregarlos todavía:
- RAG / base de datos vectorial. El conocimiento del asistente está en archivos Markdown (
CLAUDE.mdynotes/) y registros de conversación. Las llamadas a la herramienta Read se encargan de la recuperación. Esto es suficiente para un alcance de mandato único. Una vez que el espacio de trabajo supere cierto tamaño, una capa RAG real (PostgreSQL + pgvector, por ejemplo) se vuelve necesaria, pero hasta entonces es innecesariamente complejo. - Transcripción de audio. Las notas de voz se descargan y los metadatos se registran, pero el asistente aún no puede transcribirlas. Agregar Whisper o un pipeline similar es medio día de trabajo, diferido hasta que sea necesario.
- Endpoint de health check. El contenedor no tiene servidor HTTP; no hay nada que monitorear. La política de reinicio de Docker más el monitoreo de registros cubre la mayoría de los modos de fallo.
- Respuestas en streaming. Ver Decisión 6 más arriba.
- Multi-tenant en un único contenedor. Cada mandato obtiene su propio contenedor. Esto es intencional — ver el modelo de instancia por proyecto más arriba.
Las funcionalidades diferidas no son errores. Son elecciones, y reducen la superficie de todo lo que sí existe. Para contexto sobre la disciplina de mantener las herramientas de IA acotadas, consulta construir habilidades de agente de IA para flujos de trabajo específicos del dominio y la visión general de Claude Skills.
Clonar para el próximo mandato
El diseño de dos variables de entorno da frutos aquí. Para poner en marcha una nueva instancia:
- Clona el repositorio en
/opt/<new-instance-id> - Cambia
HERMES_PROJECT_NAMEyHERMES_INSTANCE_IDen.env - Registra un nuevo bot con BotFather (una sola vez)
- Rellena la plantilla del espacio de trabajo con el contexto del nuevo mandato (una sola vez)
- Login OAuth desde dentro del nuevo contenedor (una sola vez, idealmente en una cuenta de Anthropic separada)
docker compose up -d
El código es bit a bit idéntico entre instancias. Lo único que varía son las dos variables de entorno, las credenciales del bot, el contenido del espacio de trabajo y la sesión OAuth.
Puedes ejecutar múltiples instancias en el mismo servidor. Cada una tiene su propio directorio, su propio nombre de contenedor, su propio árbol de bind-mount. El uso de disco es aproximadamente 700 MB de imagen (compartida entre instancias gracias a la caché de capas de Docker) más el crecimiento del espacio de trabajo por instancia (típicamente decenas de MB después de meses).
Dónde encaja esto en la cadena de herramientas
Un asistente de IA específico por proyecto no reemplaza las herramientas de IA de uso general para programación. Seguimos usando Claude Code, Cursor y Gemini CLI directamente para el trabajo de desarrollo. El asistente es para el contexto de consultoría: memoria del proyecto, análisis de documentos, actualizaciones de estado, investigación ad hoc dentro de un mandato definido. Se ejecuta en paralelo al resto de la pila de herramientas de IA, no en su lugar.
El patrón tampoco reemplaza a los productos de IA basados en chat como Claude.ai o ChatGPT. Esos son la respuesta correcta para tareas puntuales, preguntas personales y trabajo de conocimiento general. Un asistente específico por proyecto es la respuesta correcta cuando el proyecto tiene su propio límite, sus propios participantes y su propia base de conocimiento en evolución que no quieres volcar en un chatbot genérico cada vez que necesitas referenciarla.
Si estás pensando en construir algo similar para tu práctica de consultoría, agencia o equipo interno, la pila anterior es un punto de partida razonable. El Dockerfile, el archivo compose y la estructura de módulos son reproducibles a partir de esta guía. Las decisiones están documentadas. Si tu contexto difiere del nuestro en cualquiera de las siete decisiones, ramifica y adapta — el código es lo suficientemente corto como para que reescribir un módulo sea sencillo. Contáctanos si quieres intercambiar experiencias o que te ayudemos con una implementación específica.