Облачное хранилище
Сессия Claude Code на VPS и чат с ботом в Telegram
Одна сессия Claude Code — доступ из Telegram, приложения и claude.ai

Захотелось, чтобы Claude был под рукой не только в браузере. В итоге на своём VPS я поднял постоянную сессию Claude Code: с ней можно говорить из Telegram, кидать туда файлы и переносить разговоры из обычного чата claude.ai и обратно. Собрано всё из штатного Telegram-плагина Claude Code, юнита systemd и небольшого MCP-сервера на Python в Docker.

Типичный сценарий: я не за компом, а на сервере что-то легло. Скидываю боту лог или скриншот ошибки и через минуту читаю разбор. Или начинаю разбираться в браузере, продолжаю из Telegram, а итог забираю обратно в исходный чат. По железу хватило самого скромного VPS: 2 ядра и 2–4 ГБ памяти.

Из обязательного — платная подписка Claude (Pro или Max) и свой бот, которого за минуту выдаёт @BotFather.

Как это устроено

Схема получилась такая. На сервере всё время живёт одна сессия Claude Code. Достучаться до неё можно двумя путями: из Telegram через официальный плагин и из приложения Claude через Remote Control. Третий кусок — MCP-мост. Он нужен обычным чатам claude.ai: через него они шлют мне в Telegram сообщения и файлы и передают разговор сессии.

Тонкий момент: бота опрашивает только плагин в сессии. Мост в Telegram лишь отправляет, потому что два процесса, опрашивающих один токен, начинают отбирать друг у друга сообщения. Между собой сессия и мост общаются через файлы в data/.

Подготовка сервера

Под агента заводим отдельного пользователя claude — без sudo и без группы docker. Давать ИИ root на сервере, где живут и другие сервисы, я не готов. Всё, что относится к хабу, лежит в одной папке /opt/claude-hub, а не размазано по диску.

Создаём пользователя, папки и swap. На 2 ГБ памяти без свопа Claude Code вместе с Docker живут впритык:

useradd -m -s /bin/bash claude
mkdir -p /opt/claude-hub/{systemd,session,bridge,caddy,notes,data/{inbox,outbox,handoff}}
chown -R claude:claude /opt/claude-hub/session /opt/claude-hub/data /opt/claude-hub/notes

fallocate -l 2G /swapfile && chmod 600 /swapfile
mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf && sysctl --system

Что в итоге лежит в папке:

/opt/claude-hub/
├── README.md     — карта хаба: что где и как чинить
├── systemd/      — claude-hub.service
├── session/      — рабочая папка Claude Code (CLAUDE.md, .claude/settings.json)
├── bridge/       — MCP-мост: server.py, Dockerfile
├── caddy/        — Caddyfile и сертификаты
├── compose.yml
├── .env          — токены и секреты, chmod 600
├── notes/
└── data/
    ├── inbox/    — файлы из Telegram
    ├── outbox/   — файлы от Claude
    └── handoff/  — выжимки перенесённых чатов

README.md в корне — не для галочки. Его читает и сам Claude в сессии, и вы через полгода, когда что-то отвалится и надо вспомнить, что где лежит.

И по привычке — ярлыки в ~/links, чтобы не вспоминать пути:

mkdir -p ~/links
ln -s /opt/claude-hub ~/links/claude-hub
ln -s /home/claude/.claude ~/links/claude-hub-config
ln -s /opt/claude-hub/systemd ~/links/claude-hub-systemd

Шаг 1. Claude Code и Telegram-плагин

Дальше всё ставится от пользователя claude. Переключаемся отдельной командой — если вставить её одним куском с остальными, хвост выполнится не там:

su - claude

Уже под ним ставим Claude Code и Bun — на Bun работает Telegram-плагин. Оба встают в домашнюю папку, в ~/.local/bin и ~/.bun/bin:

curl -fsSL https://claude.ai/install.sh | bash
curl -fsSL https://bun.sh/install | bash
cd /opt/claude-hub/session
claude

При первом запуске Claude даст ссылку для входа в аккаунт. Потом прямо в сессии ставим плагин, скармливаем ему токен бота и спариваем свой Telegram — код для пары бот пришлёт сам:

/plugin install telegram@claude-plugins-official
/telegram:configure <токен от BotFather>
/telegram:access pair <код из Telegram>

Токен плагин складывает в ~/.claude/channels/telegram/.env — запомните этот путь, пригодится при замене токена. Выходим из Claude (/exit), затем из пользователя:

