Skip to content

ADR-0008: TypeScript and a plain pipeline for the podcast engine

Listen to this page12:07
Read the transcript

1. Assumptions Carried In, Constraints Already Present

Host: So we’re looking at ADR-0008 today, which is about how a podcast-generation pipeline got built inside this repo — the thing that takes a handbook page and turns it into an actual episode. It has to load the source content, plan the episode, write dialogue between two voices, review that dialogue for accuracy, direct the delivery, and then synthesize the audio. But before any of that got built, the design document already made two big calls, and neither of them came from looking at the repo itself.

Guest: Right, and that’s really the crux of it. The original spec said: use Python packages under packages/, and use LangGraph to orchestrate the steps. Both of those were assumptions carried in from outside, rather than decisions made by looking at what’s actually here.

Host: And what’s actually here turns out to matter a lot. So walk me through it — what’s the real shape of this repository that those two assumptions are about to collide with?

2. Two Questions Usually Answered by Habit

Guest: So there are really two questions here, and both of them tend to get answered by habit instead of by looking at the evidence. First, which language — AI tooling defaults to Python, and the original design just inherited that default. But the largest piece of Phase 1 is a content loader, and the content, its schemas, and its structural linter are all already written in TypeScript. Second, which orchestration framework — the design specified LangGraph, and the handbook literally teaches LangGraph, so it feels like the obvious choice, which is exactly why it needs a real argument instead of a shrug.

Host: So the very familiarity of Python and LangGraph is what’s making people skip the argument entirely. Why does getting either of those two calls wrong end up being so expensive down the line?

Guest: Because the language decides what you can reuse versus what you’re forced to reimplement from scratch, and the framework decides the shape of every future change to the pipeline.

3. Weighing a Python Engine Against Plain TypeScript

Host: So lay out the actual menu here. What were the four options on the table before this got decided?

Guest: A Python engine living in labs, TypeScript with LangGraph JS, TypeScript with a heavier agent framework that brings workflow primitives and persistence, and TypeScript with plain async orchestration. The Python option is legible to the audience but the content loader would have to reimplement MDX parsing and restate every content schema in a second language, with nothing to catch the two definitions drifting apart. The heavier framework is real capability aimed at a problem this pipeline doesn’t have yet, and it’s harder to leave than to enter.

Host: And plain async wins because of that same content loader — importing beats restating.

Guest: Exactly. In TypeScript the loader just imports extractFrontmatter and the content schemas from packages/shared, so a page gets validated by the same Zod library the linter and site already use. Model access goes through a separate provider-abstraction layer that validates the episode plan at the model boundary with that same Zod library. Do that content-schema sharing in Python and you’re maintaining two definitions of a valid page with no mechanism to compare them — that’s invisible drift waiting to happen, and it’s why the engine landed in TypeScript with ordinary async functions instead of a graph.

4. Why Not LangGraph: A For-Loop in Disguise

Host: So the engine’s TypeScript and async functions, no graph framework. Walk me through why LangGraph specifically got ruled out, given there’s a whole module built around it.

Guest: Because the actual workflow is source pack to plan to dialogue to review to a bounded revision loop to voice script to audio. That’s one cycle with a capped iteration count — structurally that’s just a for loop, not a graph. LangGraph earns its keep when you need branching across many paths, dynamic node selection, concurrent fan-out, or interrupt-and-resume against durable state, and this pipeline has none of those. The one candidate is human approval, and that’s a file and a CLI between runs, not a suspended graph waiting on durable state.

Host: But you do write plan.json, draft-dialogue.json, review.json at every stage. Isn’t that just checkpointing with a different name — which is the strongest thing LangGraph gives you for free?

Guest: That’s the honest counterargument, and it’s worth maybe forty lines of hand-rolled state-writing to get it without the dependency. What tips it is that adopting LangGraph here would mean owning the shape of every future change to this pipeline because it happens to be the module topic — and that’s exactly the kind of framework-first reasoning the handbook argues against everywhere else. A graph on a linear pipeline is a dependency bought with no property actually gained.

5. The Real Corpus Finds Two Bugs a Fixture Never Would

Host: So once LangGraph is off the table, the pipeline still has to actually work against real content, not toy fixtures. What happened when you pointed the loader at the live handbook tree instead of test data?

Guest: Two things broke that fixtures would never have surfaced. Architecture pages and cheat sheets live one directory deeper than the loader expected, so readdir on the parent found no mdx files and just returned empty — no error, nothing. A test asserting more than forty documents total still passed with a quarter of the handbook missing, because a total can’t see that two whole collections silently went to zero.

