What I Built
Most churches produce a 45-60 minute sermon every week and do nothing with it afterward. The pastor doesn't have a social media team, the AV volunteer is already stretched thin, and clipping video for Instagram and Facebook requires skills and time the church doesn't have. Pulpit Engine solves that end to end.
I built a 15-step TypeScript runner that takes a raw sermon recording and outputs 6 production-ready vertical video clips scheduled directly to the church's Facebook Page — with no human in the loop except the pastor's one-time approval in the review app. The pipeline handles source intake from multiple streaming platforms (BoxCast, Manual Source, Cloudflare RTMP), downloads and trims raw video on Railway, transcribes with AssemblyAI, runs Claude Sonnet to detect sermon boundaries and score clip candidates by virality potential, generates animated-caption vertical clips via Submagic Magic Clips, writes captions with Claude Haiku, stores clips in a dated Google Drive folder, and schedules posts to Facebook Reels via the Pages API — then sends the pastor a SendGrid briefing email with a summary of what's going out and when.
The runner is a checkpoint-resume system: every step persists its output to Supabase before moving to the next, so a mid-run failure restarts from the last completed step without re-spending on vendor APIs. The bounded manifest enforces which external calls each step is allowed to make at runtime — a step that shouldn't touch Facebook cannot touch Facebook, even if someone adds code to try. The QC flow gates scheduling behind a review session: the pastor sees 6 clip slots in a mobile-optimized review app, can approve or reject each one, and scheduling only runs after explicit approval.
The system is architected for multi-church scale: all church-specific config lives in Supabase (Facebook page ID, Drive folder IDs, pastor email, voice profile, service windows), and the runner is church-agnostic. Adding a new church means inserting a config row and pointing the source adapter at their streaming platform — no code changes required.
Pastor Review Interface
The mobile-optimized QC app deployed to Railway staging. Pastors review 6 clip slots and 3 backups before anything gets scheduled. Approve All triggers the schedule-claim and lock-to-schedule steps; nothing posts until this gate clears.
15-Step Runner Architecture
The TypeScript runner that owns the full pipeline. Each step is independently versioned, has a typed input/output contract, and persists state to Supabase before proceeding. The bounded manifest enforces which external systems each step can touch at runtime.
// 03_BUILD/pulpit-runner — 15 ordered steps, 278 passing tests 1 source-intake // normalize recording from streaming platform adapter 2 queue-claim // atomic claim from worker_jobs; idempotent lease 3 railway-download // download raw sermon video from Cloudflare Stream 4 railway-trim // FFmpeg service on Railway: trim to sermon window 5 assemblyai-transcribe // transcribe sermon; swappable provider seam 6 sonnet-boundaries // Claude Sonnet: detect sermon start/end, score clips 7 submagic-upload // upload trimmed video to Submagic Magic Clips 8 submagic-poll // checkpoint/resume poll until clips are ready 9 ingest-candidates // persist 9 candidate clips to Supabase (6 primary + 3 backup) 10 host-previews // generate signed preview URLs for review app 11 haiku-captions // Claude Haiku: write captions for each clip 12 open-review // create review session; halt until pastor approves 13 schedule-claim // claim posting slots from weekly schedule window 14 lock-to-schedule // lock approved clips to slots; DB-unique wall prevents doubles 15 briefing // SendGrid email to pastor; Slack ops alert
03_BUILD/pulpit-runner/src/. Each step has a typed contract enforced at compile time and a bounded allowance list enforced at runtime. Steps 12–15 only run after the review session reaches status = locked. The step list is the same for every church; only the config row changes.Supabase Schema — System of Record
Supabase is the only system of record. All pipeline state, QC decisions, schedule claims, and metrics live here. n8n was retired; the runner owns all business logic and writes directly to these tables.
-- Durable queue tables worker_jobs -- job intake from Cloudflare webhook; atomic claim lease processing_queue -- per-sermon processing state; checkpoint/resume anchor -- Execution state runner_runs -- one row per sermon; links to all steps runner_steps -- one row per step execution; persists typed output JSON runner_side_effect_attempts -- idempotency log for every external call source_runs -- normalized recording intake from source adapter -- Content tables candidate_clips -- 9 clip candidates per run (6 primary, 3 backup) review_sessions -- QC session per run; status: open → locked review_slots -- 9 slots per session; approved/rejected per slot sermon_transcripts -- lean provenance row; full transcript in Google Drive -- Scheduling tables scheduled_posts -- Facebook Reels schedule; unique (slot, platform) wall cloudflare_webhook_events -- signed webhook intake log; duplicate suppression -- Church config churches -- per-church config: FB page, Drive folders, voice profile operational_events -- structured error log; pipeline halt events
0001_runner_execution_state.sql, 0002_processing_queue_foundation.sql, 0002_runner_side_effect_idempotency.sql, 0003_production_webhook_worker.sql. All schema applied via ordered single-transaction migrations — no loose repair SQL. The lock_review_session_v1 RPC enforces the review gate at the database level.Source Adapter Pattern
The intake layer is designed around a normalized source recording object so the rest of the pipeline is platform-agnostic. BoxCast and Manual Source are built; Subsplash, Resi, YouTube Live, and Facebook Live are planned adapters. Cloudflare Stream RTMP is the default recording intake path (PERC).
// Normalized source recording object — output shape every adapter must produce { "church_slug": "first-baptist-sandbox", "source_platform": "boxcast", "source_external_id": "boxcast_broadcast_id", "source_recording_id": "boxcast_recording_id", "source_title": "Sunday Morning Service", "source_video_url": "https://...", "source_url_type": "direct_mp4", "source_started_at": "2026-05-18T10:00:00Z", "source_ended_at": "2026-05-18T11:30:00Z", "church_timezone": "America/Chicago", "sermon_type": "Sunday", "sermon_date": "2026-05-18" } // Adapters built: Manual Source (permanent fallback), BoxCast // Trigger path: Cloudflare Stream RTMP webhook → pulpit-runner-webhook → worker_jobs // Future adapters: Subsplash, Resi, YouTube Live, Facebook Live, ChurchStreaming.tv
church_slug, source_video_url, sermon_type, sermon_date, and church_timezone to run. All per-platform fields are for idempotency, logging, and future reporting. Adding a new church means inserting a churches config row — no code changes to the runner.Technologies
Build Progress
Phase 5 is complete. The no-spend worker proof passed on Railway Staging under commit d5325b7. Phase 6 is the first normal-mode sandbox run gated on a separate approval.
worker_jobs row per unique event.result_json.status = no_spend_proof_halt. No vendor spend occurred.