Social Poster — Draft-Only, POST APPROVED Gated
Contract
- Draft, never post — default mode produces text files + browser-staged tabs + screenshots. No network mutation without explicit
POST APPROVED.
- Persistent browser session — uses
aside CLI (signed into $USER@gmail.com) to keep one session across multiple platform tabs; never spawns a fresh Playwright Chromium per platform.
- Per-platform conformance — character limits, title rules, hashtag placement, threading, self-promo ratios checked at draft time (no "fix it after posting").
- Reddit 10/90 + per-sub rules — every Reddit draft is checked against the live-verified rules in
references/subreddit-rules.md (r/LocalLLaMA, r/Rag, r/OpenAI verified via Aside probe 2026-07-05).
- POST APPROVED token —
scripts/post_approved.py checks for literal POST APPROVED (or per-platform like POST APPROVED linkedin,hackernews) before clicking any submit button. Without the token, exits code 2 with "BLOCKED — no POST APPROVED token".
- No LLM augmentation by default — pure templating. To enable LLM refinement, pass
--use-llm (which routes through your existing ~/.hermes/config.yaml auxiliary provider; never hardcodes API keys).
- Auto-stage on /social — when invoked as
/social <intent>, run Phase 1 + Phase 2 back-to-back without asking. The user invoked the command → staging is implied. (Jeffrey preference, 2026-07-17: "stage it and next time stage without asking".) Skip staging ONLY if Aside extension bridge is down or all drafts are intentionally login-wall / load_only / no_recipe.
Supported Platforms
| Platform | Template | Has web compose? | Notes |
|---|
| LinkedIn | templates/linkedin.md | ✅ | long-form + 300-char short variant |
| Hacker News | templates/hackernews.md | ✅ | "Show HN:" prefix when self-promo; title ≤80 chars |
| Twitter/X | templates/twitter.md | ✅ | single tweet + thread; hashtags at end of last tweet only |
| Reddit | templates/reddit.md | ✅ | one file per subreddit; text-post preferred over link-post |
| Threads | templates/threads.md | ✅ | ≤500 chars, casual |
| Facebook | templates/facebook.md | ✅ | medium-length, link-friendly |
| Instagram | templates/instagram.md | ⚠️ no web compose | caption + 30-hashtag block; surfaces mobile instructions |
| Mastodon | templates/mastodon.md | ✅ | 500-char default; configurable instance |
| Dev.to | templates/devto.md | ✅ | markdown article format |
Phases
Phase 1 — Draft (deterministic, no browser)
Run scripts/draft_social_post.py with intent + key-points + link + platforms. Produces per-platform files in --out <dir>. Character-limit hard-rejects; spam-rule soft-warns.
PY=$HOME/.hermes/skills/social-poster/scripts/draft_social_post.py
python3 "$PY" \
--intent "announce jleechanclaw open-source release" \
--key-points "AI agent orchestration, hermes deploy pipeline, skill framework" \
--link "https://github.com/jleechanorg/jleechanclaw" \
--platforms linkedin,hackernews,twitter,reddit,threads,facebook,instagram,mastodon,devto \
--reddit-subs "LocalLLaMA,Rag,OpenAI" \
--image "" \
--out /tmp/drafts/social-2026-07-06/
Output: one .md file per platform (or per subreddit for Reddit). Filename-safe slugs.
Phase 2 — Stage in Aside (browser, no auto-post)
Prefer the MCP path (stage_in_aside_mcp.py) when available. The aside repl CLI silently no-ops on programmatic paste (lesson #11; verified 2026-07-17 across 9/11 platforms failed despite "compose-ready" status). The HTTP MCP at 127.0.0.1:8013/mcp with openTab() + Playwright locator().fill() reliably pastes when the session is healthy.
PY=$HOME/.hermes/skills/social-poster/scripts/stage_in_aside_mcp.py
python3 "$PY" --drafts /tmp/drafts/social-2026-07-17/
python3 "$PY" --drafts /tmp/drafts/social-2026-07-17/ --only hackernews,twitter
Falls back to the legacy aside repl script when MCP is unreachable:
PY=$HOME/.hermes/skills/social-poster/scripts/stage_in_aside.py
python3 "$PY" --drafts /tmp/drafts/social-2026-07-17/
PY=$HOME/.hermes/skills/social-poster/scripts/stage_in_aside.py
python3 "$PY" --drafts /tmp/drafts/social-2026-07-06/
Contract (v3, 2026-07-11): The script now enforces paste-and-verify. For each platform:
- Opens a new Aside tab on the platform's compose URL.
- Optional trigger click (LinkedIn "Start a post", Facebook "What's on your mind").
- Pastes the draft into the relevant field via per-platform paste function:
react_textarea — vanilla <textarea> / <input> (Reddit, HN, Mastodon, Dev.to)
exec_command — contentEditable (Twitter, LinkedIn, Facebook, Threads); uses document.execCommand('insertText', false, text) after el.focus() — the only reliable pattern for React-controlled contentEditable fields.
- Reads the field back with
verify_text(selector) and compares against expected draft length (≥70% threshold to allow for trailing whitespace stripping).
- Captures post-paste screenshot →
/tmp/drafts/social-2026-07-06/screenshots/<platform>.png.
- Sets status:
"staged" — paste verified, screenshot saved
"paste_failed" — compose form loaded but text didn't persist (user must paste manually)
"login_wall" — page is a sign-in screen, no paste attempted
"load_only" — Instagram (no web compose)
"failed" — Aside subprocess error (e.g. extension bridge dead)
The script will NOT claim a platform is "ready" unless the draft text was verified inside the field. Vision-verify at least one screenshot per platform after staging for a final cross-check.
Aside recipes for each platform's compose URL live in references/aside-recipes.md.
Phase 3 — Surface to user
Print all draft file paths + screenshot paths. Attach screenshots via Slack MEDIA:/path (per evidence-attach-to-slack skill). Ask: "POST APPROVED?"
Phase 4 — Post (gated)
ONLY after the user types POST APPROVED (literal, case-insensitive, optionally with comma-separated platform allowlist), run:
PY=$HOME/.hermes/skills/social-poster/scripts/post_approved.py
python3 "$PY" \
--drafts /tmp/drafts/social-2026-07-06/ \
--approval-token "POST APPROVED" \
[--platforms linkedin,hackernews]
Behavior per platform:
- LinkedIn / HN / Twitter / Reddit / Threads / Facebook / Mastodon / Dev.to → click submit, capture post-URL.
- Instagram → surface draft caption as text + print mobile instructions (no web compose). Manual copy-paste from phone.
Posting log written to --drafts/posted.json with timestamps + captured URLs.
Safety Gates
POST APPROVED required — see scripts/post_approved.py check_approval() function. Hard exit 2 if missing.
--dry-run — default behavior of post_approved.py if invoked without an approval token. Prints staged tabs and exits.
- Per-platform allowlist —
POST APPROVED linkedin only posts LinkedIn. Comma-separated = OR. Default = all staged platforms.
- No silent failures — any platform that fails to post writes to
posted.json with {"status": "failed", "error": "..."}. User is notified.
- Audit log — every draft run writes
--out/manifest.json with timestamp, intent, platforms, character counts, spam-rule warnings.
Subreddit Selection (verified 2026-07-05)
| Content type | Primary | Secondary | Tertiary |
|---|
| Open-source / local AI tool | r/LocalLLaMA | r/OpenSourceAI | r/singularity (opinion-only) |
| RAG / retrieval / vector DB | r/Rag | r/LocalLLaMA | — |
| Coding agent / dev tool | r/OpenAI (text post) | r/LocalLLaMA | — |
| General AI news / opinion | r/OpenAI | r/LocalLLaMA | r/ClaudeAI (Claude-specific) |
Banned / avoid:
- r/AItools, r/AutoGen, r/LMStudio → banned subs.
- r/singularity, r/AGI, r/Futurism, r/MachineLearning → zero self-promo.
- r/philosophy, r/ProgrammerHumor → AI content banned outright.
Anti-Patterns
- ❌ Auto-posting without
POST APPROVED (bypasses the safety gate)
- ❌ Using
mcp__playwright-mcp__* for localhost testing — use aside (signed-in session)
- ❌ Calling
aside show_browser / headed mode without explicit opt-in (headless-only default)
- ❌ Hardcoding API keys in
draft_social_post.py (LLM augmentation must route through ~/.hermes/config.yaml)
- ❌ Stripping 10/90 framing from Reddit drafts ("I built this, here's the link") — guarantees removal
- ❌ Posting the same draft verbatim across Reddit subs (each sub has different norms; per-sub files required)
- ❌ Posting link-only to r/OpenAI — requires text post + context (verified rule)
- ❌ Clicking submit in Phase 2 (stage-only)
- ❌ Treating
draft_social_post.py output as final without reading it — the templater hallucinates key-points into the wrong slots when the intent is a commentary/opinion piece rather than a project announcement (e.g. "Fable AI oneshot a 2D game" gets turned into "jleechanclaw does X"). Always read every generated .md file before staging. If templating misfires, hand-author the drafts directly (write .md files in --out) and re-run staging on the hand-authored files. Worked example: references/fable-2d-game-2026-07-11.md.
- ❌ Trusting
stage_in_aside.py's "compose-ready" DOM verdict without vision-verification. The script's DOM-detector only checks for selectors like input[name="title"]; it does NOT verify that the text was actually pasted into the field. Programmatic paste via React setter / contentEditable frequently fails silently on Twitter, LinkedIn, HN, and Reddit modals. After every staging run, vision-verify AT LEAST ONE screenshot per platform (vision_analyze("Is the draft text visible in the compose field, or is it empty?")). If empty, the compose form is loaded but paste didn't stick — user must paste manually from the .md files.
- ❌ Trusting
listBrowserTabs() as proof the platform is signed in — it proves the tab is signed in, but openTab(compose-url) may still hit a session-revalidation redirect to a login wall (LinkedIn, Facebook, Dev.to, Mastodon, Threads all did this on 2026-07-11). The source-of-truth check is: vision-verify the staged screenshot shows a compose form with user avatar/handle visible, NOT a login wall.
- ❌ The compose modal accepts arbitrary input but disables the Post button (red highlight + "-666" counter visible) once the limit is exceeded. The drafter must keep single-tweet drafts ≤280 chars; longer content needs a flag that splits into N tweets. The default does NOT enforce this — check after drafting.
LinkedIn login-wall unblock signals (verified 2026-07-11)
When LinkedIn staging returns a "Welcome back" password screen, do NOT conclude LinkedIn is unreachable. Check for these one-click unblock signals in the screenshot before declaring the platform blocked:
- Google One-Tap prompt (top-right overlay, dark gray box, "Sign in to LinkedIn with Google" + blue "Continue as Jeffrey" button) — one click unblocks the session.
- Continue with Google button (white button below "Sign in" with Google G logo) — same OAuth flow, one click.
- Sign in with Apple button — same UX as Google.
If any of these are visible AND the email shown matches $USER@your-project.com, the user can unblock LinkedIn in 1-2 clicks. Tell them which button to click.
If NO unblock signal is visible (just an empty password field), the session is fully expired and the user must type their password.
Output Format
After Phase 3, the skill reply includes:
## social-poster draft ready
- Drafted: 9 platforms (linkedin, hackernews, twitter, reddit x3, threads, facebook, instagram, mastodon, devto)
- Staged: 9 Aside tabs open (URLs: …)
- Screenshots: /tmp/drafts/social-2026-07-06/screenshots/ (9 PNGs)
- Manifest: /tmp/drafts/social-2026-07-06/manifest.json
**To post:** reply with `POST APPROVED` (all) or `POST APPROVED <platforms>`.
**To revise:** tell me what to change and I'll re-draft + re-stage.
Screenshots attached via MEDIA:/path/to/png for inline Slack preview.
Trigger → Action Map
| User says | Action |
|---|
draft a post about X | Phase 1 only |
draft + stage a post about X | Phase 1 + Phase 2 |
POST APPROVED | Phase 4 (all staged) |
POST APPROVED linkedin,twitter | Phase 4 (only those two) |
revise the linkedin draft | Re-run Phase 1 + Phase 2 for that platform only |
cancel drafts | Close Aside tabs, keep files |
draft posts for <LinkedIn/Twitter URL> | Commentary-source path: extract source body via curl + og:description regex (NOT web_extract), hand-author drafts (template misfires on commentary), see references/fable-2d-game-2026-07-11.md |
Phase 3.5 — Posting the user-facing summary (Slack/Discord), MCP-down aware
The draft summary message to the user can land on Slack/Discord/chat. When the messaging MCP is available, use it. When mcp__slack__conversations_add_message returns not_in_channel or the server reports "unreachable after N consecutive failures", fall back to the user's XOX-P (user token) via curl (per SOUL.md slack-cross-workspace-fallback-xoxp):
TOKEN="$SLACK_USER_TOKEN"
curl -fsS -X POST "https://slack.com/api/chat.postMessage" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json; charset=utf-8" \
--data-binary @<(jq -n --arg txt "$BODY" \
'{channel: "<chan>", thread_ts: "<ts>", text: $txt}')
The XOX-P fallback posts as the user (not the Hermes bot identity). Say so in the body if it might confuse the user. Do NOT stall with "iteration budget exhausted" — the post still goes through. After the fallback, continue with the standard "POST APPROVED?" prompt.
For non-Slack chat (Discord/Telegram/Discord DM/etc.) the same fallback pattern applies: curl to the platform's REST endpoint with the user token rather than waiting for the MCP to recover.
Files
scripts/draft_social_post.py — deterministic drafter
scripts/stage_in_aside.py — opens Aside tabs + screenshots (legacy aside repl path — silent paste failure mode)
scripts/stage_in_aside_mcp.py — preferred: opens Aside tabs + pastes via HTTP MCP at 127.0.0.1:8013/mcp. One MCP call per platform bundles open + paste + verify + screenshot on a single page object (avoiding the "fresh tab = lost paste" failure). Verified selectors for HN, Twitter, Mastodon, Reddit ×3, Dev.to, LinkedIn, Facebook, Threads (2026-07-17).
scripts/cookie_inject_and_stage.py — uses browserclaw cookies decrypt + Playwright Chrome with injected cookies to stage + paste drafts (for platforms where Aside is not signed in)
scripts/post_approved.py — gated publisher
templates/*.md — 9 platform templates
references/subreddit-rules.md — live-verified per-sub rules
references/platform-character-limits.md — hard limits + soft guidance
references/aside-recipes.md — aside repl snippets per platform
references/platform-session-status.md — per-platform cookie injection + anti-bot results (verified 2026-07-05). Playwright Chrome only — see aside-repl-session-state.md for aside repl-based staging.
references/blog-post-to-gdoc.md — recipe for "draft a blog post in Google Docs" (canonical gws docs path + local-markdown fallback when gws OAuth is unauthenticated).
references/aside-repl-session-state.md — verified auth state per platform for aside repl workflows (2026-07-06). Includes the listBrowserTabs() live-tab probe recipe, the document scope-doesn't-persist-across-REPL-calls pitfall, and per-platform paste workarounds for LinkedIn contentEditable / Facebook click-first / Twitter thread pagination.
references/aside-repl-playwright-pattern.md — verified openTab() returns a Playwright Page object with screenshot() (Buffer→base64), evaluate(), locator(), frameLocator(). Includes 6 working paste/click/inspect idioms. Source of truth for the 2026-07-06 staging rewrite.
Operator preferences (captured 2026-07-11)
Jeffrey's standing directives for this skill — embed these in any automation flow:
- Headless only. Never open a visible browser window. Use
browserclaw cookies inject --headless or headless Playwright (p.chromium.launch(headless=True)). If a step would spawn a GUI window (e.g. open -a "Aside" or aside "Open X" without headless mode), prefer the headless equivalent or surface the limitation rather than opening the GUI.
- Use
/browserclaw to copy Chrome cookies as needed. Cookies are the persistent auth substrate; decrypt them from the local Chrome / Aside cookie DB rather than asking the user to log in or paste tokens. If browserclaw CLI is broken (see browserclaw SKILL.md broken-editable-install pitfall), fall back to hand-loading the Playwright storage_state JSON written by a previous successful decrypt.
POST APPROVED gating is mandatory. No auto-post, no scheduled post, no background post. The literal token POST APPROVED (optionally POST APPROVED <platforms>) must appear in the current session before any submit/click-post action.
- Vision-verify, don't DOM-detect. The script's DOM verdict and the actual field contents can disagree — programmatic paste via React setter / contentEditable frequently fails silently. After every staging run, vision-verify at least one screenshot per platform with the explicit question "What text is currently inside the compose field?" before claiming the platform is "ready".
Critical lessons (from real sessions, not theoretical)
staged ≠ pasted. The stage_in_aside.py script labels every successfully-navigated compose form as "staged" even when the paste silently failed (React-controlled-field re-render ate the programmatic input). Vision-verify the actual TEXT inside the title input + body textarea, not just the page state. A logged-in compose form with empty fields looks identical to a logged-in compose form with empty fields — vision models will say "compose form ready" either way unless explicitly asked "what text is currently inside the field?" (lesson from 2026-07-11 Fable-2D-game run, user pushback: "all of those drafts are obviously wrong and just random login screens so youre not even close to working"). See references/staged-vs-pasted-verification.md for the verify-paste checklist + reusable paste verification snippet.
0a. Use --account u0 ($USER@gmail.com) for aside repl automation. Default active is u1 (your-project.com) and the extension bridge can be more brittle on that profile. Verified 2026-07-11 — user pushed "use $USER@gmail.com profile next time and use the mcp or cli its supposed dto work". See references/aside-mcp-cli-gmail-profile.md.
0b. Aside HTTP MCP at 127.0.0.1:8013/mcp is the most reliable automation path (when the extension bridge is alive). Tools surface: just repl (single tool wrapping the persistent JS REPL). MCP initialize handshake requires reading the mcp-session-id header from the response and sending it on subsequent calls + notifications/initialized. Top-level await is NOT allowed in the REPL — wrap in (async () => { ... })();. The REPL has persistent scope (vars persist across calls), fs module via node:fs/promises, display() for inline image preview, sleep(ms), fetch() with user cookies. See references/aside-mcp-cli-gmail-profile.md for the full request flow + reusable client snippet.
-
aside account list does NOT show per-platform auth state. It only shows the active Google account ID. To gauge whether LinkedIn/Twitter/Facebook/etc. are actually signed in, run aside repl "const tabs = await listBrowserTabs(); console.log(tabs.map(t => t.url).join(' | '))". A signed-in tab on the platform = the session is valid, even if a fresh openTab(compose-url) shows a login wall (compose-URL navigation may trigger session re-auth).
-
document scope does NOT persist across aside repl calls. Each call gets a fresh JS context. Multi-step workflows (open → click → paste → verify) must be bundled in ONE async IIFE in a single call. Screenshot via annotatedScreenshot(pageObj) returned from openTab(), not annotatedScreenshot(null).
-
Programmatic paste into LinkedIn/Twitter/Facebook compose modals is unreliable. Twitter's textarea + React setter trick works in a single bundled call. LinkedIn's contentEditable div + el.innerHTML = text works but React state may not sync (manual paste is more reliable). Facebook requires click-first on "What's on your mind, [Name]?" before the modal opens. Default workflow: stage + screenshot the empty compose area, let the user paste manually. This avoids the React control gymnastics entirely and the user still gets visual proof the compose UI is loaded.
-
POST APPROVED is mandatory for any submit/click-post action. Drafts may be staged freely. Posting requires the literal POST APPROVED string (optionally with comma-separated platform allowlist). No exceptions, no "looks approved" heuristics. See scripts/post_approved.py check_approval() for the gate logic.
-
aside session ≠ account list ≠ Chrome cookie DB. The source of truth is listBrowserTabs(). If the user says "I'm signed in to LinkedIn", verify with that probe before declaring a platform unreachable.
-
openTab(url) returns a Playwright Page object, not a serializable tab descriptor. Use p.screenshot() directly (returns a Buffer that base64-encodes for stdout), or p.locator(...) / p.evaluate(() => ...) for DOM ops. annotatedScreenshot(null) throws ; works but is simpler. See for the verified idioms.
aside repl "
const p = await openTab('<url>');
await sleep(3000);
const ss = await p.screenshot();
console.log('SCREENSHOT_B64:' + ss.toString('base64'));
" > /tmp/out.txt 2>&1
grep '^SCREENSHOT_B64:' /tmp/out.txt | sed 's/^SCREENSHOT_B64://' | base64 --decode > /tmp/shot.png
This works because top-level await is allowed directly in the REPL (no IIFE wrapper needed — see lesson 13). For multi-platform staging loops, wrap in a Python subprocess.run loop that parses each SCREENSHOT_B64: line and decodes to disk.
-
Mastodon URLs: mastodon.social/compose is a 404; use mastodon.social/publish (verified 2026-07-15). Old docs and recipes reference /compose, but the modern Mastodon (v4.x) instance returns a 404 Not Found for that path. The compose preview lives at /publish. Caveat: /publish shows the compose form to unauthenticated users, but the "Post" button won't work until you sign in. Verify session state with vision ("Login / Create account buttons visible on the right?") before staging. Other instances (e.g. mastodon.social/@user) have different canonical paths; check the instance's help docs.
-
LinkedIn share-box trigger uses obfuscated class names that defeat programmatic clicks (verified 2026-07-15). .locator('button[aria-label*="Start a post"]') returns 0 matches. Click-by-text (document.querySelectorAll('button').forEach(el => { if (el.textContent.includes('Start a post')) el.click() })) also returns 0 — LinkedIn renders the "Start a post" box as a <div> with role="button" and randomized class names like share-box-feed-entry__closed-share-box. After 3 failed attempts (locator, click-by-text, click-by-placeholder), the recommended path is: tell the user "click 'Start a post' manually, then paste from linkedin.md". Same fallback applies to Threads (sometimes), Facebook (modal requires click on "What's on your mind, [Name]?" first), and Instagram (no web compose at all).
-
aside repl CLI silently no-ops on programmatic paste — use Aside HTTP MCP instead (verified 2026-07-17). Switching the staging script from aside repl "..." to the HTTP MCP at 127.0.0.1:8013/mcp is the difference between 9/11 platforms stuck at "compose-ready with empty fields" and 3/11 platforms actually having the draft text in the field. The aside repl path looks identical — every await loc.fill(...) returns without error — but the field stays empty because the underlying Chrome DevTools clipboard-write hook is silently disabled in that mode. Use scripts/stage_in_aside_mcp.py as the canonical staging entry point; treat aside repl paste as a known-broken path.
-
Bundle open + paste + verify + screenshot in ONE MCP call per platform, never split (verified 2026-07-17). Each openTab(url) opens a fresh tab. If the JS code is split across multiple MCP calls — call 1 opens the tab, call 2 types, call 3 verifies — the typed text is lost because call 2's reopens a new tab on the same URL, which resets the compose form. The fix is one MCP per platform wrapping in a single IIFE; the screenshot base64 emits on a separate line so the companion line stays small enough to parse reliably.