How-to › Ship an SDK

How to generate an SDK with Fern from generators.yml#

Set up a Fern project from an OpenAPI document, read the generators.yml it writes, validate it offline, and know which generator group runs before anything runs.

Audience
API producer
Level
intermediate
Topic
Choose a generator and generate an SDK
Languages
TypeScript and Python
Verified

You have an OpenAPI document and a request for a TypeScript SDK by Friday. The two generators you tried produced a client per endpoint and a dependency tree you did not choose. Fern promises consistent SDKs across languages from one config file, and its documentation assumes you know what that file holds, which group runs, and why the command wants a login.

What you get

You will end up with a Fern project from your document, a validated generators.yml with two groups, a script that prints what each form of fern generate runs, and tests that pin the config. This is for you if you own an API and want a maintained generator.

Short answer

Run fern init --openapi openapi.json --organization YOUR_ORG to create fern/ with fern.config.json and generators.yml, then fern check --local to validate the definition. fern generate runs the default-group on Fern’s cloud and asks you to log in; fern generate --local runs the same generators as Docker images on your machine. Name the group every time, because the group decides which SDKs are built and where they go.

You will need

Node 22 or later, an OpenAPI 3 document, and, for local generation, Docker. Verified 2026-10-21 against Node 22.22.0, fern-api 5.163.1, and yaml 2.9.1. The CLI is installed from a lockfile as fern-api, so npx fern runs the pinned release. That release writes generators.yml, which Fern’s documentation files under its legacy SDK docs. A later release writes an sdk-config.yml from fern init instead, with targets in place of groups. Generation itself was not run here: fern generate needs a Fern account and fern generate --local needs a Docker daemon, which the verification environment did not have. The commands on this page stop where either would start.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
FernYou want maintained generators and one generators.yml deciding languages, versions, and outputsA login for cloud generation or Docker for local, and a pinned generator version per language that you upgrade by handYou cannot run Docker and will not create an account, or you need a language Fern does not ship
OpenAPI GeneratorYou want the widest language list with no account, no Docker, and templates you can editA Java 11 runtime or its Docker image, endpoint-shaped output, and per-language templates to own once you customizeYou want entity-shaped SDKs that someone else maintains
SpeakeasyYou want a workflow.yaml in your repository that names sources and targets and pins the CLIA CLI that attempts an upgrade to its latest release on every run unless speakeasyVersion is pinned, and a vendor workflow around your repositoryYou want to run the generator with no vendor service in the loop

The three differ in where generation runs. Fern’s generators are Docker images, run on Fern’s cloud when you log in or by your own Docker daemon with --local. OpenAPI Generator is a jar, so a Java runtime and a command are the whole path, with no account anywhere on it. Speakeasy keeps a workflow.yaml in your repository that names sources and targets and pins its CLI version, and that pin defaults to latest, which upgrades the CLI underneath you.

Initialize the project from the document

fern init takes the document and an organization name and writes a fern/ directory beside it. It needs no login.

npx fern init --openapi openapi.json --organization acme

The command printed Created new API: ./fern and wrote three files: a .gitignore, the project config, and the generator config.

{
    "organization": "acme",
    "version": "5.163.1"
}
api:
  specs:
    - openapi: ../openapi.json
default-group: local
groups:
  local:
    generators:
      - name: fern-typescript-sdk
        output:
          location: local-file-system
          path: ../sdks/typescript
        version: 3.107.3
  cloud:
    generators:
      - name: fern-python-sdk
        version: 5.41.3
        output:
          location: local-file-system
          path: ../sdks/python

fern.config.json pins the CLI release the project runs, which fern init sets to the release current on the day you run it.1 generators.yml names the spec, a default group, and one generator: the TypeScript SDK, at a pinned version, written to a directory beside fern/. The cloud group came from a second command, npx fern add fern-python-sdk --group cloud, which looked up that generator’s current version and appended the group. Both versions are yours to upgrade from then on, since the CLI changes them only when asked.

inspect.mjs reads the file the CLI reads and prints what each form of the generate command would run.

node inspect.mjs
specs: ../openapi.json
default group: local
group local  (fern generate)
  fern-typescript-sdk 3.107.3  ->  local-file-system ../sdks/typescript
group cloud  (fern generate --group cloud)
  fern-python-sdk 5.41.3  ->  local-file-system ../sdks/python

Validate offline and read what Fern made of the document

fern check validates the definition and the configuration. The --local flag keeps the work on your machine; without it, the CLI’s own help text says, the definition is sent to Fern’s API for validation.

npx fern check --local
All checks passed

Fern reads OpenAPI into a definition format of its own, and fern write-definition writes that reading under fern/.definition, which the .gitignore from fern init excludes. definition.mjs runs it and prints the types and endpoints it found.

node definition.mjs
[api]: Wrote definition to fern/.definition
types: Customer, Order, OrderStatus
  createOrder    POST   /orders                  -> Order
  deleteOrder    DELETE /orders/{orderId}        -> no body
  getCustomer    GET    /customers/{customerId}  -> Customer
  getOrder       GET    /orders/{orderId}        -> Order
  listCustomers  GET    /customers               -> list<Customer>
  listOrders     GET    /orders                  -> list<Order>
  updateOrder    PATCH  /orders/{orderId}        -> Order

