Status
Last verified: 2026-08-01 (live Graph API dark-post + carousel transport tests against the Hogwarts Page, an unattended seed→drain draft cycle, workflow run logs, and a PostHog UTM read-back).
The code is in good shape. The configuration is not. This page exists because the overview describes what the pipeline can do, and it is easy to read that as what it is doing. It isn't — yet.
One-line summary: 1 of 8 distribution channels is live, 1 of 3 loops works, and the daily cron has been a no-op since it shipped.
Scope — one slice, on purpose (2026-08-06)
Read the tables below as status, not as a backlog. Since 2026-08-06 the pipeline deliberately
runs one brand, one channel, one country — hogwarts × Facebook × Sudan — until the full loop is
proven end to end. 1 of 8 is the plan right now, not the gap.
Everything red or empty further down this page is deferred by decision, not by blocker — the Instagram adapter stays complete behind its flag, Telegram stays two env variables from live, and the four other brands stay registered with their kits unfilled. Expansion is gated on five written conditions and then runs one step at a time: Instagram → Telegram → mkan → Saudi.
Two priorities invert because the country is Sudan rather than Saudi, and both are recorded so they are not quietly reintroduced: video drops down the list (this page's own Sudan line — intermittent connectivity favors text + image over video), and the Aug 23 Saudi school-year window stops being the clock.
The full reasoning, the deferral list, and the expansion gate:
.claude/memory/decisions/2026-08-06-social-one-slice-hogwarts-facebook-sudan.md.
Three words that mean different things
| Term | Means |
|---|---|
| Wired | The code path exists and is tested |
| Configured | Credentials are present in Vercel production |
| Live | A post can actually reach the platform |
Most of the gap on this page is between wired and configured. That is the good kind of gap — it closes with credentials, not engineering.
Channels
Two tiers, and keeping them apart is what makes the numbers on this page mean anything. Distribution channels carry marketing to an audience and count as reach. Slack is the one communication channel — the team surface where approvals and notices land. It used to sit in this table as a peer, which put a team chat in a marketing-reach denominator.
Distribution — the eight
| Channel | Wired | Configured | Live | Blocker |
|---|---|---|---|---|
| Facebook — hogwarts · mkan · databayt | ✅ | ✅ | ✅ | — |
| Telegram | ✅ | ❌ | ❌ | Deferred by decision 2026-07-30 — the channel focus is Facebook + Instagram. Bot valid, code ready; two env variables whenever the focus widens |
| Facebook — sijillee · moallimee | ✅ | ❌ | ❌ | No Facebook Page created yet |
| ✅ (gated) | ❌ | ❌ | The lane shipped 2026-07-30 (lib/instagram.ts, two-step Graph publish, image required, health row) behind wired: false. The gate is the ~10-minute Standard-Access test via Mkan plus a token re-minted with instagram_content_publish and INSTAGRAM_USER_ID_MKAN — runbook on kun#141. If the test fails, verification via Ownership is the long pole | |
| registry only | ❌ | ❌ | Community Management API approval | |
| X | registry only | ❌ | ❌ | Pay-per-use since Feb 2026 — needs /decide |
| TikTok | registry only | ❌ | ❌ | Full app audit, 1–4 weeks |
| Snapchat | registry only | ❌ | ❌ | Allowlist-only via a Snap contact |
| ✅ (manual) | n/a | n/a | No organic posting API exists — and none is coming. Carried through draft, media, and approval like any channel; /publish hands out a copy-out block a human forwards |
Live: 1 of 8 distribution channels, for 3 of 5 brands.
WhatsApp's ✅ (manual) reads against this page's definition of wired — the copy-out code path exists and is tested. The registry says
wired: falsefor the same channel, deliberately: that flag gates the composer's transports, and a manual channel has none. Two flags, two questions — "is the path built" here, "can a machine deliver it" there.
Instagram in detail, because it is the nearest channel to going live and the one whose blocker is most often misread as "App Review":
| Brand | Facebook Page | IG account | Linked to the Page |
|---|---|---|---|
| Mkan مكان | ✅ | @mkan.sd | ✅ |
| Hogwarts | ✅ | @osmanabdout | ❌ — 5 failed web attempts; needs the IG mobile app |
| Databayt داتابيت | ✅ | — | ⛔ Page sits in a different business portfolio |
| Moallimee | ❌ | ❌ | ❌ |
| Sijillee | ❌ | ❌ | ❌ |
The per-brand link is the cheap half. The expensive half is shared: Business Verification, which verifies a legal business, and the portfolio currently has no legal name, no address and no verification because Meta auto-created it as a side effect of the first Instagram link. Tracked in kun#141.
Communication — the one
| Channel | Wired | Configured | Live | Blocker |
|---|---|---|---|---|
| Slack (via Hermes) | ✅ | ❌ | ❌ | No HERMES_API_URL in production — and Hermes is down |
Slack is never a publish destination: the composer does not offer it, the Zod write gate
rejects it, and /api/social/relay refuses it by name. Approvals and notices reach #social
unconditionally through sendReview — there is nothing to select.
Telegram stays the cheapest channel on the board — free, no platform gate, bot answering
getMe, code ready since 2026-07-10, two environment variables from live. It is off by
decision (2026-07-30: Facebook + Instagram first), not by blocker, and the status panel
renders it as parked-neutral rather than red for exactly that reason. Setup when wanted:
Telegram guide.
One thing the table above cannot show, because it has no column for it: every channel resolves to one destination per brand. For Telegram and WhatsApp that undercounts the work rather than the progress — their audience is spread across groups other people run, and reaching those rooms needs a destination registry, per-room selection, and anti-spam guardrails that do not exist yet. Designed and deliberately unbuilt: Groups & Destinations. It ranks last, and should stay last until Facebook is live for all five brands.
Loops
The three publish paths are described in Architecture & Loops. Their current state:
| Loop | State | Why |
|---|---|---|
| A — Hub | Works (Facebook only) | The one working path, and since 2026-08-05 a wired one: a brief on the Draft tab (or Queue now on the Calendar tab) becomes a queue ask, the 60-second launchd drain answers it as a full draft — copy plus media picked from library.json — and it lands in the review queue on Publish, where a human fine-tunes and approves (now, or scheduled to the drain). Since 2026-08-06 the Draft tab is also a conversation: typing again refines the draft on screen rather than starting a new one, and angle/register/reference/model ride the ask as columns. Staging still returns the signed links in-app, so no chat relay is needed to run the gate. |
| B — Cron | Superseded by the seed lane (2026-08-01) | The Vercel route stays parked as designed (SOCIAL_AUTOPOST_PRODUCTS unset — no compliant server-side draft source, and its one-text-everywhere shape contradicts the multiplier). Its calendar half now runs billing-compliantly on the Mac: scripts/seed-drafts.sh (launchd, Mondays 07:00) files the week's briefs from content/social/pillars.json into the draft queue, and the 5-minute drain answers them on the Max pool — verified unattended end to end (seed → answered in 3 minutes). Per-channel fan-out remains session-side at staging time. |
| C — Hermes relay | Parked | Hermes is down, never observed end to end, and since 2026-07-30 nothing depends on it: reviews carry through the Hub's own links. Revisit only when a hermes-transport channel actually goes live. |
Both /api/social/cron and /api/social/relay return 401 to an unauthenticated request —
which confirms they are deployed and CRON_SECRET is set. The cron fires daily at 06:00 UTC and
returns drafted: 0.
What's done
- Egress — per-channel results, three transports in parallel, and a media set through the whole chain: one photo (Facebook
/photos, TelegramsendPhoto), 2–10 as a Facebook carousel or Telegram album (sendMediaGroup), one video (/videos,sendVideo) — with mixed image+video, two videos, or >10 images refused by name before delivery, since no platform edge carries them. The approval token addresses the variant, so the confirm page lists every URL - Per-product Facebook — its own Page and permanent token per brand, with the resolved Page name shown before you publish, which is how a crossed token gets caught
- Registry — 5 products × 8 distribution channels, AND-gated so a channel is publishable only when the transport and that brand's destination are both wired. A communication channel is refused whatever a per-brand map says
- Taxonomy —
kindseparates what a channel is for from how its bytes move, and a test asserts every channel lands in exactly one delivery lane (drain · hermes · manual), so a transport added later cannot silently fall through all three - Approval — signed HMAC links, 12h TTL, single-use by row transition, and unfurl-proof since
2026-07-30: GET renders a read-only confirm page, only the POSTed button publishes, and the
same form carries a Reject that writes
rejected. Contributor re-checked inside every mutating action - Recovery — the drain reaps variants a killed function left stuck in
publishing; every batch route pinsmaxDurationand stops starting work on an elapsed budget, deferring the rest to its next run - Hub — bilingual en/ar with RTL, Server-Component header, one-round-trip status probe, and the pipeline as five tabs (Calendar · Draft · Media · Publish · Measure) with brand, channels and status as context above them. State lives in the layout provider, so a queued draft keeps polling, typed copy survives a stage switch, and the attachment tray survives a trip to the showroom. Draft and Publish share one shape — an agent window and an editor built from the same block — and Publish's media and schedule fields collapse into toolbar pills so the copy carries weight
- The stages are wired to each other (2026-08-05) — Calendar renders
pillars.jsonwith this ISO week's rotation picks highlighted and a Queue now button; Draft answers with a full draft (copy AND/OR media — text, text+image(s), text+video, image(s) only, video only, carried onSocialDraftRequest.mediaUrls); Media's showroom has an Attach button that fills a shared tray instead of asking for copy-paste; Publish is a review queue — next-up draft, browse the rest, fine-tune, then Approve (now or scheduled), Dismiss, or send the signed link. Approval claims the request with the same conditional update the drain uses, so two reviewers race safely, and an empty queue points back at Draft rather than offering a blank textarea - The Social Agent window — in-page drafting (2026-07-30): a brief in, AR+EN copy out, one
press into the composer. Contributor-gated. It does not call the Anthropic API — D-20260730's spend lane
shipped and is blocked (
ANTHROPIC_API_KEYin Vercel returns 401, and subscription-only billing means there are no credits behind it), so the window records aSocialDraftRequestand a Claude Code session answers it on the Max pool viadraft's queue mode. The cron's no-spend default is untouched. The queue drains on a 60-second launchd tick (scripts/drain-drafts.sh --install) that beats adraft-drainheartbeat on every look; the window reads it back (position + last check), backs off its poll, and stops at 10 minutes — and the drain sweep expires hour-old unanswered asks - Drafting is a conversation with direction (2026-08-06) — the Draft stage stopped being one
shot in the dark:
- Refinement turns. Typing into the window after an answer refines that draft instead of
starting another. The parent is
supersededand a child is filed carrying the root brief, the inherited knobs, the media, and the instruction — so every turn keeps its own copy and its own approvability, and only the newest isanswered(the review queue shows the current draft, not three versions of one post). The supersede is the same conditional claimapproveuses, so two reviewers refining at once race safely. - Knobs, not prose. Angle (pain · moment · proof), register (rung 2–4), and "write it like
this one" are columns the Hub offers as controls and the drain reads directly.
pillars.jsonhad been encoding all three inside its brief strings because there was nowhere else to put them. - The model select became real. It was decoration — the queue had no
modelcolumn, and the code said so. The drain now groups the pending queue by model and makes oneclaude -p --modelcall per group, because--modelis a session property and one call cannot honour two choices. - The feedback loop is read, not just written. Dismiss reasons are stored as a structured
dismissReason;social-drafts.mjs lessonsshows the drain what reviewers rejected in the last 60 days, most common failure first, before it writes anything. Copy had claimed this loop since it was written — it was concatenating the reason into a prosenoteno query could group
- Refinement turns. Typing into the window after an answer refines that draft instead of
starting another. The parent is
- The window drafts inline on Gemini — and the gate went with it (2026-08-07) — D-20260807
routed around the funded-key blocker:
requestSocialDraft/refineSocialDraftanswer in ~11s via AI SDKgenerateObjectongemini-3.6-flash(free tier, measured 20 requests/day), with the Mac drain as the floor for every miss. The memo's unshipped spec landed the same day: the craft gate now runs on both Gemini lanes (one corrective retry with the named failures, then refuse — the row stayspendingwith acraft-refused:note thatdrain-googleskips and the claude lane reads ascraftRefused),SOCIAL_DRAFT_INLINE=offis the one-variable revert, a non-throwing limiter (8/min global, 4/min per contributor) queues the ninth draft instead of erroring, and the prompt is single-sourced in a parity-pinned mirror pair (src/lib/draft-prompt.ts⇄scripts/lib/draft-prompt.mjs, statics first for Gemini's implicit caching) carrying the scene bank (content/social/scenes.json— check 4's raw material) and the lessons line, so the dismiss-reason loop finally reaches the default lane. The Hub's model label names the model that actually answers - Docs — overview, status (this page), ownership, architecture, strategy, carousel, five brand pages, the channel guides, and the groups/destinations design
What's not
- Configuration — the largest gap and the cheapest to close. Being handled by Cowork, which can drive the platform consoles directly.
- Ownership — newly named, and the only gap on this page that is not cheap to close. Every brand's social identity hangs off one personal Facebook account inside an unnamed, unverified business portfolio with a bus factor of one, and no teammate has ever been invited. It blocks Instagram outright rather than merely being untidy. See Ownership & accounts.
- Hermes — parked (down, no production config), and since 2026-07-30 nothing user-facing depends on it: the approve stage returns its links in the composer, and the status panel shows the lane as parked-neutral instead of permanently red. Tracked in kun#143.
- Measurement — now wired on Facebook, and the remaining gap is analytics, not permissions.
- ✅ UTM tagging works — and since 2026-07-30 something consumes it. Every outbound link is tagged at delivery with
utm_source=<channel>,utm_medium=social,utm_campaign=<brand>, and posthog-js on the hogwarts site (EU project 221194) autocaptures the tags on every pageview —/measurereads them back over the PostHog MCP with HogQL. Coverage is per-site and named honestly: hogwarts counts; kun and mkan don't carry the snippet yet, so their zeros mean "nobody counted". - ✅ Platform-side numbers are fetchable.
read_insightsandpages_read_user_contentwere granted 2026-07-27 and verified returning real numbers against a live Hogwarts post (Page1228948003637000). Both turned out to be Standard Access — no App Review, no Business Verification. What was actually blocking them was neither: a permission has to be added to the app's Use Case (Dashboard → Use cases → Customize → Permissions and features) before it appears anywhere else in the console. Skip that and the Graph API Explorer answers "Found 0 results", which reads exactly like Meta having withdrawn the permission. - ⚠️ The scopes were never the whole story. Meta retired the entire
post_impressions*family during 2025, so even a correctly-scoped token gets(#100)from the old names — a metric error, not a permissions one. The live names arepost_media_viewandpost_total_media_view_unique. Granting a scope does not resurrect a retired name, and re-probing with the old names after a grant looks exactly like the grant having failed.classifyGraphErrorexists so that mistake is not repeatable. - ✅ Verified on all three tokens. Re-minted 2026-07-27 and Debugger-confirmed carrying
read_insights+pages_read_user_content(hogwarts, mkan, databayt); hogwarts additionally proven end to end with real reads intoSocialMetric(2026-07-28). sijillee and moallimee have no Page at all./api/social/metricsdegrades per product rather than globally, and gives up permanently on a permission failure rather than retrying it forever. - ✅ Analytics destination live on hogwarts. posthog-js on the hogwarts site (EU project 221194) consumes the UTMs;
utm_source=facebookvisitors verified readable over the PostHog MCP 2026-08-01. kun and mkan still don't carry the snippet — their zeros mean "nobody counted", not "nobody came".
- ✅ UTM tagging works — and since 2026-07-30 something consumes it. Every outbound link is tagged at delivery with
- Scheduling — the queue exists (
SocialPiece/SocialVariant/SocialMetricon Neon, added 2026-07-26), approval links are single-use, and/api/social/drainpublishes due variants every ~15 minutes from GitHub Actions. The composer takes a date and time. - Metrics —
/api/social/metricswritesSocialMetricon a six-hourly schedule, enabled 2026-07-30 after two manual dispatches returned real numbers and all three tokens' scopes were Debugger-verified. - Variant-awareness — narrowed but not closed. The seed lane (2026-08-01) retired the cron's one-text-everywhere drafting: briefs now become AR/EN core pieces via the
draftskill's queue mode, on the Max pool. What remains session-side is the per-channel fan-out at staging — one core piece still becomes N platform-native variants by hand, not by schedule. That is the deliberate residual: fan-out quality is exactly what the human gate reviews. - Carousel egress — Facebook multi-photo posts wired 2026-08-01 (
sendFacebookCarouselinlib/facebook.ts+post-to-facebook.mjs --photos <dir|list>, 2–10 ordered images via unpublished uploads →attached_media; transport dark-tested live). The renderer's exports now have an automated FB lane; Instagram carousels stay gated on kun#141.
The honest constraints
Hermes flaps. Vercel can never call into it — it listens on localhost with no public
route — and it will be up and down unpredictably. So kun is the source of truth and Hermes is a
client that pulls work and pushes results. Any design where kun must reach Hermes to make
progress is the wrong shape.
Which makes persistence load-bearing. Today, a Hermes outage during the 06:00 cron loses that day's draft outright. With a queue, it just waits.
Scheduling can't ride on Vercel cron. A drain needs to run every few minutes; cron granularity is a plan-tier limit and kun already uses a slot. GitHub Actions is free, runs at ~5-minute granularity, and the endpoints are already bearer-token authenticated for exactly this — so it also keeps a Vercel Pro upgrade off the critical path.
Highest-leverage next moves
Ordered by value ÷ cost, not by size — and re-ordered 2026-08-06 by the one-slice decision. The list used to lead with Instagram; under the slice, widening ranks below proving.
- One observed end-to-end pass, twice. Seed → drain → review → Facebook →
SocialMetric, on a real published post, in one unbroken run — once as text+image, once as a multi-photo carousel (sendFacebookCarouselhas been transport-dark-tested since 2026-08-01 and never used for a real post). Every hop below is individually verified; the whole chain never has been. This is the deliverable, and anything that breaks gets written into the decision file. - Make the craft bar mechanical. Copy's reject list is prose that nothing enforces — about 11 of its ~14 items are deterministic, including the invented-number guard (a set difference between the draft's digit-tokens and the brief's). Zero tokens, which is why it beats the LLM judge that page already priced and deferred.
- Collect real scenes. Check 4 is the one the drain "has never passed, structurally" — product
docs contain no Thursdays, and every scene in
pillars.jsontoday was invented by the brief author. There is exactly one real customer, in the target country, four months into the pilot, already reachable on WhatsApp. Five questions as a voice note, transcribed free through themarkitdownMCP. - Name and verify the business portfolio, and invite a second admin. Ownership is worth fixing whatever the scope is — bus factor, recoverability, and the Databayt Page all hang on it, and it is the long pole for Instagram whenever the slice widens. See Ownership & accounts.
Deferred to the expansion gate, in order: Instagram (~10 minutes of console work — the Standard Access hypothesis, kun#141) → Telegram (2 env variables) → mkan → Saudi.
PostHog now has its destination — the hogwarts site carries the snippet and /measure reads the UTMs
back over HogQL. For a Sudan audience treat click attribution as a floor rather than a measure:
intermittent connectivity plus WhatsApp forwarding means reach, shares, and comments are the
slice's real scoreboard.
4. Teach the cron to draft per channel. Done differently, 2026-08-01: the seed lane runs Loop B's calendar half on the Mac (weekly launchd → draft queue → Max-pool drain), so drafting rides the draft skill's doctrine instead of a server-side template. The Vercel route stays parked; per-channel fan-out stays at the staging gate, on purpose.
Deferred by decision, not by failure: Telegram (2026-07-30 — code ready, two env variables when the focus widens beyond Facebook + Instagram).
Done since this page was first written: persistence, real scheduling with a drain, single-use approval links, the metrics lane on schedule, the unfurl-proof confirm step, relay recording, the reaper, the Hub ledger — and the draft-queue drain on a 5-minute launchd tick with the Hub reading its heartbeat (2026-07-30).
See also
- Ownership & accounts — who owns the brand accounts, and why it gates Instagram
- Architecture & Loops — who talks to whom, and in which direction
- Channel guides — how to configure each one
- Strategy — cadence, the multiplier, and the kill criteria