Skip to main content

Social post scheduling

Timing a social promo used to mean opening the Postiz UI. This is the local queue that replaces it: a draft carries a fire time, and Poindexter posts it when that time arrives. Two ways in, both optional:
  • Manualpoindexter social schedule <draft> "tomorrow 9am".
  • Auto-drip — per-platform prime-time hours (twitter=09:00,12:30) and/or an offset from the post going live, so a post published at 11pm still promotes at 9am and the same link doesn’t hit five platforms in the same minute. Off by default.

Why the queue is ours, not Postiz’s

Postiz’s API accepts {"type": "schedule", "date": …}, so we could hand it a future-dated post and let it hold the queue. We deliberately don’t. SocialDraftsService.approve_draft gates on the promoted post actually being live (posts.status = 'published') and repairs the URL in the copy against the live posts row before sending. A Postiz-side schedule runs that check when the operator picks the time — potentially hours or days early. If the post’s publish slipped, or an operator pulled it in the meantime, Postiz would still fire the promo, at a URL that 404s. Firing locally re-runs the gate at the moment of posting. The secondary reason is the one that prompted the work: a queue held in Postiz has to be cancelled and edited in Postiz. Holding it here keeps schedule / unschedule / queue in the CLI, the API, MCP, and the console. The tradeoff we accept: if the worker is down when a slot comes due, the promo is late rather than early. See Lateness below.

Data model

social_post_drafts.scheduled_at TIMESTAMPTZ plus a scheduled status. scheduled holds its (pipeline_task_id, platform, subreddit) key. This is the one invariant to not break. create_draft’s NOT EXISTS guard, existing_draft_keys, and the ux_social_post_drafts_active_key partial unique index all key off _KEY_HELD_STATUSES. A finalize re-run (preview_gate regen loop, checkpoint restore, task retry) that saw a scheduled key as free would insert a second draft, and both would post — poindexter#833 with extra steps. The migration widens the index predicate for exactly this reason; the service binds the shared constant rather than inlining a status list, and test_social_drafts_retention_policy.py reads that constant so a newly-added status is covered the moment it’s added. Corollary: cancel_orphaned_for_rejected_tasks reaps scheduled drafts too, and clears scheduled_at. A scheduled orphan is worse than a pending one — it has a timer on it.

Time handling

Storage is UTC; operator-typed clock words are local (app_settings.operator_timezone). scheduling_service.parse_when grew a tz argument for this — it defaults to UTC, so the blog-post callers that predate it are unchanged. The subtle part is the calendar day: at 21:00 in America/New_York it is already tomorrow in UTC, so a UTC-anchored “tomorrow 9am” lands a day early. Relative specs resolve against the local date. A naive ISO string (2026-08-09 09:00) is read as local too — someone writing it with no offset means their own 9am. An explicit offset always wins.

The sweep

ScheduleSocialDraftsJob (every 1 minute — the interval is the queue’s resolution) runs two passes, auto-slot first so a publish+0m draft fires on the same sweep:
  1. auto_schedule_ready_drafts — pending drafts whose post is published get a slot (see Picking the slot below).
  2. fire_due_drafts — due drafts go through approve_draft.
Only pass 1 is gated on social_schedule_enabled. A hand-scheduled draft fires with auto-drip off, or manual scheduling would silently do nothing on a default install.

Outcomes

fire_due_drafts reports four buckets, and the two that aren’t obvious matter most:
  • blocked — the publish gate refused (post not live yet). Not a failure: the draft keeps its slot and retries next sweep, without burning RetryFailedSocialDraftsJob retries.
  • overdue — past the lateness grace period. Not posted. The draft stays queued and raises a social_draft_overdue finding (deduped per draft).

Lateness

If the worker was down through a slot, firing whenever it happens to come back silently converts a timed promo into an untimed one, hours off the slot that was chosen for a reason. Past social_schedule_max_lateness_minutes (default 180) the sweep leaves it alone and tells the operator, who can reschedule, post it anyway, or drop it. Raise the setting if you’d rather it always go.

Backfill anchoring

