Migrate from Stainless
Stainless is winding down its hosted platform. You keep your generated code — but the pipeline that regenerates it on every spec change is going away. Glotto picks up where it leaves off: a superset of Stainless’s SDK languages (all nine, plus React Native, Swift, Rust, Dart, Elixir, and Terraform), the same Astro-based docs approach, the same five spec transforms, release PRs across GitHub, GitLab, and Bitbucket, and a multi-mode MCP server including Code Mode and dynamic tools — the architecture Stainless pioneered.
More than the feature list, you land on the part that’s hard to rebuild in-house: every regeneration is verified, not just emitted — compile and contract statuses, per-target drift, and custom-code preservation, recorded in a verification report delivered to CI, the release PR, and the console. Generating a client is the easy part; keeping every artifact provably in agreement with a spec that changes weekly is what the pipeline is for.
The conversion is mechanical, complete, and reported line by line.
What Glotto needs from you
- Your
stainless.yml. - Your OpenAPI document. Stainless stores the spec out-of-band (uploaded or fetched on
their side), so export it from your repo or dashboard first — the migrated config needs a
real
openapi.sourcepath.
Optionally, the OpenAPI document’s Stainless extensions (x-stainless-*) come across too —
see Bring your renames across and
Bring your Terraform attribute shaping across
below. Your original document is never modified in place.
Run it
The conversion is in the published CLI — nothing to request, no account needed:
npm i -g @glotto/cli
glotto migrate stainless --openapi openapi.yaml
Pass --openapi whenever you have the document. Without it your transforms are translated
from the shape of each command’s value; with it every target is resolved against your real
document using the same selector glotto generate uses, so a target that matches nothing is
reported while you are migrating rather than breaking a build later. The flags are listed
under glotto migrate.
What you get back
A glotto.yml, plus a migration report saying what happened to every key.
The conversion copies every recognized key, normalizes what differs, and is honest about the rest: nothing is silently invented or silently lost.
A config that declares an AsyncAPI or GraphQL input block migrates onto Glotto’s native
asyncapi / graphql input blocks (Glotto accepts these as first-class inputs alongside
OpenAPI). glotto.yml takes exactly one input source per config, so when several are declared
one is kept (openapi over asyncapi over graphql) and each dropped block is named in the
migration report — the other surface becomes a separate glotto.yml.
Bring your renames across
Stainless keeps per-language member, parameter, and enum-value renames inside the OpenAPI
document as the x-stainless-naming (property/member renames — e.g. public → isPublic for
Java), x-stainless-param (method-parameter renames — e.g. dodging a Python-reserved argument),
and x-stainless-renameMap (enum-value renames — e.g. Ok for the value 200) extensions, and
shapes models with x-stainless-model / x-stainless-model-skip (name a schema’s model, or keep it
inline) and inputs with x-stainless-soft-required (demanded by the SDK signature, not by the
protocol) and x-stainless-const (supplied by the SDK, so the caller never passes it). Glotto keeps
the same intent in glotto.yml instead, under the naming, parameter_naming, enum_naming,
models, soft_required, and
auto_populate blocks. Supply the OpenAPI document
alongside your stainless.yml and those extensions are read and translated for you:
x-stainless-namingon a schema property →naming.<Model>.<property>.<target>.x-stainless-paramon an operation parameter →parameter_naming.<operationId>.<param>.<target>.x-stainless-renameMapon an enum schema →enum_naming.<Model>.<wireValue>.x-stainless-model/x-stainless-model-skipon a schema →models.<Model>.name/models.<Model>.inline.x-stainless-nominalon an enum schema →enums.<Model>.nominal.x-stainless-enum-deprecationson an enum schema →enums.<Model>.deprecated_values. The extension is an array parallel to the schema’senum, so each element deprecates the member at the same position. Afalseelement means “not deprecated” and is skipped; atrueelement means “deprecated, no message”, which Glotto cannot express — every deprecation carries a message — so it is dropped with a warning telling you which member to add one for.x-stainless-soft-requiredon an operation parameter, arequestBody, or a request-body schema property →soft_required.<operationId>.parameters/.body/.body_fields. A$ref’d body schema is followed intocomponents, so a shared request model’s annotation reaches every operation that sends it.x-stainless-conston an operation parameter or a request-body schema property →auto_populate.<operationId>.parameters/.body_fields. There is no whole-requestBodyform: “the one legal value” is a property a scalar enum position has and a body does not.
Target-language keys normalize to Glotto slugs just like targets (node → typescript, …).
Anything that can’t be mapped — an unsupported language, a parameter rename on an operation
with no operationId, or a malformed value — is dropped and listed in the migration report,
and the report’s summary counts how many renames were ingested. Without --openapi, migration
behaves exactly as before.
Note that renameMap is written the other way round in each tool: Stainless keys by the new name
(Ok: 200), Glotto keys by the wire value ("200": Ok). The migrator flips each pair for you. A
rename pointing at a value the schema’s own enum doesn’t declare is dropped with a warning rather
than silently carried over.
Member renames on TypeScript, React Native, and Python.
parameter_namingis honored by every SDK. Member renames (naming) apply to 11 of the 13 languages. TypeScript and React Native keep the wire name (their DTOs are transparent — the generated type is the JSON shape, so a member rename would need a (de)serialization remap they don’t carry), and Python applies member renames only in pydantic mode (targets.python.pydantic), not the default dataclass output.migratestill ingests the renames for these targets, andglotto generatewarns (GLOTTO_CONFIG_RENAME_NOT_APPLIED) where one won’t take effect — so you can prune them or switch Python to pydantic mode.
Bring your Terraform attribute shaping across
Stainless expresses per-attribute Terraform behavior — whether an attribute is server-assigned,
whether a collection round-trips as a set instead of a list, whether a field must always be sent
on update — as three more OpenAPI extensions: x-stainless-terraform-configurability,
x-stainless-collection-type, and x-stainless-terraform-always-send. Glotto expresses the same
three facts the same way, as spec extensions rather than
glotto.yml keys — so translating them means rewriting the spec itself, not the config. Ask for
that and you get back a translated copy of your OpenAPI document, with everything else in it
untouched and your original left unmodified:
x-stainless-terraform-configurabilityon a schema property →x-glotto-terraform-configurabilityon the same property, value carried through verbatim (required/optional/computed/computed_optional).x-stainless-terraform-always-sendon a schema property →x-glotto-terraform-always-sendon the same property, value carried through verbatim (true).x-stainless-collection-typeon an array schema node itself (never itsitems) →x-glotto-collection-typeon the same node, value carried through verbatim (list/set).
Each key is renamed where it sits — nothing is moved, and a $ref is never followed. That
matters for a property spelled as a pointer: Glotto reads the two property-level facts off the
property exactly as it appears under properties, so a { $ref: …, x-stainless-terraform-configurability: computed }
property keeps its stamp beside the $ref and works. Collection type is the exception, because
Glotto only reads it from a node that itself declares type: array — so
x-stainless-collection-type beside a $ref is reported rather than renamed: the translated
key would look right in your document and be read by nothing. Move it onto the array (the component
the pointer names, or the property spelled inline) and re-run. A value outside the sets
above — a false always-send, an unrecognized configurability, an annotation on a non-mapping
node — is reported and left unstamped rather than carried through: an unrecognized value would
be silently inert at generate time, so stamping it through would be worse than leaving it for you
to fix by hand. An x-glotto-* your document already carries is never overwritten — an identical
value is a silent no-op, so re-running the migration over its own output changes nothing, and a
differing value keeps yours and is reported, naming both.
The spec rewrite is opt-in. Without it the migration behaves exactly as it does otherwise — byte-identical output, and your OpenAPI document untouched.
The rewrite keeps a YAML document’s comments and key order; a JSON document is rewritten as JSON at its own indentation. Two things are worth knowing rather than discovering: a comment trailing a mapping key moves onto its own line, and a spec written as a multi-document YAML stream is refused outright rather than partially rewritten. Rewriting your spec in place is possible — it is the translated copy written back over its source — and it is subject to the same overwrite guard as every other output, so it never happens by accident.
Often you need neither flag: Glotto reads the standard OpenAPI readOnly (→ a Computed
attribute) and uniqueItems (→ a set rather than a list) directly, so a spec that already declares
those gets the right shape with no annotation or migration at all. The spec rewrite is for the
third fact, x-stainless-terraform-always-send, which has no standard OpenAPI spelling, and for
the other two where your spec doesn’t already carry the standard keywords.
Read the migration report
The output ends with a report listing exactly what needs your attention:
- Unmapped (dropped) — Stainless keys with no Glotto equivalent are dropped from the output and each one is reported with its dotted path. Unknown sub-fields are never silently copied.
- Placeholders (needs review) — required Glotto fields that can’t be derived from the
Stainless input are emitted as
TODO-…placeholder values and flagged. The common one isopenapi.source: point it at the OpenAPI document you exported above.
Then finish the loop:
glotto generate
A migrated config with resolved placeholders validates cleanly, and generate produces your
SDKs across every configured target. See the pipeline for what runs under
the hood.
Mapping reference
What translates automatically, what needs review, and what is dropped. Every key the conversion handles has a row here — the table is the complete disposition list, so anything not named below falls under the catch-all in its final row.
| Stainless surface | Disposition |
|---|---|
organization (name, contact, homepage) |
Translated. Missing required name/contact become TODO-… placeholders. |
targets |
Translated, with language names normalized to Glotto slugs (node/js/ts → typescript, react-native → react_native, c#/dotnet → csharp, golang → go, …). Per-target publish config carries across opaquely. An unsupported language is dropped with a warning. |
targets.<go>.module_path / targets.<java|kotlin>.coordinates |
Translated to the recognized code-identity fields: module_path → namespace (the go.mod module path); coordinates (group:artifact) → namespace (= group) + package_name (= artifact). A malformed coordinates is dropped with a warning. See namespace. |
targets.openapi (production_repo) |
Translated onto Glotto’s spec_repo target — not a target language, so it is handled separately from the rest of targets above. production_repo becomes targets.spec_repo.repo. The Glotto slug is spec_repo rather than the vendor’s openapi, since Glotto ingests OpenAPI, AsyncAPI and GraphQL and openapi would misname two of the three (and would sit confusingly beside the top-level openapi: input block). A missing or blank production_repo drops targets.openapi entirely with a warning; any other targets.openapi.* key has no Glotto equivalent and is dropped with its own warning. |
resources / methods / subresources |
Translated deeply, recursing through subresources. String methods (get /path) carry verbatim; method objects keep the endpoint, paginated, streaming, polling flags. The mcp, type/to, deprecated and positional_params keys are consumed into top-level blocks — see the rows below. Any other method flag is dropped with a warning. |
client_settings |
default_timeout, retry (max_attempts, initial_delay, max_delay, jitter), and auth (scheme, env_var) translate. base_url folds into environments.production. |
targets.<node>.options.mcp_server |
Translated: package_name → mcp.package_name. enable_all_resources: true matches Glotto’s register-everything default (consumed silently); enable_all_resources: false and generate_cloudflare_worker are dropped with notes. Narrow tools at runtime with the server’s --resource/--operation/--tag filters. For generate_cloudflare_worker, hosting and authorization carry over separately: the emitted server already ships an embedded fetchHandler for Workers-style hosts and hosted MCP Cloud serves it managed, while Stainless’s worker is also an OAuth authorization server (consent flow, API-key collection, token vault, and a generated client-properties input UI). If your API declares an OAuth authorizationCode flow, mcp.upstream_oauth covers that half. If it does not, Glotto emits no authorization server by design — forward the caller’s credential instead. |
resources.*.methods.*.mcp |
Translated: a curated description → mcp.operations.<tool>.description and a curated tool_name → mcp.operations.<tool>.name, both keyed by the canonical <resource>_<method> operation identity (the rename flows through the single tool-name derivation, so the emitted server, hosted gateway catalog, and RBAC policies stay in lockstep). mcp: true matches the default (silent); mcp: false is dropped with a pointer note (narrow tools at runtime with the server’s filters); a tool_name that isn’t a valid MCP tool name (^[a-zA-Z0-9_-]{1,64}$) or repeats an already-mapped rename is dropped with a warning. |
resources.*.methods.*.type: alias + to |
Translated to the top-level aliases block — aliases.<method> = <to> — at any subresource depth, so the superseded method name keeps working and breaking-change detection stops reporting the rename as breaking. Both names carry verbatim: they resolve at generate time against your spec, not at migrate time, and an entry that matches no operation is reported then (a --openapi-resolved operationId would go stale the next time you rename one). A type: other than alias, a type: alias with no to:, a bare to:, a self-alias (to: naming its own method — it could never materialize), and a second method colliding on an already-mapped alias name are each dropped with a warning. |
resources.*.methods.*.deprecated |
Translated to the top-level deprecated block, keyed by the same method name — so deprecating an alias by its own name works, which is the intended migration shape (the old name keeps working and warns). A message string carries verbatim; a per-language map has its language keys normalized to Glotto slugs. Glotto has no message-less deprecation, so a map with no default gets one synthesized from its first message rather than being dropped — listed under Placeholders (needs review) in the report, since nothing was dropped and the wording is yours to confirm, and a deprecated: true is dropped with a warning naming the missing message. An unsupported language key or a non-string message is dropped with a warning; its well-formed siblings still map. |
resources.*.methods.*.positional_params |
Translated to the top-level positional_params block — needs --openapi. Glotto keys that block by operationId, and a Stainless method key (retrieve) is Stainless’s name for the method inside its own resource tree, not the operationId; so the migrator reads the method’s endpoint: for the verb and path and takes the operationId the OpenAPI declares there. Without --openapi — or when that endpoint is absent from the document, or its operation declares no operationId — the order is dropped with a warning naming the remedy. Stainless’s key is not per-language and Glotto’s is, so one declared order is emitted under every target you configured; a language whose SDK takes no positional arguments (Ruby, whose path arguments are keyword arguments) simply ignores it. The order carries verbatim, and a short list stays short — Glotto reads it as a partial prefix, so arguments you did not name keep their derived positions behind the ones you did. The whole order is dropped with a warning — never partly applied — when it names anything that is not one of that endpoint’s path parameters: Stainless also promotes body and query parameters to positional arguments, which Glotto’s block does not express. A non-list value, a non-string or empty name, a repeated name, and an empty list are each dropped with a warning. |
environments |
Translated — plain-URL and { url } forms both resolve. If none survive, a placeholder production entry is emitted. |
openapi.source |
Usually a placeholder: Stainless uploads the spec out-of-band, so the path can’t be derived. Point it at your exported OpenAPI document. |
openapi.code_samples.formats |
Translated. |
asyncapi (top-level) |
Translated onto Glotto’s native asyncapi input block — both the mapping form and the asyncapi: ./events.yaml string shorthand, which becomes asyncapi.source. A missing source becomes a TODO-path-to-asyncapi-spec placeholder; an unrecognized sub-key, or a value that is neither a mapping nor a path, is dropped with a warning at its own dotted path. Only one input source survives per config — see the row below. |
graphql (top-level) |
Translated onto Glotto’s native graphql input block, with the Stainless spellings normalized: schema → source and autogenerate_operations → autogenerate; operations carries verbatim. The graphql: ./schema.graphql string shorthand becomes graphql.source. An alias that duplicates the canonical key it maps to is dropped with a warning and never overwrites it; a missing source becomes a TODO-path-to-graphql-schema placeholder. Only one input source survives per config — see the row below. |
Several input blocks at once (openapi + asyncapi + graphql) |
glotto.yml accepts exactly one input source, so the migrator keeps one by presence precedence — openapi over asyncapi over graphql — and each losing block is dropped with a warning naming the winner, never silently. Migrate the other surface into a separate glotto.yml. |
transforms |
Read from both spellings — the top-level key and openapi.transforms, where Stainless’s own examples put it. The five shared semantic ops translate 1:1 — rename_schema, flatten_composition, dedupe_inline_objects, extract_ref, fix_invalid_example. Stainless’s generic JSONPath commands translate into apply_overlay entries, one entry per command, in their original order, with each command’s reason carried as the action’s description: remove becomes remove: true; append becomes an update (Overlay appends when the target selects an array); update and merge become an update (Overlay’s update is a recursive merge). A command whose value is not an object targets a scalar, which Overlay deliberately leaves undefined — so the target is shortened by one segment and the value wrapped in that key: ….schema.type with "string" becomes ….schema with update: { type: string }. Pass --openapi and the translation gets exact: each target is resolved against your document with the same selector glotto generate uses, so whether it selects an object, a list, or a scalar is known rather than inferred from the value — an update that replaces a scalar with an object is rewritten to the form that applies instead of failing at your next generate, an update on a list replaces it rather than silently appending, and a target that matches nothing (or matches nodes of differing kinds) is reported while you are migrating rather than breaking the build later. move and copy also translate with --openapi, as a remove plus an update carrying the value read from your document — the emitted action’s description records that the value is a snapshot taken at migration time, so you know to re-check it if the upstream spec moves on. Dropped with a warning naming the reason: move and copy without --openapi (an Overlay action’s update is a literal value, and Overlay cannot reference another node, so there is no value to write — re-run with the flag, or re-express them by hand); a target using JSONPath outside the supported subset; a non-object value whose target ends in the document root, a wildcard, an array index, a filter, or a descendant segment, leaving no single key to lift; and — with --openapi — an append onto something that is not a list, a merge onto a list (Overlay would append, but a merge could equally mean replace, so it is not guessed), and a remove targeting the document root. Every dropped command is reported individually at its own path, carrying your reason — none is silently lost, and none is emitted optimistically to fail later at generate time. |
x-stainless-naming / x-stainless-param (OpenAPI extensions) |
With --openapi, translated into glotto.yml naming / parameter_naming. Unsupported languages, parameter renames on an operation with no operationId, and malformed values are dropped with a warning; the report counts what was ingested. |
x-stainless-renameMap (OpenAPI extension) |
With --openapi, translated into glotto.yml enum_naming (the pair is inverted: Stainless keys by the new name, Glotto by the wire value). A non-mapping extension, an empty rename target, a non-scalar value, and a value the schema’s enum doesn’t declare are each dropped with a warning. |
x-stainless-model / x-stainless-model-skip (OpenAPI extensions) |
With --openapi, translated into glotto.yml models — name (the emitted model name) and inline (emit no standalone type). Neither extension is per-language, so both map to the single-value form. A non-string or empty model name, and a non-boolean skip, are each dropped with a warning. |
x-stainless-nominal (OpenAPI extension) |
With --openapi, translated into glotto.yml enums — nominal (its own named type, or an alias over the primitive). Not per-language, so it maps to the single-value form; a non-boolean value is dropped with a warning. The defaults are opposite — Stainless aliases an enum unless told otherwise, Glotto gives it a named type — and only enums you actually annotated are translated. Glotto never synthesizes a directive you didn’t write, so review the enums that carried no extension and add nominal: false where you want the Stainless shape. |
x-stainless-enum-deprecations (OpenAPI extension) |
With --openapi, translated into glotto.yml enums — deprecated_values, which annotates the member with your target language’s own deprecation construct. The extension is an array parallel to the schema’s enum, so position — not a key — selects the member; a false element is skipped. Dropped with a warning: a true element (Glotto has no message-less deprecation, so add a message to migrate it), a value that isn’t an array, an element that is neither a string nor a boolean, an empty message, and an element past the end of the enum (or on a schema declaring none). An array shorter than the enum is fine — the members it doesn’t reach simply aren’t deprecated. |
x-stainless-soft-required (OpenAPI extension) |
With --openapi, translated into glotto.yml soft_required, keyed by operationId — from a parameter, a requestBody, or a body-schema property (following a $ref into components). Only true translates; false is a no-op. A non-boolean value, a nameless parameter, and an annotation on an operation with no operationId are each dropped with a warning. |
x-stainless-const (OpenAPI extension) |
With --openapi, translated into glotto.yml auto_populate, keyed by operationId — from a parameter or a body-schema property. Only true translates; false is a no-op, since the block only ever REMOVES an input and there is no “un-const” to express. A non-boolean value, a nameless parameter, and an annotation on an operation with no operationId are each dropped with a warning. Whether a named position is actually eligible — its schema must permit exactly one value with a sendable wire form — is decided at generation time and reported there. |
x-stainless-terraform-configurability / x-stainless-collection-type / x-stainless-terraform-always-send (OpenAPI extensions) |
With the opt-in spec rewrite (plus --openapi), translated into a rewritten copy of your OpenAPI document as x-glotto-terraform-configurability / x-glotto-collection-type / x-glotto-terraform-always-send, value carried through verbatim — see Bring your Terraform attribute shaping across. Glotto expresses per-attribute Terraform shaping as spec extensions rather than glotto.yml keys, so this is the one translation that writes a second output file rather than folding into the config. A value outside the closed set is reported and left unstamped; an x-glotto-* you already wrote is never overwritten. Without --openapi-out, unchanged: re-annotate by hand, or rely on the standard OpenAPI readOnly (→ a Computed attribute) and uniqueItems (→ a set rather than a list) where your spec already declares them. |
unspecified_endpoints (top-level) |
Translated to Glotto’s target-agnostic exclude deny-list, verbatim and in source order — each entry carries across in the "post /internal_endpoint" spelling you already wrote, because exclude accepts that positional form alongside the operationId one. Nothing is resolved at migrate time, so an entry stays correct even if the operation is later renamed. A non-list value is dropped with a warning; a non-string or empty entry is dropped with a warning at its own index while its well-formed siblings still map. The block is emitted only when at least one entry maps — never an empty exclude: []. |
settings |
Partly translated: license → top-level license, and detect_breaking_changes → settings.detect_breaking_changes. mock_server is dropped with a pointer note — Glotto’s mock is not a config toggle — and per_endpoint_security is dropped because Glotto delivers it with no toggle to set. Any other settings.* key is dropped with a warning naming it. |
code_owners (top-level) |
Translated onto Glotto’s code_owners block, verbatim and in source order — order is semantic on both sides, since a CODEOWNERS file resolves last-match-wins, so nothing is sorted or deduplicated. Owner syntax is deliberately not rewritten: which spelling is legal depends on the forge each target’s repo lives on, which Glotto knows from targets.<slug>.repo_provider and the migration does not — so a GitHub handle carried into a GitLab-targeted config is reported there, at its pointer, with the provider named. Where Stainless emits a file for GitHub only, Glotto covers all four providers: GitHub and GitLab get a committed CODEOWNERS from glotto generate, and Bitbucket and Azure Repos are configured through their APIs. Dropped with a warning: a non-mapping block, a rule whose value is not a list, a non-string or empty owner (at its own index, while its siblings still map), and a rule that maps no owners at all. |
pagination (top-level) |
Dropped with a warning — Glotto declares pagination per-method via the paginated flag instead. |
| Any other top-level key | Dropped with a warning naming the key. |
What carries over
Everything the wind-down puts at risk has a home — including the three capabilities earlier revisions of this guide tracked as open gaps, each since shipped (tracked to completion in the open):
- Every Stainless SDK language and more — TypeScript, Python, Go, Java, Kotlin, Ruby, C#, PHP, Terraform, plus React Native, Swift, Rust, Dart, and Elixir.
- Docs — an Astro site you own, with multi-language snippets, search, theming, and try-it (guide).
- MCP — both architectures, per-operation tools and Code Mode (
execute+search_docs), with filters and OAuth (guide); the generated multi-mode server self-hosts anywhere Node or Docker runs, and managed hosting ships with the hosted platform. - Release flow — release PRs on spec change across GitHub, GitLab, and Bitbucket, with drift detection keeping committed output honest.
- Custom code preservation — the
lib/directory and patch preservation (guide). If you hand-patched your Stainless SDKs, migration does not cost you those edits: hand-edits to generated files survive regeneration via a checksum-keyed three-way merge, with conflicts surfaced in the release PR rather than overwritten. - Registry publishing — a released SDK ships to its package registry (npm, PyPI, crates.io, RubyGems, Maven Central, …) as part of the release flow.
And one thing the old pipeline never gave you: the proof. Every regeneration produces a verification report — pinned inputs, per-target drift and integrity, compile and contract statuses — with a downloadable signed attestation.
Next steps
- Getting started — install the CLI and run the full loop.
- The glotto.yml reference — every key your migrated config can use.
- CLI reference —
migrate,validate,generate,drift-check. - How Glotto compares — the idiomatic-output and cross-language parity story, versus the other generators.