| name | tune-posting-schedule |
| description | Use when posting times are bunching, when /audit-buffer-queue surfaces structural bunches that re-appear after rescheduling individual posts, or when /buffer-stats finds a per-hour engagement pattern that disagrees with current Buffer slots. Analyzes each channel's postingSchedule against (a) gap-spacing rules, (b) recent sent-post engagement-by-hour, and (c) audience timezone, then proposes + applies a new schedule via the gstack web-UI driver in _shared/buffer-schedule-edit (Buffer's public API has no schedule mutation). Triggers — "tune my posting schedule", "fix my buffer slots", "analyze posting times", "my queue keeps bunching", "change posting schedule". |
| user_invocable | true |
tune-posting-schedule
Analyze each Buffer channel's postingSchedule (the time slots Buffer uses to drop queued posts into) and propose + apply a better schedule. Pairs with /audit-buffer-queue (queue hygiene) — this skill fixes the structural cause when bunches keep re-appearing after rescheduling individual posts.
When to Use
Use when:
/audit-buffer-queue flags bunching (gap < 3h) that re-appears after rescheduling — the slots themselves are too tight
/buffer-stats Phase 5 surfaces an engagement-by-hour pattern (e.g. "Threads posts at 9-10am get 3x impressions of 12-1pm posts") that disagrees with current slot allocation
- The user wants to spread out a clustered schedule (e.g. all 3 daily slots in a 3-hour morning window)
- The user wants to drop slot count to better match posting cadence (e.g. 3 slots/day → 2 slots/day on a low-velocity channel)
- After a follower-count milestone where the user wants to test a different posting cadence
Do NOT use for:
- One-off rescheduling of a single post → use
mcp__buffer__update_post directly (or /audit-buffer-queue Phase 8)
- Pausing the queue → use Buffer's web UI (
isQueuePaused toggle)
- Cancelling posts → use
/audit-buffer-queue Phase 8
🟢 Happy Path (read first; everything below is edge-case detail)
For a tune-posting-schedule run when nothing goes wrong. ~5-15 min wall-clock depending on channel count.
Phase 1 — Resolve channels (30 sec). mcp__buffer__get_account → org ID. mcp__buffer__list_channels → filter !isDisconnected && !isLocked && service != 'startPage'. Confirm the channel list with the user before proceeding.
Phase 2 — Pull current schedules (1 min). For each target channel: mcp__buffer__get_channel(channelId) → capture postingSchedule, timezone, postingGoal, isQueuePaused. All slot times are channel-local, not UTC.
Phase 3 — Find structural bunches (in-prompt). For each day's times, compute consecutive gaps. Flag any gap < min_gap_hours (default 3h). These are bunches built into the slots — rescheduling individual posts won't fix them.
Phase 4 — (Optional) Engagement-by-hour. If the user wants engagement-driven recommendations AND /buffer-stats cached data exists on disk: mcp__buffer__list_posts(status: ["sent"], dueAt: { start: 90_days_ago }, channelIds: [...]), convert sentAt → channel-local hour, bucket. If no cached engagement, skip and use gap-only.
Phase 5 — Propose new schedules (in-prompt). Per channel, build a postingSchedule that: keeps all gaps ≥ min_gap_hours, preserves original slot count/day (unless user explicitly opted to change it), stays within 06:00–22:00 local. Annotate every change with a why (cite the bunch, engagement signal, or user request).
Phase 6 — Adversarial review (REQUIRED). Apply the Adversarial Review pattern with the per-skill specifics in the section below. Must all_pass before showing the user.
Phase 7 — User review. Present a per-channel diff (original times → new times, with the why). User can approve all, approve subset, tweak times, or cancel.
Phase 8 — Apply per channel, then verify. Auth check once: $B goto publish.buffer.com/all-channels, run _shared/gstack_auth.sh buffer.com if redirected to login. Then for each approved channel: _shared/buffer-schedule-edit/buffer-schedule-edit.sh <channelId> <schedule.json> [<goal>] → re-fetch via mcp__buffer__get_channel → diff returned schedule against proposed JSON. Apply + verify each channel before moving to the next. If slot count changed, the goal MUST be updated in the same call.
Phase 9 — Report. Render the summary table: channel | original slots/wk | new slots/wk | bunches fixed | status (applied / user declined / failed).
Process
Phase 1 — Resolve target channels
If the user names channels, use those. If not, default to all non-locked, non-disconnected, non-startPage channels in the org:
mcp__buffer__get_account → org ID
mcp__buffer__list_channels → filter where !isDisconnected && !isLocked && service != 'startPage'
Confirm the channel list with the user before proceeding (channel changes are visible to followers — checking first is cheap).
Phase 2 — Pull current schedule per channel
For each target channel:
mcp__buffer__get_channel(channelId) → postingSchedule, timezone, postingGoal, isQueuePaused
The postingSchedule is an array of {day: "mon"|"tue"|..., times: ["HH:MM", ...], paused: bool} in the channel's own timezone. All times are in the channel's local timezone, not UTC.
Phase 3 — Analyze structural bunches in current schedule
For each day's times, compute consecutive gaps. Flag any gap < min_gap_hours (default 3h).
for day in schedule:
sorted_times = sorted(day['times'])
for i in range(1, len(sorted_times)):
gap_min = (parse(sorted_times[i]) - parse(sorted_times[i-1])).total_seconds() / 60
if gap_min < min_gap_hours * 60:
structural_bunches.append((channel, day, sorted_times[i-1], sorted_times[i], gap_min))
These are bunches built into the slots — rescheduling individual posts won't fix them; the schedule itself needs editing.
Phase 4 — (Optional) Engagement-by-hour analysis
If the user wants engagement-driven recommendations (vs just gap-fixing), pull recent sent posts and bucket by local-hour:
mcp__buffer__list_posts(status: ["sent"], dueAt: { start: 90_days_ago }, channelIds: [...])
For each sent post, convert sentAt → channel's local hour. Group by hour. Compare per-hour densities + (where available) engagement against current slot allocation.
Limits:
- Buffer's
list_posts doesn't return per-post engagement directly — for that, the user must have run /buffer-stats recently and have the cached engagement data on disk. Check for it; if absent, skip engagement analysis and use gap-only.
- 90-day window is a default; widen for low-velocity channels (< 1 post/day), narrow for high-velocity ones.
Cognition (skill judgment, not transport):
- Which hours qualify as "audience-active" vs "engagement-dead" → judgment call (Bitter Lesson — smarter model decides better; stays in this prompt, not in a Go helper)
- How aggressively to consolidate slots (drop count, redistribute) → judgment call
- Whether a low-engagement hour is a true low or just under-sampled → judgment call
Phase 5 — Propose new schedule per channel
Build a proposed postingSchedule per channel that:
- Has all gaps ≥
min_gap_hours (default 3h)
- Preserves the original slot count per day unless the user opted to change it
- Stays within the channel's
audience-active window (default: 06:00–22:00 local; configurable)
- (If engagement data available) overweights audience-active hours, drops engagement-dead hours
Default redistribution patterns (3 slots/day):
- Spread (recommended): morning + midday + evening, e.g.
09:30 / 13:30 / 18:30
- AM-PM-evening cluster:
09:00 / 14:00 / 19:00
- Evening-heavy (LinkedIn personal pattern observed):
08:00 / 17:00 / 20:30
Default for 2 slots/day: 09:30 / 18:30 (morning + evening).
Annotate each change with the why:
- "moved 12:24 → 18:30 because Mon/Tue/Wed/Thu/Fri all bunched 3 slots inside 09:30-12:30 window"
- "dropped Wed 09:40 because gap with 08:56 was 44 min"
Phase 6 — Adversarial review (REQUIRED before user review)
Apply the Adversarial Review pattern with these per-skill specifics:
- SOURCE_LABEL: "CURRENT POSTING SCHEDULES + USER'S STATED GOALS"
- SOURCE_CONTENT: for each channel — current
postingSchedule, channel timezone, postingGoal, plus whatever the user explicitly asked for (e.g. "spread out morning bunches", "drop to 2 slots/day on Facebook")
- SKILL_NAME:
tune-posting-schedule
- ARTIFACT_NAME: "schedule"
- RULES_LIST:
- Every gap between consecutive same-day slots MUST be ≥
min_gap_hours (default 3 hours).
- Slot count per day MUST equal the original count UNLESS the user explicitly asked to change it.
- All slot times MUST stay within the channel's audience-active window (default 06:00–22:00 local) UNLESS the user explicitly asked otherwise.
- REQUIRED: every change has a stated why that points to a structural bunch, an engagement signal, or an explicit user request.
- BANNED: silently dropping or adding slots without a cited rationale.
- BANNED: moving a slot into a hour the channel has zero historical sent posts in (probable engagement-dead) UNLESS the user asked to test that hour.
- ISSUE_GUIDANCE: "For gap violations, cite the day + the two times + the computed gap. For slot-count drift, cite original vs proposed. For unjustified changes, quote the change and note that no rationale was given."
Phase 7 — User review
Present a per-channel diff:
Channel: Threads (mikelady) [America/Los_Angeles, 3 slots/day, goal 21/wk]
Mon 10:00 11:28 12:24 → 09:30 13:30 18:30
(3 slots in 2.4hr) (spread morning/afternoon/evening)
Tue 10:12 11:14 12:42 → 09:30 13:30 18:30
(3 slots in 2.5hr) (spread)
...
Why: every weekday had 3 slots inside a 2-3 hr morning window.
Bunches in last queue: 88 min, 56 min on Mon-Fri.
Ask: "Apply these N schedule changes? Or pick which channels?"
User can:
- Approve all
- Approve subset (per-channel)
- Tweak proposed times
- Cancel
Phase 8 — Apply via gstack web-UI automation
Buffer's public GraphQL API does NOT expose a mutation for editing postingSchedule. As of 2026-04-27, the public schema (mcp__buffer__introspect_schema) only exposes deletePost, createPost, editPost, createIdea. Buffer's v1 REST API at api.bufferapp.com rejects all OIDC tokens (the only token type new accounts can issue from publish.buffer.com/settings/api). The schedule editor lives only in the web UI at publish.buffer.com/channels/:id/settings.
The skill drives that web UI via gstack browse, using known data-testids on the Posting Slots form. Apply path:
1. Auth check (once per session):
B=~/.claude/skills/gstack/browse/dist/browse
"$B" goto https://publish.buffer.com/all-channels >/dev/null 2>&1; sleep 4
"$B" url | grep -qE 'login|sign-in' && bash _shared/gstack_auth.sh buffer.com https://publish.buffer.com/all-channels
2. Per-channel apply (use the shared helper):
_shared/buffer-schedule-edit/buffer-schedule-edit.sh <channelId> <schedule.json>
schedule.json shape: {"mon": ["09:30","13:30","18:30"], "tue": [...], ...} — times in 24h, channel-local.
The helper:
- Navigates to
/channels/<channelId>/settings
- Clicks "Clear All" → confirms
- Loops the proposed schedule, for each
(day, time24h):
- Picks the day (
postingtime-form-days-selector)
- Picks the hour (
postingtime-form-hours-selector, 12h format)
- Picks the minute (
postingtime-form-minutes-selector)
- Picks AM/PM (
postingtime-form-am-pm-selector)
- Clicks submit (
postingtime-form-submit-button)
- All clicks use radix-friendly PointerEvent + MouseEvent dispatch (regular
.click() doesn't open radix menus)
3. Verify each channel (per skill rules — required after each apply):
mcp__buffer__get_channel(channelId) → postingSchedule
Compare the returned schedule to the proposed JSON. If any day's times array doesn't match, surface the diff to the user and stop.
Apply per-channel, not in one batch. Run apply then verify for each channel before moving to the next. If one fails, the user can intervene without losing the others.
Posting goal (handled in same helper): the postingGoal on Channel (e.g. "21 posts/wk") is editable on the same Settings page (numeric input under the "Posting Goal" heading) using the React form input setter pattern; auto-saves on blur. The schedule helper supports both modes:
_shared/buffer-schedule-edit/buffer-schedule-edit.sh <channelId> <schedule.json> <goal>
_shared/buffer-schedule-edit/buffer-schedule-edit.sh <channelId> --goal-only <goal>
Whenever a slot-count change is applied, the goal MUST be updated to match — otherwise Buffer's status will mis-report (a 14-slot week with goal=21 reads "AtRisk" even though it's at intended cadence).
If a future Buffer schema exposes the mutation: introspect first; prefer the mutation over the web-UI automation (faster, no browser auth needed).
Phase 9 — Report
Render a summary:
# Posting schedule update (YYYY-MM-DD)
| Channel | Original slots/wk | New slots/wk | Bunches fixed | Status |
|---|---:|---:|---:|---|
| Threads (mikelady) | 21 | 21 | 7 (Mon-Fri morning bunches) | ✅ applied |
| Threads (EVC) | 21 | 21 | 8 | ✅ applied |
| LinkedIn EVC page | 21 | 21 | 3 (Mon/Sun 21:xx, Thu 13/14:00) | ⚠️ user declined |
Closed-loop integration
This skill is the structural half of queue-hygiene; /audit-buffer-queue is the per-post half:
/audit-buffer-queue → cancels/reschedules individual bunched posts (one-time fix)
/tune-posting-schedule → fixes the slots themselves (permanent fix)
Run /tune-posting-schedule after /audit-buffer-queue flags bunches that recur week-over-week. If /buffer-stats Phase 5 starts including per-hour engagement (future work), this skill should consume that data automatically in Phase 4.
Defaults baked in
min_gap_hours = 3 — minimum spacing between consecutive same-day slots. Conservative for LinkedIn/Facebook, generous for Threads (which tolerates higher cadence). User can override per-channel.
audience_active_window = 06:00–22:00 channel-local. Slot moves outside this window require explicit user request.
lookback_window_days = 90 for engagement-by-hour analysis. Widen for low-velocity channels.
- Don't change slot counts unless the user explicitly asks. Slot count = posting cadence = a content-strategy decision, not a hygiene decision.
Common Mistakes
- Auto-applying mutations without user review. Schedule changes are visible to followers (posts shift to new times) — always show the diff and get approval.
- Mixing slot-count changes with spread fixes in one proposal. Keep them separate so the user can approve hygiene fixes (spread) without committing to a strategy change (cadence drop).
- Skipping introspect. Buffer renames GraphQL mutations periodically — guessing the mutation name leads to silent failures or wrong-input errors. Always introspect first.
- Confusing channel-local time with UTC.
postingSchedule.times are in the channel's timezone, not UTC. Convert correctly when comparing against sentAt (which is UTC).
- Treating low-engagement hours as engagement-dead without checking sample size. A 9pm slot with 3 posts in 90 days isn't a "dead hour" — it's an undersampled hour. Flag to user, don't silently drop.
Rationale: cognition vs transport
Per PRIMITIVE-TEST.md:
- Atomicity: schedule edits are single-channel single-mutation/clearAll-then-add. No race when only one editor (this skill) runs. ✅ no atomic helper needed.
- Bitter Lesson: "which hours are audience-active" / "which slot count is right per channel" is exactly the kind of judgment a smarter model does better. ✅ stays in prompt (this file).
- ZFC: the analysis IS judgment (
if morning bunched then redistribute). ✅ stays in prompt.
The deterministic transport pieces:
- Channel discovery + verification:
mcp__buffer__list_channels, get_channel (Buffer MCP)
- Schedule write:
_shared/buffer-schedule-edit/buffer-schedule-edit.sh (gstack web-UI driver — no API mutation exists)
The schedule-edit script is transport (deterministic clicks against known testids) — no judgment lives there. If the Buffer UI changes its testids, fix in the script, not the skill prompt.