workos-go: The Hand-Written Spine of a Generated SDK

WorkOS generates its Go SDK from OpenAPI, then hand-writes the parts that matter: retries, idempotency keys, session sealing, and cursor pagination. A CI workflow enforces the line.

9 min read • View on GitHub • More from workos

An editorial illustration of a workshop split down the center by a thin vertical seam. On the left, a mechanical assembly line stamps out identical brass gears while a robotic arm stacks them into a rack. On the right, a single craftsman sits at a bench, loupe in eye, hand-filing one intricate escapement mechanism. The illustration contrasts machine-generated uniformity with hand-finished judgment, the central thesis of the workos-go repository.
One repo, two instructions. Half the files say DO NOT EDIT; the other half are hand-written and protected by CI.
Key Takeaways

Two Kinds of Files

Open organizations.go in workos/workos-go and the first line tells you to keep your hands off it. Open client.go and the first line tells the generator to keep its hands off it. Same repository. Opposite instructions, stamped into the source.

// organizations.go
// Code generated by oagen. DO NOT EDIT.

// client.go
// @oagen-ignore-file
package workos

That contradiction is the whole design. WorkOS splits its Go SDK into two halves: a generated surface that wraps every REST endpoint, and a hand-written core that carries the parts where a mistake costs money. The boundary is not left to good manners. A GitHub Actions workflow named block-generated-edits.yml fails any pull request that touches a generated file, and a manifest tracks the last commit the generator ran against.

So the interesting question is not whether WorkOS uses code generation. Plenty of SDKs do. The question is what it refuses to generate, and why the refusal is worth a CI job.

What the Generator Produces

WorkOS runs an internal pipeline the company described in its engineering blog. oagen parses and normalizes the OpenAPI specification into an intermediate representation. oagen-emitters then translates that representation into language-specific code. The company's stated goal was one source of truth that could produce well-tested SDKs in every target language without a team of polyglot engineers babysitting each one.

For Go, the emitter produces the boring, high-volume surface. Request and response structs, enums, per-service methods, and the accessors that hang off the shared client:

This is exactly the work a generator is good at. It is repetitive, it is mechanical, and it changes every time the API changes. None of it requires judgment.

The Retry Loop That Refuses to Double-Charge You

The judgment lives in client.go. Every generated method, no matter which service it belongs to, ends up calling the same request() function. That function applies per-request options, resolves the base URL and retry budget, then enters a retry loop.

Here is the detail that earns the diagram. Before the loop starts, the client mints an idempotency key with uuid.New().String() for any POST that does not already carry one. The key is computed once. Attempt zero and attempt three carry the identical Idempotency-Key header.

This sounds obvious until you consider the failure it prevents. A POST times out on the client but succeeds on the server. The client retries. If each retry generated a fresh key, the server would see a second, unrelated request and happily create a second organization. Minting the key outside the loop makes a retry provably the same logical operation.

The rest of the loop is equally deliberate. Network errors and statuses in the set {429, 500, 502, 503, 504} are retried while attempts remain. The backoff() function starts at a 500ms base, doubles each attempt, adds up to 500ms of jitter, and caps at 30 seconds. If the server sent a Retry-After header, that value overrides the computed delay entirely. Sleeps run through a select on ctx.Done(), so a cancelled context stops the loop mid-backoff instead of pinning a goroutine for thirty seconds.

One more small correctness touch: when a per-request timeout is set, the code clones the shared http.Client struct rather than mutating it. A concurrent request cannot have its timeout stomped by a neighbor.

Mint the key once, outside the loop, and a retry becomes the same logical request. Mint it inside, and the server sees two.

An SDK is more than just a typed wrapper around HTTP calls.

WorkOS, WorkOS engineering blog · Handwritten SDKs Are Dead
A close-up editorial illustration of a hand pressing a wax seal onto a cream envelope, with three identical sealed envelopes stacked behind it. In the foreground, one envelope has been returned bearing a small storm-cloud stamp, and the same hand re-seals it with the same die. The illustration explains idempotency: the same mark authorizes the same logical send no matter how many times the envelope crosses the wire.
The wax seal is the metaphor. The same die, pressed on every attempt, tells the server this is one message, not many.

Errors That Mean Not Yet