Host: That’s a scary kind of pass. What was the second one?

Guest: A type error in packages/shared that pnpm verify never caught, because pnpm check only ever ran astro check on the site — the shared package’s own check script existed but nothing invoked it, and I confirmed that by deliberately breaking it and watching the build stay green. It’d gone unnoticed because that script was already failing for unrelated config reasons, so nobody noticed it wasn’t running at all. Same failure mode the Redis integration job in Async AI Gateway hit that same day — a gate that’s never actually run doesn’t report that it’s broken, it just reports success.

6. Two Ports, One Replaceable SDK

Host: Let’s stay inside packages/podcast-providers for a second, because you keep saying the engine only imports two ports. What’s actually on the other side of LlmPort and TtsPort, and why does that boundary matter more than which vendor sits behind it?

Guest: There’s exactly one file that imports the ai package and the provider packages — ai-sdk.ts. Everything else in the engine calls the port interface, so swapping a vendor is a change to that one file, not a search-and-replace through business logic. I picked the AI SDK over LangChain JS for a specific reason: both ai-sdk/openai and ai-sdk/elevenlabs expose a .speech() method, so text and voice sit behind the same provider shape and changing voice vendor is configuration, not code. LangChain’s core model interfaces are only language models and embeddings — there’s no speech abstraction at all, so a LangChain pipeline needs a second, hand-written abstraction for the voice half regardless. I checked that against the installed packages, not the docs, because I didn’t want to build on a claim that turned out to be aspirational.

Host: So if you’ve already got to hand-write an abstraction for voice no matter what, the SDK underneath it might as well cover both halves. Where does Cloudflare AI Gateway fit into that — is it a third option competing with these two?

Guest: No, and that was the thing I had to get straight in my own head first — it’s not an alternative to either, it’s a reverse proxy underneath whichever one you pick. You point the SDK at a gateway base URL, the request still speaks the provider’s own wire format, and you gain caching, retries and fallback, rate limiting, and cost logging for free. It proxies twenty-six providers, three of them voice, and the whole integration is swapping one baseURL — turning it off is deleting two environment variables. That’s why failover lives there instead of in application code; the one case it can’t cover, failing between vendors with different credentials, gets a small withFallback wrapper, documented as the exception rather than the pattern.

7. The Cost Test That Failed and Was Right

Host: So you had a test that just… failed on its own assumption. You wrote it expecting eight revision rounds to cost more than double what one round costs, and it didn’t. What actually happened there?

Guest: One round comes out to four sixty-two, eight rounds comes out to six forty-eight — nowhere near double, and the test said so. The reason is that speech synthesis costs the same regardless of how many times the script gets rewritten, and at these prices it’s three dollars eighty out of four thirty-six at zero revisions — eighty-seven percent of the bill. The LLM text calls are the cheap part even when you let them run eight times.

Host: So the thing that looks unbounded, the revision loop, isn’t actually where the money is — the real lever is regenerating a whole segment’s audio. Does that hold no matter what, or is it just true at today’s prices?

Guest: Just at today’s prices, and that’s exactly why it’s not hardcoded as a conclusion — there’s a function that computes the crossover for whatever price list you give it. Below roughly forty-nine dollars per million speech characters the intuition flips back and the revision cap does become the thing to watch. Prices are configuration with no defaults for that reason — a stale hardcoded rate gives you a confident wrong number, which is worse than no number because it stops anyone from asking the question again.

8. What Would Actually Change the Answer

Host: So this isn’t a decision that’s closed forever — what would actually make you revisit it? Not vibes, but something you could point to and say, that’s the trigger.

Guest: Three concrete things. If we ever need interactive Q&A with interrupt-and-resume — a voice session that pauses on a tool call and comes back against durable state — that’s genuinely the shape graph frameworks are built for, and it can adopt one without dragging the batch generator along. Second, if the revision loop stops being a loop, meaning review outcomes start routing to different repair paths instead of back to one composer, that’s real branching, not a for-loop wearing a costume. And third, if stage-level retry becomes load-bearing — a failed synthesis at minute thirty needs to resume rather than restart, and our hand-rolled checkpointing is piling up edge cases — that’s the point to buy the framework instead of writing more of it ourselves.

Host: So the reversal condition is a measurement, not a mood — same as the cost model, same as the two ports. That feels like the throughline of this whole ADR, honestly. Where does that leave the status right now?

