tva
← Insights

Telegramでプロジェクト専用AIアシスタントを構築する

単一のクライアント案件にスコープされたAIアシスタントは、組織全体向けのAIツールと構造的に異なります。境界が違い、メモリモデルが違い、シークレットが違い、デプロイのライフサイクルも違います。本ガイドでは、tvaが実際に使用しているプロジェクト専用AIアシスタントのパターンを解説します。コンサルティング案件ごとに1つのTelegramボット、それぞれが独自のClaude Code CLIインスタンス(永続的なOAuth付き)、独自のワークスペース、独自の参加者許可リストを持ちます。このパターンは案件をまたいでテンプレートとしてクローン可能であり、スタック全体が単一のDockerコンテナで動作します。

本ガイドは、開発者(またはLLM)が順を追って読むことで、ゼロからセットアップを再現できるように書かれています。バージョンはすべてピン留めされています。設定の選択はすべて根拠が示されています。アーキテクチャ上の判断はすべて、検討して却下した代替案とその理由とともに記載されています。

必要なもの

  • Docker Engine 29.xとDocker Compose v2が動作するLinuxサーバー(tvaではHetzner Cloud VPSを使用しています。コンテナが動作するホストであれば何でも構いません)
  • Anthropic ProまたはMaxのサブスクリプション(本ガイドではAPIキー課金ではなく、OAuthサブスクリプション認証を使用します)
  • @BotFatherに登録済みのTelegramボットとそのトークン
  • アシスタントへのアクセスを許可したいすべてのユーザーのTelegramユーザーID(@userinfobot経由で取得)
  • 一度だけ行うOAuthフローのためのラップトップ上のブラウザ
  • このインスタンスが担当するコンサルティング案件の明確なイメージ

