tva
← Insights

Construindo um Assistente de IA por Projeto via Telegram

Um assistente de IA restrito a um único mandato de consultoria é estruturalmente diferente de uma ferramenta de IA para toda a organização. Limites diferentes, modelo de memória diferente, credenciais diferentes, ciclo de vida de deploy diferente. Este guia descreve o padrão de assistente de IA por projeto que usamos na tva: um bot do Telegram por mandato de consultoria, cada um com sua própria instância do Claude Code CLI com OAuth persistente, seu próprio workspace e sua própria lista de participantes autorizados. O padrão é clonável como template entre mandatos, e todo o stack roda em um único container Docker.

Este guia foi escrito para que um desenvolvedor (ou um LLM) possa lê-lo linearmente e reproduzir a configuração do zero. Cada versão está fixada. Cada escolha de configuração está justificada. Cada decisão de arquitetura lista as alternativas que rejeitamos e o motivo.

O Que Você Vai Precisar

  • Um servidor Linux com Docker Engine 29.x e Docker Compose v2 (rodamos em um VPS Hetzner Cloud; qualquer host com suporte a containers funciona)
  • Uma assinatura Anthropic Pro ou Max (este guia usa autenticação OAuth por assinatura, não faturamento por API key)
  • Um bot do Telegram registrado via @BotFather com o token em mãos
  • Os IDs de usuário do Telegram de todas as pessoas que você quer autorizar no assistente (via @userinfobot)
  • Um navegador no seu computador para o fluxo OAuth único
  • Uma ideia clara do mandato de consultoria que esta instância irá atender

O Que Isso Constrói

  • Um único container Docker que combina um bot Python do Telegram e o Claude Code CLI em um único runtime
  • OAuth contra uma assinatura Claude Max, com credenciais persistidas entre reinicializações do container
  • Um middleware de lista de permissões que descarta silenciosamente mensagens de qualquer pessoa que não esteja na lista, tanto em DMs quanto em grupos
  • Um workspace persistente onde o assistente mantém seus próprios arquivos CLAUDE.md e notes/ ao longo do tempo
  • Ingestão de arquivos para anexos do Telegram (PDFs, fotos, notas de voz, etc.) em /workspace/incoming/
  • Logs de conversa em Markdown, legíveis por humanos, disponíveis ao assistente via Read-Tool
  • Uma camada de identidade (HERMES_PROJECT_NAME, HERMES_INSTANCE_ID) que torna o stack clonável para outros mandatos com apenas duas alterações de variáveis de ambiente

O Modelo de Instância por Projeto: Por Que Por Mandato Supera o Modelo Organizacional

O instinto padrão ao construir uma ferramenta de IA interna é torná-la abrangente para toda a organização: um bot, um workspace, todos os membros do time com acesso. Isso funciona para casos de uso de baixo risco (um bot no Slack que resume documentos, uma ferramenta interna de perguntas e respostas). Mas quebra rapidamente no trabalho de consultoria, onde cada mandato tem seu próprio limite de confidencialidade, seus próprios stakeholders, sua própria base de conhecimento e seus próprios prazos.

O modelo de instância por projeto inverte isso. Um bot por mandato. Um workspace por mandato. Uma lista de permissões por mandato. A memória do assistente é delimitada ao projeto, não ao operador. Quando o mandato termina, a instância pode ser arquivada ou destruída sem tocar em nada mais.

Na prática:

  • O bot do Telegram tem um nome de usuário específico do projeto (ex.: @some_project_assistant_bot) registrado separadamente no BotFather
  • O container Docker tem um nome específico do projeto (ex.: some-project-assistant), rodando a partir de um diretório específico do projeto (/opt/some-project-assistant/)
  • A sessão OAuth é delimitada a esta instância — idealmente uma conta Anthropic separada se você rodar múltiplas instâncias, para evitar conflitos de rotação de refresh-token
  • O workspace em /workspace/CLAUDE.md contém apenas o briefing deste mandato específico
  • A lista de permissões contém apenas os participantes deste mandato específico

