CLI, MCP & SDKsSDKs

Overview

Generate a native typed client for your API in TypeScript, Python, or Go.

Typeship generates native TypeScript, Python, and Go SDKs for your API. Each package includes typed requests and responses, documented errors, pagination, retries, hooks, and optional runtime validation.

Choose any language your users need. Each SDK is an independent package and release stream; generating a CLI or MCP server never creates a public SDK implicitly.

This page uses TypeScript for the primary examples and shows the Python and Go equivalents where the shape differs. The idioms differ on purpose. Each SDK is written the way a developer in that language expects, not translated from one shared template. For the full language-specific surface, see Python and Go.

Examples use the Acme API, whose package is acme. Your package and client names come from your API contract.

What ships in the package

acme/
  package.json          zero runtime dependencies, ESM, Node 18+
  src/index.ts          AcmeClient, ClientOptions, environments, VERSION
  src/types.ts          every schema as an interface or type
  src/errors.ts         per-status error classes and per-operation unions
  src/resources/*.ts    one module per resource
  src/core/             HTTP runtime and pagination
  src/webhooks.ts       when the spec declares webhooks
  src/schemas.ts        validation tables, plain data
  tests/*.test.mjs      one smoke test per operation, node:test only
  api.md                the full surface reference in one file
  api.json              machine-readable operations, schemas, examples, safety
  AGENTS.md             context for coding agents

The package ships as readable TypeScript source and compiles with npm run build. main, types, and exports point into dist/.

Forward-compatible response types

Request types stay strict: the SDK should stop a caller from sending a value the Definition does not allow. Response types are deliberately more tolerant because servers can add enum values, discriminator variants, and fields before every consumer upgrades.

  • When one component is meaningfully different on the wire, generated models split into directional names such as AccountWrite for requests and AccountRead for responses. Nested readOnly, writeOnly, enums, and discriminators participate in that decision; Typeship does not duplicate models whose shapes are actually identical.
  • Response enums keep autocomplete for documented values while accepting an unknown string. Request enums remain closed.
  • OpenAPI discriminator unions include an unknown-object fallback on responses. GraphQL unions remain closed over the schema revision because __typename selection is explicit; the raw GraphQL selection and response remain the escape hatch.
  • TypeScript response metadata preserves rawBody; Go's APIResponse.Body and union Raw() preserve bytes. Python response validation accepts future enum and discriminator values instead of rejecting a successful server response.

This is additive compatibility, not loose request typing. Removing a documented response variant or widening a request remains visible in compatibility review.

Create a client

import { AcmeClient } from "acme";

const client = new AcmeClient({
  bearerToken: process.env.ACME_TOKEN!,
});

The TypeScript client does not read credentials from the environment. Pass them explicitly. This keeps the client safe in browsers and edge runtimes, where process.env may not exist. The generated CLI and MCP server are the pieces that read ACME_TOKEN.

Client options

Every option is optional when the spec pins a server URL. If it does not, baseUrl becomes a required argument and generation warns you.

const client = new AcmeClient({
  baseUrl: "https://api.acme.example.com/v1", // defaults to the spec's first server
  bearerToken: process.env.ACME_TOKEN!,
  timeoutMs: 30_000,      // per attempt
  maxRetries: 2,          // retries after the first attempt
  defaultHeaders: { "Request-Source": "billing" },
  fetch: myFetch,         // bring your own: proxies, tests, instrumentation
  onRequest: (context) => {},
  onResponse: (response, context) => {},
  onError: (error, request) => {},
  debug: false,
  validate: false,
});

Authentication

Which auth options exist depends on the security schemes in your spec.

Spec declaresTypeScriptPythonGo
HTTP bearer, OAuth2, OpenID ConnectbearerTokenbearer_tokenWithBearerToken, WithBearerTokenFunc
One API key headerapiKeyapi_keyWithAPIKey
Several API keysone option per header or query paramone keyword per paramone With... per param
HTTP basicbasicAuth: { username, password }username, passwordWithBasicAuth
OAuth2 client credentialsclientCredentialsclient_credentialsWithClientCredentials

Bearer tokens and API keys accept a callback, resolved before every attempt. Use it for credentials that expire:

const client = new AcmeClient({
  bearerToken: async () => (await tokenManager.current()).accessToken,
});

When the spec declares OAuth2, clientCredentials handles the whole token lifecycle. The client posts to the spec's token URL, caches the token until a minute before expiry, and shares one in-flight token request across concurrent calls. An explicit bearer token always wins.

const client = new AcmeClient({
  clientCredentials: {
    clientId: process.env.ACME_CLIENT_ID!,
    clientSecret: process.env.ACME_CLIENT_SECRET!,
    scopes: ["read:accounts"],                             // optional
    tokenParams: { audience: "https://api.acme.example" }, // if your auth server needs one
    authMethod: "post",                                    // or "basic"
    tokenUrl: "https://auth.acme.example/oauth/token",     // required only if the spec has none
  },
});

Python takes client_credentials={"client_id": ..., "client_secret": ...}. Go takes WithClientCredentials(acme.ClientCredentials{ClientID: ..., ClientSecret: ...}). Both cache and lock the same way.

Environments

When the spec declares two or more servers, the SDK exports them by name, taken from each server's description. The first server stays the default.

import { AcmeClient, environments } from "acme";

const client = new AcmeClient({ baseUrl: environments.sandbox, bearerToken: token });

Call an operation

Operations are grouped into resources by their first tag. Path parameters are positional, in path order. The body comes next, then query and header parameters, then per-call options.

await client.accounts.get("acct_123");
await client.accounts.create({ name: "Ops", currency: "usd" });
await client.accounts.list({ limit: 50, created: { gte: 1700000000 } });

Method names are de-stuttered. An operationId of createAccount on the accounts resource becomes client.accounts.create. The type names follow: AccountsCreateParams, AccountsCreateError.

Per-call options

The last argument of every method overrides the client for that one call.

await client.accounts.get("acct_123", {
  timeoutMs: 5_000,
  maxRetries: 0,
  headers: { "Request-Source": "cron" },
  signal: controller.signal,   // composed with the per-attempt timeout
});

Precedence is the same everywhere: per-call, then per-operation policy set at generation, then the client.

Results and errors

Awaiting a call returns a discriminated ApiResult instead of throwing on HTTP errors, and the error side is a union of the documented error classes for that exact operation. Async iteration is the deliberate exception: a paginated walk throws the same typed error if any page fails.

type ApiResult<T, E> =
  | { ok: true; data: T; response: ResponseMeta }
  | { ok: false; error: E; response?: ResponseMeta };
import { AcmeClient, NotFoundError } from "acme";

const result = await client.accounts.get("acct_123");
if (!result.ok) {
  if (result.error instanceof NotFoundError) {
    result.error.body;   // typed body for the documented 404
  }
  throw result.error;    // every branch is an Error subclass
}
result.data;             // typed success payload

The union for each operation is built from four kinds of class:

  • Per-status classes such as NotFoundError, generated from the spec's documented error responses. Each extends ApiError and carries status, a typed body, and response metadata (status, headers, request id).
  • UnexpectedApiError for a status the spec did not document.
  • TransportError when there was no HTTP response at all: network failure, timeout, abort. The message names the request and the deepest cause.
  • ValidationError when runtime validation is on and a body fails its schema.

Prefer exceptions? unwrap(result) returns the data or throws the typed error:

import { unwrap } from "acme";

const account = unwrap(await client.accounts.get("acct_123"));

Pagination

List operations with a recognized pagination shape iterate every item across every page, fetching lazily.

Paginated methods return a PagePromise. It is both awaitable and async-iterable:

// for await -> every item from every page
for await (const account of client.accounts.list({ limit: 50 })) {
  console.log(account.id);
}

// await -> one page, as a typed ApiResult
const result = await client.accounts.list({ limit: 50 });
if (result.ok) {
  result.data.items;               // Account[]
  result.data.body;                // the raw envelope
  result.data.hasNextPage();       // boolean
  await result.data.getNextPage(); // next Page, or null on the last one
}

Iteration throws the operation's typed error if any page fetch fails.

Four pagination styles are detected from the spec:

StyleDetected from
cursorA cursor query param (cursor, page_token, after, starting_after, ...) plus a next-cursor field in the response (next_cursor, next_page_token, ..., including inside a meta or pagination container)
cursorFromLastIdA cursor param plus a has_more field, where items have an id. The next request cursors from the last item's id.
pageA page or page_number query param. Advances while pages look full.
offsetAn offset, skip, or start query param. Advances by the number of items received.

Iteration stops when the API signals it: has_more: false, an empty next cursor, a page shorter than the requested limit, or an empty page.

Detection is heuristic, so it has an escape hatch. A project's config can pin the style, items field, and cursor fields per operation, or turn pagination off for one. Malformed rules fall back to detection with a warning on the generation.

Retries and timeouts

  • Requests with idempotent verbs (GET, HEAD, PUT, DELETE, OPTIONS) retry on 408, 429, 500, 502, 503, and 504, and on transport failures.
  • 429 is retried for every verb, including POST.
  • Backoff is exponential with full jitter, starting at 300ms and capped at 10 seconds per wait. A Retry-After header (seconds or HTTP date) is honored, capped at 60 seconds.
  • Defaults are two retries after the first attempt and a 30 second timeout per attempt.

The policy is tunable per project in config: replace the retryable status set, change the retry count and backoff window, allow retries on non-idempotent operations, or disable retries globally or per operation.

Idempotency keys

When an operation declares an Idempotency-Key header, the SDK generates a UUID for it on every call you do not supply one for, and reuses that UUID across retries of the call. That is what makes retried writes safe. Pass your own through the params object (idempotencyKey in TypeScript, idempotency_key in Python, IdempotencyKey in Go) to control it.

Hooks and debug logging

Two hooks run on every attempt. onRequest runs after auth and body headers are assembled and may mutate headers or url. onResponse runs after every HTTP response, before parsing and retry decisions. onError runs once per failed call, after retries, with the typed error and the request method and path.

const client = new AcmeClient({
  bearerToken: token,
  onRequest: (context) => {
    context.headers["Trace-Id"] = crypto.randomUUID();
  },
  onResponse: (response, context) => {
    metrics.observe(context.method, response.status, context.attempt);
  },
  onError: (error, request) => {
    logger.warn({ error, ...request });
  },
});

debug: true (or ACME_DEBUG=1 in the environment) logs one line per attempt to stderr: acme POST /accounts -> 201 (43ms) req_8f2k1. Pass a function instead to feed your own logger a structured event with method, path, status, durationMs, attempt, requestId, and the transport error message when there was no response.

Debug events never include headers or bodies, so credentials cannot reach logs through them. When you need request-level detail, the hooks are the deliberate step up.

Every request carries a User-Agent of acme/2.3.0 (typeship), so your API can tell SDK versions apart. Override it with defaultHeaders. The package exports a VERSION constant.

Runtime validation

Types catch mistakes at compile time. They cannot see an API that drifted from its spec at runtime. Turn on validation to schema-check JSON request and response bodies against your spec's own schemas, still with zero dependencies. The validator ships in the package, and the schema table is plain data generated from the spec.

const client = new AcmeClient({ validate: true });

const result = await client.accounts.get("acct_123");
if (!result.ok && result.error instanceof ValidationError) {
  result.error.violations;
  // [{ path: "response.balance", message: "expected integer, got string" }]
}

Failures are never thrown. A ValidationError comes back as the error side of ApiResult. Use validate: { mode: "warn" } to log with console.warn and proceed, and requests / responses to toggle each direction.

Bad request bodies are caught before any HTTP is sent. The checks honor readOnly, writeOnly, and nullability. Constraints outside the emitted subset (format, multipleOf, and similar) are ignored, so validation can miss drift but never rejects valid traffic. All three languages validate against identical tables. GraphQL and streaming operations are not validated.

Webhooks

When your spec declares a webhooks section (OpenAPI 3.1, or x-webhooks on 3.0), the package ships typed events and a verifying parser. Verification follows the Standard Webhooks convention: webhook-id, webhook-timestamp, and webhook-signature headers, HMAC-SHA256, a five minute tolerance, and a constant-time compare.

const client = new AcmeClient({ webhookKey: process.env.ACME_WEBHOOK_KEY });

// in your webhook handler:
const event = await client.webhooks.unwrap(rawBody, req.headers);
switch (event.type) {
  case "account.updated": event.account;    // fully typed per event
  case "account.closed":  event.account_id;
}

unwrap throws WebhookVerificationError on any mismatch. unwrapUnsafe parses without verifying. See Webhooks for the end-to-end flow, including local testing with your CLI.

Streaming

Operations whose success response is text/event-stream return a stream of events instead of a parsed body. Each event has data, plus event and id when the server sends them.

const result = await client.events.stream();
if (result.ok) {
  for await (const event of result.data) {
    event.data; // string; JSON.parse it if your API sends JSON
  }
}

The per-attempt timeout guards the connection and headers only, so long streams are not cut off. Once events are flowing nothing is retried, because replaying a dropped connection would redeliver events you already handled. A failure to connect surfaces before the first event.

Streaming operations are SDK-only. They are not exposed as CLI commands or MCP tools.

Global parameters

APIs where every call carries the same tenant or version parameter should not make callers repeat it. Naming those parameters in a project's config promotes them to client options: set once, applied to every operation that accepts them, with per-call values winning.

// generated with globals: ["account_id", "api-version"]
const client = new AcmeClient({ accountId: "acct_123", apiVersion: "2026-08" });

await client.reports.list();                          // both applied
await client.reports.list({ accountId: "acct_9" });   // per-call value wins

The generated CLI and MCP server read the same values from ACME_ACCOUNT_ID-style environment variables. Query and header parameters are supported. Path parameters are not.

Types

Enums

Named string enums generate a runtime value and a type with the same name, so values exist at runtime and the type stays a literal union:

export const AccountStatus = { ACTIVE: "active", FROZEN: "frozen", CLOSED: "closed" } as const;
export type AccountStatus = (typeof AccountStatus)[keyof typeof AccountStatus];

client.accounts.update("acct_1", { status: AccountStatus.FROZEN });

Python emits Literal["active", "frozen", "closed"]. Go emits a defined type with AccountStatusActive-style constants.

An inline enum, written into a property or a parameter rather than declared as its own schema, stays a type-only literal union in TypeScript and a Literal[...] in Python. Go has neither, so it gets a defined type as well, named after the field it belongs to (SourceKind, GenerationMetaBaseline). When the same set of values turns up in more than one place, it is a concept the API repeats rather than a detail of one field, so it takes the concept's own name and every site shares the one type:

type Platform string

const (
	PlatformSDK Platform = "sdk"
	PlatformCLI Platform = "cli"
	PlatformMCP Platform = "mcp"
)

A defined string type marshals as the string it is, so nothing about the request or response bytes changes, and a value the SDK predates still decodes rather than failing the call.

readOnly and writeOnly

Properties marked readOnly are omitted from request types, so the compiler stops you sending id or created_at fields the server owns. writeOnly properties are omitted from response types. In TypeScript a referenced schema appears as Omit<Account, "id" | "balance"> in request positions. Python and Go materialize a named variant only when the filter removes something: AccountWrite in Python, AccountParams in Go. The canonical type keeps every property.

Union types

oneOf and anyOf become TypeScript unions and Python Union[...]. Go has no sum types, so they become a named union type holding the raw JSON with As<Variant>()/From<Variant>() accessors and a Discriminator(), never a struct that would silently drop fields. See Go: Unions.

Deep bracket encoding

Query objects and form-encoded bodies use bracket-style deep encoding:

{ created: { gte: 5 } }         ->  created[gte]=5
{ items: [{ id: "x" }] }        ->  items[0][id]=x
{ expand: ["a", "b"] }          ->  expand=a&expand=b   (top-level arrays repeat the key)
new Date(...)                   ->  ISO 8601 string

JSON bodies are sent as JSON. This encoding applies to query strings and application/x-www-form-urlencoded bodies.

GraphQL

Clients generated from a GraphQL schema put queries on client.query and mutations on client.mutation (client.Query / client.Mutation in Go). Generated methods select all scalar and enum fields to depth 2 by default, with a fragment per concrete type for unions and interfaces, and every object-returning method takes an optional selection that replaces the default: a typed select object in TypeScript that narrows the result type, select= in Python, WithSelection(...) in Go. The endpoint and auth scheme come from the project's config, since a schema cannot declare them. Relay-style connections auto-paginate. In-band errors surface as GraphQLRequestError (*GraphQLError in Go). See Generate from GraphQL.

Naming rules

  • Operations group into resources by their first tag. Untagged operations group by the first meaningful path segment. Version prefixes like /v1 and /api are skipped.
  • A meaningful operationId becomes the method name. Framework suffixes such as UsingGET are stripped.
  • An operationId that merely restates method and path (GetChargesCharge) is treated as absent, and a name is derived by verb and path shape: list, get, create, update, delete. GET /accounts becomes client.accounts.list(). GET /accounts/{id} becomes client.accounts.get(id).
  • Well-known trailing action segments win over the generic verb: POST /charges/{id}/capture becomes client.charges.capture(id).
  • Resource words are trimmed from method names when the short form is unambiguous: accounts.createAccount becomes accounts.create.
  • Schema names that would shadow a language global or a runtime export get a Model suffix: a schema named Error becomes ErrorModel.
  • Go identifiers follow Go's initialism convention: UserID, HTTPStatus.

On this page