Northlight Video — Operator Setup Runbook¶
One-time operator setup to bring the Northlight faceless-YouTube arm online:
a Google Cloud project with the YouTube Data API v3, a headless OAuth
refresh token sealed as the youtube-uploader secret, at least one YouTube
channel, and the cross-monetization wiring (Amazon Associates tag + article
URLs). After this, the render workflow (northlight-video, ns northlight)
uploads autonomously — see the northlight-video-script skill (now at
apps/northlight/video-runner/docs/) for the agent side and
apps/northlight/video-runner/upload/ for the token-mint helper (the upload
itself is youtube_upload() inline in render.py).
Verified against the current YouTube Data API v3 + Google OAuth docs 2026-07-06 (live-docs rule; re-verify before any bump).
Legend: [you] = manual operator action · [auto] = handled by the runner / API · → = where the value/secret goes.
0. Prerequisites¶
- A Google account for the channel(s). Use a dedicated brand account, not a personal one, so the channel is transferable and isolated.
- The workstation venv for the one-time token mint:
pip install google-auth-oauthlib==1.4.0(this lib is only needed offline formint_token.py; the runner image does not carry it). kubesealconfigured against the cluster (sealed-secretscontroller in nssealed-secrets) — you seal, agents cannot.
1. Google Cloud project + enable YouTube Data API v3¶
- [you] console.cloud.google.com → create
a project, e.g.
northlight-youtube. - [you] APIs & Services → Library → YouTube Data API v3 → Enable.
- → Project id noted; API enabled on that project.
2. OAuth consent screen (External)¶
- [you] APIs & Services → OAuth consent screen → User type: External.
- [you] Fill app name (
Northlight Uploader), support email, developer email. - [you] Scopes — add the two minimal upload scopes:
https://www.googleapis.com/auth/youtube.upload(→videos.insert,thumbnails.set)https://www.googleapis.com/auth/youtube.force-ssl(→commentThreads.insert, seed the top comment)- [you] Test users → add the channel's Google account. A Desktop-client refresh token issued to a Test user does not expire, so you can leave the app in Testing — publishing/verification is optional. (If you ever publish the app to Production without Google verification, refresh tokens for sensitive scopes expire in 7 days — stay in Testing.)
- → Consent screen configured, channel account is a Test user.
3. OAuth client — Desktop app¶
- [you] APIs & Services → Credentials → Create credentials → OAuth client ID → Application type Desktop app.
- [you] Download the JSON → save as
client_secret.jsonon the workstation. - →
client_secret.jsonholdsclient_id+client_secret(used next step, then sealed).
4. Mint the refresh token (one-time, on the workstation)¶
The runner never sees a browser. You mint a long-lived refresh token once; the
runner exchanges refresh_token → access_token at runtime.
- [you] Run the offline mint (from
apps/northlight/video-runner/upload/):
A browser opens → sign in as the channel's Google account → grant the two
scopes. mint_token.py forces access_type=offline + prompt=consent, so a
refresh_token is issued. It prints:
- → Copy those three values into the sealed secret (next step). Delete
client_secret.jsonand the printed values from shell history afterward.
5. Seal the youtube-uploader secret¶
- [you] Create the plaintext locally (never commit it), filling real values:
# /tmp/youtube-uploader.plain.yaml (delete after sealing)
apiVersion: v1
kind: Secret
metadata:
name: youtube-uploader
namespace: northlight
type: Opaque
stringData:
YT_CLIENT_ID: "CHANGE_ME" # from mint_token.py
YT_CLIENT_SECRET: "CHANGE_ME" # from mint_token.py
YT_REFRESH_TOKEN: "CHANGE_ME" # from mint_token.py (long-lived)
AMAZON_ASSOC_TAG: "CHANGE_ME" # Amazon Associates tracking id, e.g. northlight-20
- [you] Seal and drop the plaintext (per
sealed-secrets/README.md):
kubeseal --controller-namespace=sealed-secrets --controller-name=sealed-secrets \
--format=yaml < /tmp/youtube-uploader.plain.yaml \
> sealed-secrets/sealed/youtube-uploader.sealed.yaml
shred -u /tmp/youtube-uploader.plain.yaml
- → Commit only
sealed-secrets/sealed/youtube-uploader.sealed.yaml. The render workflow mounts these as env forrender.py'syoutube_upload(). - Rotation note: the runner reads
YT_REFRESH_TOKENfrom env at pod create. Re-mint + re-seal, then let the next render pick up the new value; there is no live rotation.
6. Create / verify the YouTube channel(s)¶
- [you] With the channel's Google account signed in, create the channel at youtube.com (Settings → Create a channel; prefer a Brand Account so it can be co-managed/transferred).
- [you] Set channel name, handle, description, banner/avatar.
- [you] For a second brand/channel, use a separate GCP project (each
project has its own 10k/day quota) and repeat §1–5 with a distinct secret name
(e.g.
youtube-uploader-<brand>). Do not shard one channel across many projects purely to dodge quota — ToS forbids it; pair each project with a distinct channel/brand. - → Channel exists and is owned by the same account that minted the token.
7. Optional — Pexels API key (stock B-roll for visual variety)¶
Optional. Adds real stock footage/photos as a variety source alongside ComfyUI, which helps the anti-slop requirement.
- [you] Get a free key at pexels.com/api.
- [you] Seal it into the same or a sibling secret:
- → The render workflow uses it when a script beat calls for stock B-roll.
8. Quota reality + the private-lock blocker¶
- Default quota: 10,000 units/day.
videos.insert= 1600,thumbnails.set= 50,commentThreads.insert= 50,videos.list= 1 → ~1700 units per full upload → ~5 uploads/day/project. Plan cadence around this (it also aligns with the anti-slop rule — do not mass-dump). - BLOCKER — unverified projects force uploads to
private. Any project created after 2020-07-28 that has not passed the YouTube API Compliance Audit has every API upload forced toprivate, regardless of theprivacyStatussent. Monetization is impossible while private. This is the #1 gate before the pipeline earns anything. - [you] Complete the YouTube API Services — Audit and Quota Extension
form (Google review, days–weeks). Do this before scaling render volume.
Until it clears, leave
privacyunset (or"private") in the job spec JSON —render.pydefaults to"private"— and publish manually in Studio, or use an already-audited client. - Raising quota (in order): (1) pass the Compliance Audit — required both to unlock public API uploads AND to raise the cap; (2) request a quota increase on the audited project; (3) additional audited GCP projects, each paired with a distinct channel/brand; (4) additional channels with their own cadence.
9. Cross-monetization description setup¶
The description is where the arm earns before YT ad-share (which needs 1000 subs - 10M Shorts views / 4000 watch-hours). Priority is monetization-first, quality secondary within YouTube's inauthentic-content limits.
- Amazon Associates tag —
AMAZON_ASSOC_TAGin the sealed secret (§5). The render step (render.py'srender_description()) appends?tag=<tag>to anyamazon.URL in the job spec'saffiliate_items[]and adds the required "As an Amazon Associate we earn from qualifying purchases." disclosure line. - [you] One-time: an Amazon Associates account must have qualifying sales within its trial window or it is closed — drive real clicks early.
- Article URLs — the job spec's
articleUrlfield (a full URL, not a slug — the render step does not resolve anything) is printed into the description as the primary CTA (site traffic = display + affiliate revenue on-site). - Chapters — the job spec's
chapters[]({start, label}, firststartmust be"00:00") are printed straight into the description so YouTube renders the chapter UI. These are not derived from the script's headings — you (or the generating agent) supply the estimated timestamps. - AI disclosure —
render.pyalways setsstatus.containsSyntheticMedia: trueand appends "This video contains AI-generated content." Non-negotiable and automatic.
10. Where each value goes (summary)¶
| Value | Source | Lands in | Consumed by |
|---|---|---|---|
YT_CLIENT_ID / YT_CLIENT_SECRET |
OAuth Desktop client (§3) | youtube-uploader SealedSecret |
render.py's youtube_upload() token exchange |
YT_REFRESH_TOKEN |
mint_token.py (§4) |
youtube-uploader SealedSecret |
render.py's youtube_upload() token exchange |
AMAZON_ASSOC_TAG |
Amazon Associates | youtube-uploader SealedSecret |
render.py's render_description() |
PEXELS_API_KEY (optional) |
Pexels (§7) | SealedSecret | render B-roll step |
| YouTube channel | Brand account (§6) | upload target | |
| Compliance Audit | Google form (§8) | GCP project | unlocks public uploads + quota |
| Article URL | articleUrl in the job spec JSON |
video description | site traffic + affiliate |
Tooling pins (verify before bump — live-docs rule)¶
- Runner:
google-api-python-client==2.198.0,google-auth==2.40.3,google-auth-httplib2==0.2.0. - Operator offline mint only:
google-auth-oauthlib==1.4.0. - AI/synthetic self-label field
status.containsSyntheticMedia— settable viavideos.insert/videos.updatesince 2024-10-30 (not Studio-only).