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.
ChannelHelm runs local-first on a Mac. You need the following before pnpm install.
# 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
node@22 + pnpm — the runtime + package manager for the Next.js app and worker fleetpostgresql@16 — single Postgres instance holds everything (brands, packages, assets, jobs, settings, providers, OAuth tokens)ffmpeg — audio extraction, scene-cut detection, clip rendering. The headline-overlay thumbnails (drawtext) and burned-in Shorts captions (ass) need a build with libfreetype + libass — use ffmpeg-full if your ffmpeg lacks them.yt-dlp — pulls YouTube videos into MEDIA_ROOTuv — Python venv/runtime for the four ML CLIs (Whisper, mlx-vlm, OCR, diarization)premium_multimodal with mlx-vlm you'll want a Mac Studio class machine (M2 Ultra+).
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.
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.
Add a brand to get started → + New brand
What you see Empty dashboard. The top nav shows the five main destinations.
pnpm db:migrate. Run it and reload.
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:
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.
Every entity in ChannelHelm is brand-scoped. A brand maps 1-to-1 to a YouTube channel and is where you connect your accounts.
/brands/new directly).UCxxxxxx…). This auto-fills if you ever drop a YouTube URL into the ingest later. Leave blank for now.standard_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.One brand = one publishing identity. Multi-brand is root in ChannelHelm.
What you see The new-brand form. Slug auto-derives from the display name; you can leave the channel ID blank.
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.
/providers).console.anthropic.com.platform.openai.com.all and check Default — this provider then serves every LLM call.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.
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.
runwarehttps://api.runware.ai/v1runware.ai (encrypted at rest, same as LLM keys)runware:z-image@turbo (a Flux / Z-Image model)all and check Default — is_default is scoped per category, so this doesn't disturb your default LLM. Add provider.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.
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.
Two ways: drop a local file, or paste a YouTube URL. Both create a Source + Package and enqueue the ingest pipeline.
/sources/new)..mp4 (or .mov, .webm, .m4v, .mkv) onto the upload zone, or click to pick a file.MEDIA_ROOT, the package is created, you land on the package page.youtube.com/watch?v=…, youtu.be/…).What you see The upload form. Streams the file to MEDIA_ROOT/<brand-slug>/<source-id>/original.<ext>, hard-capped by MAX_UPLOAD_BYTES.
ingest → audio extraction + scene-cut detection →
transcribe_audio (MLX Whisper) and
analyze_visual (mlx-vlm + Apple Vision OCR) in parallel →
fuse → analyze_intelligence (LLM) →
fan-out: generate_asset ×N + thumbnail_concepts.
For an ~8 min video this takes a few minutes total on M-series hardware.
When the package reaches ready_for_review, the package page (the "Studio") is your single command center for the upload.
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.
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.
ChannelHelm, support email + dev contact: your emailyoutube.upload AND youtubeChannelHelm localhttp://localhost:3000/api/youtube/oauth/callback (exact, no trailing slash)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.
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:
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.)
/settings in your browser.…apps.googleusercontent.com string → Save.GOCSPX-… string → Save. (Encrypted at rest immediately; subsequent reloads show the masked placeholder.)GOOGLE_OAUTH_CLIENT_ID
set
GOOGLE_OAUTH_CLIENT_SECRET
secret
set
What you see The two new GOOGLE_OAUTH_* keys on /settings, both with the green set badge after saving.
/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).What you see The brand page with YouTube connected. Direct is selected → approving the package will upload directly via the YouTube Data API.
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.
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.
/packages/<package-id>.⊘) can't be selected — they need other config (Zernio account, DojoClaw key, etc.).What you see Right after clicking. The teal pulsing pill confirms the worker has picked the jobs up and the panel is auto-refreshing.
The dispatch worker picks up the youtube_title_set job, sees the brand is configured for youtube_direct with valid OAuth tokens, then:
MEDIA_ROOT/<brand-slug>/<source-id>/original.<ext>).package.intelligence.published.youtube AND flips the other three youtube_* assets to dispatched.youtube_* asset rows flip to dispatched.What success looks like Package status flipped to "Dispatched" (green) and the red youtu.be pill is live. Click it.
failed with the exact worker error inline (red box under the row). Common causes:
ENOENT: no such file — slug renormalize moved the media folder; reset the asset and retry, the worker probes multiple paths now.quotaExceeded — YouTube Data API daily limit (10k units = ~6 uploads/day). Wait until tomorrow UTC.youtube: brand has no YouTube connection — OAuth tokens missing; reconnect on the brand page.videoCategoryId errors — rare; ping me with the exact message.ChannelHelm uploads videos as private by default. One more click on YouTube's side and you're live.
workers/integrations/youtube.ts::uploadVideo (look for privacyStatus: 'private').
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.
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/.
--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
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.
Some work isn't triggered by a click — it's periodic. scripts/enqueue-recurring.ts enqueues three job kinds on
a timer:
collect_signal — re-pulls analytics for published assets older than --stale-hours (default 6)promote_voice_examples — promotes your edits into few-shot voice memory, once per brand+type per dayarchive_package — moves published media off local disk once it's older than ARCHIVE_AFTER_DAYS (only when ARCHIVE_ROOT is set)
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
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
queued in the dashboard. pnpm dev:all starts one for you; in prod that's a launchd service.
Each of these adds a destination to your dispatch flow. None are required — set them up only if you'll use them.
Set DOJOCLAW_API_URL + DOJOCLAW_API_KEY in /settings. The article_brief asset on every package will then dispatch to DojoClaw on approval.
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.
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.
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.
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.
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