The ChannelHelm Handbook · 2026-06-04

From drop to dispatched.

The complete operator manual: install, run, configure, ship. Eighteen chapters that read top-to-bottom or jump-to-the-bit-you-need.

Contents

  1. Welcome & what ChannelHelm does
  2. Quick start
  3. Installation & first run
  4. The Dashboard
  5. Brands
  6. Adding videos
  7. The Pipeline
  8. The Studio
  9. The Shorts editor
  10. LLM providers
  11. Settings reference
  12. Publishing to YouTube
  13. YouTube Direct setup
  14. Approve & dispatch
  15. Performance & tuning
  16. Storage & the file lifecycle
  17. Troubleshooting
  18. Glossary
Chapter 01
01

Welcome & what ChannelHelm does.

ChannelHelm is your video-to-publishing command center. You drop a video in one end; you get a complete publishing kit out the other — title, description, chapters, tags, social posts, article brief, thumbnails. Then you approve what you want to ship and it goes to YouTube, LinkedIn, X, and the other platforms you've connected.

What "command center" actually means

Think of ChannelHelm as the place where one video becomes everything it needs to become. Before ChannelHelm, that workflow lived across ten browser tabs, three apps, and an hour of copy-paste. After ChannelHelm, it's one page, a few clicks, and the same content goes out everywhere you publish — drafted in your voice, scored for hook strength, ready to review.

Where it lives

On your Mac. ChannelHelm is local-first: the dashboard runs at http://localhost:3000, the database is a Postgres on your machine, your video files never leave your hard drive unless you publish them. The only external services ChannelHelm talks to are the ones you connect (an LLM provider, optionally Zernio for social, optionally DojoClaw for editorial, your YouTube channel via OAuth).

Why local-first? Because your unedited footage and your draft titles are sensitive content. The only thing that leaves your machine is the finished work, to the platforms you've explicitly connected.

What this handbook covers

Each chapter assumes you've read the previous one, but you can jump in anywhere. The Glossary at the end defines every ChannelHelm-specific term in one place.

Chapter 02
02

Quick start.

The 5-minute path from "nothing installed" to "I just shipped a video." If you read one chapter, read this one. Each step links to the deeper coverage later in the handbook.

  1. Install prerequisites via Homebrew, install dependencies, migrate the database, run the dev server. See Chapter 3 · Installation.
  2. Create your first brand at /brands/new. A brand maps 1-to-1 to a YouTube channel and is the unit of multi-channel publishing. See Chapter 5 · Brands.
  3. Configure an LLM provider at /providers. Pick a preset (Anthropic Claude is the easiest first choice), paste your API key, save. See Chapter 10 · LLM providers.
  4. Drop a video on the dashboard. The Pipeline auto-runs (~2–3 min for a typical 8-minute video). See Chapter 6 · Adding videos.
  5. Review in the Studio. Pick your title, scan the description, check the thumbnail concepts. See Chapter 8 · The Studio.
  6. Approve & dispatch. Tick the YouTube boxes, click the button. The video is on YouTube. See Chapter 14 · Approve & dispatch.
That's it — your first video should be live (as private) on YouTube within 10–15 minutes of starting installation. Subsequent videos are a 5-minute loop because all the setup is done.
Chapter 03
03

Installation & first run.

ChannelHelm needs a Mac with Apple Silicon (M1 or newer), ~16 GB of RAM, and Homebrew. The whole install takes about 10 minutes including downloads.

System requirements

Step-by-step

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

# 2. Install the runtime stack
brew install node@22 pnpm postgresql@16 ffmpeg yt-dlp uv

# 3. Start Postgres + create the database
brew services start postgresql@16
createdb channelhelm

# 4. Install Python deps for the ML CLIs
cd ml && uv sync && cd ..

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

# 5. Install the app + apply migrations
pnpm install
cp .env.example .env
# open .env, set DATABASE_URL and MEDIA_ROOT (absolute paths)
pnpm db:migrate

# 6. Run web + workers together
pnpm dev:all

Open http://localhost:3000. You should see the empty dashboard.

Verifying it's running

You should see two banners in your terminal output:

And shortly after: [settings] subscribed to chs_settings and [runner] started lockedBy=…. If you see those, both the web server and the worker fleet are alive.

What does what

ToolWhy ChannelHelm needs it
node@22 + pnpmRuntime + package manager for the Next.js app and the worker fleet.
postgresql@16Single Postgres instance holds everything: brands, packages, assets, jobs, settings, providers, OAuth tokens. No Redis, no separate queue server.
ffmpegAudio extraction, scene-cut detection, frame sampling, clip rendering. Headline-overlay thumbnails (drawtext) and burned-in Shorts captions (ass) need a build with libfreetype + libass — install ffmpeg-full if your ffmpeg lacks them.
yt-dlpPulls YouTube videos when you paste a URL into the dashboard.
uvPython venv/runtime manager for the four ML CLIs in ml/ (Whisper, mlx-vlm, OCR, diarization).
Important If the /settings page later shows "Settings table not migrated yet", you missed pnpm db:migrate. Run it and reload.
Chapter 04
04

The Dashboard.

When you open ChannelHelm, you land on the dashboard at /. This chapter is a guided tour of every element you see.

localhost:3000
ChannelHelm + New Brands Jobs Providers Settings ⌕ Jump to package, brand, or job… ⌘K YBYour Brand ▾
Packages 3 total · 1 ready · 0 pending · 2 failed
Your Latest Video Title
pkg_… · 8:19 · standard_audio_visual
Dispatched
Another Video
pkg_… · 12:04 · standard_audio_visual
Analyzing visual
The dashboard Top nav · brand switcher · search · package list with status pills

The top navigation

The package list

Each row is a video you've dropped into ChannelHelm. Click a row to open the Studio for that video.

Each row shows:

Status pill colours

PillWhat it means
Analyzing visualPipeline is running. Other "in-progress" states: Ingested · Transcribing · Fused · Analyzed.
ReadyPipeline complete; all asset cards filled; awaiting your review & approval.
DispatchedYou approved & the dispatch worker shipped everything to its target.
PartialSome assets dispatched, some failed. Usually means missing config (e.g. no Zernio account for a social network).
Tip The colour you see most while a package is processing is teal — that's the "in-flight" colour. Amber means "ready or attention needed"; green means "shipped"; red means "failed".
Chapter 05
05

Brands.

A brand is your publishing identity for one YouTube channel. Everything else in ChannelHelm — videos, generated assets, dispatched posts — is brand-scoped. Multi-brand is root: no cross-brand reads outside admin views.

Why brands exist