exit

Запуск как сервис

Claude Code — штука интерактивная, без терминала она не работает. Напрямую из systemd её не запустить, поэтому сервис поднимает tmux, а уже в нём — Claude. Юнит лежит в папке хаба и подключается в систему ссылкой через systemctl link.

mcedit /opt/claude-hub/systemd/claude-hub.service:

[Unit]
Description=Claude Hub (Claude Code + Telegram)
After=network-online.target docker.service
Wants=network-online.target

[Service]
Type=forking
User=claude
WorkingDirectory=/opt/claude-hub/session
Environment=PATH=/home/claude/.local/bin:/home/claude/.bun/bin:/usr/local/bin:/usr/bin:/bin
Environment=HOME=/home/claude
ExecStart=/usr/bin/tmux new-session -d -s hub 'claude --continue --remote-control "Claude Hub" --channels plugin:telegram@claude-plugins-official'
ExecStop=/usr/bin/tmux kill-session -t hub
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target
chown root:root /opt/claude-hub/systemd/claude-hub.service
systemctl link /opt/claude-hub/systemd/claude-hub.service
systemctl enable --now claude-hub
systemctl status claude-hub --no-pager

Что тут к чему в строке запуска:

  • --channels plugin:telegram@… — сессия слушает бота и отвечает в чат.
  • --remote-control "Claude Hub" — та же сессия видна в приложении Claude на вкладке Code под этим именем.
  • --continue — после перезагрузки сервера продолжается последний разговор, а не начинается пустой.

Подсмотреть, что творится в сессии, можно в любой момент: su - claude -c 'tmux attach -t hub'. Выйти, ничего не остановив, — Ctrl+B, затем D.

Шаг 2. Права и правила сессии

Первое, во что упираешься: Claude Code на каждое действие спрашивает разрешения, а из Telegram кнопку «Разрешить» не нажмёшь. Можно разрешить всё скопом, но я так делать не стал. Безопасное разрешаем заранее, опасное запрещаем явно — в session/.claude/settings.json. Сокращённая версия:

{
  "permissions": {
    "additionalDirectories": ["/opt/claude-hub"],
    "allow": [
      "Read(//opt/claude-hub/**)",
      "Edit(//opt/claude-hub/data/**)",
      "Edit(//opt/claude-hub/notes/**)",
      "Edit(//opt/claude-hub/README.md)",
      "mcp__plugin_telegram_telegram",
      "WebSearch", "WebFetch",
      "Bash(ls:*)", "Bash(cat:*)", "Bash(grep:*)", "Bash(tail:*)",
      "Bash(df:*)", "Bash(free:*)", "Bash(uptime:*)", "Bash(ss:*)",
      "Bash(ping:*)", "Bash(dig:*)", "Bash(traceroute:*)",
      "Bash(tar:*)", "Bash(unzip:*)", "Bash(gzip:*)",
      "Bash(systemctl status:*)", "Bash(journalctl:*)"
    ],
    "deny": [
      "Read(~/.claude/channels/telegram/.env)",
      "Read(~/.claude/.credentials.json)",
      "Read(//opt/claude-hub/.env)",
      "Edit(~/.claude/**)",
      "Edit(//opt/claude-hub/session/.claude/**)",
      "Bash(curl:*)", "Bash(wget:*)", "Bash(docker:*)"
    ]
  }
}

Логика такая: читать в хабе можно всё, писать — только в data/, notes/ и README.md. Токены, .env и собственные настройки сессии закрыты. curl и wget под запретом, чтобы агенту было нечем что-то вынести наружу. И главное — файл настроек принадлежит root, так что расширить себе права сессия не сможет при всём желании.

chown -R root:root /opt/claude-hub/session/.claude
chown root:root /home/claude/.claude/settings.json

Второй файл — session/CLAUDE.md. Claude перечитывает его в начале каждого разговора, так что туда идёт всё, что иначе пришлось бы повторять раз за разом:

  • куда плагин кладёт входящие файлы и куда класть исходящие;
  • как работать с перенесёнными чатами (об этом ниже);
  • отвечать по-русски и коротко, команды оформлять моноширинным шрифтом;
  • никогда не выводить в переписку токены и содержимое .env;
  • что делать по командам меню бота: /inbox, /server, /info.

Шаг 3. MCP-мост для чатов claude.ai

