Setup guide · new operator

Set up ChannelHelm from zero.

Nine steps from "fresh Mac" to "I just uploaded a video to YouTube directly from ChannelHelm" — plus turning it into an always-on fleet. Each step shows you exactly what to click, where to type, and what success looks like. The trickier steps come with mockups of the relevant ChannelHelm screen so you know what you're looking for.

0Prerequisites 1Install & run dev 2Create your first brand 3Configure an LLM provider 4Add a video 5Review in the Studio 6Connect YouTube 7Approve & publish 8Final review on YouTube 9Run it for real (launchd)

step 0Prerequisites

ChannelHelm runs local-first on a Mac. You need the following before pnpm install.

Required CLI tools

# Install Homebrew if you don't have it
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Core stack
brew install node@22 pnpm postgresql@16 ffmpeg yt-dlp uv

# Start Postgres at boot + create a DB
brew services start postgresql@16
createdb channelhelm

# Python for the ML CLIs (uv manages a venv in ml/)
cd ml && uv sync && cd ..

# Headline thumbnails (drawtext) + burned-in Shorts captions (ass) need an
# ffmpeg built with libfreetype + libass. If `ffmpeg -filters | grep drawtext`
# is empty, switch to the full build:
brew install ffmpeg-full && brew unlink ffmpeg && brew link --force --overwrite ffmpeg-full

What does what

Tip A Mac with Apple Silicon (M1 or later) and ≥16 GB RAM runs the standard pipeline comfortably. For premium_multimodal with mlx-vlm you'll want a Mac Studio class machine (M2 Ultra+).

How the pieces depend on each other

