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 PODSQUICK_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
| Key | Portal | Notes |
|---|---|---|
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.
- Monthly spend limit — optional, per key. Once the month's metered spend reaches it, generation is refused with
spend_limit_reached(HTTP 402) before a job is created, so nothing is charged. - Revocation is immediate — the next request with that key gets
key_revoked. - Metering is an append-only ledger, one row per billable event, attributing spend to the key that caused it. Failed jobs are billed for the language passes that actually ran.
- Spend limits are a safety net, not a hard cap. The check runs at submission, so a single very long job can overshoot its remaining allowance. Size limits with that in mind.
PODSQUICK_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.
PODSQUICK_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
| Stage | What happens |
|---|---|
| ingest | Sources fetched and stripped to readable text, then split into citable chunks with ids like S1c3. |
| plan | Editorial pass: hook, segments with time budgets, the obvious questions a listener would ask, analogies, takeaway. |
| script | The dialogue, with per-turn citations and the disfluency engine applied. |
| ground | A judge pass scores every substantive claim against the sources. This is a gate — below groundedness_threshold the job fails before any audio spend. |
| tts | The conversation is rendered in multi-turn batches by a dialogue-native multi-speaker model. |
| post | K-weighted gated loudness measurement across the whole programme; one gain decided; silence trimmed at the edges. |
| verify | Each segment transcribed and diffed against its script. Anything under 70% word agreement is re-rendered once. |
| finalize | Show notes, transcript timings, chapters and the immutable manifest. |
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
| Field | Default | Notes |
|---|---|---|
sources | — | Required. 1–12 entries of {type:"url",url} or {type:"text",text}. |
format | deep_dive | Also brief, debate, critique, interview, explainer. |
target_minutes | format default | 12 for deep_dive. Result lands within about ±15%. |
voice_pair | uk_warm | Curated pairing. British-English pairs lead the list. |
hosts | from pair | Exactly two: {name,voice,persona}. First explains, second questions. |
disfluency | format default | 0 clean → 0.5 natural → 1 loose. The strongest lever on how human it sounds. |
listener_level | informed | newcomer, informed, expert. |
style_direction | none | Free text; shapes both the writing and the voice delivery. |
require_approval | false | Pause after the script; render nothing until approved. |
groundedness_threshold | 0.75 | Minimum supported-claim share before audio is rendered. |
verify_audio | true | ASR diff against the script; re-renders bad segments; yields measured timings. |
disclosure | true | Spoken AI-disclosure line at the end. |
seed | derived | Derived deterministically from the inputs when omitted. |
idempotency_key | none | Header 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.
| Code | Meaning |
|---|---|
auth_not_configured | No API keys exist yet; generation disabled. Mint one at /admin. |
key_revoked | The key was revoked. Retrying will not help; ask for a new one. |
spend_limit_reached | Key hit its monthly cap. No job was created and nothing was charged. |
source_too_large | Split the input, trim it, or summarise before submitting. |
source_unreachable | Fetch failed, or the content type isn't extractable (e.g. PDF). |
source_empty | Page yielded no text — usually JavaScript-rendered. Paste the text instead. |
groundedness_below_threshold | Script wasn't supported by the sources. No audio was rendered. |
content_policy_blocked | Impersonation of a real person, or a provider safety block. |
provider_failed | Upstream model error. Safe to retry with the same idempotency key. |
wrong_state | e.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".
- sources — every source with its kind, title, URI, size and fetch time.
- citations — per source chunk: an excerpt, and the transcript lines resting on it.
- quality — groundedness score, every unsupported claim spelled out, ASR fidelity, which segments were re-rendered.
- models — the script model requested and the models that actually served each pass, plus the TTS and ASR models.
- audio — measured LUFS, gain applied, target, the measurement standard, and what was trimmed.
- request — every resolved parameter including the seed, for reproduction.
- cost — token counts and derived spend, with the rate-table version.
- disclosure and limitations — stated on every episode.
Connecting an MCP client
Streamable HTTP transport, JSON-RPC 2.0 over POST. A GET returns the descriptor.
{
"mcpServers": {
"podsquick": {
"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.