Auto-slotting a post published longer ago than its offsets would put every slot in the past and fire the whole drip in one burst. When the computed slot is already due, the anchor moves to now, preserving the platform stagger.

Picking the slot

Two ways to say when a promo fires. A platform opts in through either. Prime times (social_schedule_prime_times) name the hours a channel is worth posting to: twitter=09:00,12:30,17:00. The slot is the next listed time at or after the floor. This is what a night publish needs — a post going live at 11pm promotes at 09:00, not at midnight. Offsets (social_schedule_offsets) are a delay from publish: linkedin=3h. Alone, the offset is the slot. Combined with prime times it becomes the floor the scan starts from — “at least 3h after publish, then the next good hour.”

Prime times beat quiet hours

For any platform declaring prime times, the quiet window is not applied. A quiet window only says where not to post, so it clamps every displaced promo onto the window’s edge. Measured, with 22:00-07:00 and an 11pm publish: Three of four platforms collapsed onto the same minute — the exact “same link everywhere at once” burst the stagger exists to prevent. Naming the good hours is strictly better than naming the bad ones. Note the side effect: prime times override relative ordering. LinkedIn above fires at 08:00, before twitter’s 09:00, despite the longer offset. Each channel independently hits its own best hour, so offsets stop controlling sequencing once prime times exist.

Collisions

Slots are de-duplicated per platform, against both drafts queued in earlier sweeps and ones assigned in the current pass. Three posts published overnight spread across 09:00 / 12:30 / 17:00 rather than firing together. When a day’s listed hours are all taken, the scan rolls to the next day (bounded by _PRIME_TIME_SCAN_DAYS, 14). Eligible drafts are ordered by published_at, so the assignment is stable — the oldest post keeps the earliest slot across re-runs.

Settings

Both auto-drip gates default closed: the switch AND a per-platform entry in social_schedule_prime_times or social_schedule_offsets. A platform named in neither is never auto-slotted, so flipping the switch alone changes nothing — enabling auto-drip is a deliberate two-step. One malformed pair in social_schedule_offsets or social_schedule_prime_times costs that platform its drip and is logged; the rest still parse. A platform whose prime times all fail to parse falls back to its offset rather than silently posting at midnight. A malformed social_schedule_quiet_hours pauses auto-scheduling entirely instead — degrading to “no quiet hours” would post inside exactly the window the operator carved out.
Auto-drip skips per-draft review. Social copy is LLM-written, and with auto-drip on it ships on the strength of the post’s approval rather than its own. That’s the point of it, and the reason it’s off by default. If you want to read every promo first, leave it off and schedule by hand — the manual path is fully supported on its own.

Surfaces

  • CLIsocial schedule / unschedule / queue
  • RESTPOST /api/social/drafts/{id}/schedule {when, force}, POST /api/social/drafts/{id}/unschedule; scheduled_at on every draft
  • MCPschedule_social_draft, unschedule_social_draft
  • Console — Social panel: a queued KPI, a Scheduled column and filter chip, and per-row schedule / reschedule / unschedule / post-now actions
A time in the past is refused unless forced — it’s nearly always a typo’d year or a missed am/pm, and posting immediately is the wrong recovery from a typo.

See also

  • services/social_drafts.py — the service every surface delegates to
  • services/jobs/schedule_social_drafts.py — the sweep
  • social_poster.md — how the copy is generated
  • services/jobs/sync_postiz_delivery_state.py — the delivery-state loop closer. A draft goes posted when Postiz ACCEPTS the enqueue, but the platform publish runs async and can still fail (X 402 credits-depleted, 2026-08-26: five tweets never existed while our rows read posted). Every 15 min this job re-reads Postiz state for recent posted drafts: ERROR → demote to failed + a social_post_delivery_failed finding (Discord); PUBLISHED → stamp the platform permalink into platform_config.release_url and stop re-checking.
  • brain/postiz_queue_watch.py — detects Postiz’s own Temporal queue wedging after we hand a post off. QUEUE-stuck only: a restart heals a wedged queue, but a terminal ERROR (platform rejection) survives any restart — those belong to the sync job above. The pre-split probe counted ERROR too and spent 2026-08-21→26 in a permanent escalate loop against five 402s.