juxtapoandClaude Opus 5 c21399bd61 v0.7.1: install.sh survives boxes this one's assumptions did not fit
Two portability bugs, both found by pre-flighting a second machine (kdmirah,
a Debian LXC) rather than by reading the script again.

1. `systemctl` existing does not mean a user bus is reachable. Inside an LXC,
   over `su`, or with no logind session, `systemctl --user` fails with
   "Failed to connect to user scope bus". The old check asked whether the
   binary was on PATH, then ran `systemctl --user daemon-reload` under
   `set -euo pipefail` — so the install ABORTED halfway, after the venv and
   before the hermes wiring. It now asks the real question,
   `systemctl --user show-environment`, and on failure installs no units, says
   why (naming XDG_RUNTIME_DIR and the usual causes), prints the three
   commands to run by hand, and continues to hermes.

2. The hermes interpreter was hardcoded to this box's layout,
   /usr/local/lib/hermes-agent/venv/bin/python. kdmirah installs hermes under
   ~/.local/bin with its venv at ~/.hermes/hermes-agent/venv. Both are bash
   wrappers that exec their python, so the path is now read out of the wrapper
   (with a pip console-script shebang as the second shape). Verified against
   both machines: each derives its own interpreter and each reports mcp present.

Also: the closing summary no longer prints the dashboard URL and
`journalctl --user -u socialmag-refresh` when no units were installed. Pointing
someone at a journal for a unit that was never created is the same class of lie
as a silent fallback — it reads as working and answers with nothing.

Both branches fired in a sandbox with a fake HOME and two stub systemctls, one
that answers show-environment and one that refuses it. Both exit 0; the
refusing one installs nothing, warns loudly, and still registers hermes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 02:32:13 +08:00

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.

Install

git clone https://github.com/juxtapo9090/socialmag.git
cd socialmag
./install.sh

That builds a venv, installs the package, writes four systemd user units, and registers socialmag with Hermes as an MCP server. Then, once:

./launch-brave.sh      # opens Brave on the debugging port, own profile

Sign in to your platforms in that window. socialmag stores no passwords — it drives the session that now belongs to that browser profile, which is why this step is yours and cannot be scripted.

Afterwards: dashboard at http://127.0.0.1:8321/dashboard.html, a refresh pass every 30 minutes, and socialmag / socialmag-refresh / socialmag-openbridge on the venv's PATH.

⚠️ Hermes treats the mcp Python package as optional. Without it, every mcp_servers entry is ignored — no error, one debug log line. install.sh checks for it in Hermes's own interpreter and refuses to continue rather than leave you with a server that looks registered and never loads.

⚠️ install.sh will not merge into an existing mcp_servers: key. It backs up config.yaml and appends only when the key is absent; otherwise it prints the block for you to paste. Blind YAML merging is how someone's other servers vanish.

⚠️ User units stop at logout unless sudo loginctl enable-linger $USER. The installer says so when linger is off.

Run

socialmag              # stdio MCP server, logs to stderr

⚠️ Use the console script, not python3 -m socialmag, from inside the repo. The repo root is the package dir, so it ships selectors.py and queue.py — names the stdlib also uses. Python puts the current directory first on sys.path, so from inside a clone, asyncio's own import selectors finds ours and dies with module 'selectors' has no attribute 'SelectSelector'. The generated bin/socialmag never puts the current directory on sys.path, so it works from anywhere. -m still works from outside the repo.

The same trap bites python3 -m venv during install, which is why install.sh runs every python3 from /.

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. 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.
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.

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:

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").
S
Description
No description provided
Readme
274 KiB
Languages
Python 74.3%
HTML 18.1%
Shell 7.6%