Захотелось, чтобы 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, и тут надо уезжать. Раньше контекст просто пропадал, теперь пишу в чат «перенеси в телеграм».
Как это выглядит по шагам:
- В claude.ai: «перенеси в телеграм». Claude вызывает
handoff, мост пишет выжимку разговора вdata/handoff/<id>.mdи шлёт мне в Telegram уведомление с id. - В Telegram отвечаю «продолжаем <id>». Сессия читает выжимку и одной фразой подтверждает, о чём речь, — дальше работаем.
- Закончил — пишу «возвращаем». Сессия кладёт итог в
data/handoff/<id>-result.md: что решили, что сделали, какие файлы появились, что осталось. - Вернувшись к компу, в исходном чате говорю «забери итог <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 можно было спросить «что там с бэкапами» и получить ответ по живым данным. Об этом — в следующей статье.



