Skip to content

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, approve and publish refuse a workspace with no bundle:; keryx reel link records one. A reel's piece appears in the set the first time copy is composed for it; a post is created with post new <id>.
  • Due is on the hour. approve --at 2026-09-22T09:30:00Z is refused. A bare date takes the platform's default hour; no --at takes 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 due sends at most one outlet per platform per article per run, the earliest due; the rest report deferred and 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 gen or social set created it, and social rm <platform> removes it. social gen with no --platform composes 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 and publish say 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 and announce.public_base_url set). Instagram, YouTube, TikTok and LinkedIn take a reel.
  • Earlier shapes are refused. A social.json in 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.