Channels & promos
A promo is channel-ready copy that amplifies a publication on one destination. An approved publication can start collecting promos — one or more per channel — and they go out once it reaches promoting and has a canonical URL to link to. Each runs its own mini-pipeline.
A publication can have several promos on the same channel (e.g. two different angles for X). Each promo is a plain row keyed by its own id, with an optional internal name to tell same-channel promos apart. The name is a working label only — it's never published. Unnamed promos surface as "Untitled <channel> promo" in the UI.
Channels
Channels are a seeded lookup table (not user-created in Phase 1). Each channel declares how it publishes:
| Field | Notes |
|---|---|
key | substack, medium, docs, x, linkedin, instagram, threads, facebook, bluesky |
kind | publishing, promo, or both |
upload_post_platform | upload-post platform id if supported; NULL ⇒ a manual channel |
config | per-channel defaults — max_chars, target_param, default hashtags, etc. |
config.max_chars is a hard limit, not a hint: a promo body longer than its channel allows is rejected on write, whether it comes from the Promos page, an MCP agent, or the REST API. X caps at 280, Bluesky 300, Threads 500, Instagram 2200, LinkedIn 3000; a channel with no max_chars (Facebook) is unconstrained. The error names the count and the cap — promo body is 312 characters; x allows 280.
The distinction that matters most is auto vs. manual:
- Auto channels (
upload_post_platformis set, and the project's posting profile has that platform connected): scheduling a reviewed promo uploads it to upload-post with its dispatch time, and upload-post's own scheduler posts it — their pending schedule is the source of truth, retrieved on page load. - Manual channels (
upload_post_platformisNULL, e.g. Substack — or the profile isn't connected to the platform): a human publishes and records the resulting URL — nothing is dispatched.
Accounts — who the promo posts as
A channel says which platform. A promo account says which identity on it — your own feed, or a company page you administer. Each promo picks one, so a single publication can promote to a personal LinkedIn feed and a company page side by side, with different copy.
Accounts are set up once per project, in Project settings → upload-post. Pressing Refresh pages asks upload-post which LinkedIn organizations the posting profile administers and stores them; renamed pages update in place, pages you lose access to are archived rather than deleted, and pages that come back are restored.
| Field | Notes |
|---|---|
name | what you see in the picker — Skillforests, Juliusz Fedyk |
upload_post_profile | which posting profile; blank means the project's default |
external_id | the platform's page id. Blank means the profile's own feed |
A promo with no account posts as the project's default profile — the behaviour every promo had before accounts existed, so nothing changed underneath existing work.
A LinkedIn company page rides the same connected account as your personal feed. If a page you administer doesn't show up after a refresh, the organization wasn't granted when LinkedIn was connected — reconnect it in the upload-post dashboard and approve the page.
Two rules exist to keep a promo's record of where it went honest: an account can't be repointed at a different page after it's created (make a new one instead), and a promo's account can't be changed once it's published or while it has a scheduled post pending — unschedule it first.
The promo lifecycle
draft ──▶ reviewing ──▶ scheduled ──▶ published
▲ │
└──────────┘ (request_changes)
- Draft. An agent (or person) writes channel-ready copy. Copy past the channel's
max_charsis refused at write time, so a draft that saved is a draft that fits. - Review. Submit for review; a reviewer approves or requests changes.
- Approved. Ready to ship — schedule it (upload-post posts it at the chosen time), post it now, or reopen it to draft for edits.
- Published. Auto channels record the
external_urlfrom upload-post's result; manual channels are marked published by hand. Published promos are terminal — they can't be reopened or deleted.
If an upload-post dispatch fails, the promo stays in scheduled with the error captured in its dispatch JSON — it's retried or fixed manually, never silently marked published.
Approving an auto-channel promo books the post at its scheduled_for, in the same call. So a promo with no dispatch time — or one whose time has already passed — is refused, and stays in review until you give it a future one.
Manual channels are exempt: there's nothing to book, so they approve and wait for you to publish them.
:::
Media
Images live in Supabase Storage and are linked to publications and promos through join tables (publication_media, promo_media) with a position — so the same asset can appear in an article and several promos.