Publish to YouTube · dispatch targets

Three ways out. One is the default, and it's built.

When you approve a package's youtube_title_set asset, the dispatch worker routes it by the brand's youtube_dispatch_target. There are three settings: Direct YouTube Data API v3 (uploads straight from this Mac — built and recommended), Zernio (cloud publishing — the only path for Shorts in v1), and manual (the safe default — you paste fields into YouTube Studio yourself). This page is the current, code-accurate map of all three.

At a glance

One approved asset, three exits.

The pickTarget() switch in workers/kinds/dispatch.ts decides. Only youtube_title_set triggers a long-form upload; its sibling youtube_description / youtube_chapters / youtube_tags assets ride along inside that one call (or are pasted, on the manual path).

APPROVED ASSET youtube_title_set status = approved → dispatch worker pickTarget() by brand youtube_direct Upload via Data API v3 ▶ YOUTUBE videoId · privacy=private zernio Cloud publish (LATE) TT + IG + YT Shorts one createPost, many nets manual · default Recorded as dispatched YOU paste in Studio no API call made

Default youtube_dispatch_target = 'manual'. Direct fires only when the brand opted in and an OAuth refresh token is present — otherwise it falls back to manual rather than failing silently.

Side by side

What each commits you to.

All three end with your video published. They differ in who holds the auth, whether a public URL is needed, and whether they cover Shorts and other networks.