Бот в Telegram — это половина дела. Обычный чат на claude.ai про ваш сервер ничего не знает, и тут нужен мост: свой MCP-сервер, который claude.ai подключает как коннектор. Инструментов у него шесть:

ИнструментЧто делает
tg_send_messageпишет вам в Telegram
tg_send_fileотправляет файл в Telegram, копию кладёт в data/outbox
hub_listсписок файлов в хабе
hub_readчитает файл, бинарные — в base64
hub_writeпишет только в README.md, notes/, data/
handoffпереносит текущий чат в Telegram

Пишется он на официальном Python SDK (mcp, класс FastMCP), к Telegram Bot API ходит через httpx. Весь сервер — один файл bridge/server.py, вот ключевые куски:

HUB = Path(os.environ.get("HUB_ROOT", "/hub")).resolve()
SECRET = os.environ["BRIDGE_SECRET"]
WRITABLE = [HUB / "README.md", HUB / "notes", HUB / "data"]
SECRET_RE = re.compile(r"(^|/)\.env[^/]*$|(^|/)caddy/(data|config)(/|$)|\.(key|pem)$")

mcp = FastMCP(
    "claude-hub",
    host="0.0.0.0", port=8000,
    streamable_http_path=f"/{SECRET}/mcp",   # адрес знает только claude.ai
    stateless_http=True,
    log_level="WARNING",                      # секрет из URL не попадёт в лог
)

def _resolve(rel: str) -> Path:
    p = (HUB / rel.strip().lstrip("/")).resolve()
    if p != HUB and HUB not in p.parents:
        raise ValueError("path is outside /opt/claude-hub")
    if SECRET_RE.search(p.relative_to(HUB).as_posix()):
        raise ValueError("this file is not accessible through the bridge")
    return p

@mcp.tool()
def tg_send_message(text: str) -> str:
    """Send a text message to the owner in Telegram."""
    for i in range(0, len(text), 4000):          # лимит Telegram ~4096
        _tg("sendMessage", data={"chat_id": CHAT_ID, "text": text[i:i + 4000]})
    return "sent"

Любой путь проходит через _resolve: через ../ за пределы хаба не выйти, а .env и сертификаты не отдаются вообще. Docstring у инструмента — не для красоты: по нему Claude решает, когда инструмент звать, так что пишем его внятно. Ещё отдельный поток раз в 6 часов чистит inbox и outbox от файлов старше 14 дней, чтобы диск не зарастал.

bridge/requirements.txt и bridge/Dockerfile:

mcp>=1.13,<2
httpx>=0.27
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY server.py .
ENV PYTHONUNBUFFERED=1
CMD ["python", "server.py"]

HTTPS и секретный путь

claude.ai подключает коннекторы только по HTTPS. Возиться с certbot не хотелось, поэтому перед мостом стоит Caddy: сертификат Let’s Encrypt он получает и продлевает сам, а к мосту пускает только один путь. На всё остальное — 404. caddy/Caddyfile:

