Architecture & Loops

Who talks to whom — kun, Hermes, the platforms, and the marketing team — the three publish loops, the generate→approve→schedule→publish chain, the cron jobs, and the keywords that drive them.

Architecture & Loops

Social Automation describes what the pipeline does. This page describes who talks to whom, in which direction, and which of the three loops a given post actually travels. If you are wiring a new channel, adding a cron, or wondering why a draft did not publish, start here.

The five actors

ActorWhere it runsHoldsCan call out toCan be called by
kunVercel (public HTTPS)Every platform token (FACEBOOK_PAGE_ACCESS_TOKEN_*, TELEGRAM_BOT_TOKEN), CRON_SECRETPlatform APIs · Anthropic APIThe internet · Vercel Cron · Hermes
HermesAbdout's Linux desktop, localhost:18789Slack bot/app tokensThe internet (outbound) · Slack · kunNothing — not publicly routable
Distribution platformsFacebook, Telegram, … (the eight)The published postkun (direct API) · Hermes (adapters)
SlackThe one communication channelApprovals, notices — never a published postkun · Hermes (review relay)
Marketing teamHumans (Abdout · Samia · Ali · Moutaz)Judgment, the approval decisionThe Hub, Slack, Claude CodeSlack · the review channel

The asymmetry that shapes everything

Vercel can never call into Hermes. Hermes listens on localhost behind a home connection — there is no public route to it. Two consequences that explain most of the surprising behaviour:

  • The Hermes row in the Hub's status dialog is red in production and green only on Abdout's machine. That is correct, not a bug.
  • SOCIAL_DRAFT_SOURCE=hermes (the default) is a half-loop on Vercel — the cron can hand a drafting request out, but the reply never comes back in-band.

Loop C below exists precisely to invert that arrow.

                    ┌──────────────── writes copy ─────────────────┐
                    │                                              │
              Claude Code                                    Marketing team
             (/social skill)                            Abdout · Samia · Ali
                    │                                         │        ▲
                    │ paste                          approves │        │ reviews
                    ▼                                         ▼        │
   ┌────────────────────────────────────────────────────────────────────────┐
   │                        kun  ·  Vercel  ·  public                       │
   │   /social Hub    /api/social/cron    /api/social/publish   /relay      │
   │                        holds every platform token                      │
   └───────────┬──────────────────────────────────────────────▲────────────┘
               │ direct API (Graph, Bot)                      │ POST + CRON_SECRET
               ▼                                              │
      ┌─────────────────┐                          ┌──────────────────────┐
      │   Platforms     │◄──── Slack adapter ──────│  Hermes · localhost  │
      │ Facebook·Telegram│                         │  (outbound only)     │
      └─────────────────┘                          └──────────────────────┘

The three loops

They differ in one thing: where the human sits, and who holds the tokens.

Loop A — Hub (human-composed)

The everyday lane. Nothing scheduled, nothing autonomous.

Claude Code (/social) ──► human pastes into the Hub's Publish tab ──► brand + channels
   └─ drafts AR/EN copy          └─ optional media URL from /higgs
                                       │
   ── or, without a session ──►  Hub's Draft tab ──► SocialDraftRequest (pending)
                                       │                      ▲        │
                                       │        answers ──────┘        │ polls
                                       │   Claude Code, Max pool       │
                                       │   (`draft` queue mode)        │
                                       │◄─────────────────────────────-┘
                                       │   "Use Arabic" → Publish tab, copy in the editor
                                       ▼
                            publishPostDirect (Server Action)
                            contributor re-check → Zod → deliverPost
                                       │
                    ┌──────────────────┼──────────────────┐
                    ▼                  ▼                  ▼
              Telegram Bot API   Facebook Graph API   Hermes /webhook
                                                       (Slack, …)

The second path is Loop C's inverted arrow again, for the same reason: Vercel holds no funded Anthropic key (D-20260730 shipped, then 401'd), so the machine that writes reaches in rather than being called. Asynchronous — minutes, and only while some session is draining the queue.

  • Human sits: at the editor, before Publish.
  • Guard: session + contributor allowlist re-checked inside the action (JWT sessions outlive removal from the allowlist).
  • Result: per-channel — a fan-out that half-lands reports exactly which channel failed and why.

Loop B — Cron (draft-and-approve)

The only scheduled lane today. Drafts unattended; never publishes unattended.

