A shell entered via `su` keeps the ORIGINAL user's XDG_RUNTIME_DIR, so `systemctl --user` hunts for the bus in someone else's directory and finds nothing — even when this user's manager is running perfectly. v0.7.2 detected that and told the operator to export the right path. Typing /run/user/100 instead of /run/user/1000 cost a whole run. The runtime dir is /run/user/<uid> by definition. The script now derives it, retries the bus check with it, and installs the units — then tells the operator to export it for the REST of their shell, because a child process cannot fix its parent's environment. The failure message also stopped guessing. It now distinguishes the two real cases: /run/user/<uid> missing (no logind session — enable-linger, and no fresh login is needed, contrary to what v0.7.1 said) versus present but unanswered (the manager itself is the problem — go look at user@<uid>.service). Four branches fired in a sandbox with three stub systemctls: bus answers directly; bus answers only with the corrected dir; runtime dir absent; runtime dir present but manager silent. All exit 0, and the middle one installs units that the previous version would have skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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) overonSnapshot. - Local mode — served over plain HTTP, it falls back to fetching
dashboard.jsonfrom beside itself and re-polls every 10s. The freshness line readslocal · 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.
openbridgerefuses to move to a different port if:8322is taken, for the same reason. - Unmapped flows refuse rather than guess: Facebook
visibilityswitching 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_loginimplementations 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").