If you run multiple YouTube channels (your main channel, a side project, a client's channel), each gets its own brand. The brand carries:

Creating a brand

Top nav → + NewBrand (or open /brands/new).

localhost:3000/brands/new
New brand

One brand = one publishing identity.

Display name
Your Brand Name
Slug (auto-derived)
your-brand-name
Default processing profile
standard_audio_visual ▾
YouTube channel ID (optional, auto-fills later)
UCxxxxxxxxxxxxxxxxxxxxxx
Create brand
Brand-creation form Slug auto-derives in kebab-case; channel ID is optional and fills automatically when you ingest a YouTube URL.

The brand detail page

After creation you land on /brands/[id]. The page has three sections:

  1. YouTube connection card (top) — for connecting your channel via OAuth so ChannelHelm can upload directly. See Chapter 13 for the full walkthrough.
  2. Brand form — display name, slug, processing profile, website, YouTube channel ID, Zernio profile/accounts, approval-required-for list, auto-dispatch-for list, active toggle.
  3. Normalize-slug banner (only appears if your slug has unusual characters — spaces, capitals). Clicking it renames the media folder + rewrites all stored paths so everything stays consistent.

The Zernio accounts field

This is a per-platform map: { x: "acc_…", linkedin: "acc_…", tiktok: "acc_…" }. You only need to fill in the platforms you plan to publish to via Zernio. If you don't use Zernio at all, leave it empty — the dispatch worker will skip those platforms and mark the social assets as "no account configured" (which is harmless, just shows in the panel).

Multi-brand behaviour

Slug warning The brand slug becomes part of every media path: MEDIA_ROOT/your-brand-name/src_…/original.mp4. Renaming the slug after videos are ingested is supported — the "Normalize slug" button moves the folder and rewrites every stored path — but it's easier to pick a clean slug at creation time.
Chapter 06
06

Adding videos.

Two ways: drop a local file, or paste a YouTube URL. Both create a Source row and a Package row, then enqueue the Pipeline. Within seconds, the Studio page for the new package opens.

Option A · Drop a local video file

  1. Top nav → + NewSource (or /sources/new).
  2. Pick your Brand from the dropdown.
  3. Drag your .mp4, .mov, .webm, .m4v, or .mkv file onto the upload zone. Files stream to MEDIA_ROOT as they upload.
  4. Leave Create package and start pipeline checked. (Unchecking just creates the Source without kicking off the pipeline — useful if you're still organising assets.)
  5. Click Upload. You land on the Studio page; the pipeline starts.

Option B · Paste a YouTube URL

  1. Top nav → + NewYouTube URL.
  2. Paste any shape: youtube.com/watch?v=…, youtu.be/…, even Shorts URLs.
  3. ChannelHelm auto-discovers the brand from the channel ID. If a brand with that channel ID already exists, the new package goes there. If not, ChannelHelm offers to create a new brand using metadata scraped from the channel's About page.
  4. Click Ingest. yt-dlp downloads the video into your MEDIA_ROOT; the pipeline starts.
Tip URL ingest works for videos on any YouTube channel — you don't need to own them. Useful for analyzing competitor content or repurposing your own old videos for new platforms.

Processing profiles

Each package runs under one of four profiles. The brand has a default; you can override per package on upload.

ProfileWhat runsBest for
transcription_onlyAudio transcription only. No visual phase, no diarization, no thumbnails — the cheapest tier.Re-mining old material via Backlog Revival; bulk transcribing a back catalogue when you only need text.
fast_audio_onlyAudio + intelligence + full asset kit. Skips visual entirely (but still drafts titles, descriptions, social posts, etc.).Podcasts, webinars, audio interviews. Fast (~1 min for a 30-min episode).
standard_audio_visualFull 4-layer pipeline at default model tiers.Your YouTube uploads. The default for almost everyone.
premium_multimodalSame layers, larger VLM (32B), denser OCR (1 fps).High-stakes content where descriptions matter (course modules, marquee episodes).
transcription_only vs fast_audio_only Both skip the visual phase. The difference: transcription_only is the bare-minimum re-mine — it exists mainly so Backlog Revival can cheaply re-run an old video through today's prompts. fast_audio_only is the everyday audio profile for content you're actively publishing.

Upload limits

The default cap on /api/uploads body size is 2 GB (MAX_UPLOAD_BYTES=2000000000 in your settings). Larger? Bump the setting on /settings.

Chapter 07
07

The Pipeline.

When you drop a video, four layers run in the background: audio, visual, fusion, intelligence. Each layer produces a concrete artifact the next consumes. Plain-English: ChannelHelm listens to the video, watches it, combines what it learned, then thinks about what to make of it.

♬ Audio MLX Whisper → transcript.json words + timing + speakers ▦ Visual mlx-vlm + Apple OCR → frame_index.json keyframes & on-screen text ⌘ Fusion composeSceneLog (TS) → scene_log timestamped windows ✦ Intelligence LLM (your provider) → analysis topics · hooks retention

Layer by layer

1 · Audio

Listens to the video. Extracts the audio track with ffmpeg, then runs MLX Whisper large-v3 (Apple Silicon optimised) to produce a word-level transcript with timestamps. If you've set HF_TOKEN and accepted the pyannote license, it also labels speakers.

Output: transcript.json — array of word entries with start/end times, plus a flat text field.

2 · Visual

Watches the video. Samples frames (dense for OCR, sparse for the VLM), runs Apple Vision OCR on each, runs mlx-vlm (Qwen2.5-VL by default) over the sparse keyframes for image descriptions, merges into a single index.

Output: frame_index.json — per-second entries with {timestamp, description, on_screen_text}.

Performance: this used to be the slowest layer (~10–15 min for an 8-min video). After the optimisation pass, it's ~65 s. See Chapter 15.

3 · Fusion

Combines what it learned. Pure-TypeScript step. Stitches the transcript + frame index into a "scene log" — windows of time, each carrying the spoken text + visual descriptions + on-screen text for that interval.

Output: scene_log on packages.intelligence — array of windows, each describing what happens during that span.

4 · Intelligence

Thinks about it. Single LLM call. The model reads the scene log and produces a brief: list of topics, candidate hooks, retention notes, structural beats.

Output: analysis on packages.intelligence. Triggers the fan-out to generate-asset jobs.

What happens after intelligence

The analyze_intelligence handler enqueues ~12 generate_asset jobs (one per asset type — title, description, chapters, tags, LinkedIn, X, article brief, short_clip_plan, etc.) plus thumbnail_concepts. Each generate_asset is one LLM call. With worker concurrency at 3, these all finish in ~35 s. The package moves to ready_for_review.

When a generate_asset job writes a *_plan asset (e.g. short_clip_plan or long_clip_plan), it fans out one clip_render job per clip automatically — so the MP4s are usually rendered by the time you open the Shorts/Clips tab (short plans → vertical, long plans → horizontal). Plans themselves are never dispatched; only the rendered_* outputs are. See Chapter 09 · The Shorts editor.

Extended social networks Beyond LinkedIn and X, ChannelHelm also drafts posts for Facebook, Threads, Bluesky, Reddit, Pinterest, Telegram, Discord and Google Business — but only for the networks a brand has connected in its Zernio accounts, so you never get drafts for platforms you can't publish to. Each lands in its own Studio tab and dispatches via Zernio. Pinned comments (youtube_pinned_comment) generate for every package.
Where to watch progress The Studio's pipeline panel updates live as each layer completes. Each layer shows "what's done" (e.g. transcript · 4,212 words) once finished, or "preparing X…" while running.

Thumbnail generation

Thumbnails are AI-generated images, not stills pulled from the video. After intelligence, the thumbnail_concepts worker turns the package analysis into visual concepts and renders each one with your configured image provider:

✦ analysis topics · hooks retention LLM concepts N distinct visual_prompt + headline image provider /providers category=image Runware · 1280×720 download → MEDIA_ROOT + plain variant + headline overlay (ffmpeg drawtext) pick operator chooses No image provider configured? Falls back to ffmpeg frame extraction at hook timestamps — the original behaviour, zero extra cost. Audio-only profiles (transcription_only · fast_audio_only) skip thumbnails entirely.
Chapter 08
08

The Studio.

When you open a package (/packages/[id]) you're in the Studio. It's your editor for a single video: review, edit, regenerate, approve, dispatch. This is where the operator-time is spent.

The layout at a glance

Three columns on a desktop:

  1. Left rail — platform navigation. Click "YouTube" / "Shorts" / "Clips" / "Blog" / "X" / "LinkedIn" / etc. to switch which asset stack you're viewing.
  2. Centre column — header (title, status, metadata, video player, 4-layer pipeline indicator), then the asset cards for the selected platform.
  3. Right pane — the Approve & Publish panel. Selection of which assets to dispatch and the YouTube publish-options picker (when applicable).

The header strip

From left to right:

The pipeline panel

Shows live status of all 4 layers. Each layer's status text updates as it progresses:

Running layers render in teal. Done layers in grey (lighter). Pending layers in dim grey.

Asset cards (the editorial heart)

Each card represents one generated asset. The shape depends on the platform tab you're viewing. The YouTube tab shows:

Card actions

The empty-state behaviour

While the pipeline is still running, asset cards that haven't been filled in yet show a quiet teal pulsing indicator: "titles — generates automatically when analysis completes". There's no button to click — these will fill in by themselves. Once the pipeline is done, if any asset is still genuinely missing (a generate_asset job failed for some reason), a "Generate" button appears as a manual recovery path.

Layout switcher

Three layout modes for the Studio (toggle in the bottom-right):

LayoutBest for
Console (default)The 3-column layout described above. Best for end-to-end review and approval.
EditorSide-by-side compare view. Best when you're heavily editing copy and want context from the transcript + scene log.
AtlasAll platforms at once. Best for "what does this video look like across every channel?"

The approval panel (right pane)

See Chapter 14 · Approve & dispatch for the full walkthrough.

The Shorts tab

The left rail has a dedicated ✂ Shorts tab. Instead of a flat list of plan + rendered assets, it collapses to one row per clip — each row carrying the clip's editable metadata and a thumbnail of the rendered MP4. Click any row to open the per-Short editor. That whole editor gets its own chapter next.

Chapter 09
09

The Shorts editor.

Vertical short-form is where ChannelHelm earns its keep. The Shorts editor at /packages/[id]/shorts/[clipIndex] is a full per-clip studio: trim on a word-snapped timeline, restyle burned-in subtitles with a live preview, auto-draft a per-clip description, and publish each clip on its own schedule. This chapter explains the one mental model that makes all of it click.

The one rule: the plan is the source of truth

There are two assets per clip and they have very different jobs:

AssetRole
short_clip_planEditable source of truth. Holds every operator decision — title, description, tags, trim window, subtitle styling, description links, publish options — under payload.clips[clipIndex]. You edit this.
rendered_short_clipBuild output. The actual vertical MP4 with burned-in subtitles. The clip_render worker produces it and copies the plan's editorial fields onto it. You never edit this directly — your edits would be lost on the next re-render.
Never edit the rendered clip. Operator edits live on the short_clip_plan only. The renderer rebuilds rendered_short_clip from the plan on every render and overwrites its editorial fields — anything written straight to the rendered asset is discarded.

Plan → render → publish, end to end

✎ Operator edits trim · title · tags styling · links publish options short_clip_plan payload.clips[i] SOURCE OF TRUTH render_rev: n saveClipEdits() clip_render ffmpeg + ASS word-snap trim UPSERT keyed by (plan, clip_index) ▶ Publish rendered_ short_clip → Zernio save render dispatch re-edit → renderClip() bumps render_rev, re-uses same asset id

The editor panels

Six subtitle animations

The style panel offers six burned-in caption animations. The live overlay previews each; clip_render emits an ASS subtitle file that ffmpeg burns into the MP4.

Word Highlight word-by-word highlight Pop scale-up rotation effect SINGLE one big word at a time Typewriter▍ fills in letter by letter Motion word pairs with movement Banner words on coloured background

How edits become a clip

  1. Edit. Every change auto-saves to the plan via saveClipEdits() (debounced). Edits are appended to payload.edits_log[] for audit.
  2. Render. Click Render. renderClip() bumps the clip's render_rev, sets pending_render, and enqueues a clip_render job keyed clip_render:<plan>:<i>:rev<n>.
  3. Re-use, not re-create. The worker UPSERTs the rendered_short_clip keyed by (plan_asset_id, clip_index) — re-renders update the same asset id, so any dispatch/publish history stays attached. The render_rev guard skips a re-encode when the rev hasn't moved (idempotent crash-recovery).
  4. Publish. Set per-clip publish options, approve the rendered clip, and dispatch it to Zernio (see Chapter 14).

Auto-render: clips render eagerly

You usually don't click Render at all for the first pass. When the pipeline finishes and the generate_asset worker writes a short_clip_plan, it fans out one clip_render job per clip automatically. By the time you open the Shorts tab, the rendered MP4s are usually already there. You only click Render after you've changed something — a new trim, a different subtitle style.

Description auto-draft + auto-seeded links

Word-snap, both sides

Trim handles never cut mid-word. The snap runs client-side in the Timeline (so you see the snapped position as you drag) and server-side defensively in clip_render before ffmpeg's -ss (so an LLM-picked or stale trim is corrected at build time too). Both share src/lib/word-snap.ts.

Backfilling existing packages

Packages ingested before auto-render existed won't have rendered clips. The backfill script enqueues clip_render for every plan with missing renders:

# every package, only the clips that are missing a render
tsx scripts/render-shorts.ts

# scope to one package or brand
tsx scripts/render-shorts.ts --package-id pkg_xxx
tsx scripts/render-shorts.ts --brand-id brd_xxx

# preview without enqueuing
tsx scripts/render-shorts.ts --dry-run
Long-form cuts too The same machinery runs the horizontal highlight cuts: long_clip_planrendered_long_clip. Everything in this chapter applies — the only differences are dimensions and the output asset type.
Chapter 10
10

LLM providers.

ChannelHelm uses an LLM for two things: pipeline analysis (one call per package) and asset generation (one call per asset type, ~10 per package). You configure which provider serves which call at /providers. Pluggable: OpenAI, Anthropic, OpenRouter, Ollama, LM Studio, OpenClaw, Codex CLI.

The three provider classes

ClassWhat it isExamples
openai-compatibleSpeaks the OpenAI HTTP shape (POST /v1/chat/completions). Works with any provider that emulates this — most cloud + local LLM stacks do.OpenAI · OpenRouter · Ollama · LM Studio · vLLM · OpenClaw
anthropicNative Claude Messages API. Best instruction following and prose quality at the premium tier.Claude Opus / Sonnet / Haiku
codex-cliSpawns the local codex CLI subprocess. No HTTP, no API key — uses your ChatGPT subscription OAuth.Codex (ChatGPT subscription)

Adding a provider

  1. Top nav → Providers.
  2. Click + Add provider.
  3. Pick a Quick preset (recommended) — fills the Base URL + a sensible default model in one click.
  4. Paste your API key (left blank for local providers like Ollama / LM Studio).
  5. Set Purpose to all for a single-provider setup, or to a specific profile to dedicate that provider to one tier.
  6. Check Default for the first one you create.
  7. Click Add provider.

How ChannelHelm picks a provider for a call

Every LLM call passes through complete({ profile, ... }). The selection rule:

  1. Exact-purpose match wins (score 3) — provider with purpose=standard_audio_visual serves standard_audio_visual packages.
  2. purpose="all" is next (score 2) — universal provider eligible for every profile.
  3. isDefault is the last-resort tiebreaker (score 1).
  4. Purpose mismatch is never eligible — a premium_multimodal-tagged provider won't serve standard_audio_visual calls.

API key security

API keys are encrypted at rest using AES-256-GCM via your PROVIDER_SECRET_KEY (set in /settings). Keys are NEVER serialized back to the browser. When you edit a provider, the API key field shows the placeholder "•••••••• saved" — leaving it blank preserves the saved key; typing replaces it.

Image providers (for AI thumbnails)

The same /providers page also holds image providers — rows with category image (Runware is the first / default type). These power AI thumbnail generation: the pipeline turns the package analysis into visual concepts and the image provider renders them. Add one the same way you add an LLM provider; keys are encrypted at rest identically. If you don't configure one, ChannelHelm falls back to ffmpeg frame extraction for thumbnails — see Chapter 7 · Thumbnail generation.

Three preset configurations

SetupSetup timeBest for
All Anthropic~2 minOne row, purpose=all, Sonnet 4.6. Best default for "I don't want to think about it".
Tiered Anthropic~5 minHaiku → fast, Sonnet → standard, Opus → premium. Quality scales with package profile.
Local + cloud~15 minLM Studio (Qwen3) for batch pipeline + Anthropic Haiku for Studio actions. Cheapest steady state.
Chapter 11
11

Settings reference.

The /settings page holds every runtime-configurable knob. Changes propagate live to every running process (the Next.js server + the worker fleet) via Postgres pg_notify — no restart needed. Below: every key, what it does, when to change it.

How settings work

Settings live in two places:

Runtime-editable settings (saved on /settings)

External integrations

KeyWhat it doesWhen to set
ZERNIO_API_KEYAuthenticates outbound POSTs to Zernio (LATE).Before publishing to LinkedIn/X/etc. via Zernio.
ZERNIO_WEBHOOK_SECRETHMAC secret for inbound webhooks from Zernio.Only when exposing /api/webhooks/zernio publicly via Cloudflare Tunnel. Local-only setups don't need this.
DOJOCLAW_API_URLLAN URL for DojoClaw (article publishing).If you use DojoClaw for blog posts.
DOJOCLAW_API_KEYAuthenticates outbound POSTs to DojoClaw.Same as above.
DOJOCLAW_WEBHOOK_SECRETHMAC secret for inbound webhooks from DojoClaw.Same as Zernio — only if exposing publicly.
HF_TOKENHuggingFace token for the pyannote speaker-diarization model.Optional — only if you want speaker labels in transcripts. Token must have the pyannote/speaker-diarization-3.1 license accepted on HF.

Public exposure (Cloudflare Tunnel)

KeyWhat it doesWhen to set
CLOUDFLARE_TUNNEL_HOSTNAMEPublic base URL for webhooks + signed /media/* URLs.Only if you've set up a Cloudflare Tunnel.
MEDIA_URL_SECRETHMAC key for signing /media/* URLs.When using MEDIA_REQUIRE_SIGNATURE=1.
MEDIA_REQUIRE_SIGNATUREIf 1, /media/* requires a signed URL. Default 0.Before exposing the tunnel publicly.
ALLOW_UNSIGNED_WEBHOOKSIf 1, webhook receiver accepts requests with no signature. Default 0.Local smoke tests only. Never set to 1 on a publicly reachable host.
MAX_UPLOAD_BYTESHard cap on /api/uploads body size. Default 2 GB.If you upload larger files.

YouTube Direct (Data API v3)

KeyWhat it doesWhen to set
GOOGLE_OAUTH_CLIENT_IDOAuth 2.0 Client ID from Google Cloud Console. One client supports all brands.Once, per ChannelHelm instance. See Chapter 13.
GOOGLE_OAUTH_CLIENT_SECRETOAuth 2.0 Client secret paired with the ID above. Encrypted at rest.Same time as the client ID.

Storage lifecycle (archive worker)

KeyWhat it doesDefault
ARCHIVE_AFTER_DAYSDays since the package's latest dispatch before it becomes eligible for the archive_package worker. Eligibility also requires archived_at IS NULL.14
ARCHIVE_DELETE_CLIPSWhen true, the archive worker DELETES rendered clip MP4s instead of moving them to the archive. The source original.mp4 always moves (clip_render needs it for re-renders).false
KEEP_PIPELINE_ARTIFACTSWhen 1, disables the inline Stage-1 cleanup in transcribe_audio / analyze_visual / fuse / analyze_intelligence — the debugging escape hatch that keeps frames_*/ and intermediate JSONs on disk.unset

Full detail in Chapter 16 · Storage & the file lifecycle. ARCHIVE_ROOT (the destination path) is boot-only — see the table below.

Boot-only settings (edit in .env, restart required)

These can't change mid-flight without breaking running workers — they're shown read-only on /settings with an "edit .env and restart" hint.

KeyWhy boot-only
DATABASE_URLThe Postgres pool initializes at boot — you can't swap the database connection from inside the app.
MEDIA_ROOTWorkers spawn ffmpeg/ml subprocesses that resolve paths against this at startup. Mid-flight change would desync running jobs.
ARCHIVE_ROOTExternal-drive path for the post-publish archive worker. Unset = archiving disabled. Boot-only — workers cache the absolute path at startup and refuse to operate when it changes mid-run (e.g. drive unmounted).
LOCAL_BEARER_TOKENAPI bearer token. Rotation invalidates every in-flight worker request. Use the Rotate bearer token button on /settings to mint a new value; paste into .env; restart.
PROVIDER_SECRET_KEYAES-256-GCM key that encrypts all sensitive setting values + provider API keys + OAuth refresh tokens. Rotating mid-flight locks every saved secret out until re-encryption. Set once at install, leave alone.
Heads up The first time you visit /settings on a freshly migrated install, you may see a teal "Restart needed for live propagation" banner. That's because the worker boot hook (which opens the LISTEN connection) only runs at process start. Restart pnpm dev:all once and the banner clears.
Chapter 12
12

Publishing to YouTube.

There are four ways to get a video from ChannelHelm onto YouTube. Each suits a different setup. Pick one.

PathOperator effortBest for
Manual paste (default)Copy 4 fields into YouTube StudioFirst-time use; no setup beyond the ChannelHelm install.
YouTube Direct (Data API)One-time Google Cloud OAuth, then automatedLocal-first setups. Recommended for most operators.
Zernio (LATE) → YouTubeCloudflare Tunnel + Zernio accountOperators who also publish to LinkedIn / X / TikTok and want one unified pipeline.
ngrok + ZernioOne ngrok command (temporary)Smoke-testing the Zernio flow without committing to Cloudflare.

Manual paste — the default

Approve the YouTube assets in the Studio. ChannelHelm marks them dispatched (manual) — that's an audit record meaning "operator confirmed they'll paste this manually." Open YouTube Studio, upload your original.mp4, paste each field. Done.

If you also want to record the resulting YouTube URL back into ChannelHelm (so the package header shows the red ▶ youtu.be/… chip), paste the URL into the "+ Paste YouTube URL" pill that appears next to the status pill.

YouTube Direct — recommended

The cleanest path for a local-first setup. After a one-time Google Cloud OAuth setup, approving a video automatically uploads it via the YouTube Data API v3. No third party in the loop except Google itself.

Full walkthrough: Chapter 13 · YouTube Direct setup.

Zernio (LATE) — for multi-platform operators

Zernio is a third-party social publishing service that supports 15+ platforms including YouTube. If you're already paying for Zernio for LinkedIn/X/TikTok, you can also use it for YouTube. Setup requires a Cloudflare Tunnel so Zernio can download your video file.

ngrok + Zernio — for smoke tests

ngrok exposes localhost:3000 via a temporary public URL. Use it to verify the Zernio → YouTube flow works before committing to a permanent Cloudflare Tunnel. Free tier limits: URL rotates every restart, 2-hour sessions.

Chapter 13
13

YouTube Direct setup.

The longest single chapter in the book. Worth the read because once it's done, every future video publishes with a single click. ~15 minutes including Google Cloud Console.

Before you start: pick your browser

The whole flow works smoothest signed in as the Google account that owns your YouTube channel. Two recommended approaches:

Step 1 · Create the Google Cloud OAuth app (one-time)

  1. Go to console.cloud.google.com.
  2. Top-left dropdown → New Project → name it ChannelHelm → Create.
  3. Left nav → APIs & Services → Library → search "YouTube Data API v3" → Enable.
  4. Left nav → 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 email of your channel-owning Google account
    • Save through to Dashboard
  5. Left nav → 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)
    • Create
  6. Copy the resulting Client ID and Client Secret.
Heads up Google reshuffled this UI recently. "Test users" now lives under Google Auth Platform → Audience. If you don't see it on the OAuth consent screen page, look there.

Step 2 · Paste into ChannelHelm

  1. Open /settings on ChannelHelm.
  2. GOOGLE_OAUTH_CLIENT_ID → paste the …apps.googleusercontent.com string → Save.
  3. GOOGLE_OAUTH_CLIENT_SECRET → paste the GOCSPX-… string → Save (encrypted at rest immediately).

Step 3 · Connect the brand to your YouTube channel

Open /brands/[your-brand-id] in the same browser context that's signed in as the channel-owning Google account.

localhost:3000/brands/[brand-id]
YT
YouTube connection
Direct upload via YouTube Data API v3 — bypasses Zernio.

One-time consent on Google. Tokens encrypted at rest; never sent back to the browser.

Pre-fill account chooser (optional)
your-channel-email@gmail.com
▶ Connect YouTube
YouTube connection card on the brand page — type your channel-owning Google email to pre-fill Google's account chooser, then click Connect.
  1. Scroll to the YouTube connection card.
  2. (Optional) Pre-fill your channel-owning Google email. ChannelHelm passes it to Google as login_hint for the chooser.
  3. Click ▶ Connect YouTube (or "Connect YouTube as <email>" if you pre-filled).
  4. Google redirects you. Sequence:
    • Account chooser → pick your channel-owning account.
    • "This app isn't verified" warning → Advanced → Go to ChannelHelm (unsafe). (Your personal app, your project; "unsafe" means "Google hasn't verified you to yourself" — fine.)
    • Consent screen lists 2 scopes — Allow.
  5. You land back on the brand page with a green ✓ Connected to <your channel name> banner.
  6. Click the Direct (this connection) radio under "Dispatch target for this brand's YouTube videos". Commits immediately.

Step 4 · Publish your first video via Direct

  1. Open a package. The right panel's 4 YouTube assets are now grouped under the YouTube Title Set row (since Direct is on, the others are auto-bundled into the upload).
  2. (Optional) Set publish privacy in the new "YT publish options" picker: Public / Unlisted / Private / Schedule.
  3. Click Approve & dispatch.
  4. A teal pulsing pill appears below the button: "dispatching N · auto-refresh every 2s". The worker streams original.mp4 to YouTube's resumable endpoint (~1–3 min depending on your upstream).
  5. When done: the red ▶ youtu.be/<id> chip appears in the package header. Click it to open the video on YouTube.
That's it. Every future video on this brand publishes with a single click on Approve & dispatch. The OAuth grant is permanent (until you click Disconnect or revoke at myaccount.google.com/permissions).

Quota

YouTube Data API free tier: 10,000 units/day. Each upload costs 1,600 units = ~6 uploads/day. Fine for a single creator. If you need more, request a quota bump in the Google Cloud Console.

Chapter 14
14

Approve & dispatch.

The Approve & Publish panel on the right side of the Studio is where you decide what ships. This chapter is a guided tour of that panel.

The panel anatomy

localhost:3000/packages/[id]
Approve & Publish
2 / 12 assets approved
YT publish options
🔒 Private (default) ▾
Only you. Flip to public in YouTube Studio when ready.
YouTube Title Set bundles: description · chapters · tags
Article Brief locked
Thumbnail Concept
↗ Approve & dispatch · 2
dispatching 2 · auto-refresh every 2s
The Approve panel — counter + progress bar · publish-options picker (YouTube Direct only) · asset list with checkboxes · dispatch button · live status

The asset status lifecycle

Every asset moves through a small state machine. The pipeline fills it to ready_for_review; you approve it; the dispatch worker ships it; a publish webhook can confirm it live.

draft ready_for_review approved dispatched published pipeline approve dispatch webhook failed error rejected operator

Selection — what gets dispatched

The YT publish options picker

Appears at the top of the panel only when the brand has YouTube Direct configured. A dropdown with four options:

Choices commit immediately (no Save button). Saved on the package, so each video can have its own setting.

Where each asset routes

The dispatch worker reads the asset type and sends it to exactly one of three destinations (or records a manual audit entry). Editorial goes to DojoClaw on your LAN; social + rendered clips go to Zernio in the cloud; YouTube uploads go Direct via the Data API when the brand is configured for it.

dispatch worker pickTarget(type) article_brief editorial linkedin · x_post · x_thread social rendered_short_clip rendered_long_clip youtube_title_set brand on Direct everything else DojoClaw LAN · article Zernio (LATE) cloud · 15+ networks YouTube Direct Data API v3 manual audit record only

What happens after you click Approve & dispatch

  1. publishAsset server action runs for each selected asset: flips status to approved, enqueues a dispatch:<assetId> job.
  2. Teal "dispatching N · auto-refresh every 2s" pill appears under the button. The panel polls every 2 seconds and refreshes row statuses live.
  3. Each dispatch worker picks up its job, routes by asset type:
    • article_brief → DojoClaw
    • linkedin_post / x_post / x_thread / rendered_*_clip → Zernio
    • youtube_title_set (with brand on YouTube Direct) → Direct upload via Data API
    • Everything else → manual (recorded as dispatched; operator handles posting)
  4. Per row, the status flips to dispatched on success or failed with an inline error message on failure.
  5. Package status recomputes: dispatched (all good), partially_dispatched (some failed), or failed (all failed).

Failed dispatches — what to do

Click the row to see the worker error inline. Most common causes:

After dispatch — the feedback loop Shipping isn't the end. The /performance dashboard gives one cross-surface view of how dispatched/published assets actually did (collected signals + title/thumbnail A/B results); collect_signal pulls YouTube + Zernio metrics and now combines DojoClaw article analytics with Search Console clicks, impressions, CTR, and position when GSC is connected. After a video is live, Mine comments in the Studio turns its top YouTube comments into content_ideas + faq assets for the next upload. Seed a brand's voice from day one at /brands/[id]/voice (paste samples or pull existing published assets into voice_examples). In the Shorts editor you can translate a clip's subtitles into other languages — per-language SRT + ASS sidecar files (TTS dubbing and a burned-in per-language re-render are deferred).
Chapter 15
15

Performance & tuning.

On a typical 8-minute video, ChannelHelm's pipeline completes in ~2–2.5 minutes. That includes everything from drop to "ready for review." This chapter explains the performance budget and how to tune it.

Where wall time goes

PhaseWallWhat it is
ingest~12 sAudio extract + scene-cut detection (ffmpeg)
transcribe_audio~45 sMLX Whisper large-v3 over the audio track
analyze_visual~65 sOCR ∥ VLM keyframe descriptions (max of the two)
fuse~1 sTypeScript scene log composition
analyze_intelligence~10 sOne LLM call for topics/hooks/retention
generate_asset × 10~35 s10 LLM calls running 3-way parallel
thumbnail_concepts~7–25 sAI image generation (LLM concepts → image provider) — or ~7 s ffmpeg frame extraction when no image provider is set
Total~2–2.5 minEnd-to-end on an 8-min video

Worker concurrency tuning

Default: 3 concurrent claim slots. Set via:

# in scripts/dev.sh or shell env
WORKER_CONCURRENCY=3  # default

# bump if generate_asset jobs queue up
WORKER_CONCURRENCY=6 pnpm dev:all

# drop if your LLM provider rate-limits you
WORKER_CONCURRENCY=1 pnpm dev:all

Reasonable maxes per LLM provider:

ProviderReasonable concurrency
Anthropic (Claude)5–10
OpenAI3–8 (depends on tier)
Codex CLI (local)2–4 (bounded by CPU)
LM Studio (local)1–2 (local server queues internally)

Processing profile tradeoffs

ProfilePipeline wallQuality
transcription_only<1 min (transcript only)Just the transcript — no visual, no diarization, no thumbnails. The cheapest tier, built for Backlog Revival re-mines.
fast_audio_only~1 min (skips visual)Audio-only metadata + full asset kit. Fine for podcasts.
standard_audio_visual~2–2.5 minFull pipeline at sensible defaults. Best ratio.
premium_multimodal~4–6 min32B VLM, dense OCR. Use for content where descriptions really matter.
Chapter 16
16

Storage & the file lifecycle.

Every file ChannelHelm writes has exactly one lifecycle. Most are throwaway by the time the pipeline finishes; a few are permanent. Left alone, a typical 8-minute video would sit at ~85 MB on disk forever. Two automatic mechanisms — inline cleanup and the archive worker — plus two operator actions (Revive & Delete video) keep that lean. Here's the whole map.

The four stages

An artifact's stage is set by its last legitimate consumer. After that read, it's either re-readable later (a later stage) or never touched again (delete).

Pipeline Review Published +N days original.mp4 audio.wav · frames_*/ scene_log · frame_index transcript.json thumbs/concept_*.jpg clips/clip_NNN.mp4 Postgres rows Stage 1 — pipeline only Stage 2 — review-only Stage 3 — archive after publish Stage 4 — permanent Archived off local (ARCHIVE_ROOT) or deleted

What each file is for

ArtifactLast consumerStage
original.mp4clip_render (future re-renders)3 · post-publish
audio.wav · frames_ocr/ · frames_vlm/the step that produced them (one read)1 · pipeline
scene_log.json · frame_index.jsonfuse / analyze_intelligence — also mirrored in Postgres1 · pipeline
transcript.jsonShorts editor word-snap — also mirrored in Postgres2 · review
thumbs/concept_*.jpgthumbnail selection + dispatch upload2 · review
clips/clip_NNN.mp4 · .assdispatch + Studio preview3 · post-publish
Postgres rowseverything — the audit trail4 · permanent

Mechanism 1 · Inline cleanup (automatic, on by default)

Each pipeline worker deletes its single-consumer inputs the moment the next step starts. transcribe_audio drops audio.wav; analyze_visual drops the frame folders + intermediate JSONs; fuse and analyze_intelligence drop their Postgres-mirrored JSONs. No DB changes, no policy — a freshly ingested video averages ~40 MB instead of ~85 MB.

# keep everything on disk for debugging (e.g. ls the VLM frames)
KEEP_PIPELINE_ARTIFACTS=1 pnpm dev:all

Mechanism 2 · The archive worker (post-publish, opt-in)

The archive_package worker runs on a schedule (enqueued by scripts/enqueue-recurring.ts, fired by launchd). For each package whose latest dispatch is older than ARCHIVE_AFTER_DAYS and that hasn't been archived yet, it moves original.mp4 (and clips/, unless ARCHIVE_DELETE_CLIPS=true) from MEDIA_ROOT to ARCHIVE_ROOT, then records the move so re-renders still resolve.

Migration 0007 added the two columns this relies on: packages.archived_at and sources.archive_path.

The duplication insight Three on-disk JSONs (scene_log.json, frame_index.json, transcript.json) are also mirrored into the packages.intelligence JSONB column. The disk copies are pure duplication — every downstream reader can use the Postgres version — which is exactly why inline cleanup can delete them safely.

Operator actions: Revive & Delete video

Two buttons in the Studio header act on a source's lifecycle directly. Both live next to Retry.

♻ Revive — Backlog Revival

Re-runs the whole pipeline in place on an existing source using today's prompts. Use it when you've improved a prompt and want to refresh the kit on an old video, or to re-mine a back catalogue. By default it runs under the cheapest transcription_only profile.

📼 Delete video — hard-delete the source (Storage Option C)

Permanently removes the source video from disk — both the local copy and the archived copy — to free space, while keeping all Postgres history (assets, dispatches, signals). This is the third storage-lifecycle option, completing the set: A inline cleanup, B the archive worker, C operator hard-delete.

Delete video is irreversible. Once the source is hard-deleted you can no longer re-render Shorts or re-mine that video. Only do it after you've shipped everything you need from it.
Chapter 17
17

Troubleshooting.

The nine problems you're most likely to hit and the exact fix for each.

1. "Settings table not migrated yet"

Symptom: Red banner on /settings; saves fail.
Cause: pnpm db:migrate hasn't been run on this database.
Fix: pnpm db:migrate. Reload.

2. "Restart needed for live propagation"

Symptom: Yellow banner on /settings right after first install or after a code update.
Cause: The worker boot hook (which opens the LISTEN connection) only runs at process start.
Fix: Ctrl-C pnpm dev:all, run it again. One-time per code update.

3. Asset cards stuck on "Generate" buttons

Symptom: Titles/Description/Tags cards show "Generate" buttons instead of content, but the pipeline indicator shows all 4 layers as done.
Cause: The generate_asset jobs for those types either failed or weren't enqueued.
Fix: Click the manual Generate button (it's the fallback recovery affordance), OR open /jobs to see which generate_asset jobs failed and why.

4. YouTube upload fails with ENOENT

Symptom: Worker log shows ENOENT: no such file or directory, stat '…/your-brand/src_…/original.mp4'.
Cause: A brand slug rename moved the media folder but didn't rewrite all stored paths. Should be auto-fixed by the renorm action; if you hit this, the file probably lives at a different path.
Fix: Check MEDIA_ROOT/<brand-slug>/src_…/ on disk. If the file is there, the issue is a stale stored path — contact for a backfill script.

5. "Google did not return a refresh_token" during OAuth

Symptom: YouTube Connect flow lands back on the brand page with a red error banner mentioning refresh_token.
Cause: Google only issues a refresh_token on the FIRST grant. If you've granted ChannelHelm access before, subsequent re-grants don't include one.
Fix: Visit myaccount.google.com/permissions as the channel-owning account, find ChannelHelm, click Remove access. Then click Connect on the brand page again.

6. YouTube upload fails with quotaExceeded

Symptom: Worker log: The user has exceeded the number of videos they may upload.
Cause: YouTube Data API daily quota (10,000 units/day, 1,600 per upload = 6 uploads).
Fix: Wait until tomorrow UTC. To raise it: Google Cloud Console → APIs & Services → Quotas → request bump.

7. Worker not picking up jobs

Symptom: Drop a video, package created, status stuck at draft or ingested indefinitely.
Cause: Worker process not running, OR worker is running but has no handler for the queued job's kind.
Fix: Check tail -f /tmp/channelhelm-dev.log for [runner] started lockedBy=…. If absent, restart pnpm dev:all. If present but no [ingest] lines after dropping a video, the queue lookup might be off — check /jobs for pending rows.

8. LLM call fails with context-length error

Symptom: Worker log: tokens to keep > context length (LM Studio) or similar from another provider.
Cause: Your model's context window is too small for the prompt + scene log + response. Common with Qwen3-32B loaded at 4096-token default.
Fix: For LM Studio: lms load <model> --context-length 16384. For cloud providers: switch to a longer-context model (most cloud models default to 200k+ tokens).

9. "No such filter: 'drawtext'" or Shorts captions don't burn in

Symptom: thumbnail_concepts fails the headline overlay with No such filter: 'drawtext', or clip_render produces an MP4 with no burned-in captions.
Cause: Your ffmpeg was built without libfreetype (drawtext) and/or libass (ass/subtitles). A minimal Homebrew ffmpeg can lack both.
Fix: brew install ffmpeg-full && brew unlink ffmpeg && brew link --force --overwrite ffmpeg-full. Verify with ffmpeg -filters | grep -E 'drawtext|ass'. The plain AI thumbnail (no headline) and frame-extraction fallback work either way; only the overlay + caption burn-in need these filters.

Chapter 18
18

Glossary.

Every ChannelHelm-specific term, defined once.

Brand
One publishing identity, typically mapped to one YouTube channel. Every package/asset/source is brand-scoped.
Source
The raw video input — either a file you uploaded or a YouTube URL you ingested. Lives in MEDIA_ROOT/<brand-slug>/src_…/.
Package
One "unit of work" for a single video. Wraps the source + all derived intelligence + all generated assets. The Studio shows one package at a time.
Asset
One generated artifact — e.g. youtube_title_set, youtube_description, linkedin_post, thumbnail_concept. Each has a type, payload, status, and dispatch metadata.
Processing profile
Which pipeline tier a package runs under. transcription_only · fast_audio_only · standard_audio_visual · premium_multimodal. Set on the brand as default; per-package override allowed.
Pipeline
The four sequential layers that turn a source into intelligence: audio → visual → fusion → intelligence.
Scene log
The fused output of the pipeline. Array of time windows, each carrying the spoken text + visual descriptions + on-screen text for that interval. Used by analyze_intelligence as the LLM's input.
Intelligence
Two meanings: (1) the fourth pipeline layer (the LLM call that produces topics/hooks/retention), (2) the JSONB column on packages that holds all derived artifacts.
Dispatch
The act of sending an approved asset to its destination — DojoClaw, Zernio, YouTube Direct, or "manual". Recorded in the dispatches table for audit.
Dispatch target
Where a specific asset type routes on dispatch. For YouTube, the brand picks one of: manual / youtube_direct / zernio.
Idempotency key
A stable string set on enqueue (e.g. dispatch:<assetId>) that prevents duplicate job rows. Postgres unique index enforces.
Provenance
Metadata stamped on every generated artifact: provider, model, host, prompt_version, input_refs, generated_at, profile. Lets you trace any output back to its inputs.
short_clip_plan
The editable source of truth for one or more Shorts. Holds per-clip trim, title, description, tags, subtitle styling, and publish options under payload.clips[i]. Operators edit this; the renderer reads it.
rendered_short_clip
The built vertical MP4 (with burned-in subtitles) produced by clip_render. A build output — never edit it directly; edits would be lost on the next re-render. Long-form equivalent: rendered_long_clip.
render_rev
Monotonic revision counter on a plan's clip. renderClip() bumps it; clip_render skips a re-encode when the rendered asset's rev already matches. Makes re-renders idempotent.
Word-snap
Trim handles lock to the nearest word boundary so a clip never starts or ends mid-word. Runs client-side (Timeline) and server-side (clip_render). See src/lib/word-snap.ts.
ASS subtitles
Advanced SubStation Alpha — the subtitle file format clip_render burns into Shorts. Supports six animations (Word Highlight, Pop, Single Word, Typewriter, Motion, Banner). Replaces VTT when a styling block is set.
Auto-render
When generate_asset writes a *_plan, it fans out one clip_render job per clip so rendered MP4s exist before you open the Shorts tab. Backfill older packages with scripts/render-shorts.ts.
Lifecycle stage
Which of the four storage classes a file belongs to: 1 pipeline-only, 2 review-only, 3 post-publish (archivable), 4 permanent. Set by the file's last legitimate consumer.
archive_package
Scheduled worker that moves a published package's original.mp4 (+ clips/) from MEDIA_ROOT to ARCHIVE_ROOT after ARCHIVE_AFTER_DAYS. Sets packages.archived_at and sources.archive_path.
A/B experiment
A self-run title/thumbnail test on a published YouTube video (v1.5 — Helm Signal). The experiment_tick worker rotates variants, reads each one's performance from the YouTube Analytics API, applies the winner, and feeds it into voice_examples. Needs the yt-analytics.readonly scope (reconnect pre-v1.5 brands). Full walkthrough: the A/B testing guide.
Backlog Revival
The ♻ Revive action — re-runs the whole pipeline in place on an existing source under today's prompts (defaults to transcription_only). Clears all the source's jobs so every stage re-runs; generate_asset UPSERTs so assets refresh without losing dispatch history. Requires the source media still on disk. Server action: reviveSource(packageId, profile?).
Delete video
The 📼 hard-delete action (Storage Option C) — removes a source's local + archived media and nulls the paths, keeping all Postgres history. Irreversible; afterward re-render and Revive fail cleanly. Server action: deleteSourceVideo(packageId).
Image provider
A provider row at /providers with category image (Runware today) used to generate AI thumbnail images. When none is configured, the thumbnail worker falls back to ffmpeg frame extraction. Keys encrypted at rest like LLM providers.
RequeueLater
A special exception a worker can throw to ask the queue to defer the job to a specific future time instead of failing it. Used for Zernio's 24-hour rate limit.
Bundled (in approval panel)
An asset that doesn't dispatch on its own but is auto-included in another asset's upload. For YouTube Direct, description/chapters/tags are bundled into the title_set's upload.
Studio
The per-package page at /packages/[id]. Where you review and approve.
Console / Editor / Atlas
The three layout modes the Studio supports. Console is the default 3-column view.
Voice example
An operator edit captured automatically as a few-shot example for future LLM calls. Lets the model learn your editing style over time.
Worker / runner / slot
Worker = a Node process running workers/runner.ts. Runner = the main loop in that process. Slot = one concurrent claim loop inside the runner (default 3 per worker).
SKIP LOCKED
Postgres SQL clause that makes the queue claim atomic across N concurrent slots without any in-process mutex.
MEDIA_ROOT
Filesystem root where ChannelHelm stores all source video files + derived artifacts (audio.wav, frames_*/, transcript.json, etc.).
Zernio (LATE)
Third-party social publishing service. ChannelHelm uses it for LinkedIn / X / TikTok / etc. dispatches. Optional.
DojoClaw
Local HTTP service on the LAN for article publishing. Called via /api/v1/articles/from-brief. Optional.
Cloudflare Tunnel
Free Cloudflare service that exposes a local port via a public HTTPS URL. Used by ChannelHelm for webhook callbacks + signed /media URLs when going beyond local-only.

Beyond the handbook

Seven companion docs go deeper than this handbook can: