Social makes a post. You pick a few things — the brand, the feature, the channels, the shape — and say the topic in a few words. Those picks are not a form: each one is a paragraph of context handed to the writer. Claude writes it in Arabic and English, a craft check reads it before you do, and nothing goes out until you say so.
The four pages
/social is one job split across pages, and /social/publish can do the whole job by
itself — which is why the bare /social opens there. The other three exist for when you
want to start somewhere earlier.
Nothing is lost moving between them: the copy you are working on, the pictures you have attached, and every one of those settings follow you from page to page. Pick a brand on Draft and Publish is already publishing as that brand.
The settings each page shows
One row, one set of answers — but a page only shows the words that mean something where you are standing. A stage that cannot send has nothing to schedule; a stage that only finds pictures has no post shape to pick.
| Word | calendar | draft | media | publish | Why |
|---|---|---|---|---|---|
| Brand | ● | ● | ● | ● | Everything is per-brand: whose topics, whose voice, whose library, whose Page. |
| Feature | ● | ● | ● | ● | Which part of the product. It narrows the topics, grounds the writing, and filters the library. |
| Channel | — | ● | — | ● | Where it lands. It changes what gets written, so Draft needs it; nothing on Media or Calendar turns on it. |
| Post | — | ● | — | ● | The shape — words, one image, a carousel, a reel. It changes what the writer is told to write. |
| Draft | — | ● | — | ● | On Publish, the drafts already waiting. On Draft, its own three: angle, register, model. Same seat, different question. |
| Media | — | ● | ● | ● | Which pictures are in play. On Media it also narrows the results, and the count on the bar says by how much. |
| Queue | ○ | — | — | ● | What is already asked for and unanswered. Calendar shows this per brief as a chip, so it belongs — it is not wired into the row yet. |
| Plan | — | — | — | ● | Now, later, or out for approval. Only a stage that can send has any use for it. |
● shown · ○ belongs, not wired yet · — deliberately absent
Draft and Media grew this row on 2026-08-26; before that Draft's seat held three writing knobs and no brand, and Media's seat did not exist — so a person asking for copy had to leave the box they were writing in to say which brand it was for. Calendar is the one page still without it: Brand is currently a page-level context rather than a word in the row, and Queue would earn its place there the moment it is.
| Page | What you go there for |
|---|---|
| calendar | The topics we already decided to write about this week, per brand, with the ones due now highlighted. Press Queue now and it becomes a request for words. |
| draft | The place to ask for copy in a sentence and get Arabic and English back. Type again to refine what is on screen rather than starting over. Use Arabic carries it to publish. |
| media | Type two words and the library narrows; Attach puts a picture in the tray. Generating something new lives below it — this page finds what exists, the studio asks for what does not. |
| publish | The whole job in one place: write it, refine it, attach, and send — now, later, or out for someone else's approval. It is also the review queue, where a draft someone else asked for waits for a yes. |
| measure | The last twenty posts, with what each one reached. |
How a post travels
The fourteen steps below all happen on /social/publish — this is that one page,
opened up. Eight of them are yours — 1 to 4, then 8 to 11 — and every one of those is a tap
or a few words.
What each step means
1 · Open the box. /social opens on Publish, because it is the only stage that can
finish a post on its own. One glass bar: the field, a ⊕ on one side, a seat on the other.
2 · Set the post. One row behind the seat, four answers, and none of them a form field — each is a paragraph of context handed to the writer.
- Brand · Balqalam, Mkan, databayt, Sijillee, Moallimee. (Hogwarts writes through Balqalam, its Arabic face, so it is not in the picker.) The biggest single pick — see why below.
- Feature · which part of the product the post is about: Admission, Attendance, Exams, Grades… That brand's own block registry, narrowed to the blocks a post can be about — twelve for Balqalam, six for Mkan, four for databayt, none yet for Sijillee or Moallimee. Optional, and it is the difference between a post about a school system and a post about attendance in one.
- Channel · where it is going. A channel is offered only when both the transport and that brand's own destination are wired, so an unpublishable channel is never on the list.
- Post · the shape. Words alone · a caption beside one image · one caption over several · a video · an image or video with no words · a swipe deck · a story · a reel. Nine of them, and each changes what the writer is told to write.
They stay set between visits and follow you across the stages, so this is a step you do once and then rarely touch — which is why it is one box and not four.
3 · Say the topic. A few words is enough — "attendance". Or press one of the week's
ready briefs and type nothing at all: content/social/pillars.json holds each brand's
recurring topics with the audience, the call to action and the constraint already written.
The Calendar stage is that same file, shown as a week.
4 · Press Write it. The seat on the right of the bar. That is the last thing you do until there is a draft to read.
5 · Every pick becomes a line of the prompt. This step is the whole point of the pipeline, and it is spelled out below.
6 · Claude writes it. Arabic written first and English mirrored from it — a sibling, not a translation. About eleven seconds when the inline lane answers. When it cannot, the same ask falls to a queue that a Claude Code session drains, and the box tells you which of the two happened instead of spinning.
7 · A craft check reads it first. It refuses on its own — it is not advice. A banned
word, a link that would publish untracked, a post too short to say anything: the draft goes
back to be written again rather than forward to you, and the second attempt is told which
rules it broke by name. The very first Balqalam draft was refused for writing
balqalam.com without https://, which would have shipped with no way to count it.
8 · You read it. The copy lands in the same field you typed the topic into.
9 · Say what to change. Eighty percent right is the normal case, and it used to mean retyping the whole thing. Say "shorter, drop the parent alert" instead: the same draft is rewritten rather than replaced, so the queue shows one current draft and not three versions competing for the slot. Measured: 24 seconds.
10 · Attach a photo or a video. Optional. ⊕ opens the library — pick something that
already exists, or ask /higgs for a photo or a video and
/carousel for slides with words on them. One photo, up to ten as
an album, or one video.
11 · Say when. Now sends it. Later books a time, and a sweep every ~15 minutes publishes it when the time comes. For review makes one signed link per channel that anyone can approve from a phone — one click publishes, and a second click gets already handled rather than posting twice.
12 · It posts to your channels. One press fans out to all of them. Today that means Facebook, for three of the five brands — the rest are wired in the data and deliberately not built yet (Status).
13 · It saves the receipt. The platform's own post id, so the post can be taken down again. Every link is tagged so its clicks can be counted.
14 · It reads the numbers back. Reach and engagement, every six hours.
The rules every post obeys are in Brand & Voice; the build roadmap and
credential plan stay internal (docs/SOCIAL-AUTOMATION.md).
The other way a post travels
Everything above is you driving it — you open the box, you say the topic, you press Write it. There is a second path where nobody opens anything: the week runs on timers, and you only say yes.
Seven steps instead of fourteen, and you touch exactly one of them.
Both paths end in the same place, and they share the same queue — a post seeded on Monday
and a post you typed yourself both wait on /social/publish for the same yes.
What happens when nobody says yes
Step 4 is the only box with no timer on it, which makes it the only place the week can stop. It already did, for 27 days.
Nothing broke. Every job ran and honestly reported success, because each one truthfully emptied a queue that nothing had reached. Green meant "nothing to do", not "it worked." A daily nudge now says what is waiting, and stays quiet when nothing is.
The loop → — what each box does, and the one thing to get right when you approve.
Why this beats writing it yourself
Not speed, and not the login.
Ask a good copywriter for "a post about Mkan" and they will not start writing. They will start asking:
- What is Mkan? Who reads this — renters, or hosts?
- What is the tone? What must it never say?
- Which part of the product is this about?
- Where is it going? A Facebook caption is not a story.
- What shape is it — a caption, a swipe deck, a reel?
- What did we post before this, and what comes after?
- What time of year is it for the reader?
- What have we tried that did not work?
- How will we know whether it worked?
A brief that answers none of those gets a post that says only a topic. That interview is the real work, and doing it properly every single time is exactly what nobody has the patience for — which is why posts written by hand get shorter interviews as the week goes on, and worse as a direct result.
The pipeline's job is to have the answers ready before the question is asked. Most come from registries the engine already keeps; the rest come from four taps. Where an answer does not exist, the question is left out of the prompt rather than answered vaguely.
| The question a copywriter would ask | Who answers it here |
|---|---|
| What is it, and who reads it? | the brand pick |
| What must it never say? | the brand row, plus the craft gate |
| Which part of the product? | the feature pick |
| Where is it going? | the channel pick — named today, not yet written for |
| What shape? | the post shape pick |
| What time of year? | the calendar, on its own |
| What have we tried that failed? | the last twenty dismissals |
| How will we know? | the receipt and the six-hourly numbers |
| What came before and after? | nobody yet — see below |
Read the other way round, the picks are a prompt template being filled in. Every setting has a clause waiting for it, and picking swaps a generic clause for a specific one. Sections you leave unpicked are left out entirely rather than rendered empty — so an ask with no direction looks like an ask with no direction, instead of quietly reading as a direction to be generic.
Here is the same topic, asked both ways.
Nothing picked:
You are Databayt's lead social media writer for MENA.
Product: balqalam — SaaS product by Databayt.
House Rules: …
User Brief: attendance
Angle directive: Choose the best angle (pain, moment, or proof)
Arabic Register directive: Rung 2
Brand · feature · shape · channels · media · angle · register picked — the same three seconds of tapping:
You are Databayt's lead social media writer for MENA.
Product: balqalam (بالقلم) — the Arabic-facing face of the same school management
SaaS: admission, attendance, timetable, exams, grades and fees in one system instead
of five notebooks and a WhatsApp group. Audience: school owners and principals in
Saudi Arabia and the wider Arabic MENA market — administrators, not technologists —
with teachers and parents as the daily users. Never carries the Hogwarts wordmark.
Scenes from the reader's own week — ground the post in one it can honestly claim:
- <this month's moments, from the school year's current window>
House Rules: …
User Brief: attendance
Feature: Attendance — the part of the product this post is about.
Shape: a swipe deck. The first panel is the hook and has to stand alone; the caption
underneath is short.
Going to: Facebook, Instagram.
One asset is already attached — write copy that sits beside it without narrating it.
Angle directive: pain
Arabic Register directive: Rung 3 (pan-Arab colloquial)
Reviewers recently dismissed drafts for: hook 3×, two-posts 1× — these are habits to
break, not suggestions.
What each pick is worth
The brand is the largest one. It carries what the product is, who reads it, and what it must never say — Balqalam's row ends "Never carries the Hogwarts wordmark." One row per brand, and a test pins the map to the product registry so the next brand cannot arrive silently. It matters because it already failed once: Balqalam had no row until 2026-08-25, so every draft ever written for the Hub's default brand came from a writer who did not know it was school software. Asked for "attendance", it produced clean, on-register Arabic about tracking employees.
The feature becomes "Feature: Attendance — the part of the product this post is about." The list is not invented for social: it is each product's own block registry, narrowed to what a post can be about. Infrastructure is deliberately absent — nobody writes a post about a sidebar.
The post shape becomes a writing instruction rather than a label. A card reading "text + image" tells a writer nothing; a caption under one still is a different length from a swipe deck's opening panel, so the shape is read out as the difference:
- swipe deck → "the first panel is the hook and has to stand alone; the caption underneath is short."
- reel → "the hook is spoken in the first seconds, so the caption adds rather than repeats."
- story → "a tall frame — one thought, a handful of words, read in passing."
- gallery → "one caption over several images. It carries the whole set, not the first picture."
- words alone → "nothing rides with them, so the copy carries the image itself."
- image-only → "write nothing longer than a line, and only if it adds something the image cannot say."
The channels become "Going to: Facebook, Instagram."
Media already attached becomes "write copy that sits beside it without narrating it" — because the reader is already looking at it. With nothing attached the clause flips to "Suggested visual: …" instead: with assets in hand the register is already decided, and asking for one would describe a picture nobody is going to make.
The angle is pain, moment, or proof. The register is a rung: 2 simplified MSA, 3 pan-Arab colloquial, 4 local dialect.
The month you are in is a pick you never make. content/social/scenes.json carries each
brand's year as windows — year-start, mid-term, term-close, Ramadan revision, certificate
exams — and the current one's moments are handed over as scenes the post can honestly
claim. Documents contain no Thursdays; these do.
What reviewers dismissed recently rides too — "hook 3×, two-posts 1× — these are habits to break, not suggestions" — so the writer is corrected by the last twenty drafts, not just by the rules.
And the house rules never leave: 400–900 Arabic characters, matched to the craft gate's own floor so the prompt cannot steer a draft into the refusal band; Latin digits, never Arabic-Indic; at most one emoji and never in the first line; no exclamation marks; the English a sibling of the Arabic rather than a translation; never open with the brand name or "We're excited to announce"; one idea per post, because a bulleted feature list is two posts; and never invent a number, name, price or date that is not in the brief.
Even the order is engineered: static sections first (role, brand, scenes, house rules), per-ask ones last (brief, instruction, direction, lessons, violations), because the model's implicit caching keys on an identical prefix — measured at ~65% token reuse. Reordering a section is a cache invalidation, not a style edit.
What a person cannot do
A person writing by hand has every one of these available and none of them in front of them. That is the whole gap:
Brand knowledge lives in whoever is typing, and leaves when they do. Here it reaches the writer before it writes. Refusal does not exist by hand — nothing stops a boring or untracked post. The craft gate rejects on its own. Memory does not exist either: a post typed by hand leaves nothing behind. Here the platform id is saved so it can be retracted, links are tagged at delivery, metrics are read six-hourly, and dismissals feed the next draft. Delegation by hand means sharing the account. Here approval is a conditional claim, so two reviewers cannot double-post, and a signed single-use link approves from a phone with no token in anyone's hands.
Measured 2026-08-25, on the Balqalam Page's own brand: brand + feature picked, nothing typed, publishable Arabic in ~25 seconds. Then one sentence — shorter, drop any out-of-app notification — and 24 seconds later the draft no longer promised a parent alert that balqalam.mdx forbids in as many words.
Where it does not beat manual yet
Stated plainly, because the argument above is only worth what is true.
The channel is named to the writer, not written for. "Going to: Facebook, Instagram" is
the whole of it today — one post in one voice goes to every selected channel. Per-channel
rewriting is real in the schema (SocialVariant.text is per-channel) and deliberately
unbuilt under the
one-slice decision. That is the
next multiplier, not a shipped one.
Reach is one channel wide. One press already fans to every selected channel, but only Facebook is live, for three of five brands — so today's win is quality and memory per post, not reach per press.
The writer has no memory of the plan. It knows what reviewers rejected lately, but not
what actually went out last week or what is booked for next — so it cannot avoid repeating
itself, or deliberately follow on from the last post. The plan exists and you can see it
(pillars.json, rendered as the week on the Calendar stage), and the ledger exists on the
Measure stage; neither reaches the prompt. Of the copywriter's questions above, this is the
one still unanswered, and it is the cheapest one left.
The feature is matched on words, not stored. Nothing records which feature a post sold;
featureFits narrows a list by looking for the word. And copy typed by hand into the field
is not craft-gated, and dismiss has no button — see Status.
Four doors open onto Send to the platform, and they differ only in who says yes:
the review queue on /social/publish (a person clicks Approve), a signed link
in the private Slack review channel (a person clicks once — a second click gets
409 Already handled), the drain (a post booked for later, checked every ~15
minutes), and the relay (no person; a machine with the cron secret). All four
funnel into one shared egress path — see
Facebook for that
path step by step.
Sending it out to a Facebook Page
The step above marked Send to the platform, opened up. Every door — review queue,
signed link, drain, relay — arrives here, at deliverPost (src/lib/social-publish.ts)
and then sendFacebookPost (src/lib/facebook.ts).
Nothing in this path knows a brand by name. It builds the setting name from the brand id, which is why adding a Page is configuration and never transport code.
Where an approved post comes from
Those four boxes are the only four things in the codebase that call deliverPost.
They differ in exactly one way — who is trusted to say yes:
| Door | Entry point | Who may open it |
|---|---|---|
| Review queue | approveDraft (src/actions/post-social.ts) | A signed-in contributor. requireContributor() re-checks the session email against the allowlist on every call |
| Signed link | POST /api/social/publish | Anyone holding the link — its only protection is being unguessable, which is why the review channel is private. Approving is a one-way status change, so a second click gets 409 |
| Drain | GET /api/social/drain | The CRON_SECRET bearer. Publishes variants whose scheduledFor has passed — those were approved earlier, in schedule mode |
| Relay | POST /api/social/relay | The CRON_SECRET bearer. No human at all — the headless lane |
The words arrive one of three ways: a contributor files a brief on /social and a
Claude Code session drains the queue and writes the copy back through the craft gate
(scripts/social-drafts.mjs answer); a reviewer edits copy directly in the queue's
editor, which never creates from blank; or — since 2026-08-25 — the composer asks for
itself. Its Write it button turns the field plus every setting above it (brand,
feature, post shape, channels, attached media, and the angle/register/model knobs) into
a brief, files it with requestSocialDraft, and drops the answer back into the same
field. The inline Gemini lane answers in about eleven seconds; a miss falls through to
the same queue and the same drain, and the box says so rather than spinning. The
answered request becomes the composer's active draft, so approving there consumes it
instead of leaving it approvable twice.
The media is attached, never invented at send time: library assets picked by URL
(content/media/library.json, or the showroom's Attach), landing on
SocialDraftRequest.mediaUrls and then SocialVariant.mediaUrls. deliverPost receives
URLs and nothing else — Graph fetches the bytes itself, which is why they must already be
public on the CDN.
A fifth door opened 2026-08-25: the composer itself.
publishPostDirectandschedulePostused to be a publish path with nothing driving it — no route, no component. The review queue's composer now calls both: copy typed into the box that answers to no queue entry publishes throughpublishPostDirect(Now) orschedulePost(Later, for the time the Plan tab picks). Copy that came from a draft still goes throughapproveDraft, because approving is also a claim. The door count is five; who may open this one is the same signed-in contributorrequireContributor()checks on every other call.
The saved id is the only way back. Graph returns it once, in its reply; it cannot be
recovered from the variant afterwards, and it is the sole argument deleteFacebookPost
accepts. A test post that was never recorded is a test post the system cannot retract.
sendFacebookPost deliberately prefers post_id over id, because on a photo post the
bare id addresses the photo rather than the post.
Both Arabic-facing Pages were run through this whole path on 2026-08-24 — posted, read back, retracted, confirmed gone. Neither had ever carried a post before that. The per-brand ids, tokens and traps live on Facebook.
Business models → who we're talking to
databayt runs on four business models. Social marketing serves each one differently — B2B storytelling for the first three, B2C reach for the SaaS products.
| Model | Type | What it sells | Social job |
|---|---|---|---|
| Per Project | B2B | Custom automation builds for partners | Show the work — case studies, "automate the boring" proof |
| Partners | B2B | Outsourced product dev + specialized talent | Attract teams & talent — founder story, capability posts |
| Codebase | B2B / B2C | Reusable components, contributor revenue-share (the sharing economy) | Grow the contributor community — open-source, dev-rel |
| SaaS | B2C / B2B | Ready-to-deploy products (Hogwarts, Mkan, Souq, Shifa, …) | Sell the product — per-brand consumer & buyer marketing |
The first three are the databayt umbrella story (B2B, developer- and partner-facing). The fourth is where the product brands live — each with its own audience, channel mix, and voice.
Strategy
How we actually win is its own page — Strategy: six lenses (organic · viral · when · grow · pay off · moral), the create-once-repurpose-everywhere multiplier that makes 5 brands × 8 channels feasible for a small team, per-brand cadence, and the kill criteria that keep social honest against the cash-flow-first drive.
Brand pages
Every brand has its own presence. Its audience, channel mix, content pillars, and account status live on its page:
- Databayt — the umbrella: open source, sharing economy, Kun & dev-rel (B2B)
- Hogwarts — school management SaaS (B2B schools + B2C parents)
- Mkan — property rentals (B2C renters + B2B hosts)
- Moallimee — teachers & tutoring (pre-launch)
- Sijillee — Sudan business accounting (pre-launch) (B2B)
Souq and Shifa are live SaaS products but are not in the current priority-5 social push. When they join, they get a page on this same template.
The pipeline
The stage-by-stage view of the same journey diagrammed in How a post travels — who owns each step, and what it actually does.
A full draft is copy AND/OR media — text · text + image(s) · text + video ·
image(s) only · video only. The stages are wired to each other: pillars seed the draft
queue, media attaches to the draft (SocialDraftRequest.mediaUrls), and the answered
full draft queues for a human on /social/publish.
| Stage | Owner | Notes |
|---|---|---|
| Plan | calendar skill · weekly cadence · growth agent pillars | What to say, to whom, when. content/social/pillars.json IS the recurring calendar — the Monday seeder rotates it into the draft queue, and /social/calendar renders it with a Queue now button |
| Draft | draft skill — Claude, natively — the agent window on /social/draft, or the composer itself on /social/publish | Arabic-first bilingual copy against Brand & Voice, plus the media half. Three askers, one queue: all of them file a SocialDraftRequest, the inline Gemini lane answers in ~11s where it can, and a Claude Code session on the Max pool answers the rest — the API lane of D-20260730 has no funded key, so drafting stays on the subscription. Contributor-gated |
| Media | media keyword → /higgs (text-free) · /carousel (text-bearing) | Pick before you generate: library.json by brand + type, or the brief's (library: id) hint. New renders register in the library, then attach to the draft (social-drafts.mjs attach, or the showroom's Attach) |
| Approve | approve skill → human (Abdout / Ali / Samia) | The gate — the review queue on /social/publish is the primary lane; a signed 12h link covers a remote approver. See Autonomy ladder |
| Schedule | SocialVariant.scheduledFor + /api/social/drain | The review queue's approve-mode setting: publish right away, or schedule and delegate to the drain, which publishes due variants every ~15 minutes from GitHub Actions. No keyword of its own — it is publish's second verb ("schedule it for tomorrow") |
| Publish | publish skill → egress layer — Telegram + Facebook direct APIs · Hermes-transport channels via the gateway · WhatsApp as a copy-out block | Egress only — dumb relays, never the LLM. Media routes by shape: text · one photo · 2–10 album/carousel · one video; mixed kinds are refused by name |
| Measure | measure skill → /api/social/metrics → SocialMetric | Facebook reach + engagement, six-hourly. UTM tags are applied but not yet consumed — no PostHog in the app |
Each stage is also its own keyword: say "draft a post for hogwarts" and only that stage runs.
social is the orchestrator that chains all six.
The Hub is this table, as routes
/social mirrors the pipeline: seven stages across five routes (/social/<stage>), in order,
with brand · channels · status above them as context that spans all of them. Each stage is a
page — linkable, shareable — and the bare /social opens on Publish, which since
2026-08-25 is the only stage that can finish a post on its own: ask for the copy, refine it,
attach the media, and send.
| Route | Stages | What is there |
|---|---|---|
| /social/calendar | Plan | The same box, searching the plan: type two words and the briefs narrow, and pressing one files it as a draft ask. This ISO week's rotation picks sort first (the same math the Monday seeder runs). The full plan stays underneath — every brief in the file's own order, a queue chip on each (in the queue · answered · approved · dismissed) and Queue now on the ones nobody has asked for |
| /social/draft | Draft | The agent window, and since 2026-08-25 the same box the other stages wear: one glass bar whose field is a BRIEF, its ⊕ the shared media tray, its empty seat Publish's settings row — Brand · Feature · Channels · Post · Direction · Media, every one of which changes what gets written. Timing and Queue are absent: this stage cannot send. Typing again refines the draft on screen rather than starting another. The answer, its media and the "Use …" buttons sit under it — "Use English" is the only place the English half is reachable |
| /social/media | Media | The same box, finding rather than asking: type two words and the library narrows, and each row attaches into the shared tray. Its seat carries the settings row too — Brand · Feature · Media — which narrow the results, with the bar's count saying by how much. The showroom below it still browses — shelves by brand, cards with model, credits and source. Media Studio (generation) sits under the gallery: the box finds what exists, the studio asks for what does not |
| /social/publish | Draft · Media · Approve · Schedule · Publish | Where the bare /social opens, because it is the only stage that finishes a post on its own. One box: the field is both the post and the queue search, ⊕ attaches, and the seat sends. Behind it, eight words — Brand · Feature · Channel · Post · Draft · Media · Queue · Plan. Draft carries the plan's briefs as one-press starts, the angle/register/model knobs, Write it, and Refine. Plan is what Send does: Now, Later (with a time), or Review (one signed link per channel). A draft loaded from Queue is approved rather than re-published, which claims it answered → consumed |
| /social/measure | Measure | The same box, searching the ledger: type two words — a phrase from the copy, a channel, or a status — and the row is there with its channel, status, time and reach on it. Nothing is pressable, because a variant is a record of a decision already taken. The last 20 variants stay as a table underneath |
Cross-stage state lives in the layout's provider, which persists across stage navigations: the agent's queue poll runs on for minutes, the editor keeps its copy, the post's settings — brand, feature, channels, shape, media filters — are the same on every stage, and the attachment tray survives a trip to the showroom and back — switching stages must not throw any of it away. Taking a draft ("Use Arabic") moves the Hub to Publish carrying copy, media, and the request id, so approving there consumes the queue entry instead of leaving it approvable twice.
Drafting from the Hub — the two-sided loop
The agent window is how a teammate without a Claude Code session gets copy on the Max subscription. It has two halves and both have to run:
| Who | Where | Does |
|---|---|---|
| Any contributor | /social → Draft | Picks the brand, writes a brief, presses send. The ask becomes a SocialDraftRequest and the window polls |
| Any Claude Code session | the repo | Says draft with no topic → queue mode drains the pending asks through the same brand-voice doctrine, and writes the copy back |
node scripts/social-drafts.mjs list # what is waiting (incl. mediaUrls)
node scripts/social-drafts.mjs answer <id> --ar ar.txt --en en.txt \
--media "https://cdn…/hero.png" # the copy + its media half
node scripts/social-drafts.mjs attach <id> --media "url1,url2" # media alone, after a render
node scripts/social-drafts.mjs fail <id> --note "what is missing" # shown to the askerThe brief is the whole input. The answering session receives the brand and the brief
and nothing else, so a brief that names only a topic gets copy that says only a topic.
Name the fact, the audience, the call to action, and any date, name or link that must
appear. fail --note is rendered to the asker verbatim — use it to ask for the missing
piece rather than inventing one.
The queue drains on a schedule: scripts/drain-drafts.sh runs every 5 minutes from
launchd on a teammate's Mac, beats a draft-drain heartbeat on every look (even when
empty), and only invokes Claude when asks are pending — zero tokens idle. The window
shows the queue's truth while waiting (position + last check), backs off its poll, and
stops after 10 minutes into a "still queued" state; the drain sweep expires asks
nobody answered within an hour — see Current status.
Which of the three loops a post travels — Hub, cron draft-and-approve, or the inbound Hermes relay — plus the actor map, the cron inventory, and the keyword table, are on Architecture & Loops.
Layer boundaries
The one rule that keeps the architecture honest: Hermes is a broadcast relay, not a brain. Anthropic offers no native "post to X" primitive, so a self-hosted last-mile gateway is a legitimate egress — but the writing stays Claude-native. This is what reconciles a self-hosted gateway with the "anthropic-native over self-hosted" doctrine.
| Concern | Layer | Why here |
|---|---|---|
| Write the copy | Claude (in Claude Code / Cowork) | Best model, brand-aware, bilingual — never Hermes' LLM |
| Make the image/video | /higgs → Higgsfield CLI | Already governed by budget + brand kit |
| Decide it's good | Human (→ automated gate at L4) | Brand accounts are public and irreversible |
| Reach the platform | Egress layer, routed per transport | Telegram and Facebook: direct APIs (plain HTTPS — works from Vercel), drained by kun. Other channels via the Hermes gateway, which pulls its own work. WhatsApp: no API exists, so a copy-out block |
| Prove it worked | /api/social/metrics + UTM | Facebook reach and engagement into SocialMetric; UTM tags attribute traffic at our own site |
Note what is not in that table: Slack. It is where approvals and notices are relayed, which is a different job from reaching an audience — see the channel model.
Done (Phase 2, 2026-07-10). The old
generateAndPublishPostaction drafted via the gateway's own LLM and never published — a name that lied twice. Rather than rename it, Phase 2 removed the in-app draft lane entirely: drafting is Claude-native via the/socialskill, and the hub is compose → stage → publish only.
The eight channels
The full set: Facebook, Instagram, Telegram, TikTok, Snapchat, X, LinkedIn, WhatsApp. Each has a very different posting-API reality — some trivial, some paid, some with no organic-post API at all. Build order follows feasibility, not wish-list:
| Channel | Posting API | Effort | Note |
|---|---|---|---|
| Telegram | Bot API | Trivial | Wired direct (2026-07-10) but not configured — the token was never pushed to Vercel and no TELEGRAM_CHANNEL_ID exists. Two variables from live |
| X | API v2 | Easy (paid) | Pay-per-use since Feb 2026 (~$0.015/post) → /decide |
| Graph API | Medium | The one live channel — hogwarts, mkan, databayt | |
| Graph API | Medium | Business account linked to a FB Page | |
| Posts API | Medium | Community Management API approval + page admin | |
| TikTok | Content Posting API | Hard | App audit (~1–4 wks); posts private until passed |
| Snapchat | Public Profile API | Limited | Allowlist-only via a Snap contact |
| (none, ever) | Manual | No organic posting API for Channels or broadcast lists — not a gate to pass, a capability Meta does not offer. Modelled as transport: "manual": drafted, rendered, and approved like any channel, then handed out as a copy-out block for a human to forward |
Per-channel state — wired vs configured vs live — is on Status, which is the page to trust when the two disagree.
And one channel that is not one of them
Slack is the communication channel, not a distribution channel. It is the team surface where
approvals and notices land, and it is deliberately absent from the table above: counting an
internal chat as audience reach is how "1 of 9 channels live" came to describe a system with one
live audience channel. The composer does not offer it, the write gate rejects it, and
/api/social/relay refuses it by name. Nothing needs selecting — sendReview routes there
unconditionally.
The registry expresses this directly: a channel carries a kind (distribution |
communication) that says what it is for, orthogonal to the transport that says how its
bytes move.
Full matrix, costs, and the aggregator option (Ayrshare covers 7 of 8) are in the internal roadmap. Per-brand channel priorities are on each brand page.
Autonomy ladder
The north star is fully autonomous publishing. We climb to it in stages — not because autonomy is hard to code, but because handing a public brand account to an unsupervised bot needs guardrails first. We are at L2 on Facebook — the one live channel: compose or schedule in the Hub, one-click publish from a signed approval link, drain delivers — and L1 elsewhere. (This previously read "L2 on Telegram", which had the ladder pointing at the channel that cannot publish at all rather than the one that can.)
| Level | What runs | Human role |
|---|---|---|
| L0 — Manual | Human writes and posts | Everything |
| L1 — Assisted | Claude drafts + /higgs media | Human posts manually |
| L2 — Staged | kun stages copy+media to the dashboard | One-click approve → publish |
| L3 — Semi-auto | Auto-publish low-risk channels (Telegram); gate brand channels | Approves brand posts only |
| L4 — Full-auto | Autonomous end-to-end | None — replaced by an automated gate |
L4 is gated behind /decide + credentials + an automated guardrail layer: an LLM-judge
content + Arabic-correctness check, per-channel rate limits, and a kill switch. Autonomy without
that layer is not a feature — it's an incident waiting to happen.
The keywords
Six stage keywords — the pipeline table's seven rows fold onto six words because Schedule rides
publish. Each runs on its own; social chains all of them, and carousel is the multi-slide
sibling with its own chapter.
| Keyword | Stage | Say |
|---|---|---|
calendar | Plan | "what are we publishing next week" |
draft | Write | "draft a post for hogwarts about admission" |
higgs | Media | "make the image for it" |
approve | Gate | "send it for review" |
publish | Deliver + schedule | "publish it" · "schedule it for tomorrow" |
measure | Read back | "how did the post do" |
social | All six | "social post for mkan about the new listings" |
carousel | Multi-slide | "carousel for hogwarts intro" |
They were split out (2026-07-27) because the pipeline above had named these stages and assigned
them owners for months while four of them had no keyword — and Abdout types prose, not slash
commands, so a stage with no name is a stage that cannot be reached. Each skill's when_to_use
carries an explicit negative clause naming its siblings, which is what stops "publish it" from
routing to /ship.
The growth agent owns the strategy layer above them; .claude/vocabulary.json holds them all
in the Pensieve school. No slash needed — say the word.
Current status
The Social Hub at /social is multi-product (Phase 3, 2026-07-23):
- Works — bilingual (en/ar, RTL) composer with a config-driven channel registry (8 distribution channels, plus Slack as the one communication channel, which the picker does not offer) × a product registry (hogwarts · mkan · databayt · sijillee · moallimee). A product select — same visual language as the shadcn theme selector — scopes the whole page: channel toggles, health checks, and publish all carry the chosen brand.
- Per-product Facebook — each brand has its own Page and its own permanent Page token, read
from
FACEBOOK_PAGE_ID_<PRODUCT>/FACEBOOK_PAGE_ACCESS_TOKEN_<PRODUCT>. The Egress panel names the Page the token resolved to, which is how you catch a crossed token before publishing. Live for hogwarts, mkan, and databayt; sijillee and moallimee have no Page yet. - Per-product wiring — a channel is publishable only when the global transport and that
brand's own destination are wired (
productChannelWired). Telegram staysdatabayt-only: there is exactly one Telegram channel, org-level, so claiming it for a product brand would post "as Hogwarts" into the org channel. Slack is excluded structurally rather than per-brand — it is a communication channel, andproductChannelWiredrefuses it for every product. - Auto-post is draft-and-approve — a daily Vercel cron (
/api/social/cron,CRON_SECRET-guarded) drafts per opted-in product and sends the draft to a private review channel with a signed, 12-hour, single-use Publish link. The link opens a read-only confirm page and only its POSTed button publishes — a chat client's link-preview crawler can never consume an approval. Nothing publishes unattended. Opt in per product withSOCIAL_AUTOPOST_PRODUCTS; empty (the default) means the cron does nothing. - Inbound relay — because Vercel can never reach Hermes on
localhost,/api/social/relayinverts the arrow: Hermes drafts, a human approves in Slack, Hermes POSTs the approved text to kun, and kun delivers with its own tokens. Hermes never holds a Page token. - Drafting stays out of the relays —
SOCIAL_DRAFT_SOURCE=hermes(default) hands the ask to Hermes;=anthropicdrafts server-side via the API and is opt-in because the engine's billing posture is subscription-only. - Media is a set, and it rides the whole chain — a draft carries
mediaUrls(≤10 public URLs, typically/higgsor/carouselrenders on the CDN) from the ask through approval to delivery, which routes by shape: text · one photo (/photosedge,sendPhoto) · 2–10 as a Facebook carousel or a Telegram album (sendMediaGroup) · one video (/videos,sendVideo). Over-long copy spills into a follow-up message rather than being truncated. Mixed image+video, two videos, or more than ten images are refused by name before anything is delivered, because no platform edge carries them. The confirm page behind a signed link lists every URL. - Multi-channel, with per-channel truth — the picker selects any subset of a brand's wired channels, the three transports fan out in parallel, and the result names each channel individually. A half-landed post no longer reads as one flat failure.
- Stage for review from the Hub — the review editor can push the draft down the cron's approval path (signed 12h link into the private review channel) instead of deciding in-app, for when someone else signs off. Staging retires the queue entry, so a draft cannot be approved through both lanes.
- Scheduling and the drain — the review queue's approve-mode setting (publish right away, or
schedule) decides what Approve does; scheduled variants land as
scheduledand/api/social/drainpublishes them from GitHub Actions every ~15 minutes, claiming each row before delivering so two overlapping runs cannot double-post. Its channel filter is an allow-list, so a transport added later waits visibly in the queue rather than being attempted and silently lost. - Metrics —
/api/social/metricsreads Facebook reach and engagement back intoSocialMetricsix-hourly. A missing scope or a retired metric name is terminal on the first attempt with a named remedy, rather than retried forever. - Ledger —
/socialrenders the last 20 variants server-side (brand, channel, copy, status, time, latest reach/views), so pending approvals, scheduled posts, and publishes are visible without SQL. - The Social Agent window (2026-07-30) — the Hub's Draft tab: describe the post and Claude
drafts it, Arabic first with its English sibling; one press moves the Hub to Publish with the
copy in the editor. It is the same ai block as the
sales agent —
atom/agent-heading,atom/prompt-input,atom/ai-response-display, ported from hogwarts — so the two windows read as one product. The prompt names the selected brand, and says what a good brief contains, because the brand and the brief are the only things the answering session receives. Asynchronous by design. The window records aSocialDraftRequestand polls; a Claude Code session on the Max pool answers it throughdraft's queue mode. The API lane of.claude/memory/decisions/2026-07-30-in-app-draft-spend.mdshipped but is blocked — no funded key exists behindANTHROPIC_API_KEY— so the queue is the live path, and the reasoning panel describes that queue rather than performing invented thinking. This is not the old gateway-LLM draft card coming back: that card was removed (2026-07-23) because a relay was writing copy; here the writer is Claude itself. - The composer is an editor (2026-07-30) — the Publish tab wears the agent's shape: the same band, heading and rounded surface, sized for writing. Media URL and schedule collapsed from stacked labelled inputs into two popover pills, so the copy is the only thing on the page with weight.
- Publish became a review queue (2026-08-05) — the blank-slate composer is gone. Answered full
drafts (copy AND/OR media) queue on Publish oldest-first: the next one up is highlighted,
every upcoming one is browsable, and loading one opens the editor prefilled with its copy and its
attachments. The only actions are decisions — Approve (now or scheduled, per a settings
popover), Dismiss, or send the signed review link — and an empty queue points back at Draft
rather than offering a textarea. Approval claims the request (
answered → consumed) with the same conditional update the drain uses, so two reviewers race safely; a total delivery failure hands the draft back with the reason, while a partial one stays consumed because re-approving would double-post the channels that landed. - The publish queue got a mode row (2026-08-25) — the search bar over the queue used to see
only a third of the lane:
listAnsweredDraftsreads work awaiting a decision, and work already decided lives inSocialVariant, so "did that one go out?" meant leaving the stage for the ledger on Measure.listQueuedVariantsreads the other two thirds, and the bar wears hogwarts' category buttons as four split icons — All · Drafts · Scheduled · Published — each carrying the count of what a search actually landed on rather than the size of its table. A filter menu holds the knobs that are a preference rather than a slice (brand scope, order, media-only), and every row carries its kind's icon, its channel and its time. Only a draft loads into the composer: a variant is downstream of aSocialPiece, so approving one again would mint a duplicate — those rows open the ledger and say so on the row. The queue header's count and Refresh moved into the bar rather than standing beside it twice - Media is wired to the draft (2026-08-05) — the showroom's Attach fills a provider-owned
tray shared by the draft ask, the review editor, and the showroom itself; the drain's answering
session picks assets from
library.jsonby the brief's(library: id)hint or brand + type and passes them withanswer --media;/higgsand/carouselend by attaching their render to the ask it answers. Copy-and-paste-a-URL is no longer the hand-off. - The calendar is real (2026-08-05) —
/social/calendarrenderspillars.jsonwith this ISO week's rotation picks highlighted (a TS mirror of the seeder's math, parity-tested), a queue chip per brief, and Queue now to file one immediately. Calendar → draft is a click. - The queue drains itself (2026-07-30) —
scripts/drain-drafts.sh --installarms a launchd tick every 5 minutes: heartbeat on every look, Claude only when asks are pending. The window reads the heartbeat back (position, last check), stops polling after 10 minutes, and the drain sweep expires hour-old unanswered asks — a stall now reads as a stall, not an outage. - Still pending — adapters beyond Slack (see Hermes), per-brand Telegram
channels, a PostHog destination to consume the UTMs, and per-channel drafting in the cron.
Tracked in
docs/SOCIAL-AUTOMATION.md.
Production Scenarios & Playbooks
The pipeline executes through six canonical, end-to-end production scenarios:
| Scenario | Objective | Tools & Pipeline | Documentation Link |
|---|---|---|---|
| Mkan Port Sudan Tour | Coastal rental walkthroughs & listing packs | Media Studio (9:16 Video · 4:5 Stills) → Twenty CRM → Draft Agent → Multi-channel publish | Mkan Walkthrough Playbook · Mkan Brand Page |
| Hogwarts Admission Campaign | School SIS marketing & zero-paperwork push | Media Studio (16:9 Ad) → 5-Slide Carousel → Thmanyah Quote Card → LinkedIn / Meta publish | Hogwarts Admission Playbook · Hogwarts Brand Page |
| Airbnb Ad Repurposing | Adapt proven Airbnb campaigns to Mkan | Showroom Airbnb Shelf → "Use in Studio" → 121 Port Sudan listings | Airbnb Swipe Playbook · Twenty CRM Schema |
| Deterministic Quote Cards | High-trust typography without AI lettering hallucinations | Template Lane → HTML Canvas Engine → Live Visual Card Preview | Deterministic Cards Playbook · Carousel Docs |
| High-Impact Video Production | 24fps walkthroughs with Red Sea audio foley | Prompt Compiler → Google Veo / Seedance / Kling → Showroom Brief Queue | Video Production Playbook · Google Lane |
| Full Lifecycle Broadcast | Weekly calendar rotation to measured reach | Pillars rotation → Draft Agent → Review Queue → Scheduled Cron Drain → UTM Metrics | Social Architecture · Channels Mix |
See also
- Media Studio & Generation — photo and video generation, prompt compiler, showroom shelves.
- Status — what is configured and live right now, versus what is merely wired.
- Ownership & Accounts — who actually holds the Meta assets, and what Instagram needs.
- Channel Setup — per-platform setup guides, written to be usable on your own accounts.
- Carousel — multi-slide bilingual decks rendered at exact platform sizes.
- CRM & Listings Schema — Twenty CRM listings integration.
- Brand & Voice — the rules every post obeys.
- Connectors — the MCP-backed service integrations.
/higgsskill — media generation ·calendar·draft·approve·publish·measure·carousel·growthagent.
On This Page
The four pagesThe settings each page showsHow a post travelsWhat each step meansThe other way a post travelsWhat happens when nobody says yesWhy this beats writing it yourselfWhat each pick is worthWhat a person cannot doWhere it does not beat manual yetSending it out to a Facebook PageWhere an approved post comes fromBusiness models → who we're talking toStrategyBrand pagesThe pipelineThe Hub is this table, as routesDrafting from the Hub — the two-sided loopLayer boundariesThe eight channelsAnd one channel that is not one of themAutonomy ladderThe keywordsCurrent statusProduction Scenarios & PlaybooksSee also