opus-clips-performance
Per-clip engagement report for OpusClip-scheduled posts. A thin renderer over the shared _shared/content-attribution/ JOIN engine: it asks the engine for the unified per-source record (content-attribution join --source-id <video-id>), whose derivatives[] array is exactly the per-clip rollup this report renders (each derivative = one clip; derivative.platforms holds the per-platform engagement). This skill emits:
- A markdown
report-<date>.mdnext to the manifest — one section per clip with score, duration, scheduled fan-out, and engagement, rendered from the engine'sderivatives[]. - An additive update to the manifest: each
scheduled_posts[]entry gains anengagementblock (orengagement: null+pending_taskpointer for platforms the engine returns aspending), folding in any browser-scraped engagement this run captured.
Why this exists: /opus-clips writes a publication ledger but no engagement data ("What's NOT in the manifest" — see post-manifest README). The shared JOIN engine does the correlation ([opus:<clip_id>] tag / scheduleId / ±2h time-window — there is one correlation implementation now, in the engine, not here). This skill's remaining job is (a) drive the engine per source, (b) overlay browser-scraped engagement for the platforms the engine can't reach yet, and (c) render + persist. /flywheel Phase 4.56 reads the same engine for the cross-project rollup.
Usage
/opus-clips-performance — process every manifest under ~/dev/claude-social-media-skills/youtube-analytics/data/opus_clips/*.json whose newest scheduled post is older than 24 h (gives platforms time to ingest analytics).
/opus-clips-performance <project_id> — process one manifest (P3051823ab0w or full path).
/opus-clips-performance --since YYYY-MM-DD — only manifests with scheduled posts on/after this date.
/opus-clips-performance --dry-run — render the report to stdout, don't mutate manifests or write files.
/opus-clips-performance --platforms youtube — restrict to one platform (today only youtube is wired; the flag is for forward-compat with #370/#371/#373).
🟢 Happy Path
For one fully-fanned-out OpusClip project (23 clips × 6 channels = 138 scheduled posts, the live P3051823ab0w shape) once 24 h have elapsed. ~30 sec wall-clock today (only the engine's YouTube join hits disk; other platforms short-circuit to pending unless the interactive browser-scrape overlay runs).
Phase 0 — Build the JOIN engine if missing. The engine is a gitignored Go binary (built per-machine). Build it on demand; degrade gracefully if go is absent (see Phase 0 below).
Phase 1 — Discover sources. Ask the engine: content-attribution sources enumerates every source video with ≥1 derivative across all manifests ({id, title, manifest_path, n_derivatives, n_scheduled_posts}). If <project_id> was passed, resolve it to a single manifest and read its source_video.id to pin one source.
Phase 2 — JOIN (engine does the correlation). For each source video ID, call content-attribution join --source-id <video-id>. The engine returns the unified record (source, derivatives[], source_engagement, derived_engagement, amplification_ratio). Each derivative is a clip; derivative.platforms.youtube_shorts.engagement is the live YouTube join (the engine did the [opus:] tag → ±2h time-window correlation — this skill no longer reimplements it). The other platform keys come back {engagement: null, pending: true, pending_task: "#NNN"}.
Phase 3 — Overlay browser-scraped engagement (interactive). For the platforms the engine returns as pending (facebook_page, instagram_business, linkedin_page, linkedin_personal, tiktok_business), read each platform's native analytics in the user's logged-in browser via claude-in-chrome, match each post to a clip by its [opus:<clip_id>] tag, and overlay the result onto that clip's platform record (replacing the engine's pending placeholder for this run + the persisted manifest). The engine has no browser path — this overlay is the only home for these metrics today.
Phase 4 — Render report. One markdown section per clip, sorted by derivative.score desc, rendered directly from the engine's derivatives[] (+ the Phase 3 overlay). Include: clip_id, title, score, duration, per-platform engagement (live, scraped, or pending #NNN). Lead with the engine's derived_engagement + amplification_ratio as the aggregate. Write to report-<YYYY-MM-DD>.md next to the manifest.
Phase 5 — Persist back to manifest. Atomic write (tmp + mv, same pattern as _pm_atomic_write): add the engagement (or engagement: null + pending_task) field to each scheduled_posts[] entry, keyed by scheduleId. Additive only — never remove or rename existing fields (schema versioning rule from post-manifest README). The engine is read-only over snapshots; this skill owns the manifest write-back.
Phases
Phase 0 — Build the JOIN engine if missing
The correlation lives entirely in the shared engine _shared/content-attribution/, a gitignored Go binary (built per-machine like voice-corpus). Build it on demand — same guard pattern as /flywheel Phase 4.56:
CA_DIR=~/dev/claude-social-media-skills/_shared/content-attribution
CA_BIN="$CA_DIR/content-attribution"
# Build the Go binary if it's not on disk (gitignored — built per-machine).
if [ ! -x "$CA_BIN" ] && [ -f "$CA_DIR/main.go" ]; then
( cd "$CA_DIR" && go build -o content-attribution . ) 2>/dev/null
fi
if [ ! -x "$CA_BIN" ]; then
CA_AVAILABLE=0
CA_SKIP_REASON="_shared/content-attribution/ binary missing and build failed (need Go) — task #381/#383"
else
CA_AVAILABLE=1
fi
content-attribution is a Go binary — it runs identically under any shell, so call it directly (no bash -c, no sourcing). Graceful degradation: if CA_AVAILABLE=0, fall back to the legacy in-skill discovery (pm_count_scheduled walk below) for a publication-count-only report and tell the user the engine is missing so YouTube engagement and amplification_ratio are absent this run. Don't abort.
Phase 1 — Discover sources
Ask the engine which sources have derivatives:
# {id, title, url, manifest_path, n_derivatives, n_scheduled_posts} per source video
SOURCES=$("$CA_BIN" sources) # JSON array
If <project_id> was passed, resolve it to a single manifest under opus_clips/ and read source_video.id to pin one source ID (the engine's --source-id):
SRC_ID=$(jq -r '.source_video.id' "$MANIFEST")
For the --since filter, drop manifests whose newest scheduled_at_utc is older than the cutoff:
NEWEST=$(jq -r '[.clips[].scheduled_posts[].scheduled_at_utc] | max' "$MANIFEST")
Degraded fallback (engine missing). Discover manifests in-skill, publication-count only:
source ~/dev/claude-social-media-skills/_shared/post-manifest/post_manifest.sh
DATA_ROOT=~/dev/claude-social-media-skills/youtube-analytics/data
MANIFESTS=()
for f in "$DATA_ROOT"/*/*.json; do
COUNT=$(pm_count_scheduled "$f" 2>/dev/null)
[ "${COUNT:-0}" -gt 0 ] && MANIFESTS+=("$f")
done
Phase 2 — JOIN (the engine does the correlation)
# One unified record per source video. derivatives[] IS the per-clip rollup.
REC=$("$CA_BIN" join --source-id "$SRC_ID")
The record's shape (see _shared/content-attribution/README.md):
{
"source": { "id": "uEposKmbFvY", "type": "long_form", "title": "…", "url": "…" },
"derivatives": [
{
"type": "opus_clip", "clip_id": "La4Wghg6IX", "title": "…", "score": 99, "duration_seconds": 28,
"platforms": {
"youtube_shorts": { "engagement": { "video_id": "9nOxvAhZyWo", "views": 539, "likes": 2, "comments": 0, "subs_gained": 0, "estimated_revenue": 0.023, "join_method": "tag" } },
"facebook_page": { "engagement": null, "pending": true, "pending_task": "#371" },
"instagram_business": { "engagement": null, "pending": true, "pending_task": "#371" },
"linkedin_page": { "engagement": null, "pending": true, "pending_task": "#371" },
"linkedin_personal": { "engagement": null, "pending": true, "pending_task": "#370" },
"tiktok_business": { "engagement": null, "pending": true, "pending_task": "#373" }
},
"derivative_engagement_total": { "reach": 539, "reactions": 2, "comments": 0 }
}
// … one derivative per clip
],
"source_engagement": { "views": …, "likes": …, "comments": …, "subs_gained": …, "estimated_revenue": … },
"derived_engagement": { "reach": …, "reactions": …, "comments": …, "subs_gained": …, "estimated_revenue": … },
"amplification_ratio": 42.9
}
Read it, don't recompute it. The engine already did the [opus:<clip_id>] tag match (and the ±2h time-window fallback for re-titled Shorts) — there is now ONE correlation implementation, in the engine. Each clip is REC.derivatives[]; per-platform engagement is derivative.platforms.<key>.engagement (non-null = matched; check .engagement != null first, then read .engagement.*). score/duration_seconds/title/clip_id are on the derivative. Pull the YouTube number with e.g. jq '.derivatives[] | {clip_id, yt: .platforms.youtube_shorts.engagement}'.
Pretty-print shortcut.
content-attribution report --source-id <id>renders a default markdown view of the same record (add--format jsonfor the raw record). Phase 4 below produces the per-clip-table UX this skill's users expect;reportis the quick ad-hoc path.
Phase 3 — Overlay browser-scraped engagement (interactive)
The engine returns the non-YouTube platforms as {engagement: null, pending: true, pending_task: "#371"} (FB/IG/LinkedIn-page, via Buffer scheduleId — the underlying Buffer snapshot doesn't yet carry per-post records), "#370" (LinkedIn-personal, gated on the linkedin-stats per-post snapshot), and "#373" (TikTok). These task IDs are emitted by the engine itself (_shared/content-attribution/main.go) — don't invent your own. This phase overlays real numbers onto those pending records by reading each platform's native analytics — the only path that exists today, since none of these platforms expose the per-post engagement the engine reads from a cached snapshot.
Read each platform's native analytics in the user's logged-in browser via claude-in-chrome — the proven method (folded in from the retired clip-results skill; first validated on P3051823ab0w, 2026-05-30, total ~31.5K reach across 6 platforms). Interactive only — needs an open, logged-in session; the daily launchd reminder prompts the user to run it, it can't go headless. If the session is non-interactive (e.g. /flywheel's unattended run), skip this phase: the report shows those platforms as pending and a later interactive run fills them in.
Load chrome tools first: ToolSearch select:mcp__claude-in-chrome__tabs_context_mcp,mcp__claude-in-chrome__navigate,mcp__claude-in-chrome__get_page_text,mcp__claude-in-chrome__browser_batch,mcp__claude-in-chrome__computer.
Use get_page_text, not screenshots — it returns full captions (with the [opus:CLIPID] tag for exact
matching) plus the metric columns. Match each post to a clip by its [opus:] tag (the SAME tag key the engine
joins on), overlay the result onto that clip's derivative.platforms.<key>.engagement for the report, and
write it into the matching scheduled_posts[].engagement block in Phase 5 (the post-manifest contract that the engine + /flywheel read).
| Label prefix | Where (native analytics) | Notes |
|---|---|---|
FACEBOOK_PAGE + INSTAGRAM_BUSINESS |
Meta Business Suite → business.facebook.com/latest/posts/published_posts/ (Content) |
Covers BOTH. Widen window ~1920; Columns → add Views + Follows, drop Reach/Shares. IG captions carry the [opus:] tag; FB shows a short title only — match FB by the same publish time-slot as the tagged IG post. FB ≫ IG on views. NB: FB EVC/Sandbox/Mike share one postAccountId. |
TIKTOK_BUSINESS |
TikTok Studio → tiktok.com/tiktokstudio/content |
Views/Likes/Comments columns; captions carry the [opus:] tag. Virtualized list — scroll in modest steps and dedupe by tag (big jumps skip rows). Highest engagement of any platform. |
LINKEDIN (org) |
linkedin.com/company/<ORG_ID>/admin/analytics/updates/ → Content engagement table |
Impressions per post; "Show: 20" + paginate. Usually negligible (org page ~tiny). |
LINKEDIN (personal) |
Me → Posts & Activity → linkedin.com/in/<vanity>/recent-activity/all/ |
"N impressions" per post; scroll-load. The personal profile reaches far more than the org page. |
If a platform nav is permission-denied, ask the user before retrying; don't loop. For any clip a sweep
couldn't reach, leave the engine's {engagement: null, pending: true, pending_task: "#NNN"} placeholder
in place (don't overwrite it) so the report still shows pending and a later run can fill it.
Phase 4 — Render report
Render directly from the engine's record (REC from Phase 2) plus any Phase 3 overlay. Sort clips by derivative.score desc; per platform, render engagement.* when engagement != null, else pending (<pending_task>).
# OpusClip performance — <project_id>
Source: <REC.source.title> (<REC.source.id>)
Clips: <REC.derivatives | length> · Derived reach: <REC.derived_engagement.reach> · Amplification: <REC.amplification_ratio>×
Generated: <now>
## Top clips by score
### [99] La4Wghg6IX — "Your next 50% productivity gain..." (28s)
| Platform | Engagement |
|---------------------|---------------------------------------------|
| youtube_shorts | 539 views · 2 likes · 0 cm · +0 subs |
| facebook_page | pending (#371) |
| instagram_business | pending (#371) |
| linkedin_page | pending (#371) |
| linkedin_personal | pending (#370) |
| tiktok_business | pending (#373) |
(repeat per clip, descending score; replace `pending` rows with scraped numbers when Phase 3 ran)
## Aggregate (from the engine's derived_engagement)
Derived reach: <reach> · Reactions: <reactions> · Comments: <comments> · Subs gained: <subs_gained> · Est. revenue: $<estimated_revenue>
Source: <source_engagement.views> views · Amplification ratio: <amplification_ratio>×
Write to ~/dev/claude-social-media-skills/youtube-analytics/data/opus_clips/report-<YYYY-MM-DD>-<project_id>.md.
Phase 5 — Persist back to manifest
Build the patch from the engine's per-platform records (REC.derivatives[].platforms.<key>) plus the Phase 3 browser overlay, keyed by scheduleId. The engine is read-only over snapshots — this skill owns the manifest write-back. Use the _pm_atomic_write pattern from post_manifest.sh (tmp + mv). Update only the engagement and optional pending_task keys on each scheduled_posts[] entry. Do not touch any other field.
Map each scheduled_posts[] entry to its engine/overlay record: resolve the post's platform via its label (same label → platform mapping the engine uses: YOUTUBE→youtube_shorts, FACEBOOK_PAGE→facebook_page, INSTAGRAM_BUSINESS→instagram_business, LINKEDIN Mike Lady→linkedin_personal, other LINKEDIN→linkedin_page, TIKTOK_BUSINESS→tiktok_business), then copy that clip's platforms.<key> (.engagement or .pending_task) into the patch keyed by the post's scheduleId.
jq --argjson updates "$ENGAGEMENT_PATCH_JSON" '
.clips |= map(
.scheduled_posts |= map(
. as $p
| ($updates[$p.api_response.data.scheduleId] // null) as $u
| if $u then . + {engagement: $u.engagement, pending_task: $u.pending_task} else . end
)
)
' "$MANIFEST" > "$MANIFEST.tmp" && mv "$MANIFEST.tmp" "$MANIFEST"
scheduleId is globally unique within a manifest; the engine doesn't surface it per-derivative (see "engine mismatches" below), so derive it from the manifest's own scheduled_posts[].api_response.data.scheduleId (already open in this phase).
Out of scope
- The correlation logic. Owned by
_shared/content-attribution/(the[opus:<clip_id>]tag match, the ±2h time-window fallback,scheduleIdJOIN). This skill calls the engine and never reimplements it — there is one correlation implementation. If a JOIN key is wrong, fix it in the engine, not here. - Snapshot-backed FB / IG / LinkedIn / TikTok engagement. When the underlying per-post snapshots land, the engine stops returning those platforms as
pendingand the Phase 3 browser overlay becomes redundant. The engine's pending pointers today: #371 (FB/IG/LinkedIn-page — Buffer per-post snapshot keyed onscheduleId), #370 (LinkedIn-personal per-post snapshot), #373 (TikTok). These IDs come from the engine; this skill mirrors them. - Mutating the source content. This is read-side only; no re-renders or re-scheduling. For that, see
/opus-clips. - Aggregating across projects. One source video = one report. Cross-project rollups belong in
/flywheel(which reads the same engine). - Recomputing OpusClip's
score. That field is from OpusClip's viral-score model at curation time and is preserved verbatim (the engine passes it through on each derivative). - Backfilling missing
[opus:<clip_id>]footers. If a user edited a post and stripped the tag, the engine falls back to time-matching; neither it nor this skill re-writes the post.
Relationship to the JOIN engine (could this skill be deprecated?)
After this refactor, opus-clips-performance is a thin renderer over _shared/content-attribution/ for everything except one thing: the interactive browser-scrape overlay in Phase 3. The engine owns discovery (sources), the per-clip skeleton + the YouTube join (join), the aggregate (derived_engagement + amplification_ratio), and even a default markdown view (report). What this skill still uniquely provides:
- Phase 3 browser overlay — fills the FB/IG/LinkedIn/TikTok engagement the engine returns as
pending(no snapshot exists, no API). This has no home in the engine (which is pure transport over cached snapshots, never a browser driver). - Manifest write-back (Phase 5) — the engine is read-only over snapshots; this skill owns persisting
engagementback intoscheduled_posts[]. - The per-clip-table report UX users expect (vs the engine's source-centric
reportview).
For the integrator: once the per-post snapshots land (engine pending tasks #370/#371/#373) so the engine joins all platforms itself, items (1) collapses and this skill becomes a near-pure rendering shim over content-attribution report + a manifest write-back — at which point it's a candidate to deprecate/merge into /flywheel's per-source rendering (the open question in CLOSED-LOOP-UNIFICATION-PLAN.md §"Open questions" #3). Until then, keep it: the interactive browser overlay is real, load-bearing work the engine can't do.
Schema gaps / engine mismatches noticed
- Engine emits no per-
scheduled_postscheduleIdon its derivative platform records. This skill's Phase 5 write-back keys the manifest patch byscheduleId; the engine'sjoinoutput identifies a platform record by its<key>(e.g.linkedin_page) and the live record'surn/video_id/post_id, not the originatingscheduleId. For YouTube thevideo_idis enough; for the browser-overlay platforms this skill re-derives thescheduleIdfrom the manifest itself (it already has the manifest open) rather than from the engine output. Not a blocker, but a future engine fieldderivative.platforms.<key>.schedule_idwould let the write-back key purely off engine output. source_video.duration_secondsabsent in the manifest. The engine's ±2h time-fallback would bias better toward clips near the source duration if it had it; additive, non-breaking, lives in the manifest schema (owned by/opus-clips), not here.- No
schema_versionon manifest or engine output (the engine README acknowledges this for its own output). Both/flywheeland this skill now read the engine; an explicitschema_versionwould give consumers a check-and-warn anchor.
Related skills
_shared/content-attribution/— the JOIN engine this skill now wraps; owns all correlation. See its README./opus-clips— upstream; writes the manifests the engine reads._shared/post-manifest/— the manifest shape contract; legacy helper functions (pm_count_scheduled,pm_schedule_ids,pm_find_clip) used in the degraded fallback + Phase 5 write-back./flywheel— sibling consumer; Phase 4.56 reads the SAME engine (content-attribution join) to credit clip output toward Priority 1. Same build-if-missing guard./yt-analytics— owns~/dev/claude-social-media-skills/youtube-analytics/data/videos.json(the engine's YouTube source); refresh it (go run . fetch-analytics --all) before running if YouTube data is stale./buffer-stats— owns the Buffer snapshot the engine reads for FB/IG/LinkedIn-page once #371 lands; does NOT directly cover these posts (they bypass Buffer's normal queue).