# socialmag MCP server that posts, reads, and replies across 6 platforms — Threads, Facebook, Instagram, YouTube, TikTok, and Twitter/X — by driving abang's already-logged-in Brave over CDP. No API keys, no OAuth. ## Run ```bash cd /mnt/playground/Playground_V2 python3 -m socialmag # stdio MCP server, logs to stderr ``` ⚠️ **Must run as a package.** `python3 socialmag/server.py` puts the package dir first on `sys.path`, where `selectors.py` and `queue.py` shadow the stdlib modules `asyncio` imports — asyncio then dies at import with `module 'selectors' has no attribute 'SelectSelector'`. Run `-m`. Brave must be listening on `http://127.0.0.1:9336` (`cdp.py:29`, `DEFAULT_HTTP_ENDPOINT`). Override with `SOCIALMAG_CDP_ENDPOINT`. ⚠️ **`SOCIALMAG_CDP_PORT` is not read by anything.** `/root/.claude.json` sets it for this server, but no code looks at it — grep says the only env var consulted is `SOCIALMAG_CDP_ENDPOINT` (`server.py:65`). It appears to work today purely because the default endpoint is already `:9336`. Change the port there and nothing happens, silently. Fix the name in the config, or teach `server.py` to read the port. Requires `websockets` (17.1 present on this box). Everything else is stdlib. ## Tools | Tool | Description | |------|-------------| | `create_post` | Post to one or more platforms (threads, fb, ig, yt, tiktok, twitter) | | `check_login` | Check which platforms are signed in | | `get_status` | Last post results + open browser tabs | | `get_feed` | Read Twitter timeline or search with X operators | | `reply_tweet` | Reply to a tweet found by search | | `get_notifications` | Check platform activity/notifications (Threads) | | `open_link` | Navigate platform's browser tab to a URL (tab reuse) | `create_post` — `text` is required for threads/fb/yt and used as the caption for ig/tiktok. `media` must be an **absolute path to an existing file**; required for ig/tiktok, optional for threads/fb, rejected for yt. ## Tab reuse and focus `cdp.get_or_open_tab(url, domain)` attaches to the first open tab whose URL contains `domain`, and only opens a new tab when none matches. Every tool goes through it, so socialmag does not spawn a tab per action. A reused tab is then brought to the front via `Tab.activate()` (`Target.activateTarget`). **This is not cosmetic.** A backgrounded tab is not guaranteed to finish rendering: YouTube leaves its masthead as `#masthead-skeleton-icons` with an empty `#buttons` and never mounts `#avatar-btn`, so `youtube.check_login` — which tests for that element — reported a signed-in account as signed out. Measured 2026-09-11: `#avatar-btn` still absent after 23s in a background tab, while `ytcfg.get('LOGGED_IN')` was `true` the whole time. With activation, `yt` returns `True` 3/3; without it, `False` 3/3. **The cost:** any socialmag call that touches a platform now pulls Brave's focus to that tab. Fine for a deliberate call, disruptive on a timer — see *Surviving reboot* below. A DOM-render signal (`#avatar-btn`) needs the foreground. A JS-state signal (`ytcfg.get('LOGGED_IN')`) does not. That distinction is the lever if focus-stealing ever has to go away. ## Dashboard `dashboard.html` is a local viewer — platform status strip, post/notification timeline, and a Twitter intel rail. It runs in two modes: - **Artifact mode** — published as a Claude Artifact with `capabilities: {db: {}}`, reading four live collections (`platforms`, `posts`, `notifications`, `feed`) over `onSnapshot`. - **Local mode** — served over plain HTTP, it falls back to fetching `dashboard.json` from beside itself and re-polls every 10s. The freshness line reads `local · updated Xs ago`, and the banner is informational, not a warning. ```bash cd /mnt/playground/Playground_V2 && python3 -m http.server 8321 # http://127.0.0.1:8321/socialmag/dashboard.html ``` ⚠️ **Nothing writes `dashboard.json`.** It is hand-maintained. There is no refresher, no writer loop — an earlier version of this README claimed the server wrote it at runtime; it does not. See *Surviving reboot* for what building one actually costs. Cards and feed rows with a `url` are clickable, behind a `safeUrl()` filter that admits `http(s)` only — scraped URLs are untrusted input. ### openbridge — clicking without tab explosion A static page cannot speak MCP, so a plain `target="_blank"` card opens a new tab on every click. `openbridge.py` exposes exactly one capability — `open_link` — over localhost so a click navigates the platform's existing tab instead. ```bash cd /mnt/playground/Playground_V2 && python3 -m socialmag.openbridge # 127.0.0.1:8322 ``` | Endpoint | Returns | |---|---| | `GET /health` | `{"ok": true, "platforms": [...]}` | | `GET /open?platform=X&url=Y` | the `open_link` result, or `{"error": ...}` | It imports `tool_open_link` rather than reimplementing it, so validation and domain matching stay in one place. Verified 2026-09-11: 8 tabs before two clicks, 8 after, both reused. The dashboard treats an unreachable bridge as "just open a new tab" — progressive enhancement, so the page is never worse than without it. Ctrl/Cmd/middle-click is left alone. A bridge that answers and *refuses* surfaces the error instead of quietly opening a tab. ## Layout ``` server.py MCP stdio JSON-RPC loop + 7 tools cdp.py CDP client: CDPClient (browser) + Tab (page verbs, incl. activate) queue.py PostQueue — strictly sequential, one tab at a time selectors.py every platform selector + PLATFORMS dict, one file platforms/ one module per platform: post() + check_login() openbridge.py localhost HTTP shim exposing open_link to the dashboard dashboard.html status/timeline viewer (artifact db mode, or local dashboard.json) dashboard.json local-mode data — hand-maintained, nothing writes it ``` Each platform module exposes: ```python async def post(cdp_client, text, media_path, options) -> {"status": ..., "detail": ...} async def check_login(cdp_client) -> bool ``` Twitter additionally exposes `get_feed`, `reply`, and `check_login` (reusing the existing selector set). Threads exposes `get_notifications`. `post` opens its own tab, drives the flow, closes the tab. A failure inside the flow is caught by `platforms.guard`, which screenshots the page as it actually looked into `/tmp/socialmag/` and puts the path in the result. ⚠️ **Don't leave anything you want to keep in `/tmp`.** The v2 dashboard work was written to `/tmp/socialmag/` and was lost to a `/tmp` clear — git only held the pre-v2 file, and it had to be rebuilt from the handback. Guard screenshots are disposable by design; build output is not. ## House rules honoured - **No silent fallbacks.** Missing media, missing text, an unknown platform, a selector that never appears, a CDP call that errors — all raise with the reason. Nothing picks a default and carries on. `openbridge` refuses to move to a different port if `:8322` is taken, for the same reason. - Unmapped flows **refuse** rather than guess: Facebook `visibility` switching and YouTube media both raise, because clicking through an unmapped dialog outward-facing is worse than a stop. - Human-like delays (`random.uniform`) between every discrete action, longer on TikTok. --- # Surviving reboot, interval updates, and the VPS question **Status: not built.** This section is the requirements map, not a description of something running. Everything below was checked on this box on 2026-09-11. ## What has to survive, and what each piece actually needs | Piece | Survives reboot today? | What it needs | |---|---|---| | `dashboard.html` / `dashboard.json` | ✅ files on disk | nothing — edit and refresh; `:8321` reads from disk, JSON re-polls every 10s | | file server `:8321` | ❌ | a systemd unit. Started by hand today — parent process is `/bin/fish` | | `openbridge` `:8322` | ❌ | a systemd unit | | socialmag MCP server | ❌ and **systemd cannot help** | Claude Code spawns it as `"type": "stdio"` (`/root/.claude.json`). No unit can own that process. Editing `server.py`/`cdp.py` requires Claude Code to respawn it | | Brave + CDP `:9336` | ❌ | **not a service** — pid 267116, launched by abang. After boot there is no CDP until he opens Brave | | the data refresher | ❌ **does not exist** | has to be written — see below | The house already runs this exact shape as system units (`cc-sidepanel-bridge.service` — *"localhost HTTP shim to injectd"*), so the pattern to copy is `/etc/systemd/system/*.service`, `User=juxtapo`, `Restart=on-failure`, `WantedBy=multi-user.target`. ## The refresher, and the two things that make it hard Making the dashboard update on an interval means writing something that calls `check_login` / `get_feed` / `get_notifications` on a timer and merges the results into `dashboard.json` (atomically — write a temp file and rename, so a poll never reads a half-written file). That much is small. These two are the real constraints: **1 · A timer steals focus.** Because `get_or_open_tab` now activates the tab it reuses (and it has to, or YouTube reads as signed-out), every scheduled refresh yanks Brave to whatever tab it is scraping. Three ways out, none free: - accept it, and pick a cadence sparse enough to tolerate (30-60 min) - refresh **on demand only** — dashboard always served, data as fresh as the last call. No timer, no focus theft - rewrite the six `check_login` implementations to use JS-state signals (`ytcfg.get('LOGGED_IN')` and equivalents) which work in a background tab, and keep the genuinely DOM-dependent work (`get_feed`, `get_notifications`) on demand **2 · Boot order cannot be satisfied.** Brave is not a service, so at boot there is no CDP endpoint. A refresher unit must treat "no Brave" as a normal waiting state — log it and retry on the next tick — not crash-loop. `Restart=on-failure` with a timer is the right shape; a `Type=simple` daemon that exits on a failed connect is not. A systemd `.timer` is a better fit than cron here: it survives a missed window with `Persistent=true`, logs to the journal, and can be ordered after the network. Cron would work, but gives up the journal and the ordering. ## Running on a VPS — read this before planning it socialmag's whole premise is *drive the browser abang is already logged into*. A VPS has no such browser. Moving it there is not a port, it is a different product. What it would actually require: - **A headless Chrome/Brave on the VPS with a persisted profile**, and every one of the six accounts logged in *there*. Cookies would have to be established on the VPS and kept alive. - **A display.** Several of these flows depend on rendered layout — and we now have it in writing that background/unrendered pages give wrong answers (the YouTube masthead skeleton above). Headless-with-Xvfb is the usual answer; "headless is fine" is the usual mistake. - **Surviving anti-bot.** A datacentre IP logging into Meta/TikTok/X accounts that normally appear from a home connection is the single most likely thing to get the accounts challenged or locked. This is a real risk to abang's actual accounts, not a theoretical one. - **Re-auth with no human present.** Every one of these platforms will eventually demand a re-login or a 2FA prompt. On the desktop abang just does it. On a VPS, something has to notice and tell him — which means the failure path needs to be loud and reach him, not sit in a log. None of this is refused — it is a build, and a larger one than the units above. The honest order is: units for the two servers and a refresher on this box first, because they are cheap and the answers they produce (how often does a session drop? how often does a platform challenge a login?) are exactly the data needed to know whether the VPS version is worth it. ## Not built (out of the brief's scope) - `post_youtube_video` (video upload) — the brief scoped yt to community posts. - `socialmag.service`, `socialmag-bridge.service`, `socialmag-refresh.timer` — the units above. Deferred pending abang's call on the focus/cadence trade-off. - The refresher itself. Nothing writes `dashboard.json`. - Scheduling, analytics, multi-account (proposal "Future").