The announcement set¶
Three words, set in spec 0061:
- an article is a blog post, identified by its bundle directory;
- a reel is a reel, a workspace under
reels/<slug>belonging to an article; - a post is a social-media post of an article: one piece of messaging, text with an optional image, sent to one or more platforms.
Reels and posts are an article's pieces. An article can have any number of
each, so it can be campaigned at different times with different messaging. All
of an article's pieces, with their per-platform copy and posting state, live in
one file, social.json, in the article's bundle directory, beside
reel-caption.md.
{
"schema_version": 2,
"pieces": [
{
"id": "my-post",
"kind": "reel",
"platforms": {
"youtube": { "text": "…", "title": "…", "status": "posted", "due": "2026-09-29T12:00:00+01:00",
"posted_at": "2026-09-29T11:00:02Z", "post_url": "https://…", "post_id": "…" }
}
},
{
"id": "launch",
"kind": "post",
"label": "Launch week",
"image": { "path": "cover-my-post.jpg", "url": "https://phpboyscout.uk/my-post/cover-my-post.jpg", "alt": "…" },
"platforms": {
"bluesky": { "text": "…", "hashtags": ["…"], "link": "https://phpboyscout.uk/my-post/", "title": "…",
"description": "one sentence for the link card", "status": "approved", "due": "2026-09-22T09:00:00+01:00" },
"instagram": { "text": "…", "status": "draft" }
}
}
]
}
Fields¶
| Field | Meaning |
|---|---|
id |
a reel's workspace slug, or the name you gave post new (a slug, unique within the article) |
kind |
reel or post; recorded by how the piece was made, never typed |
label |
optional prose for a post (the campaign's name) |
brief |
the piece's framing for the composer: the campaign, its phase, the angle, the call to action. A reel's brief overrides its workspace direction for the copy. Set with post new --brief, social gen --brief, or the studio's "Brief for the composer" |
image |
a post's attachment: path (bundle-relative, for adapters that upload bytes), url (public, for Instagram, which accepts only a URL; derived from announce.public_base_url by post image and the studio, or supplied by you), alt (composed by social gen when absent), source (where post image copied it from; informational, spec 0062 D4) |
platforms.<p> |
the piece's record on one platform: the copy (text, hashtags, link, title, description) and the state |
status |
draft, approved, posted (R-SOC-6, per outlet) |
due |
the hour an approved outlet becomes sendable, RFC3339 on the hour |
posted_at, post_url, post_id |
the idempotency record |
An outlet is one piece on one platform. It is the unit approved, scheduled and sent.
Rules¶
- A reel with no article has no set.
social,approveandpublishrefuse a workspace with nobundle:;keryx reel linkrecords one. A reel's piece appears in the set the first time copy is composed for it; a post is created withpost new <id>. - Due is on the hour.
approve --at 2026-09-22T09:30:00Zis refused. A bare date takes the platform's default hour; no--attakes the article's date. - One outlet per platform per hour, across the whole article. Approving a
second piece on a platform at an hour another already holds is refused, and
publish duesends at most one outlet per platform per article per run, the earliest due; the rest reportdeferredand go on the next run. - What a platform needs is its declared contract, applied to the piece's image. A post with no image is sent text-only where the platform allows it and refused at pre-flight where it does not; nobody types a kind.
- A piece is for exactly the platforms it has a record on (spec 0062 D1).
keryx reads no front matter and keeps no second list: an outlet exists
because
post new --platform,social genorsocial setcreated it, andsocial rm <platform>removes it.social genwith no--platformcomposes for the piece's own platforms; on a piece with none, for every enabled platform whose publisher can take it, and refuses when that is none. A posted outlet cannot be dropped: its record is the ledger of the send. - Nine platforms are known:
instagram,youtube,tiktok,linkedin,bluesky,facebook,threads,x,mastodon. A piece can carry copy for one whose publisher has not landed (the copy is authored at drafting time, 0060 D7); the studio's tab andpublishsay so. A post can reach Bluesky, Facebook (a photo or feed post on the Page), Threads (text with a link card, or an image by public URL with the link in the text), X (text with the link, images uploaded), Mastodon (a status with the link, up to four images uploaded), LinkedIn (an article card built from the link, title and description with the image as its thumbnail, because LinkedIn does not scrape; an image share without a link; plain commentary without either) and Instagram (which fetches the image from its public URL, so the article must be live andannounce.public_base_urlset). Instagram, YouTube, TikTok and LinkedIn take a reel. - Earlier shapes are refused. A
social.jsonin a reel workspace, and a bundle file at schema version 1, are refused by name; neither was ever released.
Default hours¶
announce:
timezone: Europe/London # one project-wide zone; default UTC
platforms:
bluesky:
announce:
hour: 9 # default 9
instagram:
announce:
hour: 12
Keeping the file off the site¶
A static-site generator publishes page resources. Hugo publishes a .json in a
leaf bundle at /<slug>/social.json unless told otherwise; the owning project
excludes it (an ignoreFiles entry in the site config). The file name is fixed
so that line never has to chase it.
Targeting¶
Every command that reads or writes the set takes:
| Flags | Scope |
|---|---|
--article <dir> |
the whole set: every piece of the article |
--article <dir> --reel <slug> |
one reel piece |
--article <dir> --post <id> |
one post piece |
-w, --workspace <slug> |
shorthand for --article <its bundle> --reel <slug> |
publish due with none of them sweeps every article the project's
content.sources name, which is what a scheduled pipeline runs, hourly.
In the studio¶
The studio's left rail lists articles and, under each, its reel and post pieces
(+ reel, + post on every row; a new post takes a label, and its id follows from
it). A post opens an editor with the image record, a Brief for the composer (saved on
the piece; AI compose follows it), a Post to row (a chip per platform the
piece is for, with a remove, and an add menu of the platforms whose publisher
can take the piece, marking any that is disabled), the
per-platform composer (text, hashtags, link, title, LinkedIn description, due
hour) as one tab per platform, approve, and publish per outlet. The reel
cockpit has the same row. A platform whose contract cannot take
a post as it stands says so on its tab and cannot be approved. The image's
public URL is never typed: the studio derives it from announce.public_base_url
when the file is saved. Use the article cover writes cover-<slug>.jpg
beside the cover; Pick… opens a gallery of every picture the project has
(reel covers, cards, portraits and takes, article covers, other posts' images,
newest article first with this one pinned) and copies the chosen one into the
bundle as post-<id>.jpg, fetching a reel file from the object store first
when it is not local; Generate… takes a brief, theme, take count and frame
(1:1 by default), runs the paid job into the post's own slot
(posts/<article>/<post>/) and offers the takes, one click making one the
image. All three are keryx post image on the CLI. Publishing
from the studio is guarded per outlet: a second click while one is in flight gets
"already publishing", and a posted outlet is refused, so a post cannot be sent
twice by accident.