| name | audit-buffer-queue |
| description | Use when user wants a health check on the Buffer queue — bunching, dead channels, theme over-saturation, untagged posts that break closed-loop measurement. Triggers — "audit my buffer queue", "buffer queue health", "is my buffer queue too crowded", "check my queued posts for problems". |
| user_invocable | true |
audit-buffer-queue
Inspect the Buffer queue for health issues that aren't caught by the per-skill scheduling logic. The promote-* skills each see only their own batch; this skill sees the whole queue.
When to Use
Use when:
- User asks "audit my buffer queue", "check my queued posts", "is my queue too crowded"
- After a heavy promotion run (e.g.
/promote-newsletter + /tease-newsletter + /carousel-newsletter + /crosspost-newsletter on the same article = ~22 surfaces in one session). Worth a queue-health check.
- Weekly, as part of the closed-loop review cycle (companion to
/buffer-stats and /flywheel).
- When
/buffer-stats or /flywheel flags untagged_posts > 0 or bunched_posts > 0.
Do NOT use for:
- Engagement analysis → use
/buffer-stats
- Per-platform follower deltas → use
/linkedin-stats
- Cross-platform weekly rollup → use
/flywheel
🟢 Happy Path (read first; everything below is edge-case detail)
For a routine queue health check when nothing is on fire. ~3-5 min wall-clock. Advisory by default — surface findings, ask for batch approval before mutating.
Phase 1 — Pull the queue (30 sec). mcp__buffer__get_account → org ID. mcp__buffer__list_posts with status: ["scheduled", "needs_approval", "draft"], first: 100, sort: dueAt asc. If the response auto-saves to a file (over size limit), jq out just {id, dueAt, channelId, channelService, text, tags}.
Phase 2 — Bunching check (30 sec). Group posts by channelId, sort by dueAt, flag consecutive pairs with gap < 3 hours.
Phase 3 — Theme over-saturation (45 sec). Pick 4-8 word distinctive phrases from recent article titles (skip common words like "shipped", "AI", "skill"). Pipe the list_posts JSON through _shared/buffer-queue-check/buffer-queue-check --keywords "<phrase1>,<phrase2>,...". Flag any channel with 3+ matches on the same theme within 5 days.
Phase 4 — Untagged posts + auto-backfill (1-2 min). Find posts missing any of the 5 expected format:<name> tags (verbatim-quote, teaser, carousel, link-share, batch-summary). Classify each by text content (quote+CTA → verbatim-quote, GitHub URL → link-share, etc.). For SCHEDULED untagged posts: fetch full post via mcp__buffer__get_post, then call editPost GraphQL mutation with full payload (text + assets + metadata + tagIds — NOT just tagIds). For SENT untagged posts: surface as "manual backfill needed" (Buffer API blocks editPost on sent posts).
Phase 5 — Dead channels — silent (20 sec). Cross-reference list_channels against list_posts(status: ["sent"], last 14d) and list_posts(status: ["scheduled"]). Channels with 0 of both = dead (nothing posted, nothing queued).
Phase 5b — Deny-listed dead channels — actively-posted-to (20 sec). DIFFERENT from Phase 5: these channels ARE being posted to (often have queued posts) but are dead by engagement and were deny-listed in _shared/buffer-post-prep/dead-channels.json (read dead-channels.local.json first if it exists; it shadows the committed file). Flag any queued post whose channelId matches a deny-list row (or whose service+handle match) — those posts slipped in before the deny-list, or via a path that bypassed buffer-post-prep. Recommend cancelling them. See the deny-list mechanism in _shared/buffer-post-prep/README.md.
Phase 6 — Below-threshold channels (20 sec). For each channel with queued posts, mcp__buffer__get_channel → follower count. Flag any below min_followers_to_promote (default 50) with queued posts.
Phase 7 — Render report. Markdown tables, one section per finding type: 🔴 Bunched, 🟡 Theme over-saturation, ⚪ Untagged, 🔴 Dead channels (silent), 🔴 Deny-listed dead channels (queued), 🔴 Below-threshold. Include channel + recommendation columns.
Phase 8 — Batch approval. Present all recommendations and ask "apply all N? Or pick which ones?" before any delete_post / update_post calls. Default to surfacing, not acting.
Process
Phase 1 — Pull the full queue
mcp__buffer__get_account → org ID
mcp__buffer__list_posts (status: ["scheduled", "needs_approval", "draft"], first: 100, sort: dueAt asc)
If the result exceeds the tool-result size limit, the response is auto-saved to a file. Use jq to extract {id, dueAt, channelId, channelService, text, tags} for each post — you don't need the full payload.
Phase 2 — Per-channel bunching check
For each channel, compute the time gap between consecutive posts. Default minimum gap: 3 hours. Anything shorter is "bunched."
posts_by_channel = group_by(posts, key=lambda p: p['channelId'])
bunched = []
for channel_id, channel_posts in posts_by_channel.items():
sorted_posts = sorted(channel_posts, key=lambda p: p['dueAt'])
for i in range(1, len(sorted_posts)):
gap = sorted_posts[i]['dueAt'] - sorted_posts[i-1]['dueAt']
if gap < timedelta(hours=3):
bunched.append({
'channel': channel_id,
'gap_minutes': gap.total_seconds() / 60,
'post_a': sorted_posts[i-1],
'post_b': sorted_posts[i],
})
Surface bunched pairs with a recommendation: cancel one, OR reschedule one to a different time slot.
Phase 3 — Theme over-saturation check
Posts about the same article/topic going out too close together = audience fatigue.
Transport (_shared/buffer-queue-check): the deterministic substring matching + per-keyword grouping is delegated to the Buffer queue check pattern. Pipe the mcp__buffer__list_posts JSON into the helper with the candidate distinctive phrases:
echo "$LIST_POSTS_JSON" | _shared/buffer-queue-check/buffer-queue-check \
--keywords "Tokens From Our Past,Mac Pro,paperweight,Trash Can"
Cognition (skill): the DECISIONS of which phrases count as "distinctive" + how many matches per channel within what time window count as "over-saturation" stay here.
Defaults:
- Distinctive phrase = 4-8 word substring that's unique to one article/topic (skip common words like "shipped", "skill", "AI")
- 3+ matches on the same channel within 5 days = theme over-saturation flag
- 5+ matches on the same channel for the same article title = aggressive over-saturation; recommend cancelling 2+
The helper output makes this deterministic; the thresholds above are tunable judgment.
Phase 4 — Untagged post check + auto-backfill
Every post created by the promote-* skills should have a format:<name> tag (format:verbatim-quote, format:teaser, format:carousel, format:link-share, format:batch-summary — format:long-form-pulse is future-reserved and not produced by any current skill, so don't flag its absence). Posts without one of those tags break the closed-loop measurement system — buffer-stats's Phase 5 format-performance analyzer can't attribute them.
Filter by via first — only via:buffer posts are expected to carry a format tag. Buffer surfaces two kinds of post: via:buffer (composed through the promote-*/carousel skills, which set the format tag at compose time) and via:network (organic posts authored natively on the platform and auto-synced into Buffer — these never pass through buffer-post-prep, so they have no format: tag by design). Flagging via:network posts as an attribution gap is a false positive: they were never meant to be tagged. Confirmed 2026-05-31 — of 100 sent posts in the window, all 23 via:buffer posts were format-tagged (100%) and all 77 untagged posts were via:network organic cross-posts. The closed loop was healthy; a naïve "77 untagged" count read as broken. Scope the untagged check to via:buffer only; report via:network volume separately as informational (organic cross-post activity), not as a gap.
EXPECTED = {'format:verbatim-quote', 'format:teaser', 'format:carousel', 'format:link-share', 'format:batch-summary'}
composed = [p for p in posts if p.get('via') == 'buffer']
untagged = [p for p in composed if not any(t['name'] in EXPECTED for t in p['tags'])]
organic_count = sum(1 for p in posts if p.get('via') == 'network')
For each untagged post, classify by text content:
- starts with
"<quote>" and ends with Comment "newsletter"… CTA → format:verbatim-quote
- original copy +
Comment "newsletter"… CTA → format:teaser
- 10-image multi-image post →
format:carousel
- contains
github.com/ URL → format:link-share (or format:batch-summary if the post unifies multiple contributions with a theme sentence)
Auto-backfill applies to SCHEDULED posts only (since 2026-04-27, Stage A complete). The 5 format tags exist on the org and their IDs are in _shared/buffer-post-prep/tag-ids.local.json. For each scheduled untagged post, you CAN auto-apply the right tag via the Buffer GraphQL editPost mutation — caveats:
editPost is full-replace, NOT partial-update. If you only pass id + tagIds, the post's text + assets + metadata get wiped. Always fetch the full post first via mcp__buffer__get_post and pass text + assets + metadata + tagIds together. See /tmp/queue_audit/backfill_tags.sh for a working reference implementation (the curl-based script used for the original ~53-post backfill — those were all in the queue).
- Buffer enforces a 15-min sliding rate-limit window on the OIDC token (~50-100 calls per 15 min before HTTP 429). For a backfill of more than ~25 posts, expect to hit it; build a poll-retry loop with 180s sleep between attempts (also at
/tmp/queue_audit/poll_retry.sh).
Sent posts: no API backfill path. Confirmed 2026-05-03: editPost returns "Account is not allowed to perform this action on post" for any post with status: "sent", regardless of tagIds payload. Reason: sent posts' allowedActions lists updatePostTags (UI-side affordance) but NOT updatePost (which is what editPost mutation requires). The updatePostTags capability has no public GraphQL or REST equivalent — only Buffer's web UI tag picker can do it.
For sent-post tag backfill, you have two choices:
- Manual (recommended for ≤20 posts): user opens Buffer's posts dashboard, clicks each post, picks the right
format: tag from the dropdown. ~30 sec per post.
- Web-UI driver (justified for ≥50 posts): build
_shared/buffer-tag-edit/ analogous to the _shared/buffer-schedule-edit/ driver — a gstack-driven script that opens each post in the Buffer UI and clicks the tag picker. Significant skill-build investment; only worth it at scale.
For sent-post gaps below the web-driver threshold: surface the list to the user as "manual backfill needed" rather than attempting editPost (which silently fails for the user — better to be explicit).
Phase 5 — Dead-channel check
A channel is "dead" if it's connected, not paused, but has had 0 sent posts in the last 14 days AND 0 scheduled posts in the queue.
mcp__buffer__list_channels → all connected channels
mcp__buffer__list_posts (status: ["sent"], dueAt: { start: 14_days_ago })
mcp__buffer__list_posts (status: ["scheduled"])
Flag dead channels — recommend either deleting them from Buffer or reactivating with a posting goal.
Phase 5b — Deny-listed dead-channel check (dead by engagement)
This is a different notion of "dead" than Phase 5. Phase 5 finds channels with nothing posted and nothing queued (silent channels). Phase 5b finds channels that are actively being posted to but are dead by engagement — they HAVE followers (so they pass the min_followers_to_promote = 50 guard) yet produce ~zero reactions. These are recorded in the buffer-post-prep deny-list, which makes compose skills SKIP them at fan-out time.
Source of truth — the deny-list file (gitignored override shadows the committed seed):
import json, os
base = "_shared/buffer-post-prep"
path = f"{base}/dead-channels.local.json"
if not os.path.exists(path):
path = f"{base}/dead-channels.json"
deny = json.load(open(path)).get("dead_channels", []) if os.path.exists(path) else []
Each deny-list row has channel_id, service, handle, reason. A queued post is flagged if its channelId equals a row's channel_id, or its channelService + handle match a row's service + handle (case-insensitive, ignore a leading @).
def is_deny_listed(post, deny):
for row in deny:
if row.get("channel_id") and post["channelId"].lower() == row["channel_id"].lower():
return row
return None
flagged = [(p, is_deny_listed(p, deny)) for p in posts]
flagged = [(p, r) for p, r in flagged if r]
For each flagged queued post, recommend cancel (mcp__buffer__delete_post) — fan-out to this channel is wasted spend. If you find many such posts, it means a compose run bypassed buffer-post-prep (the deny-list lives in the transport layer); note that in the report so the skill path gets fixed. As of 2026-05-30 the deny-list contains Threads enterprisevibecode (6935604f29ea336fd65bacf8, 0 reactions / 64 posts / 30d — audits/threads-evc-dead-channel.md).
Phase 6 — Below-threshold-channel check
For each channel in the queue, compare its follower count (via mcp__buffer__get_channel) against the min_followers_to_promote threshold (default 50, configurable in promote-* skills). If a channel below threshold has queued posts, those were probably scheduled before the threshold was added — recommend cancelling them.
Phase 7 — Render report
# Buffer Queue Audit (YYYY-MM-DD)
**Total queued posts:** N · **Channels with queue:** M · **Window:** next K days
## 🔴 Bunched posts (gap < 3 hours)
| Channel | Gap | Post A | Post B | Recommendation |
|---|---|---|---|---|
| Threads (mikelady) | 38 min | "I had to re-skill..." | "We're not going to revert..." | Cancel one |
## 🟡 Theme over-saturation
| Channel | Article / theme | Count | Recommendation |
|---|---|---:|---|
| Facebook (EVC) | Tokens From Our Past | 6 | Cancel 3 — past saturation point |
## ⚪ Untagged posts
| Channel | Post (snippet) | Suggested tag |
|---|---|---|
| LinkedIn personal | "I had to re-skill..." | format:verbatim-quote |
## 🔴 Dead channels (no sent in 14d, no queued)
| Channel | Service | Last sent |
|---|---|---|
| Mastodon (mikelady) | mastodon | 2026-03-14 |
## 🔴 Deny-listed dead channels with queued posts (dead by engagement)
| Channel | Service | Queued | Deny reason | Recommendation |
|---|---|---:|---|---|
| enterprisevibecode (Threads) | threads | 2 | 0 reactions / 64 posts / 30d (audits/threads-evc-dead-channel.md) | Cancel — deny-listed in buffer-post-prep; fan-out is wasted |
## 🔴 Below-threshold channels with queued posts
| Channel | Followers | Queued | Recommendation |
|---|---:|---:|---|
| LinkedIn page (EVC) | 28 | 4 | Cancel — below 50-follower threshold |
Phase 8 — User action
For each flagged item, present a 1-click action:
- Cancel the offending post →
mcp__buffer__delete_post (also the action for a deny-listed dead-channel post — fan-out there is wasted)
- Reschedule with a new dueAt →
mcp__buffer__update_post with mode: "customScheduled"
- Tag an untagged post →
mcp__buffer__update_post adding the suggested format:<name> tag
Default to surfacing the recommendations and asking for batch approval ("apply all 12 recommendations? Or pick which ones?") rather than acting silently.
Common Mistakes
- Auto-deleting without user approval. This skill is advisory — it surfaces problems and proposes fixes. Destructive actions need the user's OK.
- Confusing bunching with intentional batching. Some channels (e.g. Threads) tolerate bunches better than others (FB, LinkedIn). The 3-hour default is conservative for LinkedIn/FB; the user can override per-channel.
- Missing the queue context. A "bunched" pair might be a deliberate teaser-then-followup. If post A is a verbatim quote and post B is a follow-up question, that's intentional. The format tags help disambiguate — if both are
format:verbatim-quote, it's accidental fan-out duplication.
Closed-loop integration
This skill is the "queue hygiene" half of the post-and-improve closed loop:
promote-newsletter / tease-newsletter / etc. CREATE posts with format tags
audit-buffer-queue checks the queue for problems (this skill)
buffer-stats MEASURES engagement per format tag (Phase 5 analyzer)
buffer-stats Phase 5b RECOMMENDS skill-config changes
- User accepts recommendations → SKILL.md gets updated → next batch of posts is better-targeted
Run /audit-buffer-queue weekly between /buffer-stats runs to catch queue problems before they ship.