Canonical Components (v18)
L1 · 01 Content Distribution
The Canonical Content OS (5-stage) · Content Templates · Distribution Channels.
L1 · 01 Content Distribution
Purpose: Create content and attract attention.
| Slug | Name | Kind |
|---|---|---|
l1.content-distribution.content-pool | One Content Pool & Modal | principle |
l1.content-distribution.content-os | Canonical Content OS (5-stage) | process |
l1.content-distribution.templates | Content Templates | asset |
l1.content-distribution.channels | Distribution Channels | config |
l1.content-distribution.scheduling | Publer Scheduling & Content Buckets | config |
l1.content-distribution.metrics | Canonical Distribution Metrics (ATTRACT scoreboard) | measure |
l1.content-distribution.content-pool — One Content Pool & Modal
It's all content. Ads, organic, content distribution — the same thing, viewed the same way.
There is one content pool per brand and one modal to author, view, and approve it (the creative grid + + Add Concept). "Ad" vs "organic" is not a different system — it's just where a given piece goes: Meta (paid) and/or Publer (organic FB · IG · LinkedIn · …). Build once, distribute everywhere.
- One pool:
engine.components["ad-creative-set"].review_copy.concepts[]— every concept hasstage,headline,body,cta,url,media,status. - One modal: the HIFI Content Distribution card and the Ads card render through the same surface (
surfaceFor → "ad") and read the same pool (content-distributionaliasesad-creative-set). Approve in bulk, once. - Channels are destinations: a piece approved in the modal is eligible for paid (Meta) and organic (Publer) — the brand's
connected_accountsdecide where.
Do not build separate "ads", "organic", and "content" systems. One pool, one modal, many destinations.
Where content lives — Drive (bytes) · Supabase (index)
Storage is two layers; don't pay twice.
- Bytes → Google Drive (already subscribed, effectively unlimited). Masters, raws, finished cuts, clips, and transcript files live here. Unlike YouTube, code gets the real, uncompressed master back via the Drive API (
files.get?alt=media) — fetch → upload to the destination. Requirement: the OAuth account must have download rights (capabilities.canDownload === true); some Shared Drives disable download for non-managers, which blocks the fetch. - Index → Supabase (the DB). Each content record holds the Drive file id, the transcript with timecodes, links to derived clips, plus
status·bucket·objective· performance. This is the repurpose brain: search transcript → timecode → ffmpeg cuts the clip from the Drive master → the clip becomes a new concept in the pool. (Small ad-creative screenshots already sit in the Supabasecreativesbucket — fine to leave; the 50MB project cap is why video does NOT go to Supabase Storage.) - Never hotlink Drive (interstitials, no CDN) — always fetch-via-API → upload, which is what Publer/Meta need anyway.
- YouTube = publish + SEO destination, never the master store (re-encode + no file pull).
The scheduler resolves a concept's master from drive_file_id first (Drive bytes), then a public URL fallback — apps/site/app/api/content/schedule.
l1.content-distribution.content-os — Canonical Content OS
5-stage locked flow, replaces TOFU/MOFU/BOFU:
Ideate → Analyze → Create → Promote → Optimize1 case study/week → 17 assets produced. N8N draft generator + briefs + templates all already live ([[project_canonical_content_os]] LOCKED 2026-05-11).
Do not propose alternative content matrices. This is the locked process.
l1.content-distribution.templates
Locked per-channel templates:
- Long-form post
- Short post
- Email broadcast
- Video script
- Long-form video
All seeded from the Canonical Content OS Create stage.
l1.content-distribution.channels
Channel mix per brand:
- Outbound — Lemlist programmatic via n8n
- Inbound · Referral — Share to Unlock + referral payouts
- Inbound · Organic — SEO Arch + content distribution
- Inbound · Ads — Meta + Google Ads via Ad Creative Set
l1.content-distribution.scheduling — Publer Scheduling & Content Buckets
The Promote stage runs through Publer for every brand. No manual posting, ever. This is how the 5-stage OS distributes — not a new content matrix, the scheduling layer underneath the locked one.
Content buckets = the Content OS Category field. Each bucket maps to a recurring Publer slot and recycles evergreen assets, so the queue is never empty. This is the "never run out of content to post, ever" guarantee — the 17 assets/week from the Create stage flow into buckets and auto-fill the calendar.
Caption rule — locked: every scheduled post = one sentence + one CTA, selected by the asset's Post Objective from the Social Content Brief:
| Post Objective | Caption shape |
|---|---|
| Engage | one hook sentence → soft CTA (follow / comment keyword) |
| Consume | one value sentence → mid CTA (watch / DM keyword) |
| Clickthrough | one offer sentence → hard CTA (book / visit domain) |
Mechanism:
- One Publer workspace per agency; per-brand connected accounts (FB Page · IG Business · LinkedIn · TikTok · …) are the Distribution Checklist channels.
- API key per agency in
~/.config/0hr/publer.env; posts are created scheduled, then a human approves in Publer's calendar before publish (Approval Workflow §10 — nothing goes live without approval). - Bucket → cadence → calendar is the durable plan; the calendar view is the approval surface.
Max the channels: connect every channel the brand can sustain (FB · IG · LinkedIn · TikTok · YouTube · Pinterest · X · Google Business). More connected accounts = more reach per asset at zero extra production. New connected account → add its id to the brand's connected_accounts; it auto-joins the live check and the schedule.
Reuse rule — positive signs of life → repurpose (the Optimize stage). Every post is categorized into a bucket. Publer + the weekly Optimize pass read engagement; any post showing positive signs of life (above-median engagement for its bucket) is recycled and repurposed — re-queued, cross-posted to other channels, and cut into derivative formats. Losers retire. This is what makes the queue compound instead of drain: winners never die, so the brand never runs out of proven content.
Per-brand install — ONE workspace, many brands, verified binding:
- One Publer workspace per agency holds the social accounts for ALL brands (one subscription, not one per brand). The workspace is shared; brands are NOT.
- Each brand binds to its own
connected_accounts(a subset of the workspace). MCM → its FB Page / IG / Sam's LinkedIn; DH → DH's; never the whole workspace. - VERIFY-BY-NAME at install (mandatory). Before a brand's
connected_accountsgo live, confirm everyaccount_idresolves to the expected brand-named account in the live Publer/accountsdirectory (e.g. MCM facebook id → "Medical Consulting & Management", not "Matt Kohn"). A workspace with 19 accounts means a wrong id silently cross-posts one brand's content to another's audience. Fail the install loudly on any mismatch. (Second lock: everything ships as a draft first, and Publer shows the account name before publish — but the binding must still be name-verified, never assumed.) - Map each Content OS
Categoryto its bucket cadence.
The flow — approve (per atom) → fire → draft
Approval is the trigger, and it is per atom. A "content atom" = one concept/reel in the pool. The POC approves each atom individually in the wizard (a per-card Approve button) — that single click is the only human action upstream. It fires the rest automatically:
Approve atom (the one human action, per atom)
→ categorize : drops into its bucket (objective → Engage/Consume/Clickthrough)
→ fire : drafts to Publer (every connected channel) as state="draft"
→ fire : queues a PAUSED Meta ad with the same atom as creative
← human : publishes the Publer draft / un-pauses the ad (the only public step)It's all content (one atom, two destinations): the same approved atom fans out to organic (Publer drafts) and paid (a paused Meta ad). Approve once, both queued. No scripts, no bulk jobs — the approve click on the atom is the wire.
Mechanics:
- Approve —
POST /api/content/approve{brand_slug, concept_id}sets the atomstatus: "approved"(clobber-safe). - Categorize — same call assigns a bucket (explicit, or defaulted by objective:
tofu→Engage · mofu→Consume · bofu→Clickthrough). The bucket carries the cadence + recycle. - Fire — approval triggers
…/content/schedulefor that atom → Publer draft (+ paused Meta ad). Approved-only gate; nothing publishes without the human publish/un-pause (Approval Workflow §10).
Un-approving ({approved:false}) pulls the atom back out and removes its queued drafts.
Never create drafts/ads via an out-of-band bulk script — approval-of-the-atom is the only trigger. This keeps the client surface honest: one click per atom, fully traceable.
Distribution action — approved content → Publer drafts
The action that fills the calendar. Endpoint: apps/site/app/api/content/schedule (?brand=<slug>).
- Approved-only gate: only concepts with
status === "approved"in the one content pool are eligible. Pending content never schedules. - Drafts only: default is dry run (returns the plan, writes nothing).
?live=1creates Publer drafts (state:"draft") across the brand'sconnected_accounts— never publishes, never schedules-live (Approval Workflow §10). The human approves each in Publer's calendar. - Fan-out: each approved concept × each connected channel → one draft, caption built by the locked rule (one sentence + one CTA, by Post Objective).
Verified Publer API contracts
base https://app.publer.io/api/v1
headers Authorization: Bearer-API <key> · Publer-Workspace-Id: <ws>
upload POST /media (multipart file=<binary>) → {id, path, thumbnails[]}
⚠ the Blob MUST carry a MIME type (Content-Type video/mp4, image/jpeg…)
or Publer 400s "File is not supported".
create POST /posts/schedule {bulk:{state:"draft", posts:[{
accounts:[{id, scheduled_at?}],
networks:{<provider>:{ type:"status|photo|video", text:"<caption>",
media:[{ id, thumbnails:[…from upload…], default_thumbnail:0 }] }}
}]}} → {job_id}; poll GET /job_status/<job_id> (failures:{} == success)
read GET /posts?state=scheduled|published|draft (returns bare array)
accts GET /accounts → bare array [{id,name,provider,type}] (verify-by-name)
delete NOT exposed by the API (all paths 404) — remove drafts in the Publer UIVerified contract (2026-06-19, video proven end-to-end on MCM). Both
textANDmedialive INSIDEnetworks.<provider>— NOT at the post root. Media must be uploaded to the library first (URL-attach is rejected), and the post's media object MUST carry the upload's fullthumbnailsarray +default_thumbnail:0— omitting it throws Publer'sundefined method 'first' for nil. The upload Blob MUST set a MIME type or/media400s.job_statuswith emptyfailures:{}= the draft was created. Code:apps/portal/app/api/ content/approve(per-atom) +apps/site/app/api/content/schedule(cycle).
l1.content-distribution.metrics — Canonical Distribution Metrics
Publer measures the engine up to the click. The click is the handoff. Distribution is the ATTRACT pillar; its job ends when a person clicks through. Everything past the click — lead, consult, client — is owned by QUALIFY → CONVERT (the CRM), joined back to the post that earned the click. Distribution metrics answer one question: is the content engine efficient? They are never business results on their own.
The canonical metric set (per brand, per period)
| Metric | Stage | Source | Status |
|---|---|---|---|
| Posts Published | Promote | Publer /posts?state=published → total | ✅ Live — checkPubler, apps/site/app/api/brand-systems/route.ts |
| Scheduled | Promote | Publer /posts?state=scheduled → total | ✅ Live — queue-depth; GREEN when > 0 |
| Reach | Optimize | Publer analytics/:account/chart_data (post_reach) | ⚠️ Extension — gated on Publer insights backfill |
| Engagements | Optimize | Publer post_engagement | ⚠️ Extension — see definition below |
| Clicks | Optimize | Publer link_clicks | ⚠️ Extension — the handoff seam |
Posts Published + Scheduled are canonical today — already wired and trustworthy. Reach · Engagements · Clicks are the locked extension to the ATTRACT scoreboard: their definitions are fixed here, but they render only once Publer insights actually populate (chart_data returns empty until accounts are gathering insights). Never ship zeros — a metric with no data is hidden, not shown as 0 (Kernel §11 ZERO FABRICATION).
Engagement — locked definition
Engagement = reactions/likes + comments + shares + saves. Excludes link clicks (that is Clicks, its own metric — never double-count) and video views (autoplay-inflated, tracked separately). Excludes follows.
Identical across every provider (FB · IG · LinkedIn · TikTok · X). Engagement = active interaction with the post itself; the click and the view each have their own lane.
The join — ATTRACT → QUALIFY → CONVERT
One row per brand, the full content-to-revenue chain across three pillars:
Posts → Reach → Engagements → Clicks │ → Leads → Converted
└────── ATTRACT (Publer) ─────┘ QUALIFY CONVERT
(contacts) (contacts.stage)- Leads = QUALIFY pillar — new
contactsrows attributed to content. - Converted = CONVERT pillar —
contactsadvanced to client / consult booked.
Attribution mechanism (the only bridge past the click): Publer goes blind at the click, so the link itself must carry the trace.
- The scheduler UTM-tags every link it posts:
utm_source=publer · utm_medium=social · utm_campaign={brand} · utm_content={post_id}. - Intake captures the UTM onto the
contactsrow at lead creation (extends the existingsourcecapture). - Join
contacts.utm_content → Publer post_id— a lead traces to the exact post and brand.
Efficiency ratios — "is the engine working?"
The ratios between stages are the distribution-engine efficiency signal:
| Ratio | Formula | Reads |
|---|---|---|
| Amplification | Reach ÷ Posts | how far each asset travels |
| Engagement Rate | Engagements ÷ Reach | resonance |
| CTR | Clicks ÷ Reach | pull to action |
| Click→Lead % | Leads ÷ Clicks | the seam's conversion |
| Lead→Converted % | Converted ÷ Leads | the business close |
Not the scoreboard — diagnostic only
- Followers — audience size, not a result. Trend it for sanity; never the headline number (the "XP" trap — real results, not vanity).
- Video Views · Demographics (countries/ages) — targeting QA ("are we reaching MoCo for MCM?"), not a score.
Workspaces are tool-config, not a metric. One Publer workspace per agency; per-brand connected_accounts scope every metric above to the brand (account_ids[]=…). See l1.content-distribution.scheduling.
Reference rule
Min 2 / Max 4 reviewer-rated 4-5/5 library refs per sub-deliverable.