You take a finished project (/create-video or /create-short) and put it on the creator's
channel on the schedule from its publish kit. YouTube does the scheduling: each video is
uploaded now as private with a publishAt time and YouTube makes it public at that moment.
Nothing has to run at post time — no cron, no cloud agent, the laptop can be off.
The channel (name, handle, timezone, posting slot) comes from config.yaml → channel:;
upload defaults from config.yaml → publisher:. Never hardcode a channel. The OAuth token in
.credentials/ decides which channel is written to, and cli.py youtube-auth checks it against
channel.handle.
This is outward-facing. Uploading publishes the creator's work under their name. Show the
dry run and get an explicit "yes" before any real upload, every time. Never use
--confirm-public unless they asked for an immediate public post.
0. Is this machine connected?
.venv/bin/python cli.py youtube-auth # prints "Authorised <channel> (@handle) channel id UC…"
- Works → step 1.
- "client secrets not found" → first-time setup. Walk the creator through
references/setup.mdone part at a time (it is all browser clicks in Google Cloud; ask for a screenshot when they are unsure — the console UI moves around). Do the parts you can yourself: moving the downloaded JSON, publishing the privacy-policy page withgh(only when they ask you to; it creates a public repo under their account). - Browser login needed → the creator runs it with a leading
!in the Claude Code prompt (! .venv/bin/python cli.py youtube-auth) so the browser opens on their machine. - Handle mismatch →
--reloginand pick the right Google account / Brand channel.
Also ask once whether their Google Cloud project has passed the YouTube API audit. Until it
has, every API upload is locked private and publishAt is ignored (references/api-facts.md).
Scheduling still "succeeds", so check before promising a schedule; offer the test upload in
step 4 if they don't know.
1. Captions from the script, not from Whisper
.venv/bin/python skills/upload-video/scripts/script_srt.py projects/<id> # -> publish/captions.srt
transcript.srt is Whisper listening to the render, so it carries every mishearing the voice
check caught. script_srt.py uses audio/words.json + the real clip starts in
composition/index.html, and maps phonetic spellings back (caption_word()). Skim the first
cues. Shorts already have burned-in captions; no caption track needed.
2. Write projects/<id>/publish/upload.json
Translate the publish kit (publish/video.md, shorts.md, schedule.md, or a Short's
publish/short.md) into the machine plan. Format, rules and a full example:
references/plan-format.md. In short:
- One item per file, in posting order; the long video first (key
video). publish_at= the date/time fromschedule.md, local tochannel.timezone("2026-10-09 19:30"). Must be ≥ 15 min in the future (a past time publishes immediately).- Titles ≤ 100 chars, descriptions ≤ 5000, tags ≤ 500 (commas and quotes count). No
<or>anywhere in titles/descriptions — YouTube rejects them (unique_ptr<int>→unique_ptr). - Link a Short to the long video with
{url:video}; it becomeshttps://youtu.be/<id>once the video is uploaded (the link works when the video goes public). - Long video:
"thumbnail": "poster.jpg","captions": "publish/captions.srt","playlist": true. Shorts: no thumbnail by default (the API sets it but YouTube shows it only on some surfaces). - AI disclosure (
contains_synthetic_media): ask the creator once. A cloned voice saying words they never recorded is the kind of realistic synthetic content YouTube asks to label; recommendtrue, but it is their call. Suggest saving the answer inconfig.yaml→publisher.contains_synthetic_mediaso you never ask again. The command refuses to run while it's unset.
3. Dry run → show → get a yes
.venv/bin/python cli.py upload --project <id> --dry-run
It validates everything (files, lengths, <>, links, times, Short shape via ffprobe) and prints
the table: key, kind, duration and size, go-live time in the channel timezone and UTC,
title, already-uploaded state. Paste the table to the creator with one line per item and ask
for an explicit go-ahead. Fix every validation error by editing upload.json, not by
skipping items.
4. Upload
# first time on a new API project, or audit status unknown: one private test draft
.venv/bin/python cli.py upload --project <id> --only short_01 --no-schedule --yes
# the real run (after the creator said yes) — long uploads: run in the background
.venv/bin/python cli.py upload --project <id> --yes
- Each item:
videos.insert(private +publishAt, made-for-kids flag, synthetic flag, language) → thumbnail → captions → playlist. The video id is written topublish/uploaded.jsonbefore the side steps, so a crash never causes a re-upload. - Re-running the same command resumes: finished uploads are skipped, failed side steps retry.
- A
--no-scheduletest draft stays private forever. Tell the creator to delete it in Studio after checking it (you don't delete videos), then remove its entry fromuploaded.jsonbefore the real run, or it will be treated as done.
5. Verify what YouTube actually has
.venv/bin/python cli.py upload --project <id> --verify
| privacy | publishes | meaning |
|---|---|---|
| private | a date | scheduled correctly |
| private | – | a --no-schedule draft — or, if it was scheduled, the API project isn't audited (locked) |
| public | – | live |
| any | problem column set | processing/rejection failure: read it to the creator |
The API returning success is not proof; Studio is. Ask the creator to glance at Studio → Content once after the first real run on a new setup.
6. Hand over the manual checklist
The API cannot do these; list them per date from schedule.md:
- Pin the comment (the API can post, not pin) — text is in
video.md. - Shorts → "Related video" link to the long video (no API field).
- End screens and cards (no API).
- Community posts / polls (no API).
- Test & Compare title/thumbnail A/B (no API).
Gates (do not skip)
-
youtube-authshows the creator's channel and it matcheschannel.handle. - Audit status known; if not audited, the creator knows scheduled posts will stay private.
- Captions built from the script (
publish/captions.srt), nottranscript.srt. -
contains_synthetic_mediadecided by the creator, not by you. - Dry run clean (no errors), shown to the creator, explicit yes received.
-
--verifyrun after uploading; every scheduled item shows its publish time. - Manual checklist handed over.
Files
references/setup.md— one-time Google Cloud + OAuth + audit walkthrough, with every console pitfall met so far.references/api-facts.md— the API rules this relies on (publishAt, audit lock, quotas, Shorts detection, what the API can't do), with sources.references/plan-format.md—upload.jsonschema and a complete example.scripts/script_srt.py— exact-script captions.assets/privacy-policy.md— privacy-policy template for the OAuth consent screen.- Code:
cli.py upload/cli.py youtube-auth→modules/publisher/scheduler.py,modules/publisher/youtube_upload.py; tests intests/test_upload_scheduler.py.