Skip to content

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 for mint_token.py; the runner image does not carry it).
  • kubeseal configured against the cluster (sealed-secrets controller in ns sealed-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 v3Enable.
  • Project id noted; API enabled on that project.
  • [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.json on the workstation.
  • client_secret.json holds client_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/):
pip install google-auth-oauthlib==1.4.0
python mint_token.py client_secret.json

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:

{ "YT_CLIENT_ID": "...", "YT_CLIENT_SECRET": "...", "YT_REFRESH_TOKEN": "..." }
  • Copy those three values into the sealed secret (next step). Delete client_secret.json and 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 for render.py's youtube_upload().
  • Rotation note: the runner reads YT_REFRESH_TOKEN from 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:
stringData:
  PEXELS_API_KEY: "CHANGE_ME"
  • 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 to private, regardless of the privacyStatus sent. 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 privacy unset (or "private") in the job spec JSON — render.py defaults 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 tagAMAZON_ASSOC_TAG in the sealed secret (§5). The render step (render.py's render_description()) appends ?tag=<tag> to any amazon. URL in the job spec's affiliate_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 articleUrl field (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}, first start must 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 disclosurerender.py always sets status.containsSyntheticMedia: true and 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) Google 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 via videos.insert/videos.update since 2024-10-30 (not Studio-only).