Next.js app node@22 · pnpm Worker fleet tsx runner.ts PostgreSQL 16 brews · the queue ffmpeg audio · clips yt-dlp URL ingest uv · Python 3.12 4 ml/*.py CLIs LLM provider /providers · HTTP DojoClaw · Zernio dispatch targets brew installs the row of system tools · uv manages the Python venv in ml/ · LLM + DojoClaw/Zernio are configured in-app, not installed

dependency tree Both Node processes need Postgres. The worker shells out to ffmpeg, yt-dlp and the uv-managed Python CLIs, and talks to your LLM provider + dispatch targets over HTTP.

step 1Install & run dev

Clone, install, migrate, start. The dev script runs the Next.js server AND the worker fleet together so generation auto-starts when you add a video.

# Clone
git clone <your channelhelm repo URL> channelhelm
cd channelhelm

# Install deps
pnpm install

# Configure env (copy + edit)
cp .env.example .env
# at minimum set the 5 BOOT-ONLY keys (these can ONLY change via .env + restart):
#   DATABASE_URL=postgresql://<you>@localhost:5432/channelhelm  (or @m4max.local for the fleet)
#   MEDIA_ROOT=/Users/<you>/Dev/channelhelm/media              (any absolute path)
#   PROVIDER_SECRET_KEY=<32-byte hex>  →  openssl rand -hex 32  (wraps provider API keys)
#   LOCAL_BEARER_TOKEN=<random>        →  openssl rand -hex 32  (single-operator API auth)
#   ARCHIVE_ROOT=                       leave blank unless you run the archive worker
# Everything else (Zernio, DojoClaw, Google OAuth, HF_TOKEN, MAX_UPLOAD_BYTES…)
# is editable later at /settings — no restart. Leave it blank for now.

# Apply migrations (drizzle-kit migrate — runs 0000…0009 in order)
#   …0007 archive lifecycle · 0008 youtube_oauth_states · 0009 image providers
pnpm db:migrate

# Run web + workers together (web + a single worker holding all kinds)
pnpm dev:all

# Optional: dial worker concurrency (default 3; LLM-bound kinds benefit most)
#   WORKER_CONCURRENCY=6 pnpm dev:all

Open http://localhost:3000 in your browser. You'll see the empty dashboard. Both processes need to be running for the pipeline to work end-to-end.

localhost:3000
ChannelHelm + New Brands Jobs Providers Settings ⌕ Jump to package, brand, or job…
No packages yet

Add a brand to get started → + New brand

What you see Empty dashboard. The top nav shows the five main destinations.

Important If you see "Settings table not migrated yet" on the settings page, you missed pnpm db:migrate. Run it and reload.

What boots, in what order

pnpm dev:all just runs Next.js and one worker process side by side. In production each box is a separate launchd service (see Run it for real). Either way the dependency order is the same:

prerequisite PostgreSQL 16 brew services start once / on schema change pnpm db:migrate 0000 → 0009 app · port 3000 Next.js (pnpm dev) lazy-hydrates /settings worker fleet tsx runner.ts --kinds … --concurrency 3 launchd · every 15 min enqueue-recurring.ts collect_signal · voice · archive pnpm dev:all runs these two

boot order Postgres must be up before migrate; migrate must succeed before the app and workers start; the recurring enqueuer is optional and only matters once you have published packages.

step 2Create your first brand

Every entity in ChannelHelm is brand-scoped. A brand maps 1-to-1 to a YouTube channel and is where you connect your accounts.

  1. Top nav → + NewBrand (or open /brands/new directly).
  2. Fill in Display name (the slug auto-derives in kebab-case — pick a clean name, it's hard to change cleanly later).
  3. (Optional) YouTube channel ID if you know it (looks like UCxxxxxx…). This auto-fills if you ever drop a YouTube URL into the ingest later. Leave blank for now.
  4. Pick the Default processing profilestandard_audio_visual is the right choice for almost everyone. The four tiers (cheapest → richest):
    • transcription_only — audio-only, the cheapest. Skips the visual phase entirely; used for Backlog Revival re-mining of old material.
    • fast_audio_only — audio-only too, batch volume (podcasts, daily uploads) where you just need decent titles + description + tags.
    • standard_audio_visual — the default. Full audio + visual + fusion + intelligence pipeline.
    • premium_multimodal — marquee episodes; maximum reasoning, dense VLM sampling. Wants a Mac Studio class machine.
    The same four choices appear per-package on upload, defaulting to whatever you set here.
  5. Hit Create brand. You land on the brand page.
localhost:3000/brands/new
New brand

One brand = one publishing identity. Multi-brand is root in ChannelHelm.

Display name
Your Brand Name
Slug
your-brand-name · auto-derived
Default processing profile
standard_audio_visual ▾
YouTube channel ID (optional)
UCxxxxxxxxxxxxxxxxxxxxxx
Create brand

What you see The new-brand form. Slug auto-derives from the display name; you can leave the channel ID blank.

step 3Configure an LLM provider

ChannelHelm uses LLMs for one thing: drafting the asset content (titles, descriptions, scripts, posts). It pluggable — OpenAI, Anthropic, OpenRouter, LM Studio, Ollama, the Codex CLI, etc. Pick one to start.

  1. Top nav → Providers (or /providers).
  2. Click + Add provider.
  3. Choose a Quick preset (recommended first time):
    • Claude (Anthropic) — best general-purpose default. Needs an API key from console.anthropic.com.
    • OpenAI — same idea, key from platform.openai.com.
    • LM Studio (Local) — free but slower; needs LM Studio app running with a model loaded.
  4. Paste the API key. Set Purpose to all and check Default — this provider then serves every LLM call.
  5. Add provider.
localhost:3000/providers
LLM Providers + Add provider
Claude (Anthropic) default
anthropic · claude-sonnet-4-6 · all
Test Edit Delete

What you see A configured Anthropic provider with the "default" badge. API keys are stored encrypted at rest (AES-256-GCM) and never sent back to the browser.

Tip Want to learn the routing logic in depth? See the LLM routing field guide for a per-task matrix and three preset configurations.

Add an image provider (optional — for AI thumbnails)

The /providers page holds two kinds of provider, split by a category toggle: Text (the chat/LLM providers above) and Image (text-to-image, for AI thumbnail generation). The two never cross — an image provider is never picked for chat, and an LLM is never picked to paint a thumbnail. If you skip this, thumbnails simply fall back to frame extraction (stills pulled from the video at the best hook timestamps), so it's genuinely optional.

  1. Top nav → Providers+ Add provider.
  2. Flip the category toggle to Image.
  3. Pick the Runware preset (the first image provider type), or set the fields by hand:
    • Type: runware
    • Base URL: https://api.runware.ai/v1
    • API key: your key from runware.ai (encrypted at rest, same as LLM keys)
    • Model: runware:z-image@turbo (a Flux / Z-Image model)
  4. Leave Purpose as all and check Defaultis_default is scoped per category, so this doesn't disturb your default LLM. Add provider.
localhost:3000/providers
Providers Text Image
Runware default
image · runware · runware:z-image@turbo · all
Test Edit Delete

What you see The /providers Image tab with a Runware row. The Image default is independent of the Text (LLM) default — they live in the same table but never cross.

Behind the scenes The thumbnail_concepts worker calls getImageProvider(profile) (rows where category = 'image'). It returns null when no image provider exists → frame extraction. With Runware configured, an LLM writes an image-gen prompt and Runware paints the thumbnails. See the LLM routing field guide for how text vs image resolution works.

step 4Add a video

Two ways: drop a local file, or paste a YouTube URL. Both create a Source + Package and enqueue the ingest pipeline.

Option A · Drop a local file

  1. Top nav → + NewSource (or /sources/new).
  2. Pick your Brand from the dropdown.
  3. Drag your .mp4 (or .mov, .webm, .m4v, .mkv) onto the upload zone, or click to pick a file.
  4. Make sure Create package is checked — this kicks off the pipeline immediately.
  5. Click Upload. The file streams to MEDIA_ROOT, the package is created, you land on the package page.

Option B · Paste a YouTube URL

  1. Top nav → + NewYouTube URL.
  2. Paste the URL (any shape — youtube.com/watch?v=…, youtu.be/…).
  3. ChannelHelm auto-discovers the brand from the channel ID. If a brand with that channel doesn't exist, it offers to create one.
  4. Click Ingest. yt-dlp downloads the video, the pipeline starts.
localhost:3000/sources/new
Add a source
Brand
Your Brand Name ▾
Source type
Uploaded video ▾
⬆ Drop your .mp4 here
or click to browse · max 2 GB
Upload

What you see The upload form. Streams the file to MEDIA_ROOT/<brand-slug>/<source-id>/original.<ext>, hard-capped by MAX_UPLOAD_BYTES.

Behind the scenes Once the package is created, the worker fleet picks it up: ingest → audio extraction + scene-cut detection → transcribe_audio (MLX Whisper) and analyze_visual (mlx-vlm + Apple Vision OCR) in parallel → fuseanalyze_intelligence (LLM) → fan-out: generate_asset ×N + thumbnail_concepts. For an ~8 min video this takes a few minutes total on M-series hardware.

step 5Review in the Studio

When the package reaches ready_for_review, the package page (the "Studio") is your single command center for the upload.

localhost:3000/packages/pkg_…
← All packages · pkg_…
Your video's top-scored title
Your Brand Name· standard_audio_visual· 8:19· Ready
▶ video player
Titles · 5 Regenerate · Copy selected
○ Title variant 1 — score 93/100
○ Title variant 2 — score 91/100
○ Title variant 3 — score 88/100
YouTube Description
Hook + body + chapters block + CTA + hashtags …
YouTube Tags · 15
tag one tag two tag three

What you see The Studio. Left: assets (cards per type, each with Copy/Regenerate). Right: the approval panel — every asset checkbox you tick will be dispatched when you click the button.

What's on this page

step 6Connect YouTube (one-time per brand)

This is the longest step but only done once per brand. Two halves: (a) create a Google OAuth app once for all your brands, (b) authorize the brand to upload to your specific channel.

6a · Create the Google OAuth app (one-time)

  1. Open console.cloud.google.com — sign in as the Google account that owns your YouTube channel. (If different from your main account, use an incognito window signed in as the channel-owning account, or switch profiles.)
  2. Top dropdown → New Project → name it "ChannelHelm" → Create. Select it once provisioned.
  3. APIs & Services → Library → search "YouTube Data API v3" → Enable.
  4. APIs & Services → OAuth consent screen:
    • User type: External → Create
    • App name: ChannelHelm, support email + dev contact: your email
    • Scopes → Add or Remove → check youtube.upload AND youtube
    • Test users → add the Google email that owns your YouTube channel
    • Save through to the dashboard
  5. APIs & Services → Credentials → + Create credentials → OAuth client ID:
    • Application type: Web application
    • Name: ChannelHelm local
    • Authorized redirect URI: http://localhost:3000/api/youtube/oauth/callback (exact, no trailing slash)
    • Create
  6. Modal pops up with Client ID + Client secret. Copy both, or click Download JSON.

Where to find the Test users section if you missed it

Google rebranded the OAuth UI recently — what used to be on the "OAuth consent screen" page is now split. Test users lives under APIs & Services → Google Auth Platform → Audience (scroll to the "Test users" section near the bottom). Add the channel-owning email there.

6b · Paste the credentials into ChannelHelm

The two OAuth keys are runtime-editable settings — you paste them on the /settings page and they propagate to every running process with no restart. That's a different mechanism from the five boot-only keys you put in .env. Here's the decision rule and how a save propagates:

Which knob do I turn? Does a restart break it? yes — boot-only Edit .env + restart DATABASE_URL · MEDIA_ROOT ARCHIVE_ROOT · *_SECRET_KEY · TOKEN no Type it at /settings live · no restart How a /settings save propagates You click Save setSetting(key, value) Postgres · settings table UPSERT (AES-256-GCM for secrets) → pg_notify('chs_settings', key) Next.js process refresh into process.env[key] Worker (LISTEN) refresh into process.env[key] every consumer keeps reading process.env.X — unchanged

env vs DB Boot-only keys live in .env and need a restart. Everything else is DB-backed and propagates live via pg_notify on the chs_settings channel. Both land in process.env, so every reader is unchanged. (Full architecture: docs/settings.md.)

  1. Open /settings in your browser.
  2. Find GOOGLE_OAUTH_CLIENT_ID → paste the long …apps.googleusercontent.com string → Save.
  3. Find GOOGLE_OAUTH_CLIENT_SECRET → paste the GOCSPX-… string → Save. (Encrypted at rest immediately; subsequent reloads show the masked placeholder.)
localhost:3000/settings
Settings
Runtime settings
GOOGLE_OAUTH_CLIENT_ID set
OAuth 2.0 Client ID from Google Cloud Console. One client supports all brands.
123456789-abc….apps.googleusercontent.com
Save
GOOGLE_OAUTH_CLIENT_SECRET secret set
OAuth 2.0 Client secret. Encrypted at rest; never sent back to the browser.
•••••••• saved
Save

What you see The two new GOOGLE_OAUTH_* keys on /settings, both with the green set badge after saving.

6c · Connect the brand to your YouTube channel

  1. Open /brands/<your-brand-id> in the same browser context that's signed in as the YouTube-channel-owning Google account (use incognito if your main browser is signed in differently).
  2. Scroll to the YouTube connection card.
  3. (Optional but recommended) Type your channel-owning Google email in the "Pick the right Google account" input — this pre-fills Google's account chooser.
  4. Click ▶ Connect YouTube as <your-email>.
  5. You'll be redirected to Google. Sequence:
    • Account chooser → pick the channel-owning Google account
    • "This app isn't verified" warning → Advanced → Go to ChannelHelm (unsafe). (Your personal OAuth app, your project — "unsafe" here means "Google hasn't verified you to yourself" — fine.)
    • Consent screen lists 2 scopes — Allow
  6. You land back on the brand page with a green "✓ Connected to <your channel name>" banner. The card shows the channel info + a dispatch-target picker.
  7. Click the Direct (this connection) radio. Commits immediately.
localhost:3000/brands/<brand-id>
Your Brand Name
YT
YouTube connection
Direct upload via YouTube Data API v3 — bypasses Zernio.
connected
✓ Connected to Your Channel Name — tokens encrypted at rest.
Your Channel Name
UCxxxxx… · connected just now
Dispatch target for this brand's YouTube videos

What you see The brand page with YouTube connected. Direct is selected → approving the package will upload directly via the YouTube Data API.

Tip The refresh token Google issues is encrypted with your PROVIDER_SECRET_KEY and stored on the brand row. Disconnect at any time clears it (and resets the dispatch target to Manual). To revoke from Google's side, visit myaccount.google.com/permissions.
Gotcha If you see "Google did not return a refresh_token" after the consent screen, you've granted ChannelHelm access before — Google only issues a refresh_token on the first grant. Revoke the existing grant at myaccount.google.com/permissions, then click Connect again.

step 7Approve & dispatch

Back to the package page. Now that the brand is connected and set to Direct, the four YouTube assets will trigger an actual upload to your channel.

  1. Open the package: /packages/<package-id>.
  2. In the Approve & Publish panel on the right, make sure the four YouTube rows are checked:
    • ☑ YouTube Title Set (the dispatch trigger — bundles the others into the upload)
    • ☑ YouTube Description
    • ☑ YouTube Chapters
    • ☑ YouTube Tags
  3. Uncheck anything you don't want this round. Locked rows (greyed out with ) can't be selected — they need other config (Zernio account, DojoClaw key, etc.).
  4. Click Approve & dispatch · N.
  5. A teal "dispatching N · auto-refresh every 2s" indicator appears below the button. The panel polls the API every 2 seconds; row statuses update live as the worker progresses.
localhost:3000/packages/pkg_…
Approve & Publish
4 / 12
YouTube Title Set
YouTube Description
YouTube Chapters
YouTube Tags
↗ Approve & dispatch · 4
dispatching 4 · auto-refresh every 2s

What you see Right after clicking. The teal pulsing pill confirms the worker has picked the jobs up and the panel is auto-refreshing.

What happens behind the scenes

The dispatch worker picks up the youtube_title_set job, sees the brand is configured for youtube_direct with valid OAuth tokens, then:

  1. Refreshes the OAuth access token (Google API auto-refresh).
  2. Bundles the title + description + tags from the sibling assets on the same package.
  3. Resolves the source video path (MEDIA_ROOT/<brand-slug>/<source-id>/original.<ext>).
  4. Streams the file to YouTube's resumable upload endpoint (~1–3 min for an 8 min video).
  5. (Optional) Uploads the top-ranked thumbnail concept as a separate API call.
  6. Writes the returned YouTube URL onto package.intelligence.published.youtube AND flips the other three youtube_* assets to dispatched.

When it completes

localhost:3000/packages/pkg_…
← All packages · pkg_…
Your video's top-scored title
Your Brand Name· standard_audio_visual· 8:19· Dispatched ▶ youtu.be/<id>

What success looks like Package status flipped to "Dispatched" (green) and the red youtu.be pill is live. Click it.

If it fails The row shows failed with the exact worker error inline (red box under the row). Common causes:

step 8Final review on YouTube

ChannelHelm uploads videos as private by default. One more click on YouTube's side and you're live.

  1. Click the red ▶ youtu.be/<id> pill on the package header → opens the video on YouTube in a new tab.
  2. Click Edit video in YouTube Studio (or the pencil icon).
  3. Review:
    • Title — should match the one you picked in ChannelHelm
    • Description — full hook + body + chapters + CTA + hashtags (YouTube auto-renders the chapter timestamps as a chapter bar on the player)
    • Tags — all of them present
    • Thumbnail — pick from ChannelHelm's two concepts or upload your own design
  4. When happy → Visibility → Public → Save. Video goes live to your subscribers.
Why private by default? Safer first cut — you get to review what was uploaded before it hits your subscribers' feeds. If you want to skip the manual gate, you can change the default in workers/integrations/youtube.ts::uploadVideo (look for privacyStatus: 'private').

You're done.

Subsequent videos are now a 5-minute loop: drop the file → wait for the pipeline (a few minutes) → review in the Studio → Approve & dispatch → 1-click flip-to-public in YouTube Studio.

step 9Run it for real (launchd)

pnpm dev:all is perfect for a single Mac you're sitting at. For a fleet — or just an always-on master — you split the web server, the worker(s) and the recurring enqueuer into launchd services. Templates live in infra/launchd/.

Worker roles & the --concurrency flag

A worker is just tsx workers/runner.ts --kinds <csv> --concurrency <N>. You assign job kinds to Macs by their strengths. Concurrency defaults to 3; each slot is an independent claim→run→ack loop and the queue's SELECT FOR UPDATE SKIP LOCKED is the only mutex, so it's always safe to add slots. LLM-bound kinds (generate_asset, analyze_intelligence) benefit most.

# Master Mac (M4 Max): orchestration + media-heavy kinds
tsx workers/runner.ts --kinds ingest,fuse,clip_render,thumbnail_concepts,dispatch --concurrency 3

# LLM box (M3 Ultra): transcription, vision, intelligence, drafting
tsx workers/runner.ts --kinds transcribe_audio,analyze_visual,analyze_intelligence,generate_asset --concurrency 6

# Mac Mini: spare drafting + dispatch + analytics + voice promotion
tsx workers/runner.ts --kinds generate_asset,dispatch,collect_signal,promote_voice_examples --concurrency 4

# Or set it once via env (picked up by the runner default):
WORKER_CONCURRENCY=6 tsx workers/runner.ts --kinds generate_asset
Tip The shipped plists in infra/launchd/ don't pass --concurrency, so they run at the default of 3. Add <string>--concurrency</string><string>6</string> to the ProgramArguments array (or set WORKER_CONCURRENCY in the plist's EnvironmentVariables) to raise it.

The recurring enqueuer (analytics, voice, archive)

Some work isn't triggered by a click — it's periodic. scripts/enqueue-recurring.ts enqueues three job kinds on a timer:

Wire it into launchd via infra/launchd/com.channelhelm.recurring.plist — it carries StartInterval=900 so it fires every 15 minutes. The script uses time-windowed idempotency keys, so an over-trigger is harmless. Run it by hand any time:

# Install the recurring service (copy → edit WorkingDirectory + env → load)
cp infra/launchd/com.channelhelm.recurring.plist ~/Library/LaunchAgents/
launchctl load ~/Library/LaunchAgents/com.channelhelm.recurring.plist

# Or just run it manually whenever you want
tsx scripts/enqueue-recurring.ts --stale-hours 6 --archive-after-days 14

Backfill Shorts renders

New short-clip plans auto-render via the pipeline's auto-render hook. But if you import old packages, change render settings, or a render slipped through, scripts/render-shorts.ts enqueues clip_render jobs for any plan with missing clips. It's idempotent — re-running is a no-op once everything's queued.

# All packages, missing clips only
tsx scripts/render-shorts.ts

# Scope to one package or brand; preview without enqueuing; force every clip
tsx scripts/render-shorts.ts --package-id pkg_xxx
tsx scripts/render-shorts.ts --brand-id brd_xxx --dry-run
tsx scripts/render-shorts.ts --force
Heavy work is worker-only Transcription, VLM, OCR and ffmpeg never run inside Next.js routes or Server Actions — they're always enqueued and picked up by a worker. If a worker process isn't running, jobs just sit queued in the dashboard. pnpm dev:all starts one for you; in prod that's a launchd service.
What's next

Beyond YouTube.

Each of these adds a destination to your dispatch flow. None are required — set them up only if you'll use them.

📝 Connect DojoClaw (article briefs)

Set DOJOCLAW_API_URL + DOJOCLAW_API_KEY in /settings. The article_brief asset on every package will then dispatch to DojoClaw on approval.

📱 Connect Zernio (LATE — socials)

Set ZERNIO_API_KEY in /settings, connect accounts in the Zernio dashboard, paste the acc_… ids in brand.zernio_accounts per platform. Enables LinkedIn / X / TikTok / Instagram dispatch.

🔊 Public webhooks (Cloudflare Tunnel)

For Zernio to notify ChannelHelm when posts go live: stand up cloudflared, paste the hostname in CLOUDFLARE_TUNNEL_HOSTNAME, set ZERNIO_WEBHOOK_SECRET on both sides. See docs/cloudflare-tunnel.md.

🎬 Voice examples (style memory)

The Studio's per-asset Approve action automatically captures your edits as "voice examples" — next package's LLM calls use them as few-shot context. Nothing to configure; just keep editing.

🗄 Storage lifecycle (archive old media)

Set ARCHIVE_ROOT (boot-only, in .env) to an external drive to enable the archive_package worker. Tune ARCHIVE_AFTER_DAYS (default 14) and ARCHIVE_DELETE_CLIPS at /settings. Set KEEP_PIPELINE_ARTIFACTS=1 to keep intermediate frames/JSON for debugging. Full map: storage lifecycle.

Ship.

The whole first-run flow lives behind the same dashboard you started with. Add a video and run through it end-to-end — the second one is faster.

← back to dashboard