Duas variáveis de ambiente tornam o stack clonável como template: HERMES_PROJECT_NAME (o nome de exibição, usado no system prompt e na saída do /help) e HERMES_INSTANCE_ID (o slug usado em caminhos de diretório e no identificador de sessão do Claude). Para clonar o stack para um novo cliente, você altera duas variáveis de ambiente, registra um novo bot no BotFather, executa um login OAuth do zero, preenche o template do workspace e o código completo permanece bit-idêntico.

Sete Decisões de Arquitetura a Tomar Antes de Escrever Código

A razão pela qual este stack é pequeno e previsível é que tomamos sete decisões deliberadas antes de escrever a primeira linha de código. Cada decisão tem alternativas, e as alternativas importam. Se o seu contexto é diferente do nosso, escolher o outro caminho em qualquer uma delas resulta em um stack diferente (e possivelmente melhor). Listamos as decisões, as alternativas que consideramos e os trade-offs que guiaram nossa escolha.

Decisão 1: Granularidade de Memória

A escolha é entre memória global do assistente (uma sessão Claude para todos os chats; o assistente lembra tudo entre DMs e grupos) versus memória por chat (cada chat tem sua própria sessão isolada, com limites rígidos de privacidade entre DMs e conversas em grupo).

Escolhemos global. O raciocínio: um assistente de consultoria se beneficia de conseguir conectar informações entre conversas. O que foi discutido em um DM sobre uma avaliação de fornecedor alimenta a conversa em grupo sobre o contrato desse fornecedor. Memória por chat forçaria o operador a repetir contexto, e o assistente pareceria desconectado.

O custo é real: não há limite de privacidade entre DMs e grupos. Qualquer coisa mencionada em um DM é potencialmente recuperável em uma resposta de grupo. Esta é uma escolha explícita e documentada — não um efeito colateral. Para um caso de uso diferente (por exemplo, um bot de RH onde revelações pessoais precisam permanecer privadas), a memória por chat seria a resposta correta.

Implementação: a flag --continue do Claude Code CLI com um diretório de trabalho fixo. O arquivo de sessão fica em ~/.claude/projects/-workspace/sessions/<auto-id>.jsonl, persistente via bind-mount, e é retomado a cada invocação subsequente do claude a partir do mesmo diretório de trabalho.

Decisão 2: OAuth por Assinatura vs. API Key

Você pode acionar o Claude Code CLI de duas formas: com a assinatura Pro/Max do operador (baseada em OAuth, sem faturamento por chamada) ou com uma Anthropic API key (pagamento por token). O padrão é por assinatura. A armadilha é que várias variáveis de ambiente mudam silenciosamente para faturamento por API key se estiverem definidas no ambiente pai.

De acordo com a documentação de autenticação da Anthropic, a ordem de resolução é: flags de cloud provider Bedrock/Vertex/Foundry primeiro, depois ANTHROPIC_AUTH_TOKEN, depois ANTHROPIC_API_KEY, depois apiKeyHelper, depois CLAUDE_CODE_OAUTH_TOKEN, e finalmente o OAuth por assinatura via /login. Se qualquer uma das opções de maior precedência estiver definida — mesmo como string vazia em alguns shells — o CLI não chegará ao auth por assinatura.

Para garantir operação somente via OAuth, defina explicitamente todas as seis variáveis de ambiente como strings vazias no bloco environment: do compose file. É defensivo, mas tem custo mínimo.

environment:
  ANTHROPIC_API_KEY: ""
  ANTHROPIC_AUTH_TOKEN: ""
  ANTHROPIC_BASE_URL: ""
  CLAUDE_CODE_USE_BEDROCK: ""
  CLAUDE_CODE_USE_VERTEX: ""
  CLAUDE_CODE_USE_FOUNDRY: ""

O trade-off: o auth por assinatura tem rotação de refresh-token de uso único que conflita se a mesma conta Anthropic for usada tanto pelo container quanto pelo notebook do operador simultaneamente. Se você usar em paralelo, terá logouts aleatórios. Para um assistente dedicado, uma conta Anthropic dedicada é a resposta mais limpa. Para contexto de comparação sobre diferentes ferramentas de IA e seus modelos de auth, veja nossa comparação honesta de Claude Code, Cursor e outros agentes CLI.