① Direct YouTube API recommended · built ② Zernio built · Shorts ③ Manual paste default
how the file reaches YT The dispatch worker streams original.mp4 straight to YouTube's resumable upload endpoint Zernio fetches the rendered clip from your signed media URL, then publishes it to its connected networks You upload the file by hand at studio.youtube.com
public URL needed no — Mac → Google, direct HTTPS yes — signed /media/* via CLOUDFLARE_TUNNEL_HOSTNAME + MEDIA_URL_SECRET no
who holds the OAuth You — per brand, refresh token encrypted on brands.youtube_oauth Zernio holds the platform connections; you hold a Zernio API key You, in the browser — nothing stored
YT API quota lives on Your Google Cloud project (≈10k units/day = ~6 uploads/day) Zernio's app, plus a §9.6 cap of 20 posts/account/day n/a — browser upload
covers long-form video yes — its whole job technically — but Direct is the long-form path; Zernio shines for Shorts yes
covers YT Shorts deferred to v2 yesrendered_short_clip → TT + IG + YT Shorts in one call manual upload
covers LinkedIn / X / TikTok no yes — same pipeline routes those too no
privacy / scheduling Full: public / unlisted / private (default) / schedule via per-package picker → videos.insert privacy='schedule' + publish_at → Zernio scheduledFor You pick it in YouTube Studio
operator setup ~5 min — Google Cloud OAuth client, paste GOOGLE_OAUTH_CLIENT_ID/SECRET in /settings, click Connect YouTube on the brand ~10 min — set ZERNIO_API_KEY, connect networks in Zernio, paste acc_… on the brand, stand up a signed-media tunnel none
good for Local-first, single-creator setups where YouTube long-form is the primary destination Anyone cross-posting Shorts + social, who already lives in Zernio One-offs, smoke tests, or when you simply want control
Direct YouTube Data API v3

ChannelHelm uploads the file straight to YouTube's resumable endpoint. No tunnel, no third party — just OAuth between your brand and Google. Already implemented in workers/integrations/youtube.ts.

recommended
YOUR MAC (worker) dispatch worker decrypt refresh_token refresh → access_token bundle title/desc/tags stream original.mp4 GOOGLE OAUTH oauth2.googleapis.com grant_type= refresh_token scope: youtube.upload short-lived token back YOUTUBE DATA API v3 videos.insert (resumable) snippet + status privacyStatus=private thumbnails.set (AI image) returns videoId BACK IN CH ▶ youtu.be/… asset → dispatched siblings flipped too URL → package chip dispatches row logged

Honors the per-package privacy picker. With schedule, the worker normalizes to private + publishAt — YouTube auto-flips public at the scheduled time.

What you do

  • Google Cloud Console (5 min, free) — create a project, enable YouTube Data API v3, configure the OAuth consent screen (External; add yourself as a Test user; scopes youtube.upload + youtube), then create one OAuth client of type Web application with redirect URI http://localhost:3000/api/youtube/oauth/callback.
  • ChannelHelm — paste GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET in /settings. One client serves every brand.
  • On /brands/[id], click Connect YouTube → Google consent → back with the refresh token saved (encrypted) on the brand row. Set the brand's dispatch target to youtube_direct.
  • Approve the package's youtube_title_set → dispatch worker uploads → the red ▶ youtu.be/… chip populates.

What's already built

  • brands.youtube_oauth jsonb (encrypted refresh_token, channel_id/title, scope, connected_at) + brands.youtube_dispatch_target text default 'manual'.
  • workers/integrations/youtube.tsyoutubeAuthUrl(), on-demand token refresh, uploadVideo() (resumable, streams from disk), thumbnails.set of the picked concept (an AI-generated image, or a captured frame on the fallback path).
  • OAuth routes: /api/youtube/oauth/start · /callback · /disconnect.
  • dispatch.ts youtube_direct branch: bundles siblings, honors privacy/schedule, mirrors the URL onto packages.intelligence.published.youtube, flips youtube_description/chapters/tags to dispatched in the same transaction.
  • Per-package picker via setYoutubePublishOptions(); per-brand routing via setYoutubeDispatchTarget().
# Google Cloud Console — one-time, free tier sufficient
1. New project → "ChannelHelm"
2. APIs & Services → Library → enable YouTube Data API v3
3. OAuth consent → External → scopes: youtube.upload, youtube
4. Credentials → OAuth client ID → Web application
   Authorized redirect URI: http://localhost:3000/api/youtube/oauth/callback
5. Copy Client ID + Client secret → paste in /settings
   GOOGLE_OAUTH_CLIENT_ID · GOOGLE_OAUTH_CLIENT_SECRET
Pros
  • No public URL ever — the file streams Mac → YouTube directly over HTTPS
  • You own the OAuth app; the quota is yours alone
  • Full native field surface — title, description, tags, privacy, schedule, thumbnail (an AI-generated image, with frame-extract fallback)
  • Matches the local-first constraint perfectly; survives reboots (tokens persist in the DB)
  • If Zernio has an outage, YouTube still works
Cons
  • Long-form only — Shorts route through Zernio in v1 (Direct Shorts is v2)
  • Doesn't solve LinkedIn / X / TikTok — those need Zernio
  • ≈10k units/day ≈ 6 uploads/day; a quota bump from Google is needed to scale
  • Re-encrypting PROVIDER_SECRET_KEY would break the saved refresh token (same caveat as LLM provider keys)
Zernio (cloud publishing)

Zernio is the external publishing API. It routes social posts and rendered clips, and one post can hit TikTok + Instagram + YouTube Shorts at once. In v1, this is the only path for Shorts.

built · Shorts + social
YOUR MAC dispatch worker rendered_short_clip signedMediaUrl() createPost(...) platforms[]+caption SIGNED MEDIA URL /media/* (tunnelled) CF tunnel or similar MEDIA_URL_SECRET HMAC-signed link Zernio fetches clip ZERNIO (LATE) posts.create downloads clip scheduledFor (opt.) fan-out to networks webhook → CH NETWORKS — one call TikTok Instagram YT Shorts BACK IN CH webhook /api/webhooks /zernio → published collides on event id

Shorts (rendered_short_clip) and social (linkedin_post / x_post / x_thread) all flow this way. A §9.6 cap of 20 successful posts/account/24 h requeues to the next UTC day rather than failing.

What you do

  • Set ZERNIO_API_KEY in /settings and connect your networks (TikTok / IG / YouTube) in the Zernio dashboard.
  • Copy each network's acc_… id into the brand's zernio_accounts map at /brands/[id].
  • Stand up a signed-media surface so Zernio can fetch rendered clips — set CLOUDFLARE_TUNNEL_HOSTNAME + MEDIA_URL_SECRET + MEDIA_REQUIRE_SIGNATURE=1.
  • Render the Shorts (approve the short_clip_planclip_render), then approve each rendered_short_clip → it dispatches via Zernio.

What's already built

  • workers/integrations/zernio.tscreatePost(), resolveZernioPlatforms(); thin typed fetch fallback in zernio_http.ts.
  • dispatch.ts zernio branch: per-clip platform toggles (publish_options.platforms), per-clip scheduling (privacy='schedule' + publish_atscheduledFor), signed media URLs for clips.
  • Inbound webhook receiver at /api/webhooks/zernio, idempotent on (source, source_event_id).
  • Daily-limit enforcement (§9.6) with RequeueLater.
Pros
  • One pipeline for Shorts and LinkedIn / X / TikTok
  • One createPost fans out to many networks at once
  • Per-clip platform toggles + scheduling already wired through the Shorts editor
  • You don't maintain per-platform OAuth — Zernio does
Cons
  • Needs a permanent signed public surface for clip media (a tunnel)
  • External cloud dependency — Zernio's outage = no dispatch
  • Quota shared on Zernio's app + a 20/account/day cap
  • For long-form YouTube, Direct (①) is the better fit; Zernio is the Shorts/social path
Manual paste

The default and always-available path. ChannelHelm gives you every field; you upload the MP4 and paste them into YouTube Studio. The dispatch is still recorded for auditability — it just makes no API call.

default · zero setup
CHANNELHELM STUDIO Approve youtube_* assets Copy buttons per field title · desc · chapters · tags dispatch logged = manual YOU (browser) studio.youtube.com upload original.mp4 paste each field pick an AI thumbnail YOUTUBE ▶ video live your choice of privacy no webhook back STATUS asset = dispatched stays here (no external state)

No YouTube webhook exists for manual uploads, so the asset settles at dispatched rather than published. Switch the brand to youtube_direct to get true published tracking.

Pros
  • Zero setup — works the moment assets are approved
  • Full control over every field and the upload itself
  • No third party, no tokens, no quota
Cons
  • Hands-on every time — copy, paste, repeat
  • No automated status — settles at dispatched, never published
  • Doesn't scale past a handful of videos
For the Direct path

The OAuth connect flow.

One Google Cloud OAuth client serves every brand. Each brand connects once; the refresh token lands encrypted on its row. Re-running just overwrites it.

1 OPERATOR · /brands/[id] Click “Connect YouTube” → /api/youtube/oauth/start?brandId= 2 GOOGLE CONSENT accounts.google.com scope: youtube.upload + youtube brandId carried in ‘state’ 3 CALLBACK · YOUR MAC /api/youtube/oauth/callback exchange code → refresh_token read channel id + title 4 ENCRYPT + STORE brands.youtube_oauth (AES-256-GCM) refresh_token · scope · connected_at 5 READY Set dispatch target = youtube_direct uploads now flow automatically Prereq · /settings: GOOGLE_OAUTH_CLIENT_ID + GOOGLE_OAUTH_CLIENT_SECRET — one client, all brands · redirect URI must match exactly
Per-package picker

Privacy & scheduling, decided.

The per-package picker writes packages.intelligence.publish_options.youtube. The Direct branch reads it and normalizes. The default is private — nothing goes public by accident.

PER-PACKAGE PICKER setYoutubePublish Options() → intelligence.publish_options public privacyStatus = public · live now unlisted privacyStatus = unlisted · link-only private <default> privacyStatus = private · only you schedule needs publish_at, ≥ 60 s ahead NORMALIZE (worker) schedule → (private, publishAt) YouTube auto-flips public at the time server rejects publish_at < now + 60 s → videos.insert.status.privacyStatus public / unlisted / private map 1:1 to YouTube’s native field. Zernio path instead uses: schedule + publish_at → scheduledFor (per-platform privacy is operator-managed)
The whole routing table

Asset type → where it goes.

YouTube is one of three downstream systems. This is the complete pickTarget() map: each asset type resolves to exactly one of DojoClaw, Zernio, YouTube Direct, or manual.

ASSET TYPE article_brief linkedin_post x_post · x_thread rendered_short_clip rendered_long_clip youtube_title_set * youtube_description youtube_chapters · _tags thumbnail_concept · transcript TARGET DojoClaw local LAN · article syndication Zernio cloud · social + Shorts fan-out YouTube Direct Data API v3 · long-form only manual operator pastes · always available

* youtube_title_set resolves to YouTube Direct only when the brand has youtube_dispatch_target='youtube_direct' AND a refresh token; otherwise it falls back to manual. The other youtube_* assets are pasted manually (or ride along inside the Direct upload). *_plan assets are never dispatched — they're rendered into rendered_* first.

Decide

Match your situation.

If…

Pick by what you actually publish, not by what sounds fancy.

① Direct YouTube long-form is your primary destination and you want it to “just work” with no public surface. Single creator, local-first. This is built, recommended, and the path of least resistance. Set the brand to youtube_direct, connect once.
② Zernio You ship Shorts (the only v1 path) or cross-post to LinkedIn / X / TikTok. One createPost hits multiple networks; per-clip toggles and scheduling are already wired through the Shorts editor. Needs a signed-media tunnel.
③ Manual You want zero setup, full control, or you're just doing a one-off. Works the instant assets are approved — copy, paste, publish. The trade-off is no automated status tracking.
① + ② together The common real setup: Direct for long-form YouTube, Zernio for the Shorts and social cross-posts cut from the same video. They coexist — routing is per asset type.

Connect a brand.

Direct is built. Paste GOOGLE_OAUTH_CLIENT_ID/SECRET in /settings, click Connect YouTube on the brand, set the dispatch target to youtube_direct, and the next approval uploads itself.

← back to package readiness report how it works