threads
Provides an agent with programmatic access to a Threads account: publishing posts, replying to posts and conversations, liking, following, reposting, searching posts, and reading feeds, profiles, and comments.
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
Threads как API
Личный сервис на VPS, дающий агенту программный доступ к одному аккаунту Threads: публикация, ответы, лайки, подписки, репосты, чтение ленты, профилей и комментариев.
Публикация и ответы идут через официальный Threads API — стабильно и в рамках правил. Лайки, подписки, лента и комментарии под чужими постами официальным API не покрыты, поэтому выполняются браузером с сохранённой сессией. Какой путь выбрать, решает сервис; агент просто зовёт действие.
Что нужно до начала
VPS с Docker и доменом, направленным на его IP (для TLS).
Приложение Threads: developers.facebook.com → создать приложение → сценарий использования «Threads API». Понадобятся App ID, App Secret и Redirect Callback URL вида
https://<домен>/oauth/callback.Аккаунт Threads, под которым всё будет работать.
Related MCP server: Instagram MCP Server
Настройка
Подробный пошаговый деплой — в docs/DEPLOY-RUNBOOK.md.
Ниже сжатая версия.
cp .env.example .env
openssl rand -hex 32 # положи в API_TOKENЗаполни API_TOKEN, THREADS_APP_ID, THREADS_APP_SECRET, THREADS_REDIRECT_URI,
DOMAIN и порты API_BIND_PORT / NOVNC_BIND_PORT.
Все хостовые порты задаются только в .env: оттуда их берёт и docker-compose, и
генератор конфига nginx, так что разъехаться они не могут. Контейнеры слушают
исключительно 127.0.0.1 — наружу сервис отдаёт nginx.
./deploy/check-ports.sh # не заняты ли порты соседними сервисами
docker compose up -d --build
sudo htpasswd -cB /etc/nginx/threads-novnc.htpasswd admin # пароль к noVNC
sudo certbot --nginx -d <домен>
sudo ./deploy/render-nginx.sh --install # конфиг из .env
curl https://<домен>/healthПервый запуск: две авторизации
Их именно две, и они независимы.
1. Threads API (для публикации)
Открой в браузере, подставив свой токен:
https://<домен>/oauth/startРазреши доступ. Тебя вернёт на /oauth/callback, токен сохранится и дальше будет
продлеваться сам. Проверка:
curl -H "Authorization: Bearer $API_TOKEN" https://<домен>/actions/get_my_profile -X POST -d '{}'2. Браузерная сессия (для лайков и ленты)
Chromium не открывает один профиль дважды, поэтому сначала отбираем профиль у сервиса:
curl -X POST -H "Authorization: Bearer $API_TOKEN" https://<домен>/browser/suspend -d '{"minutes": 20}'
docker compose --profile login up -d novncОткрой https://<домен>/vnc/vnc.html, войди под своим паролем от noVNC, залогинься
в Threads в открывшемся окне Chromium — с 2FA, как обычный человек. Затем:
docker compose --profile login down
curl -X POST -H "Authorization: Bearer $API_TOKEN" https://<домен>/browser/resumeОтвет {"loggedIn": true} означает, что браузерные действия готовы.
Логин делается на самом VPS намеренно: IP при входе и при работе совпадает, и сессия живёт заметно дольше, чем перенесённые с ноутбука cookies. Повторять придётся раз в несколько недель, когда Threads разлогинит сессию.
Использование
# Что вообще умеет
curl -H "Authorization: Bearer $API_TOKEN" https://<домен>/actions
# Пост
curl -X POST https://<домен>/post \
-H "Authorization: Bearer $API_TOKEN" -H "content-type: application/json" \
-d '{"text": "привет из агента"}'
# Ответ на пост — свой или чужой. Официальный API просит media ID (число), а на
# руках после ленты или поиска обычно ссылка: разворачиваем её браузером.
# Шорткод из ссылки (DcS3AZCjth1) идентификатором не является — Threads на него
# отвечает «Param reply_to_id is not a valid threads_media ID»; resolve_media_id
# принимает и ссылку (url), и шорткод (shortcode).
curl -X POST https://<домен>/actions/resolve_media_id \
-H "Authorization: Bearer $API_TOKEN" -H "content-type: application/json" \
-d '{"url": "https://www.threads.com/@someone/post/DcS3AZCjth1"}'
# → {"ok": true, "data": {"url": "...", "media_id": "18100896626255309"}}
# Ответ в конкретную ветку — по media ID комментария (его же отдаёт get_conversation).
curl -X POST https://<домен>/actions/reply_to_post \
-H "Authorization: Bearer $API_TOKEN" -H "content-type: application/json" \
-d '{"reply_to_id": "18100896626255309", "text": "и тебе спасибо!"}'
# Ответ под чужим корневым постом Threads разрешает только с правом
# threads_keyword_search. Если API отвечает «Cannot Reply Unless Root Post
# Owner» — токен выдан без него, помогает повторный проход /oauth/start.
# Комментарии под постом — своим или чужим
curl -X POST https://<домен>/actions/read_replies \
-H "Authorization: Bearer $API_TOKEN" -H "content-type: application/json" \
-d '{"url": "https://www.threads.net/@someone/post/abc"}'
# Поиск постов
curl -X POST https://<домен>/actions/search_posts \
-H "Authorization: Bearer $API_TOKEN" -H "content-type: application/json" \
-d '{"query": "today", "limit": 100, "sort": "recent"}'
# Лайк
curl -X POST https://<домен>/actions/like_post \
-H "Authorization: Bearer $API_TOKEN" -H "content-type: application/json" \
-d '{"url": "https://www.threads.net/@someone/post/abc"}'Ответ всегда одной формы: {"ok": true, "data": …} либо
{"ok": false, "error": {"code", "message", "retryable", "retry_after"}}.
Лайк, репост и подписка отвечают парой {"changed", "state"}: changed — сделали
ли мы что-то прямо сейчас, state — итоговое состояние. {"changed": false, "state": "liked"} читается однозначно как «лайк уже стоял», а не как «не вышло».
Браузерные действия идут строго по одному: профиль Chromium один на сервис.
Звать их параллельно бессмысленно, но и не страшно — запросы становятся в
очередь и дожидаются своей очереди целиком, сколько бы ни стояли. Глубина
очереди — BROWSER_MAX_QUEUE (20 задач), дольше BROWSER_MAX_QUEUE_WAIT_MS
(240 с) задача не стоит: столько запрос всё равно не проживёт у nginx
(proxy_read_timeout 300s). Не дождавшуюся отменяем до старта и говорим об
этом прямо — с числами, сколько ждали, сколько стояло впереди и сколько занимает
одна задача, — чтобы агент не гадал. Выполнить её после отказа значило бы
поставить лайк или отправить ответ, о котором клиенту уже не сообщили.
Таймаут своего MCP-клиента подними до тех же 300 с: по умолчанию он ждёт 60 с и
обрывает запрос раньше, чем очередь дойдёт до задачи, — агент видит «не смог
достучаться до сервера», хотя сервис в этот момент спокойно доделывает работу и
пишет в журнал успех. В Claude Code это MCP_TOOL_TIMEOUT=300000 (и
MCP_TIMEOUT для запуска stdio-сервера) в окружении агента.
Коды ошибок: NEEDS_LOGIN (нужен ручной вход), RATE_LIMITED, UPSTREAM,
SELECTOR_MISS (Threads изменил вёрстку — в var/failures/ лежит скриншот),
INVALID_INPUT.
UPSTREAM у браузерных действий означает, что Threads вместо содержимого
показал свою заглушку «Что-то пошло не так» с кнопкой «Повторить»: страница
загрузилась, сессия жива, вёрстка цела — не приехало только содержимое. Мы
перезагружаем страницу трижды с растущей паузой и лишь потом отдаём ошибку, уже
как повторяемую (retryable: true). Чинить селекторы в этом случае нечего:
снимок из var/failures/upstream-*.png показывает ровно эту заглушку.
Подключение к агенту
Сервис сам по себе MCP-сервер, доступный по сети: транспорт Streamable HTTP на
POST /mcp, авторизация тем же бирер-токеном, что и у остального API. Агент
подключается откуда угодно — с ноутбука, из другого облака, — ему нужны только
адрес и токен.
{
"mcpServers": {
"threads": {
"type": "http",
"url": "https://твой-домен/mcp",
"headers": { "Authorization": "Bearer ТОКЕН" }
}
}
}В Claude Code то же самое одной командой:
claude mcp add --transport http threads https://твой-домен/mcp \
--header "Authorization: Bearer ТОКЕН"Инструментами агенту сейчас отдаются только браузерные действия (лайки,
подписки, репосты, чтение ленты и профилей, поиск, resolve_media_id,
check_login). Действия через официальный Graph API Meta — publish_post,
reply_to_post, list_my_posts, list_my_replies, get_conversation,
delete_post, get_quota, get_my_profile — временно скрыты из списка
инструментов, но по HTTP (POST /actions/<name>) работают как раньше. Вернуть
их агенту — убрать проверку EXPOSED_DRIVERS в src/mcp/server.ts.
Сессий сервер не держит: на каждый запрос поднимается свой экземпляр, заголовок
mcp-session-id не выдаётся и не нужен. Отвечает обычным JSON, без SSE, — так
проще проходить через прокси. Действия выполняются прямо в процессе сервиса, тем
же браузером и той же очередью, что и обычные HTTP-запросы.
Проверить доступ без агента:
curl -s -X POST https://твой-домен/mcp \
-H "Authorization: Bearer ТОКЕН" \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'Локальный вариант — тот же MCP по stdio, если агент живёт на этом же сервере:
{
"mcpServers": {
"threads": {
"command": "docker",
"args": ["exec", "-i", "threads-api-api-1", "node", "dist/src/mcp/main.js"]
}
}
}Переменные окружения передавать не нужно: контейнер уже поднят с ними. Действия
этот процесс не выполняет сам, а зовёт POST /actions/:action у сервиса — адрес
берётся из PORT, переопределяется переменной THREADS_API_URL.
Оба транспорта работают с одним реестром действий, расхождений между ними быть не
может. И оба намеренно не открывают браузер сами: Chromium не рассчитан на два
процесса с одной папкой профиля, а сервис держит браузер открытым постоянно.
Второй Chromium на том же профиле рано или поздно побил бы cookies — сессия
Threads слетела бы, и логиниться пришлось бы заново руками через noVNC. Пока
браузер один, POST /browser/suspend освобождает профиль целиком, а несколько
агентов делят общую очередь и общие суточные лимиты.
Сбор датасета
Разбор карточки проверен на живой выдаче Threads 19 августа 2026 года: текст поста приезжает без ника, времени и счётчиков, ноль лайков отличается от «счётчик не прочитан», картинка поста не путается с аватаром автора, а кадр-превью видео не считается отдельной картинкой. Ссылки приходят без трекинговых параметров, так что дедуп по ним работает.
Выборка задаётся английскими словами, но в выдачу регулярно попадают посты на
других языках — они не выбрасываются, а помечаются колонкой lang: ru, если
в тексте есть кириллица, en для латиницы, other для всего прочего (хинди,
каннада, арабица и так далее). Это метка по алфавиту, а не определение языка:
португальский и индонезийский попадут в en. Для «отделить русское от
остального» этого хватает, для настоящей языковой статистики — нет.
Подпись кнопки перевода, которую Threads приклеивал к тексту неанглийских постов, отрезается.
Помни про риск: прогон на настоящем аккаунте расходует суточную квоту и нарушает условия Meta, так что первый запуск стоит делать глазами.
Скрипт scripts/collect.ts копит посты в отдельную var/collect.sqlite:
один заход в ленту плюс поиск по частотным английским словам из
src/collect/queries-en.txt.
npm run collect -- --target 1000 # один прогон; сколько он идёт, замерь сам
npm run collect -- --export dataset.csvПрогон на тысячу постов идёт долго, поэтому для него есть обёртка, которая отвязывает сборщик от терминала — разрыв ssh его не убьёт:
./scripts/collect-background.sh 1000 # запустить и отвязать от терминала
./scripts/collect-background.sh status # работает ли и что в логе
./scripts/collect-background.sh stop # остановить прогон
tail -f var/collect-*.logЗапускается не сам сборщик, а присмотрщик: сборщик держит удар внутри прогона,
но от падения процесса — убитый браузер, севший сервис — защититься не может.
Присмотрщик поднимает упавший заход до пяти раз с паузой в пять минут, вычитая
уже собранное из остатка, и не перезапускает то, что перезапуском не лечится
(rate_limited, queries_exhausted, NEEDS_LOGIN, SELECTOR_MISS,
UNAUTHORIZED). Пределы меняются переменными MAX_RESTARTS и RESTART_PAUSE.
stop бьёт сигналом по всей группе процессов, так что сборщик не остаётся
сиротой.
Она же не даст запустить второй сборщик поверх первого: два прогона разом тратят двойную квоту ради одних и тех же постов.
Регулярный сбор повешен на крон — трижды в день по 300 постов:
# collect-cron:begin
0 3 * * * /var/www/apps/remote_browser/scripts/collect-cron.sh 300
0 13 * * * /var/www/apps/remote_browser/scripts/collect-cron.sh 300
0 23 * * * /var/www/apps/remote_browser/scripts/collect-cron.sh 300
0 6 19 9 * /var/www/apps/remote_browser/scripts/collect-cron-disable.sh >> …/var/cron.log 2>&1
# collect-cron:endВремя в кроне — UTC, и часы выбраны по чужим суткам, а не по нашим. Поиск отдаёт почти живую ленту: между публикацией поста и его сбором проходит в среднем полчаса (p90 — 13 часов), так что каждый прогон снимает примерно один текущий час мировых суток, и час запуска решает, чью ленту мы черпаем. Слова английские, поэтому три захода расставлены по вечерам англоязычных стран:
UTC | где вечер |
03:00 | 20:00 PT — Западное побережье США |
13:00 | 14:00 BST и 18:30 IST — день в UK, вечер в Индии и на Филиппинах |
23:00 | 19:00 ET — Восточное побережье США |
Промежуток 15-19 UTC не занят намеренно: это московский вечер, и выдача оттуда приходит русскоязычная. В первом датасете, собранном по старому расписанию, из 3223 постов 536 оказались русскими, а у захода в 18:00 UTC русских было 77%.
Зовётся не collect-background.sh напрямую, а обёртка: у крона нет ни нашего
PATH (node стоит через nvm), ни рабочей папки, а вывод надо куда-то деть —
обёртка чинит всё три и пишет в var/cron.log. Туда же попадает отказ
стартовать, поэтому проверять прогон надо там, а не в почте крона.
Стартует прогон не ровно в назначенную минуту: обёртка сначала спит случайные
0-30 минут (в секундах, чтобы не попадать и на круглую минуту) — ежедневный
запуск секунда в секунду выглядит роботом, каким он и является. Ручной проверке
ждать незачем: MAX_DELAY=0 ./scripts/collect-cron.sh 300.
Сбор поставлен на 30 дней. Последняя строка блока — самоснос: 19 сентября 2026
в 06:00 UTC collect-cron-disable.sh вырежет из crontab весь блок между метками
collect-cron:begin и collect-cron:end, включая себя. Снять руками раньше —
тот же скрипт; он идемпотентен и чужих заданий не трогает. Продлить — переставить
дату в строке самосноса.
Три раза по 300 постов — это около девяти поисков в день при суточном пределе
в 30 (LIMIT_SEARCHES_PER_DAY), запас на ручные прогоны остаётся. Короткие
заходы вместо двух длинных: между прогонами проходит несколько часов, и слова
успевают набрать свежую выдачу, а не отдавать ту же, что дедуп уже выбросил.
Если прошлый прогон к следующему сроку ещё идёт, новый упрётся в замок и тихо
уйдёт — это норма, а не сбой.
В логе видно, где прогон находится: у каждой строки метка времени, перед
запросом пишется слово и счёт ([7/85] ищу «about» — собрано 140/300, …), а
пока ответа нет, раз в полминуты идёт отметка «всё ещё жду».
Сборщик рассчитан на то, что браузер на той стороне иногда ложится. Временную
ошибку (INTERNAL, UPSTREAM, обрыв запроса) он повторяет трижды с паузами
30/90/180 секунд, а после пяти неудачных слов подряд уходит на десятиминутную
передышку и продолжает ротацию; сдаётся только если три передышки подряд не
принесли ни одного успешного слова. Неудачный поиск суточную квоту не тратит —
сервис считает только успешные, — поэтому упорство стоит лишь времени.
Ожиданием не лечатся NEEDS_LOGIN, SELECTOR_MISS и UNAUTHORIZED: на них
прогон останавливается сразу.
Браузер работает в настоящем окне на виртуальном экране, а не в headless-режиме.
Дело не в картинке: при headless: true Playwright запускает не Chromium, а
урезанный chromium_headless_shell, и тот сам пишет о себе «HeadlessChrome» в
User-Agent и в Client Hints, не отдаёт window.chrome и показывает пустой
список плагинов. Под Xvfb те же страницы открывает обычный Chromium, который
представляется Chrome/151 без пометок об автоматизации. Экран поднимает
deploy/start.sh (он же CMD образа), режим переключается BROWSER_HEADED.
Цена — память: полный Chromium тяжелее, поэтому лимит контейнера поднят с 2 до
3 ГиБ.
Скролл намеренно неровный. Круг прокрутки — не одно событие на 2000 пикселей
через ровные 1200 мс, а серия щелчков колеса по 90-170 пикселей с паузами
25-90 мс, затухающим хвостом, изредка движением курсора и паузой 1.8-3.5 с
после серии (в 15% случаев — 6-15 с, «засмотрелся»). Все значения случайны с
точностью до миллисекунды, форма круга — planScroll в src/browser/scroll.ts,
она же покрыта тестами. Сотня постов из ленты собирается за ~90 с вместо ~38 с:
скорость разменяна на непохожесть на метроном осознанно. Гарантий это не даёт,
и главный риск — объём запросов и датацентровый адрес, а не форма прокрутки.
Прогоны стоит разносить во времени: два подряд за час дадут почти одинаковую выдачу, и дедуп по ссылке её отбросит. Каждый прогон берёт слова, которых дольше всех не касался, и ищет свежее — так повторный заход приносит появившееся после прошлого раза.
Датасет смещён, и это важно при его чтении: поиск отбирает посты со словом из
английского списка, лента — персональные рекомендации аккаунта. Столбец
source позволяет разделить два потока или сравнить их, столбец lang —
отсечь неанглийское.
Лимиты
Официальный API: 250 постов, 1000 ответов, 100 удалений в сутки — их считает Threads,
текущее потребление показывает действие get_quota.
Браузерные действия ограничены нашими собственными счётчиками (по умолчанию 150 лайков, 50 подписок, 30 репостов в сутки) и разделены случайными паузами. Лимиты намеренно консервативные.
Поиск ограничен отдельно — 30 запросов в сутки (LIMIT_SEARCHES_PER_DAY).
Одного прогона сборщика это не стесняет, но зациклившийся скрипт остановит.
Риск. Автоматизация лайков, подписок и чтения ленты через браузер нарушает условия Meta и может привести к блокировке аккаунта. Постинг и ответы через официальный API этой проблемы не имеют.
Разработка
npm install
npm test # 105 тестов, живой аккаунт не нужен
npm run typecheck
npm run devБраузерные действия тестами не покрываются: они зависят от живой вёрстки Threads.
Все зацепки за DOM собраны в src/browser/selectors.ts — при редизайне правится
один файл.
Дизайн и обоснование решений: docs/plans/2026-08-17-threads-api-design.md.
This server cannot be deployed
Maintenance
Related MCP Connectors
Post, schedule, and track social posts on X, Bluesky, LinkedIn, Instagram and more from AI agents.
Instagram for AI agents: publish, read comments and DMs, insights, and engage from your account.
Lets AI agents post and reply on Instagram, TikTok, Threads and more, with a human approval queue.
Social media API for AI agents: publish, DMs, comments, Meta ads and leads.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables AI agents to interact with Twitter/X through Playwright browser automation without requiring an official API key. It provides tools for posting content, searching tweets, reading feeds, and managing social interactions like follows and likes.4MIT
- FlicenseBqualityDmaintenanceEnables AI agents to control Instagram accounts programmatically, supporting profile management, media interaction, direct messaging, and follower management.132-
- AlicenseAqualityCmaintenanceEnables AI agents to interact with X (Twitter) through its private GraphQL API using session cookies, supporting tweets, replies, likes, retweets, searches, direct messages, and more. Provides 24 MCP tools for full account automation.241,260 npm3MIT
- AlicenseAqualityDmaintenanceEnables AI agents to read, post, search, and engage with Twitter/X content, including AI-powered reply drafting and analytics.17MIT