Decisão 3: Um Container ou Dois

O bot precisa do framework Telegram (Python, aiogram). O motor Claude precisa do Node e do CLI @anthropic-ai/claude-code. Você pode rodá-los como dois containers (por exemplo, bot em container Python, claude em container Node, IPC entre eles) ou mesclá-los em um só.

A abordagem de dois containers é estruturalmente mais limpa, mas introduz um problema de IPC. O bot precisa invocar o claude como subprocess, o que significa que ele precisa de acesso ao Docker socket do outro container (um risco de escalonamento de privilégios) ou de uma camada IPC baseada em arquivos (latência extra e mais código). Nenhuma das opções é atraente.

A abordagem de container único troca pureza de container por simplicidade operacional. Uma imagem, uma sessão OAuth, um conjunto de variáveis de ambiente, um layout de bind-mount. A imagem tem ~700 MB em vez de ~120 MB, mas disco raramente é o gargalo.

Escolhemos container único. O Dockerfile instala tanto o stack Python (base slim + pip) quanto o stack Node (keyring NodeSource + claude-code) em sequência, expõe um único entrypoint, e o bot chama o claude via asyncio.create_subprocess_exec. Sem IPC, sem socket proxy, sem rede entre containers.

Decisão 4: Bootstrap do Workspace

O assistente precisa de uma base de conhecimento. A escolha é entre pré-popular com contexto do projeto (para que o operador não precise informar cada fato via chat) ou começar vazio (o assistente aprende puramente pelas interações).

Escolhemos pré-popular. Um template de workspace em templates/workspace-CLAUDE.md.template contém seções de placeholder para: o perfil do operador, os participantes e seus papéis, o contexto do mandato, convenções de linguagem e instruções de como o assistente deve manter notas ao longo do tempo. Quando uma nova instância é inicializada, o template é copiado para data/workspace/CLAUDE.md e os placeholders são preenchidos.

O assistente então mantém o arquivo por conta própria via as ferramentas Write e Edit. Quando você o corrige — por exemplo: «esse cliente não usa o termo assim» — ele pode atualizar o arquivo do workspace para que a correção persista em sessões futuras. Combinado com a memória de sessão global, isso dá ao assistente duas camadas de estado: de curto prazo na sessão Claude, de longo prazo nos arquivos do workspace. Ambas persistem entre reinicializações do container via bind-mounts.

Decisão 5: Comportamento em Grupos e Modo de Privacidade

Bots do Telegram em grupos têm uma configuração de modo de privacidade: por padrão, um bot só vê mensagens diretamente endereçadas a ele (comandos, @menções, respostas). Outras mensagens do grupo não são entregues ao bot. Você pode desabilitar isso no BotFather (/setprivacy → Disable), e o bot passa a ver todas as mensagens em todos os grupos dos quais é membro.

Para um assistente de consultoria que deve aprender com discussões em grupo, desabilitado é a configuração correta. Mas isso levanta uma pergunta subsequente: como o bot decide a quais mensagens responder e quais apenas registrar?

Nosso modelo de gatilho: em um DM, cada mensagem recebe uma resposta. Em um grupo, o bot só responde a (a) @menções explícitas do bot, (b) respostas às suas próprias mensagens anteriores, ou (c) mensagens que contenham a palavra «Hermes» como palavra isolada (sem distinção de maiúsculas/minúsculas, com correspondência por limite de palavra). Todas as outras mensagens são registradas em /workspace/conversations/chat-<id>.md mas não acionam uma chamada ao Claude.

Isso significa que o assistente tem visibilidade do contexto do grupo (pode consultar o arquivo de log via a Read tool quando precisar de contexto) mas não gera ruído. O log de conversa também é um artefato humano útil — o operador pode exibi-lo para ver o histórico do projeto.

Decisão 6: UX de Resposta

