Scheduler
Schedules pull from your approved-content queue and publish at the cadence you set. Pause, archive, and runway warnings included.
Status: M4–M11 SHIPPED (manual scheduling, Schedule entity + builder, week calendar + drag-drop, cadence resolver, Inngest publishing runtime, retry, stuck-processing reaper). Plus the 2026-06-15 additions below. Last updated: 2026-06-15
Current status (as-built — 2026-06-15)
The publishing runtime is fully live across Facebook, Instagram, TikTok, and YouTube (YouTube is video-only). The following supersede/augment the older sections below; where they conflict, this section is authoritative:
- Declared post type (per account). The publish modal shows, and
freezes, the declared post type — Feed, Reel, Story, Short — as a
"Reel · 9:16"-style chip on each selected account row, so the scheduled post
publishes to the exact native format you previewed (IG Story, FB Reel/Story,
YouTube Short each go to their own endpoint). Frozen onto
ScheduledPostTarget.postType; the publisher dispatches on it. Seedocs/functional/platform-preview.md. - Posting guardrails (pre-flight + publish-time). Before a post goes out,
the asset is validated against the target platform's media spec (duration,
file size, format, carousel count). The publish modal shows per-platform
warnings (
GuardrailWarnings) with a "post anyway" acknowledgement gate (warn, don't block). At publish time, the publisher short-circuits a doomed post — on awill_failit skips the partner call and returnserrorClass: "spec_violation"with the clear reason (src/lib/publishing/publisher.ts). See posting-guardrails.md. - Live failure reporting. The generation Schedule section and the
week calendar now poll while any post is in flight (
LiveRefresh/usePollingRefresh), so a post flips to POSTED / FAILED live — with the actionable reason + per-target Retry — without a manual reload. - AI-content labels (2026-10-09). Each platform's AI disclosure is set at publish time, by one rule: media VibeDay's AI generated (
AI_GENERATED) is always labelled. Everything that started as the user's own media (uploads, web imports,AI_CLIPPEDshorts cut from their footage, and their edits and exports) may or may not be AI, so it carries the user's declaration from the publish dialog. "Made in VibeDay" never means "AI-generated" (operator, 2026-10-09). TikTok →is_aigc("Creator labeled as AI-generated"). Instagram →is_ai_generated("AI info", Instagram Platform changelog 2026-06-22). It can only be set when the post is created, so the dialog shows an AI-generated content switch on every selected Instagram account, locked on for VibeDay-AI media;ScheduledPostTarget.settings = { aiGenerated }. On carousels it goes on the carousel container only; a Story container that rejects it is retried once without it, with a Sentry warning. AI voice-over reminder (2026-10-09, Sound toolkit Wave 1). When the post's media is a Studio Editor export whose rendered timeline (RenderJob.editState, the snapshot that was rendered) has a typed AI voice-over that's heard (an audio item withtypedVoice, track not muted, volume above zero;exportHasTypedAiVoiceOverinai-provenance.ts), the Instagram and TikTok AI-generated switches show one line: "This video has an AI voice-over." It's a reminder only; the switch stays the customer's choice and is never turned on for them. YouTube →status.containsSyntheticMedia("altered or synthetic content", YouTube Data APIvideos.insert). Hardcodedtrueon every upload until 2026-10-10, which labelled customers' real footage; now the same rule: each selected YouTube channel's panel has an AI-generated content switch under Visibility, locked on for VibeDay-AI media, otherwise off unless the customer turns it on;ScheduledPostTarget.settings = { privacy, aiGenerated }(youtubeSyntheticMediaLabelinyoutube-settings.ts). Posts scheduled before 10-10 carry only{ privacy }and publish unlabelled unless the media is VibeDay-AI. No AI voice-over reminder on YouTube: its disclosure help lists realistic visuals, not AI voices. Facebook → no label yet: Meta documentsis_ai_generatedfor Page Stories only, not Reels or feed video, which are what we publish. - Verified publish (no false success, 2026-08-09). Async platforms (TikTok
Direct Post, Facebook video/reel) ACCEPT a post before it's actually live, so
"accepted" is never shown as "Posted". Such a target sits in PROCESSING
("Publishing…") while the publish flow polls the platform's real status
endpoint (
confirmPublication), flipping it to POSTED only once the platform confirms it's live — or FAILED with the platform's own reason (e.g. TikTokphoto_pull_failed, a spam/rate limit) if it didn't. The success notification + post-count usage fire only after confirmation. Instagram, Facebook photo/feed, and YouTube confirm synchronously and skip PROCESSING. - "Not confirmed" (2026-10-13). The confirmation poll backs off over ~38 min
(
CONFIRM_SCHEDULE_SECONDSinsrc/lib/publishing/confirm-schedule.ts; it was ~4 min, which failed a public TikTok carousel mid-processing). TikTok's docs say moderation of a public post "usually finishes within one minute" but "may take a few hours". If the platform still hasn't confirmed at the end of the window, the target shows an amber Not confirmed badge (not a red failure) with: "TikTok accepted this post but hasn't confirmed it's live yet — TikTok can take a few hours to review a post. We'll keep checking and update this automatically. Before retrying, check your TikTok profile: if the post is already there, retrying would post it twice." If nothing else on the post went out, the notification says "Post not confirmed yet", not "failed". The 15-min housekeeping cron then re-checks it by status lookup only (never a re-publish) for 72 h: if TikTok confirms, the row flips to Posted with its link, the post's status and failure note are re-derived, post-count usage is recorded and the workspace gets "Your TikTok post is live". If TikTok reports a failure, the note is replaced with TikTok's real reason. After 72 h the "Not confirmed" note is the final state. Facebook video/reel rows get the same treatment. Rows with the pre-fix wording ("The platform didn't confirm…") are re-checked too. - Failure classification (
classifyFirstFailure) buckets failures asspec_violation/token/permanent_rejection/rate_limit/permissions/network/otherfor thePOST_FAILEDtelemetry + notification. - Stuck-progress reaper is the general in-progress backstop (function id
stuck-processing-reaper, 15-min cron): ScheduledPost PROCESSING→FAILED, Publication PROCESSING→FAILED (async-confirm interrupted; the publish run heartbeatsupdatedAton each confirmation poll, so a live wait is never reaped) and Generation GENERATING→FAILED after 10 min, Asset PROCESSING→FAILED after 6h. - Publishing is per-post deferred Inngest events (each ScheduledPost fires
its own event at
scheduledForwith a stale-check), NOT a polling cron.
⚠️ The older "What's NOT yet active" (M4) and "Planned (M5/M7)" / "Intended shape" placeholder blocks near the end of this doc are superseded — those features (publishing runtime, recurring schedules, calendar, OAuth connect, failure reporting) are all shipped. Treat them as historical.
Schedules (M5, top-level)
Click Schedule in the dashboard nav → land on /schedule. Each Schedule is a named, persistent, auto-publishing rule with its own:
- Cadence — days of the week + times of day (e.g. "Mon–Fri at 9am and 5pm")
- Time zone — IANA name; cadence times are interpreted in this TZ so a Pacific schedule keeps firing at 9am Pacific regardless of where you log in from
- Source —
Manual(you add specific approved posts) orCampaign pipeline(VibeDay auto-fills from a campaign's approved drafts) - Rotation policy —
Once-only(each generation publishes at most once) orReusable after N days - Targets — per-Schedule list of connected platform accounts. Different schedules can target different account combinations.
- Runway warnings — alerts when the predicted content queue runs low (default: 5 days)
- Lifecycle status —
Active,Paused,No content,Completed,Archived
Building a schedule
- Click + New schedule on
/schedule - Fill in the builder:
- Name — pick something descriptive ("Daily drumbeat", "Q4 launch push")
- Source — Manual or Campaign pipeline (pick the source campaign if the latter)
- Cadence — toggle days of week, add/remove times of day, pick start + optional end date, pick timezone
- Targets — checkbox each connected account this schedule should publish to
- Rotation — once-only (default) or reusable after a cooldown
- Runway warning — set the threshold (default 5 days) or 0 to disable
- Click Create schedule → land on the schedule's detail page
Schedule lifecycle
- Pause any active schedule from its detail page — the cadence resolver skips it and no new posts get scheduled (existing scheduled posts continue unless cancelled individually)
- Resume a paused schedule to return it to active
- Archive a schedule when you're done with it — keeps the history; hidden from the default list
Calendar view (M6)
The Schedule detail page (/schedule/[id]) now shows a week calendar of every scheduled post in this Schedule. Each day is a column with:
- Date header (today highlighted in sky blue, past days dimmed)
- Posts for that day, listed chronologically as cards
- Each card shows: image thumbnail, platform-icon row (FB/IG/TT), scheduled time, status dot, first ~100 chars of the Instagram caption
- Click a card → opens the post's full generation detail page
Adding posts to the schedule manually
Until the M9 cadence resolver lands, posts don't auto-fill from the cadence. Until then (and any time you want to insert a manual post):
- Top-right "+ Add post" button on the calendar, OR
- Click "+ Add" inside any empty future day cell
This opens a modal:
- Approved post — radio list of READY generations not yet scheduled. For CAMPAIGN_PIPELINE schedules, filtered to that campaign's generations. For MANUAL schedules, shows all approved across the workspace.
- When — datetime picker (your local time, must be in the future)
The schedule's configured target accounts are used automatically — you don't pick targets per post.
Calendar navigation
- ‹ / › prev/next week
- Today button jumps back to current week
- URL carries
?week=YYYY-MM-DDso calendar views are deep-linkable / sharable
Drag-and-drop + click-to-edit (M7)
You can now reorganize your week visually:
- Drag a card from one day column to another. Press and hold (or click and drag), drag over the target day, release. The card's date moves; the time of day stays the same. The target column highlights as you hover.
- Drag is keyboard-accessible — focus a card with Tab, press Space to pick it up, arrow keys to move between days, Space again to drop.
- Click the time on any card → popover with a datetime picker for precise time adjustments + a "Cancel scheduled post" link.
Conflict handling:
- If you try to drop a post at the exact same minute as another scheduled post on the same account, VibeDay blocks the move and shows an error. Move it a minute or two off.
- If you move a post within 30 minutes of another scheduled post on the same account, VibeDay allows the move but warns you with a toast. Useful for back-to-back posts; you can adjust if not intentional.
Past days dim out and reject drops. Cards with status other than SCHEDULED (PROCESSING, COMPLETED, FAILED) aren't draggable — only scheduled-but-not-yet-published posts can be moved.
Cadence resolver + runway prediction (M9)
Campaign-pipeline schedules auto-fill themselves. When you create one with a source campaign, VibeDay walks the next 7 days of your cadence and creates a ScheduledPost for each slot, pulling READY generations from that campaign in order. The resolver runs:
- Immediately when you create or resume a schedule (so the calendar fills in within seconds — no waiting)
- Once daily at 00:15 UTC for every ACTIVE schedule across the system (catches anything new approved overnight, advances the 7-day window each day)
- On demand via the Refresh queue button on the schedule detail page — useful after you approve a batch of drafts and want them slotted in now instead of waiting for the cron
Manual schedules don't auto-fill. You add posts yourself from the calendar's + Add button. The resolver still runs to compute your runway based on the upcoming posts you've added.
Runway prediction
The new Runway panel at the top of every schedule detail page shows:
- Runway in days — how long your current queue will keep publishing before you run out. Color-coded: green = healthy, amber = below your warning threshold, red = empty.
- Upcoming post count — total SCHEDULED posts queued for this schedule
- Slots / week — your cadence rhythm
- Last checked — when the resolver last ran
The runway is the first cadence slot you can't fill. Example: cadence "Mon/Wed/Fri at 9 AM" = 3 slots/week. If you have 6 approved generations queued, runway = 2 weeks.
Rotation policy
- Once-only: each approved generation publishes exactly once and is then retired from the pool. CANCELLED posts free the generation back up.
- Reusable after N days: a generation can be re-scheduled after a cooldown window. Use this for evergreen content you're happy to re-share.
Notifications
When your runway drops below your threshold (default 5 days), VibeDay sends a Schedule queue running low notification (in-app + email by default). When it hits 0, you get Schedule out of content and the status flips to OUT_OF_CONTENT. The moment you approve more content and the resolver runs, status flips back to ACTIVE. The low-warning is rate-limited to once per 20 hours per schedule so the daily cron doesn't spam you.
Publishing runtime (M10)
When a scheduled time arrives, VibeDay publishes for you:
- Each ScheduledPost fires its own deferred Inngest event at the exact scheduledFor time — no big polling cron, no minute-by-minute scan
- Per-target publish: a post targeting Facebook + Instagram makes two independent platform calls; each gets its own
Publicationrow with the result - Per-target outcomes show up on the generation detail page under Schedule: each target row gets a
POSTED/FAILEDbadge, and POSTED rows include a View → link to the live post on Facebook / Instagram / TikTok - Failures don't take down the whole post — partial-success is
COMPLETEDwith the failed targets listed; full-failure flips toFAILED - The publisher distinguishes between transient errors (rate limits — retried automatically up to 3 times), permanent errors (copyright rejection — recorded, no retry), and token errors (flips the account to needs reconnect and sends a notification). Instagram connections are renewed automatically before their 60-day expiry (daily job, 2026-10-16), so a connected Instagram account no longer quietly stops working at day 60.
Cancel + reschedule still work cleanly: cancel flips DB status so the deferred event no-ops when it fires; reschedule sends a fresh event for the new time, and the old event's stale-check skips it.
Publish now is greyed out while the post's video is rendering (2026-10-03, operator decision). On a post whose own video or image is still being made (a Studio Editor export, or anything else still processing), Publish now is disabled, with a note beside it: "You can't post yet: your video is still rendering. It usually takes 1–5 minutes — this button turns on by itself when it's ready." ("image" and "a few seconds" for an image), and a ? hover note explaining that Schedule for later stays open. The page refreshes itself when the render lands, which turns the button on; no reload needed. Writing captions and Schedule for later stay available throughout. Every way of posting this post right away follows the same rule: the publish dialog (its Publish now option is off and any opener lands on Schedule), the preview's "Publish to your platforms" button, a time within the next 2 minutes, a calendar move to right now, and Retry on a failed target (shown greyed with the reason). The server refuses the same cases, so nothing gets around it. If the render failed, Publish now stays off (and scheduling is refused too) with the note "your video didn't finish rendering. Open it in the Editor and Publish or Export again. If it keeps failing, contact support@vibeday.com."
Scheduling a post whose video is still exporting (2026-10-03). A post made in the Studio Editor exists while its export is still rendering. Scheduling it for a later time is fine: if the post comes due before the export is ready, it waits for the export — the top of the post says "Waiting to post: your video is still exporting", the Schedule list keeps it under Upcoming with a "waiting for the video to finish exporting" pill, and the editor's publish track shows Posting · After the render. As soon as the export is ready, each platform's crop is made and the post goes out. The wait is bounded at 45 minutes (longer than the export's own deadline). If the export fails, or still isn't ready by then, nothing is posted, the post is marked failed, and the page says so in plain words ("Not posted: the export didn't finish" + Open it in the Editor and export again / publish again when it's ready) — never "Generate media first". Publishing a post whose export already failed is refused up front with the same next step.
No double posts (2026-10-03). While a publish of a post to an account is under way (due and not yet out, waiting for its export or a platform's version, or posting), publishing the same post to that account again is refused — the dialog shows "Already publishing to TikTok (@you): started 3 minutes ago" and disables Publish; untick that account to post elsewhere meanwhile. A deliberate re-post stays possible: if the post went out to an account in the last 24 hours, Publish now asks you to tick "Post it again" first ("This was posted to TikTok (@you) 12 minutes ago"). Scheduling for a later time needs no confirmation. The server enforces both rules (schedulePostAction → src/lib/scheduling/publish-guard.ts), so a second tab or a stale page can't get round them.
Note on App Review (updated 2026-08-23): these scopes are approved and live — Meta pages_manage_posts / instagram_business_content_publish (verified in the App Review dashboard) and TikTok video.publish (app approved 2026-05-28, Content Posting audit approved 2026-07-31, public posting verified 2026-08-10). The only Meta items still Not approved are pages_manage_engagement (FB comment replies) and Human Agent (>24h DM replies) — see docs/technical/meta-app-review-resubmit-2026-08.md; neither blocks publishing. The paragraph below describes how the runtime behaves when a scope IS missing, which remains accurate for any future un-approved scope. Until they're approved, the platform APIs reject our requests with permission errors — the runtime correctly surfaces those as FAILED publications with the message "App Review pending; posts will start going through automatically once approval lands." The moment the scopes land, everything works without code changes.
Polish (M11)
A few quality-of-life additions that round out the publishing flow:
- Retry failed targets. Every FAILED Publication on the generation detail page now has an inline Retry button. Click it and VibeDay re-fires just that target through the publish runtime — no need to reschedule the whole post or worry about double-publishing the targets that already succeeded. The post's status updates automatically (a partial-failure that becomes fully successful flips back to Completed).
- Multi-platform per-post preview. The generation detail page now shows side-by-side mockups of what the post will look like on Facebook, Instagram, and TikTok — using the platform-specific caption that the publisher will actually send (with hashtags appended). Catches issues like "the Facebook caption is fine but the Instagram one is empty" before you schedule.
- Schedule list filtering. The schedule list now has Active / All / Archived tabs with row counts. Default is Active so you only see what's running today; flip to All for paused + out-of-content schedules, Archived for the historical record.
- Stuck-publish recovery. If the publisher crashes mid-publish or a deploy interrupts it, a 15-minute background reaper finds posts stuck in PROCESSING > 10 minutes and flips them to FAILED with a clear "publish timed out — click Retry" message. So a deploy can never leave a post in limbo.
Not yet live
- Month view + brand/campaign/platform filters on the calendar — follow-up
What's shipped (M4)
Once you've approved a generation (status: READY) and connected at least one platform account (Settings → Platform connections), the generation detail page shows a Schedule card.
Scheduling a single post
- On a READY generation, open the Publish split-button → Schedule for later (or use the "schedule for later" link under the platform preview).
- A modal opens:
- When to publish — a datetime picker (your local time, must be in the future). It starts empty so the time is always a deliberate choice — a pre-filled default previously let a post get scheduled for a time the user never picked. A "Use tomorrow 9:00 AM" shortcut fills a sensible default in one click.
- Publish to — checkbox list of every connected, ACTIVE platform account (Facebook Pages, Instagram accounts, TikTok accounts)
- Pick a time and at least one account. The Schedule button stays disabled until a valid future time is set, and once set it echoes the exact time (e.g. "Schedule for Fri, Aug 7 · 9:00 AM") so the outcome is unmistakable before you commit.
- The scheduled post appears under Upcoming in the generation's Schedule card, and on the Calendar (
/calendar) alongside every other scheduled post. A "View on calendar →" link on the Schedule card jumps straight to its week.
Calendar vs Schedules (two surfaces)
The Publish nav splits scheduling into two distinct surfaces:
- Calendar (
/calendar) — a workspace-wide view of every scheduled post, whether you scheduled it individually (a one-off) or it came from a recurring schedule (auto). Toggle Week (full cards with thumbnails) or Month (a 6-week grid of compact chips — no thumbnails, so a busy month loads instantly). Preset tabs sit above a filter bar — narrow by source, platform, status, brand, campaign, or who scheduled it (all URL-encoded, so a filtered view is shareable). Built-in tabs All · Mine, plus saved views: set up any filter combo and hit “+ Save view” to keep it as a named tab you can re-apply in one click (saved to your account, synced across your devices; delete with the ×). Tag any post into a campaign from its Schedule card so it can be filtered on the calendar (optional — a post can stand on its own). In Week view you can drag a scheduled post to another day to reschedule it (keeps the time; only SCHEDULED posts are draggable, and you can't drop into the past), or click a post's time to reschedule/cancel with a picker. Posts scheduled by someone who has since left the team stay on the calendar, attributed as a former member. Approvers also see an amber "N awaiting approval" chip that jumps to the pending-approval list (unapproved posts aren't on the calendar — a gate blocks them until approved). This is where a one-off post lives (it isn't tied to any recurring Schedule). - Schedules (
/schedule) — the recurring cadence rules that auto-pull approved drafts and publish them on a repeating schedule. A separate concept from the Calendar (which shows the resulting posts).
Cancelling
Each upcoming scheduled post has a Cancel link. Click it → confirm → the row flips to CANCELLED and is moved to the History section. The post will not publish.
Re-scheduling
Cancel and create a new schedule. (Direct reschedule lands in a follow-up.)
Status badges on scheduled posts
- SCHEDULED (sky blue) — queued for the future
- PROCESSING (amber) — currently being published (M6+)
- COMPLETED (green) — published successfully (M6+)
- FAILED (red) — publish failed; the failure reason shows below the row (M6+)
- CANCELLED (grey) — cancelled by user before publish
What's NOT yet active
- The actual publishing doesn't fire yet — M6 wires the Inngest runtime that picks up SCHEDULED rows at their scheduled time and calls the platform API. Until then, rows sit in SCHEDULED status.
- Recurring schedules (cadence — "post 2x daily forever") land in M5.
- Calendar / queue views at
/scheduleland in M8.
Planned (M5 — recurring posting plans)
You'll be able to set up a Posting Plan that auto-publishes from your approved topic queue on a cadence:
- "Post 2x daily at 9am and 5pm, M-F, forever"
- "Post once a week on Sunday at 6pm"
- "Post daily for the next 30 days"
Each plan has its own target accounts (Plan A → Instagram only, Plan B → Facebook + TikTok), brand / campaign filters (only pull from a specific brand or campaign's approved generations), and rotation rules (default: each generation is published only once; you can mark specific ones for reuse).
Queue depth notifications (M7)
VibeDay calculates how many days of approved content your active plans have available. When the runway drops below the threshold (default: 5 days), you'll get:
- An in-app banner on the dashboard
- An email warning you to approve more drafts or add to the topic pipeline
Failure reporting (M7)
When a publish fails, you'll get:
- Email with the platform's actual error message
- A suggested fix where possible (e.g., "TikTok rejected the post for copyright. Disable this topic or edit the content.")
- The failed scheduled post stays visible with the failure reason so you can retry or skip
For the technical shape (DB models, eligibility resolver, action catalog), see docs/technical/data-model.md and docs/technical/server-actions.md.
The scheduler is where you take a READY generation and queue it to publish on a specific date and time to specific platform accounts.
Intended shape
- Schedule a generation: from the generation detail page, pick a date + time + target platform account(s) → creates a
ScheduledPost - Calendar view: week / month grid of upcoming publishes
- Queue view: linear list of upcoming, processing, completed, failed publishes
- Per-target publication tracking: each scheduled post can target multiple platform accounts; we track success / failure per target with retries
Dependencies (must land first)
- OAuth flows (Sprint 4) — Meta + TikTok OAuth callbacks at
/api/oauth/[platform]/callback/. Without connected platform accounts, there's nothing to publish to. - Platform Account model — encrypted token storage already in the schema (
PlatformAccount), but the connect flow isn't built yet. - Inngest publish job — background job that pulls due
ScheduledPostrows, decrypts tokens, calls the platform API, writesPublicationrows withexternalPostId.
Eligibility for scheduling
Only generations with status: READY will be scheduling-eligible. DRAFT generations must be approved first (already in place — see generations.md).
A future eligibility rule (Sprint 5+) will block re-publishing a topic that's already been posted from a previous generation.
This placeholder will be replaced with full documentation when the scheduler ships.
YouTube daily capacity (pre-audit)
Until Google's API audit raises VibeDay's quota, YouTube accepts about 6 VibeDay publishes per day platform-wide. A post that hits the ceiling is not lost and nothing is wrong with it — its YouTube target shows "YouTube's daily publishing capacity is used up for today… will retry automatically tomorrow", and VibeDay re-publishes it automatically shortly after midnight Pacific when YouTube resets. The same applies when a channel hits YouTube's own per-channel daily upload cap. Other platforms in the same post are unaffected and publish normally.
Email support@vibeday.com with what you're trying to do and a screenshot if it helps — we'll write back within one business day.
- BrandsSet up brand voice, tone, forbidden words, and visual identity. Every AI generation reads this so output sounds like you.
- CampaignsGroup related topics into a campaign with shared context — useful for product launches, themed series, or seasonal pushes.
- TopicsSeed the content pipeline with topic ideas. Add 10 by hand or 50 via AI Topic Designer; each becomes a generation when you hit Generate.
- GenerationsFrom draft to approval: review captions, regenerate the image, iterate on locked fields, then push READY so the scheduler can pick it up.
- Platform preview & publishingSee how your post looks on each connected platform, tailor captions/hashtags/CTA and the crop per platform, then publish or schedule everywhere in one click. Save recipes as templates.
- NotificationsInbox for failed publishes, reauth prompts, content alerts. Configure per-type email delivery from settings.
- Using VibeDay from Claude or ChatGPTConnect Claude, ChatGPT or Claude Code, choose what it may do, confirm every spend and post with a yes, work with team approvals, and disconnect.