{$BRIDGE_DOMAIN} {
	@mcp path /{$BRIDGE_SECRET}/*
	handle @mcp {
		reverse_proxy bridge:8000 {
			flush_interval -1
		}
	}
	handle {
		respond 404
	}
}

flush_interval -1 не убирайте: MCP отвечает потоком, а без этой строки Caddy будет копить ответ в буфере.

compose.yml. Мост запускается от uid пользователя claude — иначе файлы в data/ создавались бы от root и сессия не смогла бы их трогать:

services:
  bridge:
    build: ./bridge
    container_name: claude-hub-bridge
    restart: unless-stopped
    env_file: .env
    user: "${HUB_UID}:${HUB_GID}"
    volumes:
      - /opt/claude-hub:/hub
    expose:
      - "8000"

  caddy:
    image: caddy:2
    container_name: claude-hub-caddy
    restart: unless-stopped
    env_file: .env
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - ./caddy/data:/data
      - ./caddy/config:/config
    depends_on:
      - bridge

Файл .env (mcedit /opt/claude-hub/.env):

TELEGRAM_BOT_TOKEN=123456:ABC...
TELEGRAM_CHAT_ID=<ваш chat_id>
BRIDGE_SECRET=<длинная случайная строка>
BRIDGE_DOMAIN=hub.example.ru
HUB_UID=<id -u claude>
HUB_GID=<id -g claude>

Генерируем секрет, закрываем права на .env и поднимаем:

openssl rand -hex 24
chmod 600 /opt/claude-hub/.env
cd /opt/claude-hub && docker compose up -d --build
echo "https://hub.example.ru/$(grep -oP '^BRIDGE_SECRET=\K.*' .env)/mcp"

Последняя команда печатает готовый адрес коннектора. Добавляем его в claude.ai как свой (custom) коннектор — и в любом чате, где он включён, появляются инструменты моста. Не забудьте заранее A-запись домена на IP сервера и открытые порты 80 и 443, иначе Caddy не получит сертификат.

Перенос чатов: начал в браузере, продолжил в Telegram

Ради этого я всё и затевал. Сижу за компом, разбираюсь с проблемой в claude.ai, и тут надо уезжать. Раньше контекст просто пропадал, теперь пишу в чат «перенеси в телеграм».

Как это выглядит по шагам:

  1. В claude.ai: «перенеси в телеграм». Claude вызывает handoff, мост пишет выжимку разговора в data/handoff/<id>.md и шлёт мне в Telegram уведомление с id.
  2. В Telegram отвечаю «продолжаем <id>». Сессия читает выжимку и одной фразой подтверждает, о чём речь, — дальше работаем.
  3. Закончил — пишу «возвращаем». Сессия кладёт итог в data/handoff/<id>-result.md: что решили, что сделали, какие файлы появились, что осталось.
  4. Вернувшись к компу, в исходном чате говорю «забери итог <id>» — Claude читает файл через hub_read, и разговор продолжается с учётом всего, что было в дороге.

Важный момент: сессия в Telegram не видит чаты claude.ai, у неё к ним нет доступа. Перенос всегда запускается со стороны браузера, и вся «память» передаётся через выжимку. Поэтому в описании инструмента handoff я прямо прошу писать её самодостаточной: цель, что решено, текущее состояние, открытые вопросы, файлы.

Файлы ходят так же просто. Скинул боту лог или скриншот — он лёг в data/inbox/telegram/, и его видит и сессия, и любой чат claude.ai через мост. В обратную сторону Claude из браузера отправляет мне в Telegram готовый конфиг одной фразой «скинь в телегу».

Безопасность и грабли, на которые я наступил

На сервере крутится ИИ-агент, к которому есть вход из интернета. Тут лучше перебдеть, поэтому:

  • Сессия не правит свою же обвязку. systemd/, bridge/, compose.yml, Caddyfile и настройки сессии принадлежат root. Пользователь claude убран из группы docker — иначе через docker он фактически root.
  • Секрет в пути, а не в заголовке. Городить OAuth ради личного коннектора я не стал, поэтому защита — длинный случайный путь. Корень домена отдаёт 404, сканеры ничего не находят. Засветился адрес — меняем BRIDGE_SECRET и переподключаем коннектор.
  • Логи без секретов. Мост пишет лог на уровне WARNING, иначе uvicorn честно логирует каждый URL вместе с секретом.
  • Docker и ufw. Порты 80/443, опубликованные Docker, проходят мимо правил ufw. Для Caddy это нормально, но помнить надо — закрыть их через ufw не получится.

Грабли по эксплуатации:

  • Бот молчит. В сессии /mcp — у telegram должен быть зелёный статус. Если нет — Reconnect. Если не помогло, проверить, что bun на месте: su - claude -c 'which bun'.
  • Сервис падает за доли секунды с exit 1. Висит старая tmux-сессия с тем же именем: su - claude -c 'tmux kill-session -t hub' && systemctl restart claude-hub.
  • После рестарта в приложении новая пустая сессия. В прошлом разговоре не было сообщений, и --continue нечего продолжать. Это не баг.
  • Две сессии отвечают невпопад. Не запускайте второй процесс, который опрашивает того же бота, — Telegram отдаёт обновления только одному. Именно поэтому мост умеет только отправлять.
  • Новый токен бота. @BotFather → /revoke, новый токен в .env моста, docker compose up -d, а в сессии /telegram:configure <новый токен>.

Итог

На выходе — свой Claude в кармане: один и тот же агент доступен из Telegram, из приложения и из любого чата claude.ai, а разговоры и файлы свободно переезжают между ними. По железу хватает VPS за пару сотен рублей, по коду — один Python-файл на две сотни строк плюс конфиги.

Что ещё хочу допилить: подключить к сессии свои MCP-серверы (Zabbix, инвентаризация), чтобы из Telegram можно было спросить «что там с бэкапами» и получить ответ по живым данным. Об этом — в следующей статье.

Облачное хранилище