opus-clips
End-to-end OpusClip wrapper over the official opusclip CLI (installed via the opusclip plugin at ~/.claude/plugins/cache/opus-skills/opusclip/<version>/scripts/opusclip). This skill layers user-specific defaults + workflow on top of that CLI — it doesn't reimplement the API surface.
What this skill owns vs the plugin
| Concern | Where it lives |
|---|---|
Auth (OPUSCLIP_API_KEY), CLI commands, API endpoints, EditingScript schema |
opusclip:opusclip plugin skill — call it / read its docs for CLI specifics |
| 30-90s shorts only, 5/day/channel cap, 6 connected accounts, slot times, viral-score filter | This skill |
| Brand-overlay-strip workflow (undocumented API surface), polling cadence, batch-loop rate-limiting | This skill (learned 2026-05-18 session) |
Non-negotiables
Two hard rules from the user — verified across multiple sessions, don't override without asking:
- 30-90s shorts only.
opusclip submit ... --durations "30,60,90". Never let OpusClip default to "Auto" which can produce >90s clips. - 5 posts/day/channel hard cap, not a lower bound. Diminishing returns above this. Never schedule a 6th slot on the same calendar day — roll over to next day. See
daily_time_slotsinconfig.json.
Prerequisites
OPUSCLIP_API_KEYset in shell env (stored in~/.zshrcas of 2026-05-18). API access requires Enterprise or Pro plan; key at https://clip.opus.pro/dashboard.opusclipCLI on disk via theopusclipplugin. Verify with~/.claude/plugins/cache/opus-skills/opusclip/*/scripts/opusclip templates.jq,curl,ffmpeg(last one only needed for local trim / frame extraction).
If the key isn't set, defer to the opusclip:opusclip plugin skill — its setup is canonical.
🟢 Happy Path
For a long-form YouTube video → ~20 clips at 30/60/90s → review → optionally strip brand overlay → ready for fan-out. ~10-15 min wall-clock (most of it is OpusClip's processing).
Phase 1 — Find source. YouTube URL the user provides, OR "latest long-form" via ~/dev/claude-social-media-skills/youtube-analytics:
jq -r 'if type=="array" then . else (.videos // .items) end
| map(select(.video_type == "long-form"))
| sort_by(.published_at) | reverse | .[0]
| "https://www.youtube.com/watch?v=\(.id) \(.title) \(.duration_seconds)s"
' ~/dev/claude-social-media-skills/youtube-analytics/data/videos.json
Phase 2 — Submit.
opusclip submit \
--url "<YOUTUBE_URL>" \
--durations "30,60,90" \
--model ClipBasic \
--title "<short title>"
ClipBasic for talking-head essays (default for EVC long-form). ClipAnything --prompt "..." only when you want directed curation around a specific theme — see Edge: curation-misses-theme.
Capture the returned projectId (e.g. P3051823ab0w) — every downstream call needs it.
Phase 3 — Poll until ready (~5-15 min for a 10-20 min source). Background bash with ~270s tick (just under 5 min — stays in Claude's prompt cache window so each poll is cheap on re-entry):
PROJECT=<id>
for i in $(seq 1 12); do
sleep 270
COUNT=$(opusclip list --project $PROJECT --summary | jq 'length')
[ "${COUNT:-0}" -gt 0 ] && { echo "READY ($COUNT clips)"; exit 0; }
echo "[tick $i] still processing"
done
exit 1
Run this with Bash run_in_background=true. You get notified when it exits.
Phase 4 — Filter top clips. Filter to viral_score >= config.viral_score_threshold (currently 70), then present sorted descending. Note: OpusClip generates bonus clips with _bonus suffix on the clip_id (alternate versions of the top picks) — they appear in list like any other clip; treat them as normal candidates.
opusclip list --project $PROJECT --summary | jq -r '
map(select(.score >= 70))
| sort_by(-.score) | .[]
| "[\(.score)] \(.duration_sec)s — \(.title) (\(.clip_id))"
'
Phase 5 — (Optional) strip brand overlay. See Edge: brand-overlay-baked-in for full details — overlays are applied at render-time from account-level defaults, not from the template, so the only way to remove them from existing clips is the undocumented renderPreferenceOverride body field on the re-render endpoint. Confirmed working 2026-05-18, does not consume credits.
Phase 5.5 — Newsletter CTA (REQUIRED on every clip). Added 2026-05-20 after the flywheel caught the conversion gap: clips were driving 1,368 derivative views with 0% converting to the newsletter because the descriptions had no CTA — just hook + hashtags + [opus:] tag. Every clip description must now carry a newsletter CTA, hybrid by platform (see Newsletter CTA composition below). The CTA's UTM-tagged link is also what makes per-clip conversion measurable in beehiiv_attribution — without it, the closed loop can't see which clips convert.
Phase 6 — Preview / hand off.
opusclip preview --project $PROJECT # opens HTML in browser, sorted by score
For scheduling, two paths:
- Via OpusClip's native posting (
opusclip post schedule) — fan-out to OpusClip's connected accounts. Beta pricing, may diverge from web UX. Subject to OpusClip's per-day cap interpretation. - Via Buffer (this user's preferred path) — download the mp4s from each clip's
uriForExport, schedule via/promote-newsletteror/carousel-newsletterflows. Uses the user's existing slot math (5/day across 6 channels at 09:00 / 12:00 / 15:00 / 18:00 / 21:00 PT).
🔴 YouTube needs a SEPARATE short title — never reuse the caption (confirmed 2026-05-31)
opusclip post schedule takes --title AND --description as separate fields. On every platform except YouTube, the --title value is used as the caption/body (long text is fine — 2,200+ chars). On YouTube, --title becomes the video TITLE, which YouTube hard-caps at 100 characters — a longer value fails the whole post with "The video title is invalid. Please check the title and try again." (caption-length hooks are 190–250 chars, so they always blow the cap).
This is silent and platform-specific: the identical text posts fine to FB/IG/LinkedIn/TikTok and only YouTube fails. A 2026-05-31 batch lost 25/25 YouTube posts this way.
Rule when fanning a clip out via native posting:
- YouTube account →
--title "<curated clip title>"(the short.titlefrom the manifest, ≤100 chars; e.g.Build, don't just LeetCode) +--description "<full caption: hook + CTA + hashtags + [opus:CLIP]>". - All other accounts →
--title "<full caption>"as before (it's the body there; no separate title field).
Phase 5.7 — Pre-publish constraint gate (run before EVERY native schedule)
Do not hand-check title lengths. A prompt rule ([ ${#YT_TITLE} -le 100 ]) already existed on 2026-05-31 and was skipped during a paced manual run — that is exactly how 25/25 YouTube posts shipped broken. A prompt instruction is advisory; a binary in the path is a hard gate. Validate the batch through _shared/publish-constraints and read its exit code — the native-path twin of buffer-post-prep.
Build once (gitignored, per-machine): ( cd _shared/publish-constraints && go build -o publish-constraints . ).
Assemble one cell per (clip × account) you are about to schedule, carrying the exact title/description args the opusclip post schedule call will use, then validate the whole batch in one invocation:
# PLANNED = JSON array: [{ "clip_id":"…", "label":"YOUTUBE Enterprise Vibe Code", "title":"…", "description":"…" }, …]
printf '%s' "$PLANNED" | _shared/publish-constraints/publish-constraints validate
GATE=$?
# 0 → every cell valid; proceed to the schedule loop.
# 65 → ≥1 invalid; each stdout verdict = {clip_id, platform, field, reason, fix_hint}.
# Do NOT fire `opusclip post schedule` for a failing cell. Fire the valid ones,
# surface the failures grouped by clip, regenerate the offending field
# (e.g. a ≤100-char YouTube title from the curated `.title`) and re-run the gate.
The title/description split is the SPEC (YouTube --title = short curated title + --description = full caption; all other accounts --title = full caption). The cap table and label→platform mapping live in the binary, not here — a new platform or connected account is one data edit, never a new hand-check. See PATTERNS.md → Pre-publish constraint gate.
Newsletter CTA composition (Phase 5.5)
Every clip description = <hook> + <CTA> + <hashtags> + [opus:<clip_id>]. The CTA is hybrid by platform — direct UTM link where links are clickable, comment-to-DM where they aren't:
| Platform | Links clickable? | CTA style |
|---|---|---|
| YouTube, LinkedIn (personal + page), Facebook, Threads | yes | Direct UTM link |
| TikTok, Instagram | no (link-in-bio only) | Comment-to-DM |
Direct-link CTA (link platforms) — append after the hook:
📬 The full essay + weekly newsletter (free): https://www.enterprisevibecode.com/?utm_source=<platform>&utm_medium=clip&utm_campaign=opus_<source_video_id>&utm_content=<clip_id>
Comment-to-DM CTA (TikTok, Instagram) — append after the hook:
Comment "newsletter" for the full essay + weekly drop 📬
UTM scheme (the measurement key)
The direct-link CTA carries UTMs so beehiiv_attribution can credit signups per-clip per-platform — this is what closes the "0% from YouTube, can't tell which clip works" blind spot the 2026-05-20 flywheel surfaced:
| Param | Value |
|---|---|
utm_source |
platform: youtube | linkedin | facebook | threads |
utm_medium |
clip (distinguishes from newsletter/livestream mediums) |
utm_campaign |
opus_<source_video_id> (e.g. opus_uEposKmbFvY) — groups all clips from one long-form |
utm_content |
<clip_id> — the exact clip, joinable to the post-manifest |
After clips with UTM links run, /flywheel Phase 3 (beehiiv attribution) will show youtube/linkedin as sources under campaign opus_<videoid>, and per-clip conversion becomes visible. The CTA copy itself is cognition (voice-sensitive — keep it in this prompt, not a binary); only the UTM-link assembly is mechanical.
Why hybrid, not link-everywhere
TikTok and Instagram strip/ignore links in captions (link-in-bio is the only clickable surface), so a raw URL there is dead text that also reads as spammy. Comment-to-DM is the native pattern on those surfaces and doubles as an engagement signal (comments boost reach). The tradeoff: comment-to-DM isn't UTM-measurable, so IG/TikTok conversion stays attributed only by beehiiv's coarse organic/referral buckets. Accept that — those aren't the high-converting channels anyway (the engaged audiences are YouTube→newsletter and LinkedIn personal, both link-platforms).
Edge cases (read only when the matching signal appears)
Edge: brand-overlay-baked-in
Signal: clips render with the user's default brand overlay (e.g. sandboxbjj.png) even though opusclip templates shows the active template has screenOverlays: [].
Cause: the overlay is merged in from an account-level default at render time, not from the template. Confirmed 2026-05-18 by inspecting the submit response renderPref vs the template config.
Fix: undocumented renderPreferenceOverride body field on the re-render endpoint. The plugin CLI does not expose this — call the API directly with curl. Verified payload shape:
OVERRIDE='{
"screenOverlays": [],
"screenOverlay": null,
"screenOverlayFileId": null,
"enableScreenOverlay": false,
"disableScreenOverlay": true
}'
for C in $(opusclip list --project $PROJECT --summary | jq -r '.[].clip_id'); do
SCRIPT=$(curl -sS "https://api.opus.pro/api/exportable-clips/$PROJECT.$C?include=editingScript" \
-H "Authorization: Bearer $OPUSCLIP_API_KEY" | jq -c '.editingScript // empty')
[ -z "$SCRIPT" ] && { echo "skip $C"; continue; }
BODY=$(jq -nc --argjson s "$SCRIPT" --argjson r "$OVERRIDE" '{editingScript: $s, renderPreferenceOverride: $r}')
curl -sS -X POST "https://api.opus.pro/api/exportable-clips/$PROJECT.$C/re-render" \
-H "Authorization: Bearer $OPUSCLIP_API_KEY" \
-H "Content-Type: application/json" -d "$BODY" | jq -r '.jobId // .error'
sleep 3 # see Edge: rate-limit-30-per-min
done
Each clip re-renders in ~30-45s. Confirmed 2026-05-18: no credit charge for these re-renders (dashboard balance unchanged after test of 1 clip with override). The user's dashboard view is authoritative; do NOT trust md5 comparison of downloaded mp4s — see Edge: cdn-serves-stale-bytes.
Verification: the user opens the re-rendered clip in the OpusClip dashboard or opusclip preview --project $PROJECT and confirms the overlay is gone. Do not assume the override worked from the API response alone (it returns {jobId: ...} whether the override is honored or silently ignored).
Test before batching. When you don't know if a particular override payload works for a new field, fire on ONE clip first. Wait for renderAsVideoFile.pending == false. Have the user verify in the dashboard. Then loop the remaining clips. Catching a silently-ignored override on clip 1 saves 22 wasted calls.
Edge: cdn-serves-stale-bytes
Signal: after a successful re-render (new concludedAt, new v= URL signature), downloading the mp4 returns byte-identical content to the previous render. md5 hashes match.
Cause: OpusClip's CDN keys cached content by path (/media/.../c.<clipId>/VIDEO_FILE_-0-<duration>.mp4), not by signed-URL query params. A new render at the same path may take time to propagate, OR the CDN may continue serving the old object until the cache expires.
Implication: do not use md5/file-bytes diff to verify a re-render took effect. Use the dashboard, opusclip preview, or wait several minutes and retry the download. The API's renderAsVideoFile.concludedAt + a new v= query param is the authoritative signal that a new render happened on the server.
Edge: rate-limit-30-per-min
Signal: batch loop returns 429s after ~10-15 calls.
Fix: OpusClip is documented at 30 req/min. Each clip in a batch typically takes 2 calls (GET script + POST re-render). Pace with sleep 3 between iterations to stay under the ceiling.
Edge: bash-for-loop-ifs-quirk
Signal: batch loop only runs ONE iteration; the entire newline-separated input ends up as a single $VAR value. URL becomes malformed.
Cause: for C in $CLIPS depends on $IFS doing word-splitting on newlines, but $IFS can be modified by prior code in the same script.
Fix: always use while IFS= read -r C; do ... done <<< "$CLIPS" — robust against any $IFS state. Hit this 2026-05-18 on the first batch re-render attempt; the loop only ran once with all 22 clip IDs concatenated into one URL.
Edge: curation-misses-theme
Signal: ClipBasic returns clips whose titles don't match the source video's actual narrative — the AI picked tangential moments instead of the spine.
Example 2026-05-18: source "How to Scale Without the Slop" produced clips titled "Master AI Productivity," "AI Teammates," "Beads: JSON for Agents," "Ballmer Peak," etc. — adjacent topics, not the scaling-without-slop thesis.
Fix: re-submit with --model ClipAnything --prompt "find moments where the speaker explains <theme>". Costs another ~$N credits to re-clip but produces directed curation. Don't try to fix this via edit-clip operations on the existing clips — the curation already chose the wrong source moments; you'd just be re-rendering off-theme content.
Edge: poll-cadence-cache-window
Signal: polling more often than necessary burns Claude's prompt-cache window on every re-entry.
Fix: pick poll intervals deliberately. Anthropic's prompt cache TTL is 5 minutes. Polling cadence options:
- < 270s ticks: cache stays warm; cheap re-entries; use when actively waiting on something that may finish quickly.
- 300s exactly: worst-of-both — pay the cache miss without amortizing it. Avoid.
- > 1200s ticks: one cache miss buys a long idle stretch; use when the work takes 10+ minutes.
For OpusClip clip-generation polling: 270s is the right default. Most jobs finish in 5-15 min, so 1-3 ticks gets you there.
Edge: linkedin-self-conflict
Signal: opusclip post schedule returns hasConflict: true on a LinkedIn schedule even though the user has nothing else queued at that time.
Cause (confirmed 2026-05-18): OpusClip's postAccountId is shared across LINKEDIN page (urn:li:organization:*) and LINKEDIN personal profile (urn:li:person:*) — they share one connector but have different subAccountIds. The API's conflict detector appears keyed on postAccountId alone, so scheduling a LinkedIn page post + LinkedIn profile post at the same slot triggers a false-positive conflict on the second one. The schedule still goes through; scheduleId and postId are returned normally.
Fix: ignore the flag if both schedules in the batch hit the same postAccountId with different subAccountIds. Don't waste cycles probing the API for conflict details — there's no listing endpoint exposed, and the API does not return conflictsWith IDs alongside the flag. User can verify visually at https://clip.opus.pro → Scheduled Posts.
Edge: bonus-clips
Signal: opusclip list --project P --summary returns 23 clips for a project you only submitted with 3 duration buckets (would expect ~3-15).
Cause: OpusClip generates alternate versions of high-scoring clips with _bonus suffix on the clip_id (e.g. La4Wghg6IX + La4Wghg6IX_bonus). These are real, scored clips — not duplicates. Treat as normal candidates.
Cost (as of 2026-05-18)
opusclip submit(new curation): 1 credit per minute of source video. A 15-min long-form ≈ 15 credits.opusclip edit-clip *(server-side re-render): beta pricing per call; the skill docs warn "may incur charges that don't match the web UX." Confirmed 2026-05-18: at least some re-renders (the brand-overlay-strip case) consume no credits. Other edit operations (caption-fix, censor, trim) are documented as charged but per-op cost is not published — check dashboard balance after first call to calibrate.opusclip post publish/post schedule: beta; X (Twitter) posts cost 1 credit each, other platforms unspecified.
Before kicking off a batch (>3 ops on one clip, OR >5 ops total in one session), have the user check their dashboard balance so they can audit per-op cost themselves.
Closed-loop attribution (per-post manifest)
OpusClip's native scheduler bypasses Buffer, so the format:<name> tag system can't attribute these posts. Use the shared post-manifest primitive at _shared/post-manifest/ instead:
source ~/dev/claude-social-media-skills/_shared/post-manifest/post_manifest.sh
MANIFEST=~/dev/claude-social-media-skills/youtube-analytics/data/opus_clips/$PROJECT.json
pm_init "$MANIFEST" --project "$PROJECT" --source-video "$SOURCE_YT_ID" --source-title "$TITLE"
pm_ensure_clip "$MANIFEST" --clip-id "$CLIP" --title "$NEW_TITLE" --description "$NEW_DESC" --score "$SCORE" --duration-sec "$DUR"
# After each `opusclip post schedule` call:
pm_append_post "$MANIFEST" --clip-id "$CLIP" --label "$CHANNEL_LABEL" --account-id "$AID" --sub-account-id "$SUB" --scheduled-at-utc "$AT_UTC" --api-response "$RAW_RESP_JSON"
Two non-negotiables when composing the description:
- End with
[opus:<clip_id>]on its own line — grep-able across any platform's native search; survives manifest loss. - Persist the verbatim API response in the manifest (not just the scheduleId) — preserves
hasConflictand any future fields OpusClip adds.
This applies to manual/paced/one-off scheduling too — not only full /opus-clips runs. A 2026-05-31 batch scheduled "manually via paced CLI" wrote bare {label, scheduled_at_utc} entries with no scheduleId, leaving every post dark to the JOIN (content-attribution reads scheduled_posts[].api_response.data.scheduleId). If you ever schedule outside pm_append_post, backfill afterward: GET api.opus.pro/api/publish-schedules?q=findByProjectAndClip&projectId=<P>&clipId=<C> (needs a real User-Agent header or it 403s) returns the live scheduleId per platform — write it into each entry's api_response.data.scheduleId. Note this only works while a post is still pending; once it fires, OpusClip drops it from that endpoint, so the post then attributes by [opus:<clip_id>] tag alone. Capture at schedule time to avoid the gap.
See PATTERNS.md § Closed-loop post manifest for the broader rationale and how this composes with the Buffer format: system in /flywheel.
Workflow integration
This skill is the ingest + clip-prep step. Downstream consumers:
/promote-newsletter— fan-out individual clips to Buffer channels with the user's posting schedule + copy patterns./carousel-newsletter— if a clip is being repurposed as a carousel slide instead of a video post./flywheel— counts clip output toward Priority 1 (long-form throughput, since each clip is a derivative of a long-form essay) and toward fan-out reach metrics.
Out of scope
- Uploading the source video to YouTube first (use the user's existing publishing flow).
- Captions / branding configuration (set once at https://clip.opus.pro/dashboard).
- X / Twitter cross-posting (not in the user's six standard channels).
- Real-time stream clipping (this is post-production only).
Files in this directory
SKILL.md— this workflow (CLI-driven, 2026-05-18 rewrite).config.json— channels, slot times, posts-per-day cap, viral-score threshold.- Legacy browser-automation helpers (
drive_upload.py,process_clips.py,schedule_clips.py,verify_schedule.py,wait_for_clips.sh,upload.sh,selectors.json,IMPROVEMENTS.md) — from the pre-CLI era when this skill drove the OpusClip web UI via Claude-in-Chrome MCP. Kept for reference but not invoked by the current flow. Most logic is now in theopusclipCLI directly. The one helper still worth its keep isschedule_clips.py(pure slot-math for the 5/day/channel cap); if you're hand-rolling a Buffer schedule from clip output, it's reusable.
Related skills
opusclip:opusclip(plugin) — canonical CLI reference. Always defer to this for CLI command surface; it stays up to date with the plugin version on disk./promote-newsletter,/carousel-newsletter— downstream fan-out./flywheel— measures clip-output impact on long-form throughput priority.