Luke Oliff.

When SDKs Write Themselves: Voice API Code Generation

·Developer Experience·11 min read·Luke Oliff

The hardest part of maintaining a voice API SDK is not the API. It is the gap between what the API does and what the SDK says it does. Hand-rolled types drift, async patterns fall out of sync, and documentation lags by weeks. Users blame the SDK, then the API, then they switch providers.

Deepgram started tackling this differently last year. We moved to a spec-first generation pipeline that produces SDKs in five languages from a single OpenAPI and AsyncAPI spec. JavaScript v5 shipped in April. Python v6 followed, then Go, .NET, and Java. I want to walk through what that pipeline actually looks like, what it handles well, and what it does not.

The Problem with Hand-Rolled SDKs

Before the switch, every Deepgram SDK was maintained by hand. If the API added a parameter, someone had to update the type definitions in five repos. If the WebSocket protocol changed, someone had to rewrite the streaming client in every language. If a bug was fixed in one SDK, it usually stayed broken in the others for weeks.

The JavaScript SDK had types that did not match the API because they were written from memory of a six-month-old spec. The Python SDK had a generic send_control() method that required you to look up the exact JSON shape every time you wanted to send a keep-alive or finalize a stream. The Go SDK was missing features that had shipped in the API three releases ago.

None of this was anyone being careless. Keeping five SDKs in sync by hand is not sustainable when the API ships weekly. The drift was structural. Not personal.

The real cost was not the maintenance burden on our team. It was the trust erosion every time a developer hit a type error that should not exist, or found that their language did not support a feature the docs claimed was available. Voice APIs are hard enough to integrate without the SDK lying to you.

Spec-First Generation: How the Pipeline Works

The pipeline starts in the deepgram-api-specs repo, a public repository that holds the canonical OpenAPI and AsyncAPI definitions for everything Deepgram exposes. Every hour, a sync action pulls the latest specs from the developer portal and opens a PR if anything changed.

The specs are the source of truth, not the code. They describe every REST endpoint, every WebSocket message type, every query parameter, every error response. When a product team ships a new feature, the spec updates first. The code follows.

From the specs, Fern generates client libraries for each target language. The generation step produces:

  • Type definitions that match the API surface exactly. If the spec says a field is optional, the type is optional. If it is a string enum, the type is a string enum. No drift, no manual maintenance.

  • REST client classes with typed methods for every endpoint. The method names, parameter names, and return types all come from the spec. A developer in Python calls client.listen.transcribe_file(). A developer in JavaScript calls client.listen.transcribeFile(). The shape is the same; the casing is the language convention.

  • WebSocket client classes generated from the AsyncAPI spec. This was the harder lift. REST is straightforward because OpenAPI has mature tooling. Streaming WebSocket APIs with bidirectional messaging, keep-alives, and binary audio frames are less standard. Fern had to build AsyncAPI generation support, and we had to write our AsyncAPI spec carefully to describe the message flow accurately.

The generated code is not the whole SDK. Each language gets a hand-written runtime layer on top of the generated types. Authentication flows, retry logic, custom transports, and logging are not spec concerns. They stay in language-specific code that ships alongside the generated client.

The Layered Runtime: What Stays Hand-Written

The generation pipeline handles everything that comes from the spec. It does not handle anything that is specific to how a language works or how developers in that ecosystem expect to use an SDK.

Authentication is the obvious one. Deepgram supports API key auth, token-based auth, and project-scoped credentials. The generated client exposes a generic auth interface, and each language fills it in with idiomatic patterns: Python uses context managers, JavaScript uses constructor options, and Go uses functional options.

Custom transports are another. Deepgram supports running models on AWS SageMaker, which uses HTTP/2 bidirectional streaming. That transport is a hand-written extension in each language, implementing the same interface the generated WebSocket client expects. The Python SDK has a deepgram-sagemaker package that swaps in the SageMaker transport with one line of code. The JavaScript equivalent is in progress.

Error handling sits in the runtime layer too. The spec describes error shapes, but how errors propagate differs by language. Python raises typed exceptions. JavaScript returns rejected promises with structured error objects. Go returns errors as values. The generated code produces the raw error data; the runtime layer wraps it in the right convention.

The line between generated and hand-written is not always clean. When we shipped declarative reconnect in the Python SDK v7.3.0, the reconnect flag lived in the generated client constructor. The actual reconnect logic, which is a transport concern, lived in the hand-written runtime. Getting the boundary right took a few iterations.

What Auto-Generation Handles (and What It Does Not)

The wins of spec-first generation are clearest in the areas where hand-rolled SDKs consistently failed.

Type accuracy is the biggest one. Before generation, you could spend twenty minutes debugging a TypeScript error that turned out to be the SDK’s type definition being wrong. That does not happen anymore. If the spec says a field is optional, the generated type makes it optional. If the spec adds a new field, the type picks it up on the next regeneration. The trust problem of “does this type actually match the API” just goes away.

New feature coverage is second. Before generation, a new API feature took weeks to show up in all five SDKs. Someone had to read the spec, write the types, write the client method, test it, and ship it. Now the feature shows up in the generated code on the next regeneration cycle. The PR is opened by a bot. A human reviews it for language-specific issues, and it ships.