Most SDKs treat a failed request as a failure. workos-go treats some of them as instructions. When an authentication flow is incomplete, the API returns an error, and the SDK surfaces a typed one: MFAChallengeError, SSORequiredError, OrganizationSelectionRequiredError, EmailVerificationRequiredError, or RadarEmailChallengeError.

Each of these embeds *APIError and implements Unwrap(), so the standard errors.As machinery can pull out the specific type or fall back to the base. The application is expected to catch them and continue the flow, not abort it. An authentication attempt is modeled as a sequence of state transitions, and the error type is the transition signal.

Error typeWhat the caller must do next
MFAChallengeErrorPrompt for a multi-factor code and resubmit
SSORequiredErrorRedirect the user into the SSO connection
OrganizationSelectionRequiredErrorAsk the user which organization to enter
EmailVerificationRequiredErrorSend and confirm an email verification
RadarEmailChallengeErrorComplete the Radar challenge before proceeding

The base APIError carries the fields these flows need: PendingAuthenticationToken, EmailVerificationID, RadarChallengeID, and AuthenticationFactors. It also holds StatusCode, RequestID from the X-Request-Id header, and RetryAfter. When the response body is not JSON at all, Error() truncates the raw body to 200 characters so a 502 from a load balancer still produces something a human can read.

Pagination as a Generic Iterator

List endpoints return an Iterator[T], a generic type with Next(), Current(), Err(), and Cursor(). The call site reads like a normal Go loop.

iter := client.UserManagement().List(ctx, &workos.UserManagementListParams{})
for iter.Next() {
    user := iter.Current()
    // iter.Cursor() can be persisted to resume later
}
if err := iter.Err(); err != nil {
    return err
}

Under the hood, Next() drains a buffer and then fetches the next page using the after cursor from the API's list_metadata. It stops when after is nil or a page comes back empty. The generator bakes each service's default parameters into the iterator, which is why a bare List call already knows to request ten items in descending order.

The Cursor() accessor is the feature worth noticing. It exposes the last after value so a consumer can store it and resume iteration after a process restart. It is easy to miss and genuinely useful for long backfills.

There is one honest wart. In withCursorAndDefaults, if encodeQuery fails, the function returns a query containing only the cursor and silently drops every other filter parameter. A caller filtering by domain would receive unfiltered results with no error raised. It is the most significant correctness concern in the hand-written core, and it is exactly the kind of edge case a generator would never have produced in the first place.

A Repo Built for Agents

The boundary from the first section is not only a policy for humans. The repository ships AGENTS.md, .claude/CLAUDE.md, and context7.json, instruction files that tell coding agents which parts of the tree they are allowed to modify. A bot contributor, workos-sdk-automation[bot], has landed 52 commits. The generated surface is refreshed by automation; the hand-written spine is guarded for everyone else.

If the surface is machine-written, then the real skill an agent needs is knowing where it is forbidden to write. The CI gate and the agent instructions are two expressions of the same rule. WorkOS's own framing puts the point plainly: the generator exists to remove the polyglot maintenance burden, not to replace the engineering judgment that lives in the core.

We needed a single source of truth that could produce well-tested SDKs in every target language without requiring a team of polyglot engineers to babysit each one.

WorkOS, WorkOS engineering blog · Handwritten SDKs Are Dead

Where It Fits

It helps to separate layers. Protocol libraries solve OAuth, OIDC, and SAML. workos-go solves the WorkOS product surface. The two are not competitors, and the SDK's trade-off is the obvious one: adopting it couples you to WorkOS's API and data model.

ProjectLayerWhat you getWhat you give up
workos-goVendor SDKTyped clients for roughly 23 WorkOS services under one shared client, with retries, idempotency, and iterators built inCoupling to WorkOS's API and data model
auth0/go-auth0Vendor SDKAuth0 Management API clientAuth0-specific; no SSO or Directory Sync breadth
golang.org/x/oauth2ProtocolOAuth 2.0 token acquisition and transport integrationNo product APIs; you build the rest
coreos/go-oidcProtocolOIDC relying-party and token verificationNo product APIs
crewjam/samlProtocolSAML toolkit for implementing SAML flowsNo hosted service, no directory sync

If you are building B2B SaaS and need enterprise SSO, directory sync, and audit logs without writing an integration per identity provider, the generated surface saves you a large amount of typing. If you need raw protocol control, reach for the protocol libraries instead. The boundary inside workos-go is a good guide to which one you actually want: the generated part is the product, and the hand-written part is the engineering.