構築されるもの

  • PythonのTelegramボットとClaude Code CLIを1つのランタイムに統合した単一のDockerコンテナ
  • Claude Maxサブスクリプションに対するOAuth認証(コンテナ再起動後も認証情報が永続化)
  • 許可リストにないユーザーからのメッセージを、DMでもグループでも静かにドロップするミドルウェア
  • アシスタントが独自のCLAUDE.mdとnotes/ファイルを継続的に管理する永続ワークスペース
  • Telegramの添付ファイル(PDF、写真、音声メモなど)を/workspace/incoming/に取り込むファイルインテーク
  • Markdownで人間が読める形式の会話ログ(アシスタントがRead-Toolで利用可能)
  • 2つの環境変数の変更で他の案件にスタックをクローン可能にするアイデンティティレイヤー(HERMES_PROJECT_NAMEHERMES_INSTANCE_ID

プロジェクトインスタンスモデル:なぜ案件単位が組織全体より優れているか

社内AIツールを構築する際のデフォルトの発想は、組織全体で使えるものにすることです。ボットが1つ、ワークスペースが1つ、すべてのチームメンバーがアクセスできる。これは低リスクなユースケース(ドキュメントを要約するSlackボット、社内Q&Aツールなど)では機能します。しかし、コンサルティング業務ではすぐに破綻します。案件ごとに独自の機密境界、独自の利害関係者、独自の知識ベース、独自の締め切りがあるからです。

プロジェクトインスタンスモデルはこれを逆転させます。案件ごとに1つのボット。案件ごとに1つのワークスペース。案件ごとに1つの許可リスト。アシスタントのメモリはオペレーターではなく、プロジェクトにスコープされます。案件が終了すると、他のものに触れることなくそのインスタンスをアーカイブまたは削除できます。

具体的には:

  • Telegramボットはプロジェクト専用のユーザー名(例:@some_project_assistant_bot)でBotFatherに個別登録されています
  • Dockerコンテナはプロジェクト専用の名前(例:some-project-assistant)を持ち、プロジェクト専用のディレクトリ(/opt/some-project-assistant/)から起動されます
  • OAuthセッションはこのインスタンスにスコープされており、複数のインスタンスを運用する場合はリフレッシュトークンのローテーション競合を避けるため、別のAnthropicアカウントが理想的です
  • /workspace/CLAUDE.mdのワークスペースにはこの特定の案件のブリーフィングのみが含まれています
  • 許可リストにはこの特定の案件の参加者のみが含まれています

2つの環境変数がスタックをテンプレートとしてクローン可能にします。HERMES_PROJECT_NAME(システムプロンプトと/help出力で使用される表示名)とHERMES_INSTANCE_ID(ディレクトリパスとClaudeセッション識別子で使用されるスラグ)です。新しいクライアント向けにスタックをクローンするには、2つの環境変数を変更し、BotFatherに新しいボットを登録し、新しいOAuthログインを実行し、ワークスペーステンプレートを記入するだけで、コードベース全体はビット単位で同一のまま保たれます。

コードを書く前に決定すべき7つのアーキテクチャ上の判断

このスタックが小さく予測可能である理由は、最初の1行のコードを書く前に7つの意図的な決定を下したからです。それぞれの決定には代替案があり、その代替案は重要です。もしあなたのコンテキストがtvaと異なるなら、これらのいずれかで別の選択をすることで、異なる(そしておそらくより優れた)スタックが生まれます。判断内容、検討した代替案、選択を導いたトレードオフを以下に記載します。

判断1:メモリの粒度

選択肢は、グローバルアシスタントメモリ(すべてのチャットに対して1つのClaudeセッション、アシスタントがDMとグループをまたいですべてを記憶する)と、チャットごとのメモリ(各チャットが独自の分離されたセッションを持ち、DMとグループ会話の間に厳格なプライバシー境界がある)のどちらかです。

tvaはグローバルを選択しました。理由は:コンサルティングアシスタントは会話をまたいで情報を結びつけられることで恩恵を受けるからです。ベンダー評価についてDMで議論された内容が、そのベンダーの契約についてのグループ会話に活かされます。チャットごとのメモリでは、オペレーターがコンテキストを繰り返し提供しなければならず、アシスタントが断絶した印象を与えます。

コストも現実的です。DMとグループの間にプライバシー境界がありません。DMで言及されたことは、グループへの返答で想起される可能性があります。これは副作用ではなく、明示的に文書化された選択です。異なるユースケース(例えば、個人的な開示が秘密にされなければならないHRボット)では、チャットごとのメモリが正解です。

実装:固定の作業ディレクトリを使用したClaude Code CLIの--continueフラグ。セッションファイルは~/.claude/projects/-workspace/sessions/<auto-id>.jsonlに保存され、バインドマウント経由で永続化され、同じ作業ディレクトリからの後続のclaude呼び出しのたびに再開されます。

判断2:サブスクリプションOAuth対APIキー

Claude Code CLIを駆動する方法は2つあります。オペレーターのPro/Maxサブスクリプション(OAuthベース、通話ごとの課金なし)と、Anthropic APIキー(トークンごとの従量課金)です。デフォルトはサブスクリプションです。落とし穴は、いくつかの環境変数が親環境で設定されていると、静かにAPIキー課金に切り替わることです。

Anthropicの認証ドキュメントによると、解決順序は次のとおりです:まずBedrock/Vertex/Foundryのクラウドプロバイダーフラグ、次にANTHROPIC_AUTH_TOKEN、次にANTHROPIC_API_KEY、次にapiKeyHelper、次にCLAUDE_CODE_OAUTH_TOKEN、最後に/loginからのサブスクリプションOAuth。より高い優先度のオプションのいずれかが設定されていると(一部のシェルでは空文字列でさえ)、CLIはサブスクリプション認証にフォールスルーしません。

OAuth専用動作を保証するために、composeファイルのenvironment:ブロックで6つの環境変数すべてを明示的に空文字列に設定します。これは防御的ですが、コストはかかりません。

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

トレードオフ:サブスクリプション認証には単一使用のリフレッシュトークンローテーションがあり、同じAnthropicアカウントがコンテナとオペレーターのラップトップの両方で同時に使用される場合に競合します。並行して使用すると、ランダムなログアウトが発生します。専用アシスタントには、専用のAnthropicアカウントがよりクリーンな解決策です。異なるAIツールとその認証モデルの比較コンテキストについては、Claude Code、Cursor、その他のCLIエージェントの正直な比較を参照してください。

判断3:1つのコンテナか2つか

ボットにはTelegramフレームワーク(Python、aiogram)が必要です。Claudeエンジンにはノードと@anthropic-ai/claude-code CLIが必要です。2つのコンテナとして実行することもできます(例:PythonコンテナでBot、NodeコンテナでClaude、その間のIPC)し、1つにまとめることもできます。

2コンテナアプローチは構造的にはよりクリーンですが、IPCの問題が生じます。ボットはclaudeをサブプロセスとして呼び出す必要があり、これは別のコンテナへのDockerソケットアクセス(権限昇格リスク)か、カスタムのファイルベースIPCレイヤー(追加レイテンシとコード)のどちらかが必要です。どちらも魅力的ではありません。

単一コンテナアプローチは、コンテナの純粋性を犠牲にして運用のシンプルさを得ます。1つのイメージ、1つのOAuthセッション、1組の環境変数、1つのバインドマウントレイアウト。イメージサイズは約120 MBではなく約700 MBになりますが、ディスクがボトルネックになることはほとんどありません。

tvaは単一コンテナを選択しました。Dockerfileは、Pythonスタック(slimベース + pip)とNodeスタック(NodeSourceキーリング + claude-code)を順番にインストールし、単一のエントリポイントを公開します。ボットはasyncio.create_subprocess_exec経由でclaudeを呼び出します。IPC、ソケットプロキシ、コンテナ間ネットワーキングは不要です。

判断4:ワークスペースのブートストラップ

アシスタントには知識ベースが必要です。選択肢は、プロジェクトコンテキストでシードする(オペレーターがチャット経由ですべての事実を入力しなくて済む)か、空の状態から始める(アシスタントは純粋にインタラクションから学習する)かです。

tvaはシードを選択します。templates/workspace-CLAUDE.md.templateのワークスペーステンプレートには、次のプレースホルダーセクションが含まれています:オペレーターのプロフィール、参加者とその役割、案件の背景、言語の規則、アシスタントが時間をかけてメモを維持する方法についての指示。新しいインスタンスがブートストラップされると、テンプレートがdata/workspace/CLAUDE.mdにコピーされ、プレースホルダーが埋められます。

アシスタントはその後、WriteツールとEditツールを使ってファイル自体を維持します。修正すると(「このクライアントはその用語をそう使わない」)、ワークスペースファイルを更新して将来のセッションでも修正が反映されるようにできます。グローバルセッションメモリと組み合わせることで、アシスタントには2層の状態が与えられます:Claudeセッション内の短期記憶と、ワークスペースファイル内の長期記憶。どちらもバインドマウント経由でコンテナ再起動をまたいで永続化されます。

判断5:グループの動作とプライバシーモード

グループ内のTelegramボットにはプライバシーモード設定があります。デフォルトでは、ボットは直接アドレス指定されたメッセージ(コマンド、@メンション、リプライ)のみを受信します。他のグループメッセージはボットに一切届きません。これはBotFatherで無効化できます(/setprivacy → 無効化)。その場合、ボットはメンバーになっているすべてのグループのすべてのメッセージを受信します。

グループディスカッションから学習すべきコンサルティングアシスタントには、無効化が正しい設定です。しかし、これはフォローアップの問題を提起します。どのメッセージに返答し、どれをただログに記録するかをボットはどう判断するのでしょうか?

tvaのトリガーモデル:DMでは、すべてのメッセージが返答を受け取ります。グループでは、ボットは(a)ボットへの明示的な@メンション、(b)ボット自身の前のメッセージへのリプライ、または(c)「Hermes」という単語をスタンドアロンの単語として含むメッセージ(大文字小文字を区別せず、単語境界にマッチ)にのみ返答します。他のすべてのメッセージは/workspace/conversations/chat-<id>.mdにログされますが、Claudeの呼び出しをトリガーしません。

これはアシスタントがグループコンテキストへの読み取りアクセスを持つ(必要なときにReadツールでログファイルを調べられる)一方、ノイズを生成しないことを意味します。会話ログはオペレーターにとっても有用な人間向けのアーティファクトです。catすることでプロジェクトの履歴を確認できます。

判断6:レスポンスUX

Claudeの返答には、ツール使用が含まれる場合(ウェブフェッチ、ファイル読み取り、多段階の推論)5〜30秒かかることがあります。選択肢は、バッファリング(完全な返答を待って1つのメッセージとして送信)かストリーミング(トークンが届くにつれて1つのメッセージを段階的に編集)かです。

ストリーミングの方がクールですが、より複雑です。Telegramのメッセージごとの編集レートは制限されていますが、正確な上限は単一の数値として文書化されていません。一般的な送信レート(Telegramの公開制限:グローバルで毎秒約30メッセージ、チャットごとに毎秒1メッセージ以下、グループでは毎分20メッセージ)が大まかな上限を示しています。単純なトークンごとの編集ストリームはすぐにスロットリングされます。実装には、チャンクの集約、Claudeのstream-json出力からの部分的なメッセージ処理、編集がスロットリングされた場合のグレースフルデグラデーションが必要です。

tvaはバッファリングを選択しました。返答が生成されている間、ボットは5秒ごとにtypingチャットアクションを送信するため、ユーザーはTelegramクライアントで「入力中」と表示されます。返答の準備ができると、1つまたは複数のメッセージとして送信されます。4,000文字を超える返答は、制限前の最後の段落境界で自動的に分割され、Telegramの送信レート内に収まるようにメッセージ間に0.3秒のポーズを挿入します。

判断7:外側の境界としての厳格な許可リスト

Telegramボットはユーザー名を知っている誰もがアクセスできます。フィルタリングしなければ、ランダムなユーザーがボットを発見してコマンドを試みます。コンサルティングアシスタントにとって、これは許容できません。アシスタントにはオペレーターのOAuth認証情報があり、ワークスペースへのアクセス権があり、ツール呼び出しを実行できます。このループに見知らぬ人を入れたくはありません。

許可リストは、アップデートレベルにおけるaiogramのアウターミドルウェアとして実装されています。これはフィルター解決とハンドラー検索の前、最も高いインターセプションポイントです。チェックはevent_from_user.id(ユーザーがユーザー名を変更しても安定している数値のTelegramユーザーID)で行われます。許可リストのメンバーはCSV環境変数(HERMES_ALLOWED_USERS)で設定されます。送信者がセットに含まれていない場合、ミドルウェアはハンドラーを呼び出さずにNoneを返します:デバッグレベルのドロップイベント以外のログエントリなし、会話ログへの書き込みなし、Claude呼び出しなし、返答なし。

これはオペレーターが許可されていることの検証にも適した場所です。設定ローダー(pydantic-settingsを使用)は起動時にHERMES_OPERATOR_IDHERMES_ALLOWED_USERSに含まれていることを確認します。設定ミスは、オペレーターを静かにロックアウトするのではなく、即座にコンテナをクラッシュさせます。

コンテナスタック

7つの判断を下したことで、スタックは自然と定まります。以下は完全なdocker-compose.ymlです:

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"

一読しただけでは明らかではないキーとなる選択:

  • init: true — tiniをPID 1として実行し、PythonプロセスがSIGTERMをクリーンに受け取れるようにします。これがないと、docker compose stopは10秒待ってからSIGKILLを送り、ボットセッションが閉じられないまま終了します。
  • stop_grace_period: 15s — aiogramのデフォルトのpolling_timeoutである10秒よりわずかに長い。これにより、シャットダウンフックがTelegramセッションをクリーンに閉じる時間が確保され、コンテナがTelegramが前のロングポール接続を解放するより速く再起動した場合に発生するTelegramConflictError: terminated by other getUpdates requestを防ぎます。
  • cap_drop: ALLno-new-privileges:true — コンテナはLinuxケーパビリティも権限昇格も必要ありません。両方ともデフォルトで制限されています。
  • read_only: trueなし — Claude Codeは自己更新のために~/.claude/~/.npm/、時折/tmp/に書き込みます。読み取り専用ルートにすると、補完するために大規模なtmpfsマウントが必要になります。セキュリティ上の利点に対してそれだけの価値はありません。
  • 2つのファイルマウント./data/claude.json:/home/hermes/.claude.jsonは(ディレクトリではなく)特定のファイルをマウントします。このファイルはcompose upの前にホスト上に{}で初期化して存在している必要があります。そうでないと、Claude CodeはJSON解析エラーで起動時にクラッシュします。

Dockerfileは2つのランタイムを組み合わせます:

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"]

知っておくべきピン留めバージョン:

  • python:3.13-slim-trixie — Debian 13ベース、現行安定版
  • aiogram>=3.28,<4.0 — ボットフレームワーク(執筆時点では3.28.xにロック、現行最新は3.28.2)
  • pydantic-settings>=2.14,<3.0 — 設定ローダー
  • structlog>=25.4,<26.0 — JSONロギング
  • @anthropic-ai/[email protected] — NodeSource Node 20のnpmグローバル経由でインストール
  • poppler-utils — ClaudeのPDF解析がファイルに適さない場合のフォールバックとしてpdftotext用

このDockerfileで注意すべき2点:

  1. userdel -r ubuntuの行。Debian 13上のPython slimベースにはデフォルトのUID-1000ユーザーは含まれていませんが、含まれているベースイメージ(一部のUbuntuデリバティブ)に切り替えると、useradd -u 1000が失敗します。常に既存のUID-1000ユーザーを先に削除してください。
  2. NodeSourceキーリングのパターン。curl | bashインストールスクリプトは避けています。非推奨であり、再現性がありません。キーリング + nodistroコードネームのアプローチは再現可能で監査に適しています。

コンテナのハードニングが多サービスの本番スタックにどう組み込まれるかについての詳細は、本番環境で100を超えるDockerコンテナを運用するのウォークスルーを参照してください。

Pythonスタック:実作業を担う5つのモジュール

ボットのコードは焦点を絞ったモジュールに分割されています。どれも大きくなく、最大のものでも約200行程度です。

  • settings.py — コンマ区切り許可リスト用のカスタムBeforeValidatorを持つpydantic-settingsのBaseSettings。バリデーターは、pydantic-settingsが単純な整数(HERMES_ALLOWED_USERS=12345のような単一要素の許可リスト)をJSON-decodeしてintにしてしまうエッジケースを処理し、単一要素のリストに変換します。
  • middleware.pydp.update.outer_middlewareとしてのAllowListMiddlewaredata.get("event_from_user")のチェックはaiogramの組み込みユーザーコンテキスト抽出を使用しています。
  • trigger.pyis_trigger(message, bot_id, bot_username)(True, "DM" | "@mention" | "text_mention" | "reply" | "keyword")または(False, None)を返します。キーワードマッチは単語境界の正規表現(\bhermes\b)を使用するため、部分文字列はトリガーしません。
  • conversation_log.py/workspace/conversations/chat-<id>.mdへの追記専用Markdownログ。受信と送信の両方のメッセージがログされます。
  • file_intake.py — 8つのTelegram添付ファイルタイプ(document、photo、video、audio、voice、animation、sticker、video_note)を処理します。20 MBのハードリミット(TelegramのBot APIダウンロード上限)を設けて/workspace/incoming/chat-<id>/<timestamp>-<name>にダウンロードします。
  • hermes_engine.py — Claude Code CLIサブプロセスをラップします。cwd=/workspace--continue(最初の呼び出し後)を使ってasyncio.create_subprocess_execを使用し、グローバルセッションを維持します。
  • response_pipeline.py — タイピングインジケーターのリフレッシュ、自動分割、メッセージ間の遅延を組み合わせます。
  • handlers.py — 3つのコマンドハンドラー(/ping/status/help)と、トリガーチェックを実行してエンジンにディスパッチするデフォルトメッセージハンドラー。

Claude CLIの呼び出しが中心的な部分です。以下は完全なサブプロセス呼び出しです:

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()

システムプロンプトは、アシスタントのペルソナ、バリデーションの規律(主張せず、まずツールでバリデートする)、学習ループ(新しい事実を/workspace/CLAUDE.mdまたはnotes/に書き込む)、Telegram出力フォーマット(プレーンテキスト、Markdownなし — TelegramのデフォルトモードはMarkdownを確実にレンダリングしない)、そして会話ログがオンデマンドで読み取り可能であることを設定します。

ユーザープロンプトは実際のメッセージをコンテキストメタデータでラップします:チャットソース(XとのDM、またはメンバーA、B、CとのグループY)、送信者のアイデンティティ、タイムスタンプ、トリガーの理由、このチャットの会話ログのファイルパス。これにより、アシスタントは返答する前にグループコンテキストを調べるかどうかを判断できます。

ステップバイステップ:新しいインスタンスのブートストラップ

コードベースがGitリポジトリにあり、Dockerがインストールされたサーバーがある前提で、以下が完全なブートストラップ手順です。<instance-id><project-name>は実際の値に置き換えてください。

ステップ1:BotFatherにボットを登録する。

  • Telegramで@BotFatherにメッセージを送る
  • /newbot → 名前とユーザー名を設定する(例:some_project_assistant_bot
  • BotFatherが返すトークンを保存する
  • /setprivacy → ボットを選択 → 無効化(ボットがコマンドとメンションだけでなく、すべてのグループメッセージを受信できるようにする)
  • オプション:/setcommandspingstatushelpを設定する

ステップ2:リポジトリをクローンし、サーバー上にディレクトリを準備する。

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/

chownのステップは重要で見逃しやすいです。Dockerは不足しているバインドマウントのソースディレクトリをrootが所有する形で作成します。chownを省略すると、コンテナユーザー(UID 1000)がそれらに書き込めなくなり、OAuthログインが静かに失敗します。stat -c "%a %u:%g" data/claudeで確認してください — 0:0ではなく1000:1000になっている必要があります。

ステップ3:ワークスペースを初期化する。

cp templates/workspace-CLAUDE.md.template data/workspace/CLAUDE.md
# ファイルを開いてプレースホルダーを置き換える:
# {{HERMES_PROJECT_NAME}}, {{OPERATOR_NAME}}, {{CLIENT_LEGAL_ENTITY}}, など

ステップ4:シークレットを設定する。

cp .env.example .env
chmod 600 .env
sudo chown 1000:1000 .env
# .envを編集する: TELEGRAM_BOT_TOKEN, HERMES_ALLOWED_USERS,
# HERMES_OPERATOR_ID, HERMES_PROJECT_NAME, HERMES_INSTANCE_ID

ステップ5:前提条件 — ボットに/startを送信する。

Telegramはユーザーが少なくとも一度チャットを開始するまで、ボットがそのユーザーにDMを送ることを許可しません。最初のコンテナ起動の前に、オペレーターはTelegramでボットを開き、/startを送信する必要があります。ボットは返答しません(/start用のハンドラーは登録されていません)が、TelegramはDMチャンネルを内部的に開きます。このステップを省略すると、最初のオンボーディングDMでTelegramForbiddenErrorがスローされます。

ステップ6:コンテナをビルドして起動する。

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

JSONログのシーケンスが表示されるはずです:startup(プロジェクト名とallowed_usersの数)、polling_initializeddrop_pending=true)、onboarding_sent(オペレーターへの最初のDM)、そしてaiogramのポーリング開始イベント。

ステップ7:OAuthログイン。

これは一度だけ行う対話的なステップです。OAuth URLを1行に表示できる十分な幅のターミナルで実行します(250文字以上 — でないとURLが折り返され、CloudflareがUnknown scope: usというエラーで認証を拒否します):

docker exec -it project-assistant tmux new-session -s claude
# コンテナ内:
claude
# Claude REPL内:
/login
# 「Claude Pro or Max subscription」を選択する
# 表示されたURLをコピーしてブラウザで開き、ログインし、
# 認証コードをターミナルに貼り付ける
/status   # 「API key」ではなく「Subscription」と表示されるはず
/quit
# Ctrl-b dでtmuxをデタッチし、docker execを終了する

ステップ8:Telegramでのスモークテスト。

  • ボットにDMを送る:/pingpong
  • ボットにDMを送る:/status → 診断出力(許可リスト数、セッションメッセージ数、ログ数)
  • ボットにDMを送る:自然な質問 — アシスタントはClaudeエンジンを使って返答するはず
  • キャプション付きの小さなPDFや画像を送る — アシスタントはその内容を参照するはず
  • 2つのメッセージを続けて送り、2番目が1番目を参照する — マルチターンメモリが保持されるはず

ステップ9:追加の参加者を追加する(準備ができたら)。

  • 各参加者は@userinfobotからTelegramユーザーIDを取得する
  • .envを更新する:HERMES_ALLOWED_USERS=<operator_id>,<member1_id>,<member2_id>
  • docker compose restart hermes
  • Telegramグループを作成し、全メンバーとボットを招待し、/ping@some_project_assistant_botでテストする

運用上の注意点と既知のリスク

このスタックは安定して稼働していますが、知っておくべきいくつかの問題があります。

OAuthリフレッシュ時のCloudflare WAF。 AnthropicのAuth エンドポイントはCloudflareの背後にあります。anthropics/claude-codeリポジトリには既知の問題(オープン)があり、Cloudflareが特定のサーバーIPをヘッドレスLinuxと分類してOAuthリフレッシュを永続的にブロックします。数週間に渡るロックアウトが報告されています。回復パスは別のIP(住宅用、VPN)から再認証することです。緩和策は、カスタムユーザーエージェントと積極的なリトライループを避け、高リスクのIP範囲で運用する場合のフォールバックとしてclaude setup-token(認証済みマシンで生成された1年間有効なトークン、CLAUDE_CODE_OAUTH_TOKEN経由でコンテナに設定)を検討することです。

リフレッシュトークンの単一使用ローテーション。 AnthropicのOAuthリフレッシュトークンは単一使用です。2つのクライアント(例:ラップトップとコンテナ)が同じアカウントを共有し、両方が並行してリフレッシュすると、最初のリフレッシュが他方を無効化します。実践的なアドバイス:アシスタントインスタンスごとに専用のAnthropicアカウント。できない場合は、時折の再ログインを受け入れ、アシスタントと同時にラップトップでClaude Codeを実行しないようにしてください。

空のclaude.jsonのトラップ。 最初のコンテナ起動時にdata/claude.jsonがゼロバイトのファイルである場合、Claude CodeはConfiguration Error: invalid JSON, Unexpected EOFをスローします。touchではなくecho "{}" > data/claude.jsonで初期化してください。このエラーはREPL(「デフォルト設定でリセット」)で回復できますが、そもそも遭遇しない方がよいです。

OAuth URLの行折り返しバグ(tvaの観察)。 OAuth URLはおよそ530文字です。通常の幅のターミナルでは複数行に折り返されます。折り返された出力をコピーすると、改行も一緒についてきて、URLエンコード後にスコープパラメーターがuser:inference us\ner:profileのようになります。CloudflareはそのときCapをusというスコープとして解釈し、Invalid OAuth Request — Unknown scope: usで拒否します。これは執筆時点では上流の問題としてトラッキングされていません — tva自身が遭遇しました。claudeを起動する前にターミナルを250文字以上の幅に広げることで回避できます。または、ブラウザのアドレスバーに貼り付けた後、手動でURLの折り返しを解除することもできます。

Ubuntu UID 1000の競合。 一部の最新のUbuntuベースイメージ — 特にCanonic OICリベース後のubuntu:24.04(Noble)— はデフォルトのubuntuユーザーをUID 1000で提供します。DockerfileのベースをデフォルトUID-1000ユーザーが存在しないpython:3.13-slim-trixieから存在するものに切り替えると、useradd -u 1000 hermesUID 1000 is not uniqueで失敗します。このためDockerfileにはuseraddの前に防御的なuserdel -r ubuntu 2>/dev/null || trueが含まれています。

会話ログは大きくなりえます。 /workspace/conversations/の追記専用ログはすべてのメッセージとともに大きくなります。数ヶ月の活発な使用により、個々のファイルはメガバイトに達することがあります。組み込みのローテーションはありません。必要であれば、N日より古いログをアーカイブするcronスタイルのジョブを追加するか、月ごとに分割してください。

自己ホスト型サービスに関するより広範な運用上の懸念と、それらが失敗した場合の対処については、自己ホスト型サービスのディザスターリカバリーに関するtvaの解説を参照してください。

意図的にスコープ外としているもの

社内AIツールを構築する際の誘惑は機能を追加することです。tvaはこのスタックを小さく保っています。以下は本稿で説明するバージョンでは明示的にスコープ外とされており、まだ追加しないという積極的な選択がなされています:

  • RAG / ベクターデータベース。 アシスタントの知識はMarkdownファイル(CLAUDE.mdnotes/)と会話ログにあります。Read-Toolの呼び出しがリトリーバルを処理します。これは単一案件のスコープには十分です。ワークスペースが特定のサイズを超えたら、実際のRAGレイヤー(例えばPostgreSQL + pgvector)が必要になりますが、それまでは過剰です。
  • 音声転写。 音声メモはダウンロードされてメタデータがログされますが、アシスタントはまだ転写できません。Whisperまたは類似のパイプラインを追加するのは半日の作業ですが、必要になるまで先送りにされています。
  • ヘルスチェックエンドポイント。 コンテナにはHTTPサーバーがなく、スクレイピングするものがありません。Dockerの再起動ポリシーとログモニタリングでほとんどの障害モードをカバーしています。
  • ストリーミングレスポンス。 上記の判断6を参照してください。
  • 単一コンテナのマルチテナント。 各案件は独自のコンテナを持ちます。これは意図的です — 上記のプロジェクトインスタンスモデルを参照してください。

先送りにされた機能はバグではありません。それらは選択であり、実際に存在するものの表面積を削減します。AIツールを絞り込んで維持する規律のコンテキストについては、ドメイン固有ワークフロー向けのAIエージェントスキルの構築Claude Skillsの概要を参照してください。

次の案件へのクローン

2つの環境変数という設計がここで効果を発揮します。新しいインスタンスを立ち上げるには:

  1. リポジトリを/opt/<new-instance-id>にクローンする
  2. .envHERMES_PROJECT_NAMEHERMES_INSTANCE_IDを変更する
  3. BotFatherに新しいボットを登録する(一度だけ)
  4. 新しい案件のコンテキストをワークスペーステンプレートに記入する(一度だけ)
  5. 新しいコンテナ内からOAuthログインする(一度だけ、理想的には別のAnthropicアカウントで)
  6. docker compose up -d

コードはインスタンス間でビット単位で同一です。変わるのは2つの環境変数、ボットの認証情報、ワークスペースのコンテキスト、そしてOAuthセッションだけです。

同じサーバーで複数のインスタンスを実行できます。それぞれが独自のディレクトリ、独自のコンテナ名、独自のバインドマウントツリーを持ちます。ディスク使用量は約700 MBのイメージ(Dockerのレイヤーキャッシュのおかげでインスタンス間で共有)にインスタンスごとのワークスペースの増加(通常数ヶ月後に数十MB)を加えたものです。

ツールチェーンにおける位置づけ

プロジェクト専用AIアシスタントは、汎用AIコーディングツールの代替ではありません。tvaでは開発作業に引き続きClaude Code、Cursor、Gemini CLIを直接使用しています。アシスタントはコンサルティングのコンテキスト向けです:プロジェクトのメモリ、ドキュメント分析、ステータス更新、定義された案件の範囲内でのアドホックなリサーチ。残りのAIツールスタックと並行して動作し、置き換えるものではありません。

このパターンはClaude.aiやChatGPTのようなチャットベースのAI製品の代替でもありません。それらは一度限りのタスク、個人的な質問、一般的な知識作業に適切な答えです。プロジェクト専用アシスタントは、プロジェクトが独自の境界、独自の参加者、そして参照が必要なたびに汎用チャットボットにダンプしたくない進化する知識ベースを持つときに正しい答えです。

コンサルティング業務、代理店、または社内チームで同様のものを構築することを検討しているなら、上記のスタックは合理的な出発点です。Dockerfile、composeファイル、モジュール構造はこのガイドから再現可能です。判断内容は文書化されています。7つの判断のいずれかでtvaと異なるコンテキストがある場合は、分岐して適応させてください — コードは十分短いので、1つのモジュールを書き直すのは簡単です。特定の実装について比較検討したい場合や、tvaにサポートを依頼したい場合はお問い合わせください。


関連インサイト

関連記事