Podsmith

Docs

Two interfaces over one pipeline: an MCP server at /mcp and a REST API under /api. Agents should generally read llms.txt or openapi.json instead of this page.


Setting up a deployment

Three secrets. The first two pay for generation; the third unlocks the admin dashboard.

wrangler pages secret put ANTHROPIC_API_KEY      # scripts, groundedness judging, show notes
wrangler pages secret put GEMINI_API_KEY         # multi-speaker voices
wrangler pages secret put PODSMITH_ADMIN_PASSWORD # gates /admin

Secrets take effect immediately — no redeploy needed. Then sign in at /admin and mint an API key. Until at least one key exists, generation endpoints return auth_not_configured, deliberately, so a public deployment cannot spend your model credit anonymously.

Where the provider keys come from

KeyPortalNotes
ANTHROPIC_API_KEY platform.claude.com → API keys Starts sk-ant-. Needs credit on the account (Billing → prepaid credit or a card). Shown once.
GEMINI_API_KEY aistudio.google.com → API keys Starts AIza. If TTS calls come back with a quota or permission error, enable billing on the key's Google Cloud project — TTS models have tighter free-tier limits than text models.

Read-only endpoints — /api/formats, /api/voices, /api/estimate, /llms.txt, /openapi.json and the MCP descriptor — never require a key.


Managing API keys

Keys are minted in the dashboard at /admin, stored as a SHA-256 hash, and shown exactly once. Losing one means minting a replacement; there is no recovery, which is the correct trade for a credential that spends money.

PODSMITH_API_KEYS still works as a comma-separated bootstrap secret — useful for CI, or for the first request on a fresh deployment before the dashboard is reachable. Keys from that secret are unmetered and uncapped, so prefer minted keys for anything real.

Admin sessions are an 8-hour signed HttpOnly cookie, and the signing key is derived from the password, so rotating PODSMITH_ADMIN_PASSWORD invalidates every session at once. Sign-in attempts are rate limited per IP. For anything beyond a single operator, put Cloudflare Access in front of /admin and treat this as defence in depth.

The job lifecycle

ingest → plan → script → ground → (awaiting_approval) → tts → post → verify → finalize → done

StageWhat happens
ingestSources fetched and stripped to readable text, then split into citable chunks with ids like S1c3.
planEditorial pass: hook, segments with time budgets, the obvious questions a listener would ask, analogies, takeaway.
scriptThe dialogue, with per-turn citations and the disfluency engine applied.
groundA judge pass scores every substantive claim against the sources. This is a gate — below groundedness_threshold the job fails before any audio spend.
ttsThe conversation is rendered in multi-turn batches by a dialogue-native multi-speaker model.
postK-weighted gated loudness measurement across the whole programme; one gain decided; silence trimmed at the edges.
verifyEach segment transcribed and diffed against its script. Anything under 70% word agreement is re-rendered once.
finalizeShow notes, transcript timings, chapters and the immutable manifest.
Polling drives the work, and a poll can take minutes. There is no background queue. A GET /api/jobs/{id} takes a lease and runs the next pipeline steps inside that request, returning when they finish. Submission itself does no work — the first poll starts the job, and a job nobody polls makes no progress.

Set a 10-minute request timeout and keep one poll in flight at a time. A poll that picks up the scripting pass will block for several minutes; that is the pipeline working. Concurrent polls are safe but pointless: the lease means only one request runs a given step, so no model call is paid for twice, and each step is persisted before the next begins.

Expect a 3-minute Brief to take 4-6 minutes end to end, and a 12-minute Deep Dive rather longer — the scripting pass and TTS dominate.

Why the work runs inside the request

It looks wrong, and the obvious alternative is worse. Pages offers no queue consumer, and a background context started with waitUntil is killed by the runtime long before a multi-minute model call returns — and strands the job's lease as it dies, freezing the job until that lease expires. Running inline keeps the step alive for as long as it needs. The response streams whitespace while it works, so the connection never looks idle to an intermediary; whitespace before a JSON value is legal, so clients still parse the body with a plain JSON.parse.


Parameters

FieldDefaultNotes
sourcesRequired. 1–12 entries of {type:"url",url} or {type:"text",text}.
formatdeep_diveAlso brief, debate, critique, interview, explainer.
target_minutesformat default12 for deep_dive. Result lands within about ±15%.
voice_pairuk_warmCurated pairing. British-English pairs lead the list.
hostsfrom pairExactly two: {name,voice,persona}. First explains, second questions.
disfluencyformat default0 clean → 0.5 natural → 1 loose. The strongest lever on how human it sounds.
listener_levelinformednewcomer, informed, expert.
style_directionnoneFree text; shapes both the writing and the voice delivery.
require_approvalfalsePause after the script; render nothing until approved.
groundedness_threshold0.75Minimum supported-claim share before audio is rendered.
verify_audiotrueASR diff against the script; re-renders bad segments; yields measured timings.
disclosuretrueSpoken AI-disclosure line at the end.
seedderivedDerived deterministically from the inputs when omitted.
idempotency_keynoneHeader or body. Reuse makes retries safe.

Draft and approve

POST /api/podcasts   {"sources":[…], "require_approval": true}
GET  /api/jobs/{id}  → status "awaiting_approval", script.draft returned
POST /api/jobs/{id}/approve   → renders audio
POST /api/jobs/{id}/reject    → ends the job, no audio spend

Errors

Every error is JSON with a stable code, a human message, and a remediation field that says what to change. A job that fails still returns HTTP 200 from GET /api/jobs/{id}, with the error inside — don't treat that as a transport failure and retry it.

CodeMeaning
auth_not_configuredNo API keys exist yet; generation disabled. Mint one at /admin.
key_revokedThe key was revoked. Retrying will not help; ask for a new one.
spend_limit_reachedKey hit its monthly cap. No job was created and nothing was charged.
source_too_largeSplit the input, trim it, or summarise before submitting.
source_unreachableFetch failed, or the content type isn't extractable (e.g. PDF).
source_emptyPage yielded no text — usually JavaScript-rendered. Paste the text instead.
groundedness_below_thresholdScript wasn't supported by the sources. No audio was rendered.
content_policy_blockedImpersonation of a real person, or a provider safety block.
provider_failedUpstream model error. Safe to retry with the same idempotency key.
wrong_statee.g. approving a job that isn't awaiting approval.

What's in the manifest

One per episode, written at finalisation and never rewritten. It is the answer to "where did this claim come from, and what produced this audio".


Connecting an MCP client

Streamable HTTP transport, JSON-RPC 2.0 over POST. A GET returns the descriptor.

{
  "mcpServers": {
    "podsmith": {
      "url": "https://podsmith.pages.dev/mcp",
      "headers": { "Authorization": "Bearer YOUR_KEY" }
    }
  }
}

Tools: generate_podcast, get_job_status, get_episode, approve_draft, estimate_cost, list_formats, list_voices.


Audio format

24 kHz mono 16-bit PCM WAV, served with range support so it can be handed straight to a player. Loudness is normalised to −19 LUFS mono, which is the equivalent of the −16 LUFS podcast convention for a stereo programme; both figures are in the manifest so nobody has to guess which reference was used.

The measurement implements ITU-R BS.1770 K-weighting with EBU R128 two-stage gating, using contiguous 400 ms blocks rather than overlapping windows — within a few tenths of a LU of a reference meter. A single programme gain is applied; there is no compression or limiting beyond a hard peak ceiling.