Vercel Cron 06:00 UTC ──► GET /api/social/cron   (Bearer CRON_SECRET, timing-safe)
                               │
                               ▼
                     for each SOCIAL_AUTOPOST_PRODUCTS
                               │
                          draftPost()  ── SOCIAL_DRAFT_SOURCE
                               │          ├─ "hermes"    → hands the ask out (half-loop on Vercel)
                               │          └─ "anthropic" → drafts server-side (opt-in, costs money)
                               ▼
                     sendReview() → PRIVATE review channel
                     message carries a signed 12h one-click link
                               │
                     ┌─────────┴─────────┐
                     │   human clicks    │  ◄── the gate
                     └─────────┬─────────┘
                               ▼
                  GET /api/social/publish?token=…
                  verify HMAC → deliverPost → echo outcome back to review channel
  • Human sits: in the review channel, clicking the link.
  • Opt-in: SOCIAL_AUTOPOST_PRODUCTS is empty by default — the cron authenticates, does nothing, and says so.
  • Single use. The link names a SocialVariant, and publishing is a conditional status transition, so a second click finds the row no longer pending and gets a 409. Two people clicking at the same moment race on one UPDATE and exactly one wins. Until persistence landed (2026-07-26) the link was replayable and clicking twice genuinely posted twice.
  • Still true: anyone holding a live link can publish it once. Keep the review channel private.

Loop C — Hermes inbound relay

Written because Loop B's Hermes path can't complete on Vercel. This one inverts the arrow.

Hermes (Abdout's desktop, its own cron)
   └─ drafts copy ──► shows it in Slack ──► human approves in Slack   ◄── the gate
                                                  │
                                                  ▼
                              POST /api/social/relay   (Bearer CRON_SECRET)
                              { product, text, channels? }
                                                  │
                                                  ▼
                              kun delivers with ITS OWN tokens

Why it matters: Hermes never holds a Facebook Page token. The secrets stay in Vercel env, and Hermes only ever sends approved text. The bearer secret means "a human already said yes" — so whoever holds CRON_SECRET can post to a brand page. Treat it as a publishing credential.

generate → approve → schedule → publish

The chain the whole system is built around, and where each stage actually lives:

StageWho / whatCodeLoopState today
GenerateClaude (/social skill) · the Hub's Draft tab · /higgs · /carousellib/social-draft.ts (cron lane) · SocialDraftRequest + draft queue mode (Hub lane)A · B · CClaude-native, and a full draft: copy AND/OR media on SocialDraftRequest.mediaUrls. The relays never write copy. The Hub lane drains on a 5-minute launchd tick (scripts/drain-drafts.sh) — heartbeat every look, Claude only when asks are pending; that session picks media from library.json but never generates (no tools for it, by design).
Medialibrary.json pick first · /higgs (text-free) or /carousel (text-bearing) on a missscripts/social-drafts.mjs attach · showroom Attach → the Hub's trayAAttaches to the draft rather than being pasted into a field.
ApproveHuman — Abdout, Samia, or Alithe review queue on /social/publish (approveDraft) · signed link (stageForReview) · SlackA · B · CAlways required. L2 on Facebook — the one live channel; L1 elsewhere. The queue claims answered → consumed conditionally, so two reviewers race safely.
ScheduleContributor — the review queue's approve-mode setting, or --atapproveDraft / schedulePostscheduled variants → /api/social/drainAReal: approve for a moment, the drain publishes within ~15 min of it.
PublishEgress fan-out, per transport, in parallellib/social-publish.tsdeliverPostA · B · COne code path for all three loops; routes by media shape (text · photo · 2–10 album/carousel · video) and refuses mixed kinds by name.
MeasureFacebook Graph — insights + edge summaries/api/social/metricsSocialMetric rowsA · B · CWired on Facebook, six-hourly. UTM rides every link; PostHog not yet consuming.

Scheduling is real now. The review queue's approve-mode setting (or the composer's date-time field) decides the moment, approveDraft/schedulePost writes one scheduled variant per channel, and the drain (GitHub Actions, every ~15 minutes) claims and publishes each one when its moment passes — three bounded attempts with backoff, and a reaper that recovers any row a killed function left stuck in publishing.

The persistence that unlocked it is Prisma on Neon: SocialPieceSocialVariant (the status machine: pending → publishing → published / failed / rejected, plus scheduled for the drain lane; media as mediaUrls) → SocialMetric (append-only time series). Alongside it, SocialDraftRequest carries the draft's own lifecycle — pending → answered → consumed (approved into a piece) or dismissed (a human said no) — which is what makes the review queue a queue rather than a list. Persistence is also what made the approval link single-use — approving is a conditional row transition, so a second click refuses — and, since 2026-07-30, a two-step confirm: GET renders a read-only page and only the POSTed button publishes, so a chat client's link-preview crawler can never consume an approval.

What remains fixed: 0 6 * * * in vercel.json is when Loop B drafts, and its links still carry the 12-hour approval TTL.

Cron jobs

Running today