Guest: Accepted, and already implemented — the content package reads the real handbook tree and builds those source packs, and pnpm check now typechecks every workspace, not just the site. Nothing here is theoretical; the bugs it caught were real and the numbers it produced overturned our own intuition — the revision-loop cost test failed at $6.48 against $9.24, which is the opposite of what the obvious reading predicts. That’s really the whole case for writing it down: not that Python or LangGraph were wrong in the abstract, but that neither one matched what this repository already was.

Generated from this page by Claude Sonnet 5 on , spoken by Kokoro-82M running locally. Two synthetic voices, not a recorded conversation. Every claim is drawn from this page — where it differs from the text above, the text is correct.

ADR-0008acceptedAugust 15, 2026

Accepted. Implemented by packages/handbook-content, which loads the real content tree and builds source packs, and by extending pnpm check to typecheck every workspace package rather than the site alone.

The podcast engine generates episodes from handbook pages: load the source material, plan an episode, write dialogue between two voices, review it for groundedness, direct the voices, synthesise audio. The design document specified Python packages under packages/ and a LangGraph workflow.

Both were assumptions carried in from outside rather than decisions made against this repository, which already had constraints that bear on them.

Every Python project here is a lab. There are eleven, each in labs/<name>/ with its own pyproject.toml, virtualenv, and CI workflow. Nothing shares a Python environment with anything else. That isolation is deliberate — one lab’s dependency mistake cannot take the others down — but it means there is no shared Python foundation for a product to build on.

Everything else is TypeScript, and the content tooling already exists. packages/shared holds Zod schemas for every page type, frontmatter extraction, and the freshness classification with its review windows. scripts/lint-content-structure.ts walks the content tree to enforce each page’s required-section contract.

The content is MDX, not Markdown. Page bodies carry import statements and JSX components, and the frontmatter is the source of the metadata the generator needs.

Two questions, usually answered by habit rather than by evidence:

  1. Which language? AI tooling defaults to Python, and the design assumed it. But the largest component of Phase 1 is a content loader, and the content, its schemas, and its structural linter are all TypeScript.
  2. Which orchestration framework? The design specified LangGraph. The handbook teaches LangGraph, which makes adopting it feel obvious and is precisely why it needs an argument rather than an assumption.

Getting either wrong is expensive later: the language decides what can be reused versus reimplemented, and the framework decides the shape of every future change to the pipeline.

Python engine in labs/podcast-engine/. Consistent with where Python lives, legible to an audience that mostly writes Python. But the content loader would reimplement MDX parsing and restate every content schema in a second language, with no mechanism to detect divergence when one side changes.

TypeScript with LangGraph JS. Keeps schema reuse and adds the graph. But it is the less mature of the two LangGraph implementations, and it still has to justify the graph.

TypeScript with a heavier agent framework (workflow primitives, agent abstractions, built-in persistence). Real capability, aimed at a problem this pipeline does not have yet, and harder to leave than to enter.

TypeScript with plain async orchestration. Reuses the schemas and freshness logic directly. Checkpointing between stages has to be written by hand.

The podcast engine is TypeScript, in packages/ alongside the rest of the platform’s shared code, and it orchestrates with ordinary async functions rather than a graph framework.

Model access goes through a provider-abstraction layer whose schema validation is the same Zod the handbook already uses for frontmatter, so a typed EpisodePlan is validated at the model boundary by the same library that validates a page.

The content loader settles it. In TypeScript it imports extractFrontmatter and the content schemas from packages/shared, sharing the definition of a valid page with the linter and the site. In Python it would restate all of that, and the two would drift invisibly, because nothing would compare them.

Source freshness makes the same point. The design’s freshness tracking is not a new mechanism to build; it is packages/shared/src/freshness.ts applied to a different artifact. In TypeScript that is an import.

The cost is real and worth naming. This handbook’s readers are AI engineers, and AI engineering is overwhelmingly Python. A Python engine would be more legible to them. That cost is acceptable because the podcast engine is product infrastructure, not a lab — the eleven labs remain Python, and they are where readers go to read code.

The workflow is linear: source pack → plan → dialogue → review → bounded revision loop → voice script → audio. One cycle, capped iteration count. That is a for loop.

The properties that justify a graph framework — branching across many paths, dynamic node selection, concurrent fan-out, interrupt-and-resume against durable state — are mostly absent. Human approval is the one candidate, and it is a file-and-CLI approval between runs, not a suspended graph.

The strongest argument for LangGraph is that per-stage artifacts (plan.json, draft-dialogue.json, review.json) are checkpointing under another name, and a graph framework supplies that plus per-node retries for free. That is worth roughly forty lines here, against a dependency that would own the shape of every future change.

