Table of Contents
A couple of years ago I built a little tool I open most days called the Deepgram STT Explorer. It's the thing I reach for when a customer asks "does redaction actually work in streaming?" or "what does endpointing=10 do in practice?" Rather than spinning up a notebook or hand-crafting a wss:// URL, I open the app, click some toggles, drop in an audio file, and watch the API respond in real time.
It worked, but it had drifted. The Deepgram Python SDK had moved on, twice. My app was pinned to deepgram-sdk==3.10.1 while the rest of the world was on v6. The backend was Flask with gevent monkey-patching. The frontend was the kind of jQuery soup I keep promising myself I'll rewrite.
So I rewrote it. Front to back. In two days. Most of which I spent talking to the documentation.
It's live at deepgram-python-stt.fly.dev and the repo is at github.com/Jacob-Lasky/deepgram-python-stt. This post is about what made those two days possible, and what I think it says about where SDK migrations are headed.
The setup
The Explorer is a single-page app that exposes every Deepgram Speech-to-Text parameter I could find: model, language, smart format, VAD, endpointing, the entire redaction matrix, keyterms, callbacks. It runs in four modes: live mic, file streaming, batch, and a TTS-to-STT round trip with a word-level diff panel.
The right-hand panel shows the live wss:// or https:// URL as you toggle things, so you can copy a fully-formed request, or paste one in to reproduce a config. The whole app is, in a sense, a graphical wrapper around a Deepgram URL.
The old version was Flask, Flask-SocketIO, gevent, and deepgram-sdk==3.10.1. The new version is FastAPI, async python-socketio, uvicorn, and deepgram-sdk==6.0.1. Same idea, almost no shared code.
What changed in v6, and why a small change is a big change
The v6 SDK is what the release notes call a "fully generated SDK." That phrase is doing a lot of work. In practice it means the WebSocket clients for Listen, Speak, and Agent are produced from the API schema rather than hand-rolled, which has a few downstream consequences.
send_media() takes raw bytes. Control messages are dedicated methods (send_keep_alive(), send_finalize(), send_flush()) instead of a generic send_control(). Types live in domain-specific namespaces (deepgram.listen.v1.types, deepgram.agent.v1.types) instead of one shared barrel import. And anything new in the protocol shows up in the SDK automatically when the schema regenerates, instead of waiting for a hand-written patch.
Going from v3 to v6 means almost every line that touches the SDK changes. Pre-2023 me would have spent a week on this. Trying to do it as incremental edits to the old codebase would have been worse than a rewrite.
So I rewrote it. The interesting part is how I rewrote it.
The migration was a conversation with the docs
Before I asked Claude to do anything, I added one line to the project:
claude mcp add deepgram-docs --scope project --transport http https://deepgram.mcp.kapa.aiThat's the Deepgram docs MCP server. It gives any AI coding tool (Claude Code, Cursor, anything that speaks MCP) semantic search over the full Deepgram documentation corpus. Every API page, every SDK README, every changelog entry, every code example, reachable as a tool the model can call mid-task.
With that in place, the migration stopped looking like a refactor and started looking like a dialog. Three patterns kept repeating.
First, I'd describe what I wanted in plain English. "Use the async v6 Listen client, stream raw bytes from the SocketIO audio_stream event, emit interim and final results back to the browser."
Second, Claude would query the docs MCP for the actual current API surface, fetching what the docs said today rather than relying on training memory. It came back with the right import paths, the right method signatures, working examples.
Third, I'd run it, find a gotcha, and paste the symptom back in. Claude would query the docs again, narrow it down, and produce a fix.
The whole repo got rewritten across about thirty commits over two days. The commit log reads like a story: each phase planned in a markdown doc, executed, verified, archived. That's not how I write code on my own. That's what happens when the docs become a tool inside the thing writing the code, instead of a tab you context-switch to.
The gotchas the docs couldn't fix (yet)
A migration is never just "read the docs, write the code." Here are the ones the docs MCP didn't catch for me, all of which now live in a Key Gotchas section of the README.
gevent.monkey.patch_all() outlives your migration. I'd told Claude to drop gevent. It did, except for one stray import. That single line corrupts the asyncio event loop at import time, before any of the async code runs. Symptom: WebSocket connections that hang silently. Lesson: grep -r gevent before trusting anything.
SDK callbacks need async def and **kwargs. v6 dispatches events with keyword arguments that may grow over time. Write def on_message(result): and the SDK doesn't crash. It just never calls your handler.
MediaRecorder timeslice matters. I had it at 1000ms (old example). The last word before Stop was getting dropped maybe 30% of the time, because the WebSocket closed before the final chunk got through. Dropping to 250ms fixed it.
stream_started has to fire on connect, not on Metadata. I'd been waiting for the SDK's Metadata event to flip the UI to "live," because that's when you know the WS is real. But Metadata timing is non-deterministic and can take 10+ seconds. The UI felt frozen. Now the server emits stream_started the moment the WS opens, and Metadata is just another response in the log.
Boolean query params have to be lowercase strings. redact=true, not redact=True. Python's str(True) is the wrong answer. My favorite footnote on this one: this exact bug was fixed in the SDK itself in v7.1.1, "lowercase bool query params on websocket connect." So if you're picking up v6 today, you don't get this one. The docs literally write themselves out of the gotcha list.
What "documentation as code" actually means
The phrase has been kicking around for a while, but I think the meaning is finally settling. It used to mean "treat your docs the way you treat your codebase": version control, code review, CI. That's still true. There's a newer meaning, and this migration was the first time I felt it directly.
Your documentation is now an executable input to the development process. A tool reads it while writing code, on every keystroke, as authoritative as the API surface itself.
That's a real shift, and it changes what good docs look like. Docs that are correct but unsearchable are now worse than docs that are slightly less polished but structured for retrieval. Code examples that drift from the current SDK are now actively harmful, because an MCP server will surface them to a coding agent that doesn't know they're stale. Changelogs that just say "bug fixes" leave the agent without enough signal to migrate around them.
It also changes what easy feels like. The first time I migrated between major SDK versions, years ago, "easy" meant "the changelog had a migration section." This time, "easy" meant "I described what I wanted, the docs answered, and the code that came out worked." That's a different kind of easy. It's the kind that scales: to the next SDK bump, to the next API surface, to the next developer who shows up with a blank repo and wants to ship something on Friday.
Try it, or steal from it
The Explorer is at deepgram-python-stt.fly.dev. Bring your own API key. The repo at github.com/Jacob-Lasky/deepgram-python-stt is MIT-licensed. The README has the gotchas, the test setup, and a one-command Fly.io deploy.
If you're staring down an SDK migration of your own, Deepgram or otherwise, my actual recommendation is this. Wire up your docs to your AI tool first. Then let it write the migration. Then plan to spend a day chasing five or six subtle async, callback, or timing bugs that no diff would have caught. That day is where the real learning happens.
But it'll be one day, not two weeks. That part is new.









