# Audit Buffer Queue

> 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".

- Skill: `michaellady/audit-buffer-queue` (Agent Skill)
- Install (CLI): `npx skillmds@latest add michaellady/audit-buffer-queue`
- Raw SKILL.md: https://api.skillmd.com/api/skills/michaellady/audit-buffer-queue/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: michaellady (https://skillmd.com/u/michaellady)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/michaellady/audit-buffer-queue

---


# 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."

```python
# Pseudocode
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](../PATTERNS.md#pattern-buffer-queue--recently-sent-overlap-check). Pipe the `mcp__buffer__list_posts` JSON into the helper with the candidate distinctive phrases:

```bash
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.

```python
EXPECTED = {'format:verbatim-quote', 'format:teaser', 'format:carousel', 'format:link-share', 'format:batch-summary'}
# Only via:buffer posts are expected to be format-tagged; via:network are organic, tagged by design = never.
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')  # informational, NOT a gap
```

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:

1. **`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).
2. **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:
1. **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.
2. **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.

```python
mcp__buffer__list_channels → all connected channels
mcp__buffer__list_posts (status: ["sent"], dueAt: { start: 14_days_ago })
mcp__buffer__list_posts (status: ["scheduled"])
# Cross-reference to find channels with 0 of both.
```

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):

```python
# Prefer the operator override, else the committed seed.
import json, os
base = "_shared/buffer-post-prep"   # relative to repo root (git rev-parse --show-toplevel)
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 `@`).

```python
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
        # handle match is best-effort — list_posts may not carry the handle;
        # channelId match is the reliable path. Fall back to channelService when present.
    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

```markdown
# 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.