Respostas do Claude podem levar de 5 a 30 segundos quando há uso de ferramentas (buscas na web, leituras de arquivo, raciocínio em múltiplas etapas). As opções são: em buffer (aguardar a resposta completa, enviar uma mensagem) ou em stream (editar uma única mensagem progressivamente conforme os tokens chegam).

O modo stream é mais elegante, mas mais complexo: a taxa de edição por mensagem do Telegram é limitada, mas o teto exato não está documentado como um único número. A taxa geral de envio (limites publicados pelo Telegram: cerca de 30 mensagens por segundo globalmente, no máximo uma por segundo por chat, 20 por minuto por grupo) fornece um limite superior aproximado. Um stream ingênuo token por token é limitado rapidamente. A implementação exige agregação de chunks, tratamento de mensagens parciais da saída stream-JSON do Claude e degradação graciosa quando as edições são limitadas.

Escolhemos em buffer. Enquanto a resposta está sendo gerada, o bot envia uma ação de chat typing a cada 5 segundos para que o usuário veja «digitando...» no cliente do Telegram. Quando a resposta está pronta, ela é enviada como uma ou mais mensagens. Respostas com mais de 4.000 caracteres são divididas automaticamente no último limite de parágrafo antes do limite, com uma pausa de 0,3 segundos entre as mensagens para permanecer dentro da taxa de envio do Telegram.

Decisão 7: Lista de Permissões Estrita como Limite Externo

Um bot do Telegram é acessível por qualquer pessoa que encontre seu nome de usuário. Se você não filtrar, usuários aleatórios vão descobrir o bot e tentar comandos. Para um assistente de consultoria, isso é inaceitável: o assistente tem as credenciais OAuth do operador, tem acesso ao workspace, pode fazer chamadas de ferramentas. Você não quer estranhos nesse loop.

A lista de permissões é implementada como um outer-middleware do aiogram no nível de atualização — o ponto de interceptação mais alto, antes da resolução de filtros e da busca de handlers. A verificação é feita em event_from_user.id (o ID numérico de usuário do Telegram, que é estável por usuário mesmo quando ele muda o nome de usuário). Os membros da lista são configurados via uma variável de ambiente CSV (HERMES_ALLOWED_USERS). Se o remetente não estiver no conjunto, o middleware retorna None sem invocar o handler: nenhuma entrada de log além de um evento de descarte em nível debug, nenhuma escrita no log de conversa, nenhuma chamada ao Claude, nenhuma resposta.

Este também é o lugar certo para a validação de que o operador deve estar na lista: o carregador de configurações (usando pydantic-settings) verifica que HERMES_OPERATOR_ID está contido em HERMES_ALLOWED_USERS na inicialização. Configurações incorretas causam falha imediata do container em vez de silenciosamente bloquear o operador.

O Stack do Container

Com as sete decisões tomadas, o stack se define naturalmente. Aqui está o 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"

Escolhas-chave que não ficam óbvias apenas lendo:

  • init: true — executa tini como PID 1, para que o processo Python receba SIGTERM corretamente. Sem isso, docker compose stop aguarda 10 segundos e então envia SIGKILL, deixando a sessão do bot aberta.
  • stop_grace_period: 15s — ligeiramente maior que o polling_timeout padrão do aiogram de 10 segundos. Isso dá ao hook de shutdown tempo para fechar a sessão do Telegram de forma limpa, o que evita TelegramConflictError: terminated by other getUpdates request quando o container reinicia mais rápido do que o Telegram libera a conexão de long-poll anterior.
  • cap_drop: ALL e no-new-privileges:true — o container não precisa de capabilities Linux nem de escalonamento de privilégios. Ambos estão restringidos por padrão.
  • Sem read_only: true — o Claude Code escreve em ~/.claude/, ~/.npm/ e ocasionalmente em /tmp/ para auto-atualizações. Um sistema de arquivos root somente leitura exigiria mounts tmpfs extensos para compensar. Não vale o ganho de segurança.
  • Os dois mounts de arquivo./data/claude.json:/home/hermes/.claude.json monta um arquivo específico (não um diretório). Este arquivo deve existir no host antes de compose up, inicializado com {}, ou o Claude Code lança um erro de parse JSON na inicialização.

