PlatformsSDK

Overview

A typed client for your API with zero runtime dependencies, in TypeScript, Python, or Go. Typed errors, auto-pagination, retries, hooks, and validation, generated from your spec.

The sdk platform is the foundation of every package typeship generates. It is always on. In TypeScript, the CLI and MCP server both import it. Python and Go packages are SDK-only.

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 names come from your spec's title.

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
  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/.

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

Nothing throws on HTTP errors. Every call returns a discriminated ApiResult, and the error side is a union of the documented error classes for that exact operation:

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. Inline enums stay type-only literal unions.

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