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 agentsThe 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 declares | TypeScript | Python | Go |
|---|---|---|---|
| HTTP bearer, OAuth2, OpenID Connect | bearerToken | bearer_token | WithBearerToken, WithBearerTokenFunc |
| One API key header | apiKey | api_key | WithAPIKey |
| Several API keys | one option per header or query param | one keyword per param | one With... per param |
| HTTP basic | basicAuth: { username, password } | username, password | WithBasicAuth |
| OAuth2 client credentials | clientCredentials | client_credentials | WithClientCredentials |
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 payloadThe 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 extendsApiErrorand carriesstatus, a typedbody, andresponsemetadata (status, headers, request id). UnexpectedApiErrorfor a status the spec did not document.TransportErrorwhen there was no HTTP response at all: network failure, timeout, abort. The message names the request and the deepest cause.ValidationErrorwhen 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:
| Style | Detected from |
|---|---|
cursor | A 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) |
cursorFromLastId | A cursor param plus a has_more field, where items have an id. The next request cursors from the last item's id. |
page | A page or page_number query param. Advances while pages look full. |
offset | An 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, and504, and on transport failures. 429is retried for every verb, including POST.- Backoff is exponential with full jitter, starting at 300ms and capped at 10 seconds per wait. A
Retry-Afterheader (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 winsThe 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 stringJSON 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
/v1and/apiare skipped. - A meaningful
operationIdbecomes the method name. Framework suffixes such asUsingGETare stripped. - An
operationIdthat 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 /accountsbecomesclient.accounts.list().GET /accounts/{id}becomesclient.accounts.get(id). - Well-known trailing action segments win over the generic verb:
POST /charges/{id}/capturebecomesclient.charges.capture(id). - Resource words are trimmed from method names when the short form is unambiguous:
accounts.createAccountbecomesaccounts.create. - Schema names that would shadow a language global or a runtime export get a
Modelsuffix: a schema namedErrorbecomesErrorModel. - Go identifiers follow Go's initialism convention:
UserID,HTTPStatus.