O Dockerfile combina os dois 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"]

As versões fixadas que você deve conhecer:

  • python:3.13-slim-trixie — base Debian 13, estável atual
  • aiogram>=3.28,<4.0 — o framework do bot (fixamos em 3.28.x no momento desta escrita, o latest atual é 3.28.2)
  • pydantic-settings>=2.14,<3.0 — carregador de configurações
  • structlog>=25.4,<26.0 — logging JSON
  • @anthropic-ai/[email protected] — instalado via npm global a partir do Node 20 do NodeSource
  • poppler-utils — para pdftotext como fallback da Read-tool quando o parsing de PDF do Claude não se encaixa no arquivo

Dois pontos a observar neste Dockerfile:

  1. A linha userdel -r ubuntu. A base slim do Python no Debian 13 não inclui um usuário padrão com UID 1000, mas se você mudar para uma imagem base que inclui (alguns derivados do Ubuntu), o useradd -u 1000 falhará. Sempre remova o usuário UID 1000 existente primeiro.
  2. O padrão de keyring do NodeSource. Evitamos o script de instalação curl | bash; ele está obsoleto e não é reproduzível. A abordagem com keyring e o codename nodistro é reproduzível e amigável para auditoria.

Para uma visão mais aprofundada de como o hardening de containers se encaixa em um stack de produção com múltiplos serviços, veja nosso passo a passo de rodar mais de cem containers Docker em produção.

O Stack Python: Cinco Módulos que Fazem o Trabalho

O código do bot está dividido em módulos focados. Nenhum deles é grande; o maior tem cerca de 200 linhas.

  • settings.pyBaseSettings do pydantic-settings com um BeforeValidator personalizado para a lista de permissões separada por vírgulas. O validator trata o caso extremo em que o pydantic-settings decodifica JSON de um inteiro simples (uma lista de permissões com um único elemento como HERMES_ALLOWED_USERS=12345 se torna int, não list[int]) e o converte em uma lista de um único elemento.
  • middleware.pyAllowListMiddleware como dp.update.outer_middleware. A verificação em data.get("event_from_user") usa a extração de contexto de usuário embutida do aiogram.
  • trigger.pyis_trigger(message, bot_id, bot_username). Retorna (True, "DM" | "@mention" | "text_mention" | "reply" | "keyword") ou (False, None). A correspondência de palavra-chave usa uma regex por limite de palavra (\bhermes\b) para que substrings não acionem o gatilho.
  • conversation_log.py — logs Markdown somente-acréscimo em /workspace/conversations/chat-<id>.md. Tanto as mensagens de entrada quanto as de saída são registradas.
  • file_intake.py — trata oito tipos de anexo do Telegram (document, photo, video, audio, voice, animation, sticker, video_note). Baixa para /workspace/incoming/chat-<id>/<timestamp>-<name> com limite rígido de 20 MB (o teto de download da bot-API do Telegram).
  • hermes_engine.py — encapsula o subprocess do Claude Code CLI. Usa asyncio.create_subprocess_exec com cwd=/workspace e --continue (após a primeira chamada) para manter a sessão global.
  • response_pipeline.py — combina atualização do indicador de digitação, divisão automática e delay entre mensagens.
  • handlers.py — três handlers de comando (/ping, /status, /help) e um handler de mensagem padrão que executa a verificação de gatilho e despacha para o motor.

A invocação do CLI do Claude é a peça central. Aqui está a chamada completa ao 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()

O system prompt define a persona do assistente, a disciplina de validação (não afirme, valide via ferramentas primeiro), o loop de aprendizado (escreva novos fatos em /workspace/CLAUDE.md ou em notes/), o formato de saída para o Telegram (texto simples, sem Markdown — o modo padrão do Telegram não renderiza Markdown de forma confiável) e uma nota sobre os logs de conversa estarem disponíveis para leitura sob demanda.

