Migrate from Fern
Fern was absorbed by Postman in January 2026. You keep the SDKs you already generated — but the pipeline that regenerated them on every spec change is no longer the product you bought. Glotto picks it up, and adds the part that is 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.
The conversion is mechanical, complete, and reported line by line.
You don’t need anything from your old vendor. Fern is Apache-2.0, so fern/generators.yml,
.fernignore and your OpenAPI document are already in your own git repo. That is the practical
difference from migrating off Stainless, where the config has to be
exported from a dashboard first.
What Glotto needs from you
Your fern/ directory — that is it. The OpenAPI document your api.specs[] block points at is
read from there, resolved relative to generators.yml exactly as fern generate does.
How you get it
Unlike the Stainless conversion, this one does not
run in the published CLI. It reads your API surface through the spec pipeline — the part of
Glotto that stays on our infrastructure — and there is no self-serve route for it yet, so
glotto migrate fern in the installed CLI exits 2 and says exactly that rather than
half-running.
Email hello@glotto.dev with your fern/ directory and we run the
conversion and hand back the glotto.yml and the report described below. Everything on this
page is what that conversion does; nothing here is a preview of unbuilt work.
What you get back
A glotto.yml, plus a migration report saying what happened to every key.
Three choices are yours to make, and only one is ever forced:
- Which generator group. Required when your project declares several and has no
default-group— see Several generator groups below. - Which API document. By default the one your
api.specs[]block names; supply another to override it. - Whether to pin member names to what your Fern generator produced, rather than take Glotto’s idiomatic casing. See Keep your SDK’s member names.
The conversion copies every recognized key, normalizes what differs, and is honest about the rest: nothing is silently invented or silently lost. Nothing already on disk is overwritten.
Several generator groups
A Fern project often has more than one group — say sdks and server. A default-group selects
one and the migration just works. Without one, the migrator stops and names them rather than
guessing:
groups error GLOTTO_MIGRATE_FERN_GROUP_AMBIGUOUS this project declares 2 generator
groups ("sdks", "server") and no `default-group`; name the group to migrate.
No file is written. Each group migrates into its own config instead — sdks into
glotto.yml, server into server.glotto.yml.
Read the migration report
This is the part worth actually reading, and it is where Glotto’s Fern migration differs most from
a hand-translation. Every key in your generators.yml is classified into exactly one of four
verdicts — “no verdict” is not a state the report can express:
| Verdict | What it means |
|---|---|
| Mapped | Translated into glotto.yml. The report names the key it became. |
| Already guaranteed | You are not losing this. Glotto provides it unconditionally, so there is no config to carry across. |
| Not honored | A Fern emission-shape knob Glotto answers differently on purpose. Reported with the reason. |
| Unmapped | Genuinely no Glotto home. This is the list that needs your attention. |
The already-guaranteed column is why the report has four verdicts rather than two. A two-way report would list these beside your genuine losses, telling you that you are giving up forward-compatible enums at the exact moment you are gaining a stronger, un-configurable version of them:
| Your Fern setting | Why you no longer need it |
|---|---|
enable-forward-compatible-enums, respect-forward-compatible-enums, pydantic_config.enum_type: forward_compatible_python_enums |
Glotto emits open enums unconditionally — an unrecognized wire value round-trips instead of throwing. See Forward compatibility. |
allowExtraFields, generate-unknown-as-json-node, pydantic_config.extra_fields: allow |
Glotto preserves unknown response fields unconditionally and round-trips them on re-send. |
enable_wire_tests, enableWireTests, generate-mock-server-tests |
Glotto emits a test suite with every SDK, and its mock serves a spec-validated local server. |
The not-honored column is the largest, and it is a deliberate non-goal rather than a gap to
close later. noSerdeLayer, useBrandedStringAliases, neverThrowErrors, union: v0|v1,
package-layout, the pydantic_config block — these configure Fern’s emitter. Glotto owns its
own emission shape; honoring them would mean re-implementing Fern.
Timeouts are converted, not copied
Worth calling out on its own, because getting it wrong is invisible. Fern’s timeout key changes units per language:
| Generator | Key | Unit |
|---|---|---|
| TypeScript | defaultTimeout |
milliseconds |
| C# | default-timeout-in-milliseconds |
milliseconds |
| Python | timeout |
seconds |
So a project declaring defaultTimeout: 30000 and timeout: 30 is declaring one timeout, and
the migrator emits one: client_settings.default_timeout: 30s. Copying the numbers across instead
would be a silent 1000× error in your production timeout — it validates clean, and only surfaces
under load.
'infinity' (legal in TypeScript and Python) has no default_timeout spelling, so it is reported
rather than coerced to some arbitrary large number you never chose.
Keep your SDK’s member names
A migration that renames your SDK’s public members breaks every consumer downstream. Two facts, and they are good news:
Your method names already survive. Glotto derives an emitted method name from the OpenAPI
operationId and re-cases it per language — which is exactly Fern’s rule. listPets() stays
listPets() in TypeScript and list_pets() in Python, with no configuration at all.
Your property names need a choice. Glotto keeps the wire spelling for an SDK member where
Fern re-cases it — a wire property adoptedAt emits as adoptedAt in the Python SDK, where Fern
emitted adopted_at. Ask for the Fern spelling to be pinned and the conversion emits the
naming / parameter_naming entries that hold them there, and reports
every name it pinned:
Pinned names: 3
- property adopted_at → naming.Pet.adoptedAt.python
- parameter starting_after → parameter_naming.listPets.startingAfter.python
- parameter pet_id → parameter_naming.getPet.petId.python
It also refuses the pins that would break your build, and says so:
- A property whose wire name is a reserved word in the target language.
classis a Python keyword; Glotto escapes it toclass_, and pinning the raw name back would emit a module that does not compile. The refusal is per language — Go has noclasskeyword, so it is pinned there. - A property containing an acronym, where casing conventions genuinely disagree
(
HTTPStatus→http_statusorhttpstatus?). Rather than guess and introduce the divergence it is supposed to remove, the migrator names both candidates and leaves the choice to you.
Anything Glotto cannot pin is reported naming both spellings — including the client class name,
which has no glotto.yml key (every Glotto SDK exports Client). Nothing is left silently
divergent.
.fernignore becomes something better
Fern’s .fernignore freezes a file: it stops being generated, and you stop receiving generator
updates to it. That is a real cost — the file is yours now, including its bugs.
Glotto’s custom-code preservation three-way-merges instead, so you keep your
edits and keep the updates. Because the two are semantically different, the migrator reports
each .fernignore entry with the construct that replaces it rather than translating it, and writes
no files:
- Hand-written files belong in
lib/, which Glotto writes if absent and never overwrites. - Generated files you have edited stay where they are — the checksum guard preserves your changes across regeneration.
Mapping reference
| Fern | Glotto | Notes |
|---|---|---|
api.specs[].openapi |
openapi.source |
Re-based onto the directory your glotto.yml is written to. |
api.specs[].origin |
openapi.source |
Used when no local path is declared. |
api.specs[].overlays |
transforms[].apply_overlay.source |
One apply_overlay entry per overlay document, in the order you listed them. Relative paths are re-based onto your glotto.yml’s directory. |
api.specs[].overrides |
transforms[].merge_document.source |
One merge_document entry per overrides file, re-based onto your glotto.yml’s directory. This is deliberately not apply_overlay: an overrides file is an OpenAPI-shaped merge document, not an Overlay with actions[]. Your file carries across unchanged. |
api.specs[].namespace |
— | Reported. Glotto takes one input source per glotto.yml. |
api.specs[].git |
— | Reported. Glotto reads a path or URL; vendor the spec or point at its raw URL. |
api.specs[].settings.filter.endpoints |
exclude |
Direct — Glotto’s exclude accepts the "POST /users" spelling. |
api.specs[].settings.* |
— | Fern’s OpenAPI ingestion knobs; Glotto’s ingestion answers each with one behavior. |
default-group / groups.<id> |
targets |
The selected group’s generators become your target map. |
groups.<id>.generators[].name |
targets.<slug> |
By generator name. A language Glotto doesn’t generate is omitted and reported, never emitted as an invalid target. |
generators[].output.package-name |
targets.<slug>.package_name |
The published registry id. |
generators[].output.location |
— | Reported as the registry that target is published to. |
generators[].github.repository |
targets.<slug>.repo (+ repo_provider) |
Feeds the multi-VCS release flow. |
generators[].version |
— | Glotto versions the generator, not your config. |
config.maxRetries / default_max_retries |
client_settings.retry.max_attempts |
See Retries & timeouts. |
config.defaultTimeout / timeout / default-timeout-in-milliseconds |
client_settings.default_timeout |
Unit-converted — see above. |
config.auto-generate-idempotency-key |
client_settings.idempotency |
See Idempotency. |
config.namespace / namespaceExport / clientModuleName |
targets.<slug>.namespace |
The emitted code module — distinct from the registry id above. |
config.client_class_name / clientName |
— | Reported. Every Glotto SDK exports Client; re-export it under your own name if consumers depend on it. |
config.offsetSemantics |
— | Reported. Glotto models pagination per method, not per client. |
.fernignore |
lib/ + checksum-guarded managed files |
Reported, not translated — see above. |
Your docs migrate too
If your project has a fern/docs.yml, it migrates too — the branding into glotto.yml#/docs,
the navigation tree into the generated site’s guides collection, and the page content into
MDX the site can build. It is picked up automatically; the content tree lands in ./docs-site
unless you say otherwise, and can be left out entirely if you want the SDK config alone.
The content tree obeys the same rule as the config: nothing already on disk is overwritten.
Your existing links keep working
This is the part no other migration guide offers. Your docs have inbound links you do not control — StackOverflow answers, blog posts, bookmarks, other people’s READMEs — and moving a page breaks every one of them silently.
So the migration derives a redirect map. A Fern page’s URL comes from its navigation slug chain;
a Glotto guide serves under /guides/…. Where those differ, you get a docs.redirects entry:
docs:
redirects:
- from: /introduction/overview
to: /guides/introduction/overview
glotto generate turns that into two things, because one is not enough:
public/_redirects— the rule file Cloudflare Workers Static Assets and Netlify read.- A static redirect page at the old URL — a zero-second meta refresh with a canonical link at the target — so the promise holds on a host that reads no rule file at all.
The stubs carry no noindex, deliberately: a zero-second refresh is read as a permanent redirect and
consolidates the old URL’s search ranking onto the new one, which is the whole point.
If you also set docs.deploy.target to vercel or netlify, you get a third: that host’s own
redirect config (vercel.json or netlify.toml), so the status you declared is returned as a real
HTTP status rather than an HTML refresh. It matters most on Vercel, which reads neither the rule file
nor the stubs. A wildcard source is only translated into Vercel’s dialect when the translation is
exact — a trailing * is, a * in the middle is not — and glotto generate tells you about the
latter rather than rewriting it into a rule matching URLs you did not write.
Those two files are host config, not just redirect config, so regeneration leaves your own keys
alone. If you add a build command, a headers block, or a framework override to vercel.json, only
the redirects array is refreshed on the next glotto generate — every other key stays exactly as
you wrote it, and the run tells you it kept them. netlify.toml is preserved the same way every
other generated file you edit is: your version is kept and reported rather than overwritten, and
glotto generate --merge merges the new redirects into it. In both cases --force gives you the
generator’s file back.
A page whose URL doesn’t change produces no entry — a redirect from a path to itself is a no-op
loop, and one per page would bury the moves that matter. You can add your own entries to
docs.redirects at any time; glotto generate reports a source that is not site-absolute,
duplicated, or pointing at itself, and a source that collides with a page the generator emits is
refused so a redirect can never shadow real documentation.
Your components are translated, and nothing is dropped
Fern’s callouts land on the two components the generated site ships:
| Fern | Glotto |
|---|---|
<Note> |
<Note> |
<Info> / <Tip> / <Check> / <Success> |
<Note title="…"> |
<Warning> |
<Warning> |
<Error> / <Danger> |
<Warning title="Error"> |
<Callout intent="…"> |
<Note> or <Warning>, by intent |
The structural components have no Glotto counterpart, so each becomes the semantic HTML that carries
the same meaning and renders with no component at all — <Accordion> → <details>/<summary>,
<Steps> → an ordered list, <CardGroup> → a link list, <Frame> → <figure>/<figcaption>,
<Tooltip> → <abbr>, <CodeBlocks> and <Tabs> → titled fences and headed sections. Every
substitution is named in the report.
A component we do not recognize — including your own, from fern/components/ — is unwrapped, not
deleted. MDX resolves component names when the site builds, so leaving the tag would fail the
build; removing the content would lose your prose. You keep the prose, the report names the
component, and you re-create it as an Astro or React component if you want the presentation back
(the generated site is Astro + React). A self-closing one has no children to keep, so it becomes an
MDX comment carrying its original source — visible in the file, inert on the page, never silently
gone.
Your endpoint embeds become links to the pages that replace them
<EndpointRequestSnippet> and <EndpointResponseSnippet> are the one construct where “re-create it
as a component” is the wrong answer: Glotto generates the page each one stood in for, with that
operation’s request and response examples and an interactive Try-It panel already on it. So the
migration names that page as the remedy, and can rewrite the embed into a link to it.
The route is not guessed. It is derived through the same pipeline that decides where a reference
page goes, so a link and the page it points at cannot drift apart. An endpoint that names no
operation in your OpenAPI document is not linked — it keeps the comment treatment and appears in
the report with the endpoint named, because a migration that reports success while shipping dead
links would be worse than one that says what it could not resolve. If your project declares
versions:, the links carry the default version’s route prefix, matching where the versioned site
actually serves those pages.
Fern’s playground switch needs no migration at all: the Try-It panel ships on every reference page Glotto generates, so it is reported as already guaranteed rather than as something you are losing.
What the docs migration reports rather than translates
docs.yml |
Glotto | Note |
|---|---|---|
instances[].custom-domain |
docs.deploy.custom_domain |
Direct. |
instances[].url |
— | The *.docs.buildwithfern.com subdomain is Fern’s hosting — the thing you are leaving. |
logo.light / .dark / .alt / .href |
docs.logo |
Direct, field for field. |
colors.<token>.light |
docs.theme |
Becomes a --glotto-* custom property. |
colors.<token>.dark |
— | docs.theme emits one :root block. Glotto ships its own dark palette; override it with a :root[data-theme="dark"] rule. |
navigation section: / page: |
src/guides/** + order: |
Your sidebar order is reproduced. |
navigation - api: |
— | Already guaranteed. Glotto generates the API reference from your spec on every run. |
navigation - link: |
— | Reported. Use the header layout slot. |
versions |
docs.versions |
Mapped, first marked default. Point each entry at its spec snapshot — Fern’s versions[].path names a docs config, not a spec, so a TODO placeholder is emitted with a warning. |
tabs / products |
— | Reported. These are Fern chrome shapes, not a version axis; folding them into docs.versions would restructure your site. |
redirects |
docs.redirects |
Carried across, alongside the derived entries. |
favicon |
— | Reported. Drop your icon into the generated project’s public/ — files you add are never overwritten. |
navbar-links / footer-links |
— | Reported. BaseLayout exposes named header and footer slots for exactly this. |
layout / css / js / typography |
— | Reported. Fern’s shell chrome; Glotto emits its own layout and exposes theme tokens plus the layout slots. |
title |
— | Reported. The site titles itself from your OpenAPI info.title and your organization.name. |
Page title / description / subtitle / slug |
guide frontmatter | description wins over subtitle (the guides collection has one prose field). A page with no title gets one synthesized, because a title-less guide fails the build. |
Page keywords / image / hide-toc / layout |
— | Reported per page. |
Next steps
The migrated config is validation-clean by construction, so the next step is the only one:
glotto generate # your SDKs, docs site, and MCP server
The migrated glotto.yml fills any required field your Fern config cannot supply — your
organization name and contact, and your production base URL, which Fern keeps in
fern.config.json and its API definition rather than in generators.yml — with a clearly-marked
TODO placeholder and a warning in the report. Replace those, then generate.