A multi-agent live show generator that turns Markdown scripts into broadcast-style video + audio. Multiple LLM "agents" play out debates, lateral-thinking puzzles (海龜湯), panel discussions, and TV series episodes, narrated with TTS and rendered into a TV-channel UI (HLS) or a downloadable MP4.
It ships as a single Go binary that embeds a React (Vite) single-page app and orchestrates the LLM, TTS, image, and music providers behind the scenes.
- Multiple content types —
debate,situation-puzzle,discussion,series. Eachtopic.mddeclares itstypein front-matter. - Two server modes:
- stream (default) — airs every queued topic over per-channel HLS video + MP3 audio,
with a TV-tuner web UI. New
.mdfiles dropped into a watched folder are picked up live, no restart needed. - video — no channels; the browser uploads a
script.md(and, for series, a zip of prior generations) and the server renders a downloadable.mp4.
- stream (default) — airs every queued topic over per-channel HLS video + MP3 audio,
with a TV-tuner web UI. New
- Audio-only feed — render a podcast-style
.mp3(mixed TTS + music bed) plus asubtitles.vttsidecar, skipping all image/video generation. Opt in per job (audio_only) or force it server-wide with the--audioflag. - Pluggable providers — OpenAI-compatible chat endpoint, Azure / ElevenLabs TTS, Gemini (Lyria music + scene image generation).
- MCP tools — optional
mcp.jsonlets agents call external Model Context Protocol tools.
| Tool | Version | Why |
|---|---|---|
| Go | 1.25+ | builds the backend (uses CGO for the SQLite driver) |
| ffmpeg + ffplay | recent | live-stream pacing, audio concat, playback (both must be on PATH) |
| bun | latest | installs & builds the React frontend |
C toolchain (gcc/clang) |
— | required because mattn/go-sqlite3 is a CGO package |
API credentials (see Environment) for your chat / TTS / image / music providers.
git clone https://github.com/sirily11/debate-bot.git
cd debate-bot
# macOS
brew install go ffmpeg oven-sh/bun/bun swiftlint
# Debian/Ubuntu
sudo apt-get install -y golang ffmpeg build-essential
curl -fsSL https://bun.sh/install | bashCopy the provided .env and fill in your keys:
cp .env .env.local # or edit .env in place.env is loaded automatically at startup (it takes precedence over your shell env).
make build # builds the frontend (bun) then the Go binary into ./bin/debate-botOr build the pieces individually:
make frontend # bun install && bun run build -> internal/server/web-dist
make backend # go build -> bin/debate-botRequired vars (validated at startup — the process refuses to boot if any are missing):
| Var | Required | Description |
|---|---|---|
OPENAI_BASE_URL |
✅ | OpenAI-compatible chat endpoint shared by host + agents |
OPENAI_API_KEY |
✅ | API key for the chat endpoint |
GEMINI_API_KEY |
✅ | drives Lyria music + Gemini scene image generation |
COMPRESSION_BASE_URL |
— | optional endpoint for the admin-configured compression model; defaults to OPENAI_BASE_URL |
COMPRESSION_API_KEY |
— | optional key for the compression endpoint; defaults to OPENAI_API_KEY |
LLM_INPUT_COST_PER_MILLION |
— | optional input-token price used when the provider does not return cost usage |
LLM_OUTPUT_COST_PER_MILLION |
— | optional output-token price used when the provider does not return cost usage |
AZURE_SPEECH_KEY / AZURE_SPEECH_REGION |
when tts_provider: azure |
Azure Speech credentials |
ELEVENLABS_API_KEY |
when tts_provider: eleven |
ElevenLabs credentials |
CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN |
for summary PDF export | Cloudflare Browser Rendering credentials (token needs "Browser Rendering - Edit") used to render a podcast summary into a downloadable PDF; empty returns 503 from GET /api/discussions/{id}/summary/pdf |
OUT_DIR |
— | output root for audio/video/transcripts (default ./out) |
SERIES_ROOT |
— | cross-run archive root for series episodes (default OUT_DIR) |
APP_PASSWORD |
— | if set, gate the web UI + API behind this password (same as --password) |
REVENUECAT_WEBHOOK_AUTH |
for points purchases | shared secret expected in Authorization on POST /api/revenuecat/webhook; empty disables purchase credits |
REVENUECAT_REST_API_KEY / REVENUECAT_PROJECT_ID / REVENUECAT_APP_ID |
for IAP admin product setup | RevenueCat REST API v2 secret key plus project/app ids used when admins create enabled products |
POINTS_COST_LEVERAGE |
— | multiplier over the points sale rate used for usage charges; default 3 |
POINTS_PER_USD_COST |
— | exact raw points-per-provider-dollar override; bypasses POINTS_COST_LEVERAGE when set |
POINTS_SIGNUP_GRANT |
— | optional starter balance granted once per signed-in user |
Provider-specific TTS keys are only required when a topic.md selects that provider.
Model ids are not environment variables. Configure them in the admin dashboard
under App Config: host/agent, planning/visual director, compression,
summary/mindmap, translation, judgement, slide deck, Q&A, embedding, and Gemini
transcription. Each feature returns a configuration error when its model is
blank; models never fall back to another role or to .env.
The iOS app uses RevenueCat for paywalls, but the server owns the points balance.
After a successful purchase, the app polls GET /api/points/balance; points are
credited only when RevenueCat posts a webhook to POST /api/revenuecat/webhook.
Configure every top-up product explicitly in the admin dashboard under In-App Products. Creating or updating an enabled product syncs it to RevenueCat only. App Store Connect product metadata, review notes, screenshots, prices, and subscription groups are managed manually in App Store Connect. The server does not infer points from the App Store price. Each enabled product row must include:
- the exact RevenueCat / App Store
product_id - the store environment (
test_storefor sandbox testing,app_storefor production) - the product type
- the number of points granted
- optional display price metadata for admin visibility
Use the exact RevenueCat product_id values. For example, if the products are
app.rxlab.debatebot.points1000, app.rxlab.debatebot.points5000, and
app.rxlab.debatebot.points10000, create one enabled admin row per product. If a
purchase webhook arrives for an unknown, disabled, zero-grant, or wrong-store
product id, the server rejects it with 400 {"error":"invalid_product_id"}
instead of silently crediting 0 points.
The only remaining environment variable for purchase fulfillment is the webhook secret, but admin product setup also requires server-side RevenueCat credentials:
REVENUECAT_WEBHOOK_AUTH=change-me
REVENUECAT_REST_API_KEY=rc_secret_v2_key
REVENUECAT_PROJECT_ID=proj_...
REVENUECAT_APP_ID=app_...For iOS local development, copy iOS/Config/Secrets.xcconfig.example to
iOS/Config/Secrets.xcconfig and set:
REVENUECAT_API_KEY = your-revenuecat-development-public-sdk-key
REVENUECAT_API_KEY_PROD = your-revenuecat-production-public-sdk-key
Debug uses REVENUECAT_API_KEY. Release maps the app's RevenueCatAPIKey
Info.plist value to REVENUECAT_API_KEY_PROD.
To test points locally, RevenueCat must be able to reach your server. Either expose the local engine with a tunnel and use that public URL as the RevenueCat webhook URL, or simulate the webhook yourself:
curl -X POST http://localhost:8000/api/revenuecat/webhook \
-H 'Authorization: change-me' \
-H 'Content-Type: application/json' \
--data '{"event":{"id":"evt-local-1","type":"INITIAL_PURCHASE","app_user_id":"OAUTH_SUBJECT","product_id":"points_1000"}}'app_user_id must be the signed-in OAuth subject without the oauth: prefix; the
server credits oauth:<app_user_id>. The app should fetch the balance at least
once before a purchase so the backend has registered that user locally; otherwise
the webhook returns 400 {"error":"invalid_user_id"}. Use a fresh webhook
event.id for each manual test because RevenueCat events are idempotent; replaying
the same id returns the unchanged balance with credited:0 and duplicate:true.
./bin/debate-bot server \
--channel ./channels/channels.json \
--content "./topics/*.md" \
--addr :3000Then open http://localhost:3000. Each topic.md front-matter must declare a channel
that matches an id in channels.json. The directory behind --content is auto-watched:
drop a new .md in and it airs without a restart.
Flags:
| Flag | Default | Description |
|---|---|---|
--content |
— | path or glob to topic .md file(s); repeatable |
--channel |
./channels.json |
channel registry ({id, number, title} array) |
--mcp |
— | optional mcp.json for MCP tools |
--out |
$OUT_DIR |
output directory override |
--addr |
:3000 |
HTTP listen address |
--password |
$APP_PASSWORD |
gate the web UI + API behind a password (see below) |
Start the server with --password (or set APP_PASSWORD) to require a login:
./bin/debate-bot server --content "./topics/*.md" --password "hunter2"
# or
APP_PASSWORD=hunter2 ./bin/debate-bot server --content "./topics/*.md"When a password is set, the SPA shows a login screen and every /api/* route
returns 401 until the browser signs in. Authentication is a cookie set by
POST /api/login, so the SSE event stream and HLS audio/video keep working
automatically. Omit the flag (the default) to leave the server open.
Both --mode stream and --mode video honour the password.
./bin/debate-bot server --mode video --addr :3000 --max-concurrency 2Open the web UI and upload a script.md; the server renders an MP4 you can download.
--max-concurrency caps simultaneous renders.
Generation jobs (podcast renders, summaries, mindmaps, PPT/PDF exports,
audiobook videos, planning turns) run through a RabbitMQ work queue with up
to 3 attempts and exponential backoff. Set RABBITMQ_URL to enable the
durable broker-backed queue — locally, docker compose up -d starts one
(see docker-compose.yml; .env already points at it), or use
make rabbitmq-dev for a Homebrew broker on machines without Docker.
Without RABBITMQ_URL the server falls back to an in-process queue with
the same retry semantics but no durability across restarts.
MQ_QUEUE_PREFIX optionally prefixes queue names for isolation (used by
the E2E harness).
Video/dashboard-mode flags:
| Flag | Default | Description |
|---|---|---|
--mode |
stream |
stream | video | dashboard |
--max-concurrency |
2 |
cap on simultaneous renders |
--audio |
false |
force every job to render as an audio-only feed (see below) |
--addr |
:3000 |
HTTP listen address |
--password |
$APP_PASSWORD |
gate the web UI + API behind a password |
Skip all image/video generation and produce a downloadable .mp3 (mixed TTS + music bed)
with a subtitles.vtt sidecar — useful for podcast-style output and far cheaper/faster
since no scene images are generated. Works for debate, discussion, and series topics.
Two ways to enable it:
-
Per job — submit with
audio_onlyset: the multipart form fieldaudio_only=true, orvideoConfig.audio_only: trueonPOST /api/jobs/json. -
Server-wide — start with
--audioto force every job onto the audio feed regardless of the request:./bin/debate-bot server --mode video --audio --addr :3000
GET /api/configreportsforce_audio: trueso a frontend can hide video-only controls.
Download the artefacts from GET /api/jobs/{id}/audio (the .mp3) and
GET /api/jobs/{id}/subtitles (the .vtt).
For S3-compatible storage, set the engine environment:
S3_BUCKET=your-bucket
S3_REGION=auto
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
S3_PREFIX=podcasts
S3_ACCESS_KEY_ID=your-access-key
S3_SECRET_ACCESS_KEY=your-secret-key
S3_DOWNLOAD_BASE_URL=https://media.example.comS3_ENDPOINT supports R2/MinIO/custom S3 APIs. S3_DOWNLOAD_BASE_URL is
optional; when set, download URLs use that public/custom domain. When omitted,
the engine returns presigned S3 URLs. S3_ACCESS_KEY_ID and
S3_SECRET_ACCESS_KEY configure explicit S3/R2 credentials; if they are empty,
the AWS SDK falls back to its standard credential chain. With S3 enabled,
audio-only jobs must upload the final MP3 successfully and the job download
source is the S3 object, not the local staging file.
make dev # Vite on :5173 (proxies /api), Go server on :8080Each topic.md is YAML front-matter + Markdown body. Minimal debate example
(see examples/topic.md and examples/discussion.md for full samples):
---
title: "AI 是否會取代程序員"
type: debate # debate | situation-puzzle | discussion | series
language: zh-CN
channel: tech # must match an id in channels.json
total_minutes: 30
segment_max_seconds: 60
affirmative:
- { name: "Linda", model: "gpt-4o" }
negative:
- { name: "Alice", model: "gpt-4o" }
judge: { model: "gpt-4o" }
---
## Background
...| Target | Description |
|---|---|
make build |
full production build (frontend + backend) |
make frontend / make backend |
build one half |
make run |
build then run the server |
make dev |
Vite + Go in parallel for development |
make lint |
enforce the 800-line maximum for Swift source files |
make gen-assets |
regenerate the embedded TV-studio background plates |
make series-smoke / make series-recap-smoke |
end-to-end series smoke tests |
make tidy |
go mod tidy + bun install |
make clean |
remove build artifacts |
Build the image and run the server (stream mode):
docker build -t debate-bot .
docker run --rm -p 3000:3000 \
--env-file .env \
-v "$PWD/channels:/app/channels" \
-v "$PWD/topics:/app/topics" \
-v "$PWD/out:/app/out" \
debate-bot \
server --channel ./channels/channels.json --content "./topics/*.md" --addr :3000For video mode, override the command:
docker run --rm -p 3000:3000 --env-file .env -v "$PWD/out:/app/out" \
debate-bot server --mode video --addr :3000The image bundles ffmpeg/ffplay and the compiled binary with the embedded web UI.
Mount channels/, your topics folder, and out/ so config and generated media persist
outside the container.
Each run writes to OUT_DIR/session-<timestamp>/ — per-channel HLS segments, the stitched
debate.mp3, transcript.txt, per-agent memory/, and run.log. Series episodes also
archive into SERIES_ROOT/tv-series/<show>/... for cross-episode recaps.
Video-mode jobs land under OUT_DIR/session-<timestamp>/jobs/<jobID>/: video.mp4 (or
audio.mp3 for an audio-only feed), the subtitles.vtt sidecar, and the per-turn audio.