O user prompt encapsula a mensagem real com metadados de contexto: origem do chat (DM com X, ou grupo Y com membros A, B, C), identidade do remetente, timestamp, motivo do gatilho e o caminho do arquivo do log de conversa deste chat. Isso permite que o assistente decida se deve consultar o contexto do grupo antes de responder.

Passo a Passo: Inicializando uma Nova Instância

Assumindo que o código está em um repositório Git e que você tem um servidor com Docker instalado, aqui está o bootstrap completo. Substitua seus próprios valores por <instance-id> e <project-name>.

Passo 1: Registre o bot no BotFather.

  • No Telegram, envie mensagem para @BotFather
  • /newbot → defina nome e nome de usuário (ex.: some_project_assistant_bot)
  • Salve o token que o BotFather retornar
  • /setprivacy → selecione seu bot → Disable (para que o bot veja todas as mensagens do grupo, não apenas comandos e menções)
  • Opcional: /setcommands com ping, status, help

Passo 2: Clone o repositório e prepare os diretórios no 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/

O passo do chown é crítico e fácil de esquecer. O Docker cria diretórios de origem de bind-mount ausentes como propriedade do root; se você pular o chown, o usuário do container (UID 1000) não consegue escrever neles e o login OAuth falha silenciosamente. Verifique com stat -c "%a %u:%g" data/claude — você quer 1000:1000, não 0:0.

Passo 3: Inicialize o workspace.

cp templates/workspace-CLAUDE.md.template data/workspace/CLAUDE.md
# Abra o arquivo e substitua os placeholders:
# {{HERMES_PROJECT_NAME}}, {{OPERATOR_NAME}}, {{CLIENT_LEGAL_ENTITY}}, etc.

Passo 4: Configure os segredos.

cp .env.example .env
chmod 600 .env
sudo chown 1000:1000 .env
# Edite .env: TELEGRAM_BOT_TOKEN, HERMES_ALLOWED_USERS,
# HERMES_OPERATOR_ID, HERMES_PROJECT_NAME, HERMES_INSTANCE_ID

Passo 5: Pré-condição — envie /start para o bot.

O Telegram não permite que um bot envie um DM para um usuário até que o usuário tenha iniciado o chat pelo menos uma vez. Antes do primeiro start do container, o operador deve abrir o bot no Telegram e enviar /start. O bot não responderá (nenhum handler está registrado para /start), mas o Telegram abre o canal de DM internamente. Sem este passo, o primeiro DM de onboarding lança TelegramForbiddenError.

Passo 6: Construa e inicie o container.

docker compose build hermes
docker compose up -d hermes
sleep 5
docker compose logs --tail=20 hermes

Você deve ver uma sequência de logs JSON: startup (com nome do projeto e contagem de allowed_users), polling_initialized (com drop_pending=true), onboarding_sent (o primeiro DM ao operador), depois os eventos de início de polling do aiogram.

Passo 7: Login OAuth.

Este é um passo interativo único. Em um terminal largo o suficiente para exibir a URL OAuth em uma única linha (250+ caracteres — caso contrário a URL quebra e o Cloudflare rejeita o auth com Unknown scope: us):

docker exec -it project-assistant tmux new-session -s claude
# Dentro do container:
claude
# Dentro do REPL do claude:
/login
# Escolha: "Claude Pro or Max subscription"
# Copie a URL exibida, abra-a em um navegador, faça login, cole o
# código de autorização de volta no terminal.
/status   # deve mostrar "Subscription", não "API key"
/quit
# Desanexe o tmux com Ctrl-b d, depois saia do docker exec.

Passo 8: Smoke test no Telegram.

  • DM para o bot: /pingpong
  • DM para o bot: /status → saída de diagnóstico (contagem da lista de permissões, mensagens de sessão, contagens de log)
  • DM para o bot: uma pergunta natural — o assistente deve responder usando o motor Claude
  • Envie um PDF pequeno ou imagem com uma legenda — o assistente deve referenciar seu conteúdo
  • Envie duas mensagens em sequência, onde a segunda referencia a primeira — a memória multi-turno deve funcionar

