Self-hosted state engine for social accounts, with a native Hermes Agent plugin.
medsos owns what an agent needs to run a social account safely: a state DB, webhook receiver, OAuth onboarding, platform client, and 12 tools. It owns no scheduler, worker, or LLM — when and whether to act is yours (cron, agent loop, manual). The agent decides what to say; medsos publishes it.
v1 platform: Threads. More platforms plug in via a Platform protocol later.
Meta webhooks / OAuth Hermes agent (plugin tools)
│ │
▼ ▼
┌─────────────┐ medsos_find_*
│ medsos API │ medsos_publish_*
│ :8768 │ medsos_update_* …
└──────┬──────┘ │
└────────────┬─────────────────┘
▼
SQLite or PostgreSQL
(accounts, posts, replies, events)
If an agent is helping you install this, point it at AGENTS.md.
- Python 3.11+
- Hermes Agent already installed
- A public HTTPS URL that can reach the medsos API (reverse proxy or tunnel)
- A Meta developer app with Threads API access
medsos cannot create these for you. Get them from the
Meta developer dashboard (Threads app)
before filling .env or running OAuth:
| You need | Env var | Who provides it |
|---|---|---|
| App ID | MEDSOS_THREADS_META_APP_ID |
Human, from Meta app settings |
| App Secret | MEDSOS_THREADS_META_APP_SECRET |
Human, from Meta app settings |
| Webhook verify token | MEDSOS_WEBHOOK_VERIFY_TOKEN |
Human picks any random string (or agent generates one); human pastes the same value into Meta webhook settings |
Also decide your public base URL (MEDSOS_CALLBACK_URL_BASE) up front — Meta
OAuth redirect and webhook URL are derived from it.
Agents helping with install: see AGENTS.md — do not invent or guess App ID / App Secret.
git clone https://github.com/suhz/medsos.git
cd medsos
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
cp .env.example .env && chmod 600 .env
# edit .env — see Configuration
alembic upgrade headThen finish these three tracks (order matters for OAuth/webhooks):
- API up — Run the API
- Public HTTPS — Reverse proxy + Meta dashboard URLs
- Hermes plugin — Wire Hermes
Smoke path once all three are ready:
agent: medsos_add_account
you: open authorize_url, approve in browser
agent: medsos_find_accounts → shows the connected account
All vars are MEDSOS_-prefixed. Template: .env.example.
| Var | Required | Purpose |
|---|---|---|
MEDSOS_DB_URL |
yes | e.g. sqlite:////var/lib/medsos/medsos.db (prefer absolute paths) |
MEDSOS_MASTER_KEY |
yes | Fernet key — python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" |
MEDSOS_THREADS_META_APP_ID |
yes | Meta app id |
MEDSOS_THREADS_META_APP_SECRET |
yes | Meta app secret |
MEDSOS_WEBHOOK_VERIFY_TOKEN |
yes | Any random string; same value in Meta webhook settings |
MEDSOS_CALLBACK_URL_BASE |
yes | Public base URL, e.g. https://medsos.example.com |
MEDSOS_PUBLISH_WAIT |
no | Seconds between Threads container create and publish (default 30) |
MEDSOS_SHARE_DIR / MEDSOS_SHARE_URL_BASE |
no | Local dir + public URL prefix for image hosting |
| Process | Needs MEDSOS_*? |
Typical source |
|---|---|---|
| medsos API | yes | project .env (EnvironmentFile= / process manager) |
| Hermes (plugin tools) | yes | $HERMES_HOME/.env or ~/.hermes/profiles/<name>/.env |
Hermes does not read the medsos project .env. Copy the same keys (or
symlink one file into both places). Restart each process after env changes.
Dev (foreground):
set -a && source .env && set +a
python scripts/serve.py
# listens on 127.0.0.1:8768
curl -sS http://127.0.0.1:8768/healthz # → {"ok":true}Daemon (systemd user unit):
mkdir -p ~/.config/systemd/user
cp deploy/medsos.service.example ~/.config/systemd/user/medsos.service
# edit REPLACE_MEDSOS_ROOT (and EnvironmentFile if needed)
systemctl --user daemon-reload
systemctl --user enable --now medsos.service
loginctl enable-linger "$USER" # survive SSH logoutOptional: swap ExecStart for gunicorn under heavier load.
Meta must reach:
| Path | Use |
|---|---|
POST {BASE}/webhooks/threads |
Webhook delivery (HMAC) |
GET {BASE}/accounts/callback |
OAuth redirect |
GET {BASE}/healthz |
Health (optional but handy) |
Minimal nginx:
location /webhooks/ { proxy_pass http://127.0.0.1:8768; }
location /accounts/ { proxy_pass http://127.0.0.1:8768; }
location /healthz { proxy_pass http://127.0.0.1:8768; }In the Meta app:
- Webhook URL =
{MEDSOS_CALLBACK_URL_BASE}/webhooks/threads - Verify token =
MEDSOS_WEBHOOK_VERIFY_TOKEN - OAuth redirect =
{MEDSOS_CALLBACK_URL_BASE}/accounts/callback
# if you use a named profile:
export HERMES_HOME="$HOME/.hermes/profiles/<name>"
medsos-installmedsos-install only symlinks plugin/ → $HERMES_HOME/plugins/medsos.
It does not copy secrets or enable the plugin.
-
Enable in
$HERMES_HOME/config.yaml:plugins: enabled: - medsos
-
Put
MEDSOS_*in the env file Hermes loads:grep '^MEDSOS_' .env >> "$HERMES_HOME/.env" chmod 600 "$HERMES_HOME/.env"
-
Restart Hermes (CLI and/or gateway).
-
From the agent:
medsos_find_accounts(empty list is fine before OAuth).
Account-scoped except medsos_add_account / medsos_find_accounts.
Handlers return JSON strings and never raise (Hermes plugin contract).
| Tool | What it does |
|---|---|
medsos_find_accounts |
List accounts, or one by account_id |
medsos_add_account |
Start OAuth; returns authorize_url for the human |
medsos_find_posts |
Filter by id / status / limit |
medsos_create_post |
Insert draft |
medsos_update_post |
Edit draft only |
medsos_publish_post |
Publish draft or inline text (~30s Threads 2-step) |
medsos_delete_post |
Delete remote + soft-flag local |
medsos_find_replies |
Inbound/outbound; full=true returns thread context |
medsos_publish_reply |
Reply to inbound reply_id (~30s) |
medsos_update_reply |
Mark skipped / replied / failed |
medsos_delete_reply |
Delete remote + soft-flag local |
medsos_get_insights |
Account-level metrics |
Typical reply loop:
medsos_find_replies(account_id, status="new", direction="inbound", limit=1, full=true)
→ draft with thread context
→ medsos_publish_reply(...) or medsos_update_reply(..., status="skipped")
- API process — Flask on loopback: webhooks, OAuth, health.
- Hermes process — plugin tools call
medsos.ops→ same DB + Threads Graph API. - Tokens — encrypted at rest with
MEDSOS_MASTER_KEY. - Webhooks — HMAC-verified; idempotent on event id; fields:
replies,mentions,publish,delete. - Publish — Threads requires container → wait ~30s → publish; 401 triggers token refresh and retry with the new token.
medsos does not schedule work. Pair with Hermes cron, a gateway loop, or manual tool calls.
| Symptom | Likely cause |
|---|---|
| Plugin tools fail on settings / missing env | MEDSOS_* only in project .env, not Hermes env; restart Hermes after copy |
medsos_find_accounts → [] forever |
OAuth not finished, or API/DB URL differs between processes |
OAuth callback invalid or expired state |
API restarted or different DB than the one that stored oauth_states (10‑min TTL) |
| Webhook handshake 403 | Verify token mismatch |
| Webhook POST 401 | App secret mismatch / bad HMAC |
| Publish “hangs” ~30s | Normal — MEDSOS_PUBLISH_WAIT |
| Tools work, webhooks don’t | Public URL / proxy not reaching :8768, or Meta subscription not set |
. .venv/bin/activate
pytest -q # mocked; no live APILayout worth knowing: plugin/ (Hermes), src/medsos/ (ops + platforms + web),
deploy/medsos.service.example, scripts/serve.py.
MIT — see LICENSE. Copyright (c) 2026 Suhaimi Amir (suhz).