Seven endpoints and three types, with the inline enum promoted to OrderStatus. Read this before generating, because an operation missing here is missing from every SDK.

Choose the group, then generate

A bare fern generate runs the default group remotely. The SDK commands page says remote generation, the default, requires authentication, and the CLI says so as well.

npx fern generate
[api]: Using default group 'local' from generators.yml
Authentication required. Please run 'fern login' or set the FERN_TOKEN environment variable.

Both lines matter. The first names the group that would have run, which is the one thing to check before a login makes the command real. --local takes the account out of the loop and puts Docker in: the same page says a Docker daemon must be running on your machine, because SDK generation runs inside a Docker container.2

npx fern generate --local
npx fern generate --group cloud --local

Run here, the first command validated the definition, named the image it was about to execute, fernapi/fern-typescript-sdk at the pinned version, and stopped when no Docker daemon answered. Each group’s output.path says where its SDK lands once a daemon is present: sdks/typescript for the first command and sdks/python for the second, which leaves the first alone.

Check it worked

Five tests with the Node test runner pin the config and run the CLI twice: once to validate, once to refuse a group that does not exist.

test('a group that does not exist is refused before anything runs', () => {
  const r = spawnSync('npx', ['fern', 'generate', '--group', 'nope'], { encoding: 'utf8' })
  assert.equal(r.status, 1)
  assert.match(r.stdout + r.stderr, /'nope' is not a valid group or alias\. Available groups: local, cloud/)
})
node --test fern.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0

# pass 5 is the line to look for. The test for the missing group asserts exit status 1 and the message naming the available groups, so a typo in a CI script fails before any image is pulled.

When it goes wrong

Every generator in a group runs when the group runs. A default group holding a TypeScript, a Python, and a Go generator that publish to three registries runs all three on a bare fern generate, which is what forgetting --group costs. Keep default-group pointing at a group that writes to the local file system, as fern init does, and name the publishing group explicitly. Without a default group the CLI refuses and lists the choices, which the copy of the project under nodefault/ shows.

cd nodefault && npx fern generate
[api]: No group specified. Use the --group option, or set "default-group" in generators.yml:
        › local  fern generate --group local
        › cloud  fern generate --group cloud

A group name is misspelled. The CLI stops before anything runs and lists what exists.

npx fern generate --group nope
[api]: 'nope' is not a valid group or alias. Available groups: local, cloud

The CLI that runs is not the CLI you installed. fern.config.json pins a release, and the version policy says each project runs the release it pins. A project initialized with an earlier installed release can therefore carry a later pin from the day it was created. fern upgrade moves the pin on purpose. Read the file before assuming the two match.

When not to do this

Do not pick Fern if you cannot run Docker and will not hold a vendor account. Both routes to a generated SDK go through one or the other, and OpenAPI Generator produces a client from a jar with neither.3

Do not let default-group name a group that publishes. A bare fern generate runs whatever that group holds, so a maintainer who runs it to try a change ships to a registry. Default to a group that writes to the local file system and make publishing a group you have to name.

Do not take fern check --local passing as a generated SDK. It validates the definition and the configuration, and fern generate --local ran the same checks here before it reached for Docker. The SDK exists when the image has run, not before.

Do not loosen the generator versions. generators.yml takes an exact version per generator and fern init writes one. Keep it exact, because a version that moves turns the next regeneration into a review of someone else’s changes.

Last verified

Verified 2026-10-21 against Node 22.22.0, fern-api 5.163.1, and yaml 2.9.1. Every output block is what the command preceding it printed, run in the page’s code directory after npm ci. fern init and fern add ran once to write the committed fern/ directory and are not re-run by the checks, because both fetch current versions from the registry. fern generate --local was run once to observe the Docker requirement and did not produce an SDK. Nothing on this page ran with a Fern account, and the generator versions in generators.yml are the ones the CLI wrote on 2026-10-18. On 2026-10-21, fern init from fern-api 5.170.3, run once in a scratch directory, wrote sdk-config.yml and no generators.yml.

Footnotes

  1. Fern’s version policy says each project runs the version it pins in fern.config.json, and that an organization policy can move the version that runs without changing the file. fern init writes the release current on the day it runs rather than the one running it. A project initialized here with the previous CLI release carried the newer number before that release was installed anywhere on the machine. ↩︎ Back to text

  2. The SDK commands page says a Docker daemon must be running on your machine, as SDK generation runs inside a Docker container. The container is the generator. generators.yml names it by package name and version, and the CLI resolves that pair to an image under the fernapi namespace. That image name is what it printed here in the moment before it found no daemon to talk to. ↩︎ Back to text

  3. The OpenAPI Generator installation page asks for a Java 11 runtime at a minimum and describes its npm package as a cross-platform wrapper around the jar. It also offers a Docker image that acts as a standalone executable. The page asks for no account at any step. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.