Passo 9: Adicione participantes adicionais (quando estiver pronto).

  • Cada participante obtém seu ID de usuário do Telegram via @userinfobot
  • Atualize o .env: HERMES_ALLOWED_USERS=<operator_id>,<member1_id>,<member2_id>
  • docker compose restart hermes
  • Crie um grupo no Telegram, convide todos os membros e o bot, teste com /ping@some_project_assistant_bot

Notas Operacionais e Riscos Conhecidos

O stack tem rodado de forma estável, mas alguns problemas merecem atenção.

Cloudflare WAF no refresh OAuth. O endpoint de auth da Anthropic fica atrás do Cloudflare. Existe um problema conhecido (aberto no repositório anthropics/claude-code) em que o Cloudflare classifica certos IPs de servidor como Linux headless e bloqueia o refresh OAuth permanentemente. Usuários relataram bloqueios que duraram semanas. O caminho de recuperação é reautenticar a partir de um IP diferente (residencial, VPN). A mitigação é evitar user-agents personalizados e loops de retry agressivos, e considerar claude setup-token (um token de um ano gerado a partir de uma máquina autenticada, inserido no container via CLAUDE_CODE_OAUTH_TOKEN) como fallback se você operar em uma faixa de IP de alto risco.

Rotação de refresh-token de uso único. Os refresh tokens OAuth da Anthropic são de uso único. Se dois clientes (por exemplo, seu notebook e seu container) compartilharem a mesma conta e ambos renovarem em paralelo, o primeiro refresh invalida o outro lado. O conselho prático: uma conta Anthropic dedicada por instância de assistente. Se não for possível, aceite o eventual re-login e não rode o Claude Code no seu notebook e no assistente simultaneamente.

A armadilha do claude.json vazio. Se data/claude.json for um arquivo de zero bytes no primeiro start do container, o Claude Code lança Configuration Error: invalid JSON, Unexpected EOF. Inicialize-o com echo "{}" > data/claude.json, não com touch. O erro é recuperável no REPL (via «Reset with default configuration»), mas é melhor evitar o atrito.

O bug de quebra de linha da URL OAuth (nossa observação). A URL OAuth tem aproximadamente 530 caracteres. Em um terminal de largura normal, ela quebra em múltiplas linhas. Quando você copia a saída quebrada, as quebras de linha vêm junto, e após a codificação URL, o parâmetro de escopo parece user:inference us\ner:profile. O Cloudflare então vê us como um escopo e rejeita com Invalid OAuth Request — Unknown scope: us. Isso não está rastreado como um problema upstream no momento desta escrita — descobrimos por conta própria. Alargar o terminal para 250+ caracteres antes de lançar o claude evita o problema, ou você pode manualmente remover as quebras de linha da URL na barra de endereços do navegador após colar.

O conflito de UID 1000 do Ubuntu. Algumas imagens base modernas do Ubuntu — notavelmente ubuntu:24.04 (Noble) após o rebase OCI da Canonical — incluem um usuário padrão ubuntu com UID 1000. Se você mudar a base do Dockerfile de python:3.13-slim-trixie (que não inclui um usuário padrão UID 1000) para uma que inclui, useradd -u 1000 hermes falhará com UID 1000 is not unique. O Dockerfile inclui um userdel -r ubuntu 2>/dev/null || true defensivo antes do useradd por esse motivo.

O log de conversa pode crescer. Os logs somente-acréscimo em /workspace/conversations/ crescem com cada mensagem. Após meses de uso ativo, arquivos individuais podem chegar a megabytes. Não há rotação embutida. Se isso for uma preocupação, adicione uma tarefa estilo cron para arquivar logs com mais de N dias, ou divida por mês.

Para preocupações operacionais mais amplas sobre serviços auto-hospedados e o que acontece quando eles falham, veja nosso artigo sobre recuperação de desastres para serviços auto-hospedados.

O Que Está Deliberadamente Fora do Escopo