packages/handbook-content reads the real content tree — every page across all seven collections — and builds source packs with per-excerpt attribution and a source hash for freshness comparison. Its tests run against the live corpus rather than fixtures, which is how two of its own bugs surfaced.

Two collections initially loaded as empty and the tests passed anyway. Architecture pages live under architecture/systems/ and cheat sheets under cheatsheets/sheets/; the first version of the loader pointed at the parent directories. readdir on a directory of subdirectories returns no .mdx files, so the collections came back empty rather than erroring, and a test asserting “more than forty documents total” passed with a quarter of the handbook missing. The fix is a per collection floor, asserted individually — a total is exactly the assertion that cannot see this.

A type error in packages/shared did not fail pnpm verify. pnpm check ran only astro check on the site. @handbook/shared had a check script that nothing invoked, confirmed by introducing a deliberate error and watching the build stay green. It had gone unnoticed because the script was already failing for unrelated reasons — @types/node referenced by its tsconfig but never declared, and a rootDir conflicting with an included vitest.config.ts. A gate that is never run does not report that it is broken. pnpm check now covers every workspace package, and both config bugs are fixed.

That is the same failure the Async AI Gateway found in its Redis job on the same day: a check reporting success without checking.

Parameter properties are unavailable. This repo runs TypeScript directly under node --experimental-strip-types, which erases types without transforming syntax and rejects parameter properties outright. Vitest transpiles and would not have caught it. Constructor assignment is written out longhand.

packages/podcast-providers defines two ports — LlmPort and TtsPort — and the engine imports nothing else. One file, ai-sdk.ts, imports ai and the provider packages. That is what makes the SDK a replaceable component rather than a structural commitment.

The AI SDK rather than LangChain JS, for one decisive reason: it covers speech and text through the same provider shape. @ai-sdk/openai and @ai-sdk/elevenlabs both expose .speech(), so changing voice vendor is configuration. @langchain/core has no speech abstraction at all — its model interfaces are language models and embeddings — so a LangChain pipeline needs a second, hand-written abstraction for the voice half regardless. Given that a hand-written abstraction has to exist either way, the layer beneath it may as well cover both halves. Both facts were checked against the installed packages rather than taken from documentation.

Cloudflare AI Gateway is not an alternative to either. It is a reverse proxy that sits underneath whichever SDK is chosen: point the SDK at a gateway base URL and the request still speaks the provider’s own wire format, while gaining caching, retries and fallback, rate limiting, and cost logging. It proxies 26 providers, three of them voice — ElevenLabs, Deepgram, Cartesia. The integration is a single baseURL substitution, and disabling it is deleting two environment variables.

Because failover lives in the gateway, it does not live in application code. The one case the gateway cannot cover — failing between vendors whose credentials differ — is a small withFallback wrapper, documented as the exception rather than the pattern.

The cost model, which contradicted the obvious reading

Section titled “The cost model, which contradicted the obvious reading”

A test asserting that eight revision rounds cost more than double one round failed, at $6.48 against $9.24. The revision loop is the only unbounded term in the pipeline, so it looks like the one to control. At premium voice pricing it is not:

Revision rounds LLM Speech Total Speech share
0 $0.56 $3.80 $4.36 87%
1 $0.82 $3.80 $4.62 82%
8 $2.68 $3.80 $6.48 59%

Seven extra rounds of rework cost less than one synthesis. So at premium voice pricing the primary cost control is segment-level regeneration, not the revision cap — the reverse of the intuitive answer. Below roughly $49 per million speech characters it flips back, and revisionBreakEvenSpeechPrice computes that crossover for a given price list rather than baking in a conclusion that depends on a number which changes.

Prices are configuration with no defaults, because a stale hardcoded rate produces confident wrong numbers — worse than no number, since it stops the question being asked.

Concrete conditions, so a reversal is a measurement rather than a mood:

  • Interactive Q&A needs interrupt-and-resume. A voice session that pauses on a tool call and resumes against durable state is the shape graph frameworks exist for. That is a different workload from batch generation and can adopt one without dragging the generator along.
  • The revision loop stops being a loop. If review outcomes route to different repair paths rather than back to one composer, the branching is real.
  • Stage-level retry and resume becomes load-bearing. If a failed synthesis at minute thirty should resume rather than restart, and hand-rolled checkpointing is accumulating edge cases, buy the framework.
  • ADR-0002 — the workspace layout this extends.
  • ADR-0004 — the content schema the loader reads through.
  • Module 7: LangGraph — what the framework is for, including the cases where it earns its place.
  • Async AI Gateway — the Redis job with the same failure shape as the typecheck gap above.