Consistency across languages is third. When every SDK is generated from the same spec, the method names, parameter orders, and error shapes are structurally identical. The casing conventions differ by language, but a developer moving from Python to JavaScript does not have to relearn the SDK structure. The mental model transfers.

What auto-generation does not handle is anything that requires understanding the developer experience of a specific language. Idiomatic patterns, naming conventions, and ecosystem integrations all stay hand-written. The generated code is the floor, not the ceiling.

Streaming WebSocket patterns were the hardest to get right. An AsyncAPI spec describes messages, but it does not describe how a developer wants to interact with a real-time audio stream. Should they use callbacks, async iterators, or event emitters? That depends on the language and the use case. The spec generates the message types and the connection lifecycle. The interaction pattern is a hand-written choice in each SDK.

Cross-Language Consistency vs Native Idioms

Generation gives you consistency for free. Every SDK supports the same features with the same parameter shapes. But consistency is not a universal good. A Python developer who finds Go-style error handling in their SDK is not delighted by the consistency. They are annoyed that it does not feel like Python.

We tried to push the generated code toward language conventions rather than a common denominator. The Fern generator supports language-specific output configuration. Python types use snake_case. JavaScript and Go use camelCase. .NET uses PascalCase. The parameter names in the generated code match what developers in each ecosystem expect.

But some things do not translate cleanly. Generics work differently across languages. Type narrowing behaves differently. Optional fields, nullable fields, and default values each have their own semantics. The generated code handles the common cases well, but edge cases require hand-written annotations in the spec that tell the generator how to represent a specific type in each language.

The practical result is that the generated SDKs are 80 to 90 percent spec-driven, with the remaining percentage being language-specific overrides and hand-written extensions. The 80 percent buys you the consistency, the type accuracy, and the rapid feature coverage. The 20 percent buys you an SDK that does not feel generated.

The Release Cadence Shift

Before generation, SDK releases were manual events. Someone decided it was time, prepared a changelog, bumped the version, and shipped. Releases happened every few weeks. Bug fixes accumulated.

After generation, releases happen whenever the spec changes. The regeneration pipeline runs automatically, opens a PR, and a human reviews it. Minor and patch releases ship multiple times per week. Major releases happen when the spec changes in a way that breaks backward compatibility, which is rare for a mature API.

The developer experience of this cadence is different. You never wait for an SDK update. The feature ships in the API, and the SDK follows within a day or two. The changelog is a diff of the spec, not a human recollection of what changed.

The downside is that developers see more frequent updates. Some teams pin SDK versions and do not want to review a regeneration PR every week. We handle that by keeping backward compatibility. The generated code is additive. New fields appear in types but do not break existing code. Developers on a pinned version stay on it until they are ready to update, and when they do, everything still works.

What I Would Tell Other API Teams

If you maintain SDKs in more than two languages and you are still hand-rolling them, the ROI on spec-first generation is huge. Not marginal. Huge. The type drift alone costs more than the migration effort.

Do not try to generate everything at once. Start with REST clients. Those are well supported by OpenAPI generation tooling and the risk is low. Add WebSocket generation once the AsyncAPI spec is stable. Add the runtime extensions as a separate layer so you can iterate on them without touching the generated code.

Invest in the spec first. The quality of the generated SDKs is bounded by the quality of the spec. If the spec is vague, the generated types are vague. If the spec is wrong, the generated code is wrong. Every hour spent making the spec precise pays back ten hours of SDK maintenance.

And accept that some things will stay hand-written. Authentication, error handling, custom transports, and language-specific patterns are not spec concerns. Do not try to push them into the generator. Keep the boundary clean and let each language’s SDK be itself where it matters.

We shipped the May 12 SDK update across five languages from one spec change. That used to take a week of coordinated work across three teams. Now it is a single PR review. The code talks to the API honestly, and that has been worth the migration.

FAQ

What is spec-first SDK generation?

Spec-first SDK generation is the practice of writing canonical API specifications (OpenAPI for REST, AsyncAPI for streaming) and using code generators to produce client libraries from those specs. The specs are the source of truth. The SDK code is a derived artifact. This eliminates type drift and reduces the manual effort of maintaining multiple language SDKs.

Why not just maintain SDKs by hand?

Hand-rolling SDKs across multiple languages creates structural drift. Types fall out of sync with the API, features land weeks late in some languages, and maintaining consistency takes disproportionate effort. Spec-first generation solves all three problems at once by deriving every SDK from the same authoritative definition.

Does auto-generation produce idiomatic code?

The generated code follows each language’s conventions for naming, casing, and module structure. But deeper patterns like authentication flow, error propagation, and streaming interaction are hand-written extensions that sit on top of the generated client. The result is an SDK that is consistent across languages where it matters and idiomatic where it does not.

How does generation affect the release cadence?

Releases happen whenever the spec changes, often multiple times per week. The generation pipeline opens automated PRs that a human reviews. The cadence shift means features land in the SDK within days of shipping in the API, not weeks. Backward compatibility is preserved so pinned versions continue to work.

What should I generate first in my own SDKs?

Start with REST clients. OpenAPI generation is mature and well supported across languages. Add streaming WebSocket generation once your AsyncAPI spec is stable. Invest in spec quality before generation quality. The generated code is only as good as the spec it comes from.