A tentação ao construir uma ferramenta de IA interna é adicionar funcionalidades. Mantemos este stack pequeno. Os itens a seguir estão explicitamente fora do escopo para a versão descrita aqui, e fizemos uma escolha ativa de ainda não adicioná-los:

  • RAG / banco de dados vetorial. O conhecimento do assistente está em arquivos Markdown (CLAUDE.md e notes/) e logs de conversa. Chamadas da Read-tool lidam com a recuperação. Isso é suficiente para o escopo de um único mandato. Quando o workspace ultrapassar um determinado tamanho, uma camada RAG real (PostgreSQL + pgvector, por exemplo) se tornará necessária, mas até lá é exagero.
  • Transcrição de áudio. Notas de voz são baixadas e os metadados são registrados, mas o assistente ainda não consegue transcrevê-las. Adicionar Whisper ou um pipeline similar é meio dia de trabalho, adiado até ser necessário.
  • Endpoint de health check. O container não tem servidor HTTP; não há nada para fazer scraping. A política de restart do Docker somada ao monitoramento de logs cobre a maioria dos modos de falha.
  • Respostas em stream. Ver Decisão 6 acima.
  • Multi-tenant em um único container. Cada mandato tem seu próprio container. Isso é intencional — ver o modelo de instância por projeto acima.

As funcionalidades adiadas não são bugs. São escolhas, e elas reduzem a superfície de área para o que de fato existe. Para contexto sobre a disciplina de manter ferramentas de IA com escopo restrito, veja construindo AI agent skills para fluxos de trabalho específicos de domínio e a visão geral das Claude Skills.

Clonando para o Próximo Mandato

O design com duas variáveis de ambiente se paga aqui. Para inicializar uma nova instância:

  1. Clone o repositório para /opt/<new-instance-id>
  2. Altere HERMES_PROJECT_NAME e HERMES_INSTANCE_ID no .env
  3. Registre um novo bot no BotFather (único)
  4. Preencha o template do workspace com o contexto do novo mandato (único)
  5. Login OAuth de dentro do novo container (único, idealmente em uma conta Anthropic separada)
  6. docker compose up -d

O código é bit-idêntico entre instâncias. As únicas coisas que variam são as duas variáveis de ambiente, as credenciais do bot, o conteúdo do workspace e a sessão OAuth.

Você pode rodar múltiplas instâncias no mesmo servidor. Cada uma tem seu próprio diretório, seu próprio nome de container, sua própria árvore de bind-mounts. O uso de disco é de aproximadamente 700 MB de imagem (compartilhado entre instâncias graças ao cache de camadas do Docker) mais o crescimento do workspace por instância (tipicamente dezenas de MB após meses).

Onde Isso Se Encaixa no Conjunto de Ferramentas

Um assistente de IA específico por projeto não é um substituto para ferramentas de IA de propósito geral para desenvolvimento. Ainda usamos Claude Code, Cursor e Gemini CLI diretamente para trabalho de desenvolvimento. O assistente é para o contexto de consultoria: memória do projeto, análise de documentos, atualizações de status, pesquisa ad hoc dentro de um mandato definido. Ele roda em paralelo ao restante do stack de ferramentas de IA, não em substituição a ele.

O padrão também não é um substituto para produtos de IA baseados em chat como Claude.ai ou ChatGPT. Esses são a resposta certa para tarefas pontuais, perguntas pessoais e trabalho de conhecimento geral. Um assistente específico por projeto é a resposta certa quando o projeto tem seu próprio limite, seus próprios participantes e sua própria base de conhecimento em evolução que você não quer despejar em um chatbot genérico toda vez que precisar referenciá-la.

Se você está considerando construir algo similar para sua prática de consultoria, agência ou time interno, o stack acima é um ponto de partida razoável. O Dockerfile, o compose file e a estrutura de módulos são reproduzíveis a partir deste guia. As decisões estão documentadas. Se o seu contexto difere do nosso em qualquer uma das sete decisões, ramifique e adapte — o código é curto o suficiente para que reescrever um módulo seja direto. Entre em contato se quiser trocar experiências ou precisar de ajuda com uma implementação específica.


Insights Relacionados

Artigos relacionados