> For the complete documentation index, see [llms.txt](/llms.txt)

# 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](/docs/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](/docs/migrate-from-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](/docs/migrate-from-stainless#run-it), 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](mailto: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](#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](#keep-your-sdks-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](/docs/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`](/docs/glotto-yml) / `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. `class` is a Python
  keyword; Glotto escapes it to `class_`, and pinning the raw name back would emit a module that
  does not compile. The refusal is per *language* — Go has no `class` keyword, so it is pinned
  there.
- A property containing an **acronym**, where casing conventions genuinely disagree
  (`HTTPStatus` → `http_status` or `httpstatus`?). 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](/docs/custom-code) 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`](/docs/transforms) 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`](/docs/transforms#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](https://spec.openapis.org/overlay/v1.0.0.html) 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](/docs/multi-vcs-release). |
| `generators[].version` | — | Glotto versions the generator, not your config. |
| `config.maxRetries` / `default_max_retries` | `client_settings.retry.max_attempts` | See [Retries & timeouts](/docs/retries). |
| `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](/docs/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](/docs/pagination), 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:

```yaml
docs:
  redirects:
    - from: /introduction/overview
      to: /guides/introduction/overview
```

`glotto generate` turns that into two things, because one is not enough:

1. **`public/_redirects`** — the rule file Cloudflare Workers Static Assets and Netlify read.
2. **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:

```sh
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.
