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.
Your own feed never comes back from that refresh — upload-post only lists pages. Press Add personal profile to store it as an account too; it then gets the same Use and Default switches as a page, so you can make the personal feed the default for new promos.
| 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.