PathScheduleAuthDoes
/api/social/cron0 6 * * * — Vercel Cron (vercel.json)Bearer CRON_SECRET, timing-safe compareOne draft per opted-in product → review message with signed link. Never publishes.
/api/social/drain*/15 * * * * — GitHub Actions (social-drain.yml)same bearerPublishes scheduled variants whose time has passed; reaps rows stuck in publishing.
/api/social/metrics0 */6 * * * — GitHub Actions (social-metrics.yml)same bearerReads reach/engagement back into SocialMetric; failure policy is terminal-per-kind, transport-only retries.
scripts/weekly-digest.shFri 09:00 local — launchd (com.databayt.social-digest)none — runs on the Mac, outbound onlyThe Friday digest: planned vs shipped, Facebook numbers, dismissal lessons, lane health → Hermes → Slack #social (Telegram review chat fallback). Deterministic, zero tokens.

The fast cadences live in GitHub Actions because Vercel Hobby grants one cron slot and it is spent on drafting. That split means CRON_SECRET must exist in two places — Vercel env for the routes, and as a GitHub repo secret for the two workflows; missing the second, both lanes self-disable with a ::warning:: rather than failing red.

Candidates, and what each needs first

Still unbuilt — listed so the follow-up is turnkey.

CandidateWhat it would doBlocked on
Token health canaryDaily probe of every per-product Page token; alert before expiryNothing — buildable now on getEgressStatus
Kill-switch watchdogHalt auto-post on an error-rate or complaint spikeL4 guardrail layer
Per-brand cadenceDifferent days/times per brand rather than one 06:00 sweepPer-brand config (the persistence it needed now exists)

The Weekly content nudge left this table 2026-08-07 — it runs as the Friday digest above (scripts/social-digest.mjs; pillars + the seed lane turned out to be the "calendar structure" it was waiting for).

A cron that publishes without a human is L4 and is gated behind /decide plus the guardrail layer (LLM-judge content + Arabic gate, rate limits, kill switch). Do not add one casually.

Who does what — the marketing team

PersonRoleIn this pipeline
AbdoutFounder, Tech LeadOwns credentials + /decide on anything that costs money or touches a brand account. Final approver.
SamiaR&D / contentCarries 2–3 core pieces a week — the input the multiplier runs on.
Ali AseelQA + SalesBusiness/LinkedIn content; Hogwarts social doubles as the trust asset for his sales calls.
MoutazProduct EngineerBusiness + network reach.
growth agentThe lane, not a personOwns content strategy and pillars; /social is its publish arm.

The binding constraint is content capacity, not channel count — which is why the pipeline is built around one core piece → N platform-native variants rather than N independent posts.

Keywords

Say these in prose — no slash needed. The engine routes them.

KeywordRoutes toUse for
social · «منشور تواصل»/social skill → growth agentThe whole chain for one brand post — chains the six below
calendar/calendar skillPlan or review which brand publishes what, when
draft/draft skillWrite the copy — Arabic-first, one variant per channel
higgs/higgs skill → HiggsfieldGenerate the image/video for a post
approve/approve skillStage for human sign-off, then stop
publish/publish skillDeliver approved copy — drain, gateway, or copy-out
measure/measure skillRead reach and engagement back off a published post
carousel/carousel skillMulti-slide bilingual decks (Instagram, LinkedIn PDF)
weeklycaptain cadenceMonday plan / Friday review — where social gets allocated
decide/decideAny billing or Type-1 (public, hard-to-reverse) call — paid APIs, L4 autonomy

Headless lanes, when you want no UI:

# Publish to a distribution channel:
node scripts/post-to-telegram.mjs --text "<approved copy>"
node scripts/post-to-facebook.mjs --text "<approved copy>" --product <brand>
 
# Relay a draft into #social for review — NOT a publish; Slack is the
# communication channel, and this is the review lane, not an audience one:
node scripts/post-to-hermes.mjs   --text "<draft copy>" --channels slack
 
node scripts/generate-social-media.mjs --product <brand> --prompt "<topic>"

Reading the failure modes

SymptomWhere to look
Hermes row red in productionExpected — Vercel can't reach localhost. Use Loop C.
Cron ran, nothing in the review channelSOCIAL_AUTOPOST_PRODUCTS empty, or no review destination (HERMES_API_URL / TELEGRAM_REVIEW_CHAT_ID)
"Draft is N chars (max 1200)"The copy rides inside the approval URL — shorten it or publish from the Hub
Approval link 403Expired (>12h) or CRON_SECRET changed since it was minted
Facebook posts to the wrong PageThe status dialog names the resolved Page — check it before publishing; per-product tokens are FACEBOOK_PAGE_*_<PRODUCT>
Some channels published, others didn'tPer-channel results in the composer name each failure; the post is already public where it succeeded

See also