Skip to content

AI Features

show.fm ships three AI capabilities, all executed asynchronously by the ai-processor worker as Cloudflare Workflows:

CapabilityWorkflow classJob type (ai_job_type)Deep dive
Episode transcription (Whisper + diarization)TranscriptionWorkflowtranscriptionTranscription Pipeline
Promo assets from a transcript (titles, summary, social posts, tags)ContentGenerationWorkflowcontent_generationThis page (below)
Guest research briefs from booking-form answersGuestResearchWorkflowguest_researchGuest Research

All three spend the single AI meter, ai_credits_per_month. Metering semantics live in Credits & Metering.

Architecture: one job pipeline

Every AI run follows the same shape, regardless of job type:

Key properties, each load-bearing:

  • Row first, then message. createAndEnqueueAiJob() in src/lib/server/ai-jobs.ts inserts the ai_jobs status row, then sends the queue message. If the send fails, the row is flipped to failed so no phantom pending row survives.
  • No client writes to ai_jobs. Migration 20260710153000_harden_ai_client_writes.sql revoked INSERT/UPDATE/DELETE from anon/authenticated and dropped the member INSERT policy: a direct PostgREST insert would create a row no worker ever sees, and the one-active-job unique index would then block the real route with job_active. Job creation goes through an explicit service-role client; the route derives podcast_id, episode_id, and billing_account_id server-side from the episode row, never from client input.
  • Instance id = job id. The queue consumer in workers/ai-processor/src/index.ts is a thin spawn: workflow.create({ id: jobId }). Duplicate queue deliveries dedupe at the platform (create() throws when the instance exists; the consumer confirms via workflow.get(id) and acks). After 3 failed spawn attempts (MAX_SPAWN_ATTEMPTS, matching max_retries = 2 in wrangler.toml) the consumer marks the job failed with a user-visible message instead of stranding a phantom pending row.
  • One active job per scope. Partial unique indexes enforce one pending/running job per (episode_id, job_type) (20260703161335_ai_jobs_and_transcripts.sql) and one active research job per booking (20260704022823_ai_jobs_booking_scope.sql). Concurrent submissions become a clean 23505 conflict, surfaced as a 409 job_active.
  • Durable steps. Work and retries live inside the workflows; step results persist, so a transcription that fails at chunk 40 of 56 resumes at 41 and Whisper spend is never repaid. Every DB/RPC effect (reserve, refund, notify, upsert) is idempotent by key.

Job status model

ai_jobs.status mirrors the ai_job_status enum: pending | running | complete | failed | skipped (TypeScript lockstep in src/lib/transcripts/types.ts). skipped is specifically the quota-refused outcome, never retried. stage and progress are cosmetic live-progress fields for the Realtime UI; GET /api/ai/jobs/:jobId is the rehydrate endpoint when a Realtime subscription reconnects.

The content-generation workflow

ContentGenerationWorkflow (workers/ai-processor/src/workflows/content-generation.ts) produces PromoAssets (titles, summary, tweet, LinkedIn post, tags) from the episode's current transcript artifact (edited_key ?? raw_key):

text
validate -> check-headroom (READ) -> generate -> record-spend (SOFT) -> finalize
catch: mark-failed (generation reserves nothing, so no refund)

The load-bearing property is the generate / record-spend step split: the generation result is durably persisted by its step, so a transient metering failure retries record-spend without re-running the LLM call. Spend is recorded from actual usage even when the response fails to parse, because the model ran and billed either way; the user-facing failure message says exactly that. Results land in ai_jobs.result.assets (no dedicated table); the transcript surface's PromoAssetsPanel (src/lib/components/transcripts/PromoAssetsPanel.svelte) renders the latest complete generation returned by GET /api/ai/transcriptions/:episodeId.

API surface

All routes live in src/api/routes/ai/index.ts, mounted at /api/ai (src/api/index.ts):

MethodPathRoleFeature gate
POST/ai/transcriptions/:episodeIdmember+ai_transcription
GET/ai/jobs/:jobIdmember+none
GET/ai/transcriptions/:episodeIdmember+none
PUT/ai/transcriptions/:episodeIdmember+none
POST/ai/transcriptions/:episodeId/publishadmin+none
DELETE/ai/transcriptions/:episodeId/publishadmin+none
POST/ai/content/:episodeIdmember+ai_promo_assets
POST/ai/research/:episodeIdmember+ai_prep_questions
GET/ai/research/:episodeIdmember+none
DELETE/ai/research/:episodeId/:researchIdadmin+none

Non-middleware denials use the GateError envelope { error, code, key, meta } with codes such as audio_not_finalized (428), unsupported_audio_format / audio_too_large (422), quota_exceeded (429), transcript_exists / job_active (409).

Feature gates

Catalog keyGatesNotes
ai_transcriptionManual transcription route, the auto-transcribe upload hook, and successor-job spawningChecked live at each site
ai_promo_assetsPromo content generationAdded in 20260703161340_ai_promo_assets_catalog_key.sql
ai_prep_questionsGuest research: research route, confirm-time enqueue, and the public booking form's AI sectionThe public submission path resolves this as a tri-state (granted / denied / unavailable) via src/lib/entitlements/ai-research-feature.ts; see Guest Research

Worker bindings

From workers/ai-processor/wrangler.toml: Workers AI (AI, via the podcasterplus-ai AI Gateway), Hyperdrive (shared config), the podcasterplus-media R2 bucket, Browser Run (BROWSER, research page fetching), queue consumer ai-jobs (DLQ ai-jobs-dlq), and queue producers automation-events (fan-out of episode.transcribed / episode.research_ready) and rss-invalidation (stale published transcripts). Shared app modules (src/lib/entitlements/*, src/lib/transcripts/*, src/lib/booking/ai-research.ts, src/lib/research/types.ts) are imported via relative paths, never $lib aliases.

Internal documentation - Not for public distribution