MainSDK
Search⌘K
Interfaces

Developer experience

Typed API and OpenAPI

api

Turn the app's actions into REST routes, an OpenAPI 3.1 document and a typed client, with idempotent writes and a breaking-change check.

Building for 0.3ships as a beta APIPhase 1 · Core, public betaSpec app-apiFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

api gives every action an HTTP route, an OpenAPI 3.1 document and a typed client from one schema. Reach for it when a partner, a mobile app or a script calls your app over HTTP.

At a glance

api:
  base: /api
  actions: src/actions.ts
  openapi:
    serve: false
    include: ["*"]
    title: Acme API
  snapshot: api/openapi.json
  idempotency:
    require: [destructive]
pagination:
  key: PAGINATION_KEY                  # secret that signs list cursors

Calls

  • api.openapi(actions, opts?)
  • api.routes(actions)
  • api.handler(options)
  • api.client(doc)
  • api.diff(before, after)
  • api.react(options)
  • createClient({ baseUrl, auth, fetch?, retries? })
  • page(rows, init)
  • pageLimit(limit, max?)
  • paginate(list, opts?)
  • inbound(object: handle)
  • inboundIssues(config, env, { today? })
  • originAllowed(origin, origins)

Guarantees

Whatever the driver.

  • The same actions always produce the same document
  • A value that fails its output schema is never sent
  • A breaking change in a shipped major version fails mainsdk api diff

Drivers

1 drivers. Select one to see its line in main.yaml.

main.yamlBuilt in core module (default)
api:
  driver: builtin

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-API-0010Checkmainsdk check, before deployAn action's input or output has no JSON Schema (a foreign schema without toJSONSchema()); the document and client type it as unknown
MAIN-API-0011Checkmainsdk check, before deployThe committed snapshot at api.snapshot differs from what the code generates
MAIN-API-0012Checkmainsdk check, before deploymainsdk api diff found a breaking change in a major version that already shipped
MAIN-API-0020Checkmainsdk check, before deployCORS lets any origin call the API (*, origin: true or a bare cors())
MAIN-API-0001RuntimeError responses and logsAn action's HTTP mapping is invalid: a GET on a write or destructive action, a path parameter that isn't a top-level string or integer input key, a nested field on a GET or DELETE, two actions on one method and path, or a name segment that isn't an identifier (500)
MAIN-API-0002RuntimeError responses and logsAn Idempotency-Key was reused with a different input (422)
MAIN-API-0003RuntimeError responses and logsThe first request with this Idempotency-Key is still running (409, retryable)
MAIN-API-0004RuntimeError responses and logsAn action returned a value that fails its output schema; nothing of it is sent (500)
MAIN-API-0005RuntimeError responses and logsA risk listed under api.idempotency.require was called without Idempotency-Key (400)
MAIN-API-0006RuntimeError responses and logsThe major version was called after its sunset date (410)
MAIN-API-0007RuntimeError responses and logsThe generated client got no answer, or an answer that isn't problem details or JSON (retryable for network failures)
MAIN-PAGE-0001RuntimeError responses and logsA list cursor is malformed, older than 24 hours, or made by a different query, tenant or user (400)
MAIN-PAGE-0002RuntimeError responses and logslimit is not a whole number of at least 1 (400)

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Output schema checked before anything is sentPhase 10.3app-api#output
Versioned REST routes from the actionsPhase 10.3app-api#routes
OpenAPI 3.1 documentPhase 10.3app-api#openapi
Idempotency-Key for writesPhase 10.3app-api#idempotency
Typed client, one file, global fetch onlyPhase 10.3app-api#client
Paged and all-items helpers in the clientPhase 10.3app-api#client-pages
Breaking-change diff against the snapshotPhase 10.3app-api#diff
One schema for actions, documents and the clientPhase 10.3app-api#one-schema
Tenant and auth on every public routePhase 10.3app-api#tenant-auth
React hooks and TanStack Query options for the generated clientPhase 20.7client-react#generator

Quick start

app/actions/orders.ts
import { action } from "mainsdk";
import { s } from "mainsdk/schema";

export const refundOrder = action({
  name: "orders.refund",
  intent: "Refund an order the signed-in user owns",
  input: s.object({ orderId: s.string().uuid(), reason: s.enum(["damaged", "late"]) }),
  output: s.object({ refundId: s.string(), status: s.enum(["pending", "done"]) }),
  risk: "destructive",
  http: { method: "POST", path: "/orders/{orderId}/refund" },
  run: async ({ input }) => ({ refundId: `r_${input.orderId}`, status: "done" as const }),
});
bash
npx mainsdk api openapi --out api/openapi.json
npx mainsdk api client --out src/acme-api.ts

Configuration

main.yaml
schema: 1
api:
  base: /api                    # public routes are /api/v<major>/…
  actions: src/actions.ts       # the module that exports the actions; the CLI and check load it
  openapi:
    serve: false                # true serves /.well-known/openapi.json, unauthenticated
    include: ["*"]              # action name globs in the document and the client
    title: Acme API
  snapshot: api/openapi.json    # the committed document that check and api diff compare against
  idempotency:
    require: [destructive]      # risks that must send Idempotency-Key
KeyWhat it does
baseThe path prefix for every public route
actionsThe module that exports your actions, for mainsdk api and mainsdk check. src/actions.ts by default
openapi.serveServes the document at /.well-known/openapi.json. Off by default
openapi.includeWhich actions appear in the document and the client. Others stay unlisted
snapshotThe committed document. mainsdk check fails when the code no longer matches it
idempotency.requireRisks whose writes must send an Idempotency-Key header

What you get

api is a core module with no drivers (spec app-api, 0.3): import it from mainsdk/api. Mount api.handler({ actions, ... }) in front of your app's own routes, or pass it as the api option of an adapter. It answers a request for a path it owns and returns undefined for any other. Set idempotency.ttl (24h by default) in the handler options to change how long a key is remembered.

  • Versioned routes, <base>/v<major>/…, from each action's http mapping. A bad mapping is refused at start.
  • An OpenAPI 3.1 document that is the same bytes for the same actions, so a snapshot diffs cleanly.
  • A one-file typed client with problem errors, retries, idempotency keys and paging helpers.
  • An output check: a value that fails its output schema is never sent.

Examples

Call the app from another service

mainsdk api client writes one file with no imports and only the global fetch. Call it like any typed function:

src/refund.ts
import { createClient } from "./acme-api.ts";

const acme = createClient({ baseUrl: "https://acme.app", auth: { apiKey: process.env.ACME_KEY ?? "" } });
const orderId = "2c8b1c1e-7d7e-4b8e-9d0a-5d1a3a8f4a11";
const r = await acme.orders.refund({ orderId, reason: "late" }, { idempotencyKey: "refund-8812" });

Every non-2xx answer and every network failure throws an ApiError with status, code, fix, retryable and, for input errors, the errors paths.

Read every page of a list

src/open-orders.ts
import { createClient } from "./acme-api.ts";

const acme = createClient({ baseUrl: "https://acme.app" });
for await (const order of acme.orders.list.all({ status: "open" })) {
  console.log(order.id);
}

Stop breaking changes in CI

bash
npx mainsdk api diff --base origin/main

api diff lists breaking changes against the snapshot. A breaking change in a major version that has already shipped fails with MAIN-API-0012. To change a shipped major, add a new one with version: 2 and keep the old one until its sunset date.

Make writes safe to retry

A write whose risk is listed under api.idempotency.require needs an Idempotency-Key header, or the call fails with MAIN-API-0005. The same key with a different input returns 422 (MAIN-API-0002). A key whose first request is still running returns 409 and is retryable (MAIN-API-0003). The client sends the key for you when you pass idempotencyKey.

Retire an old major version

ts
deprecated: { sunset: "2027-06-30" }   // after this date the old major answers 410

A call to a major after its sunset date fails with MAIN-API-0006. Until then the old major keeps working.

Errors and checks

CodeWhenFix
MAIN-API-0001An action's HTTP mapping is invalid, for example a GET on a writeFix the mapping the error names
MAIN-API-0002An Idempotency-Key was reused with a different inputSend a new key for a new request
MAIN-API-0003The first request with this Idempotency-Key is still runningRetry the same request shortly
MAIN-API-0004An action returned a value that fails its output schema; nothing is sentReturn a value that matches output
MAIN-API-0005A risk listed under idempotency.require was called without Idempotency-KeySend the header
MAIN-API-0006A major version was called after its sunset dateCall the newer major
MAIN-API-0007The client got no answer, or an answer that isn't problem detailsCheck the URL and the network
MAIN-API-0010A schema has no JSON Schema, so the document types it as unknownUse a schema from mainsdk/schema
MAIN-API-0011The committed snapshot differs from the codeRun mainsdk api openapi and commit the result
MAIN-API-0012api diff found a breaking change in a shipped majorAdd a new major version instead

Swapping drivers

There are no drivers. The document, the client and the routes are generated from the same actions that MCP and agents use, so nothing needs a second definition.

Testing

The handler runs in process, so route tests need no server. Call it with a Request and assert on the Response; createTestApp() supplies the app state the actions use.

app/api.test.ts
import assert from "node:assert/strict";
import { action } from "mainsdk";
import { api } from "mainsdk/api";
import { s } from "mainsdk/schema";

const ping = action({
  name: "ping.get",
  intent: "Say pong",
  input: s.object({}),
  output: s.object({ pong: s.boolean() }),
  risk: "read",
  http: { method: "GET", path: "/ping", cache: { scope: "public", maxAge: "60s" } },
  run: () => ({ pong: true }),
});
const handler = api.handler({ actions: [ping] });
const res = await handler(new Request("http://app.test/api/v1/ping"));
assert.equal(res?.status, 200);

See the Testing kit.

Security and privacy

  • output is also the list of fields allowed to leave the app. Keep it strict.
  • A risk you list under idempotency.require cannot be retried into a double write.
  • openapi.serve is off by default, so the document stays private until you turn it on.

FAQ

Is the OpenAPI document public? No. openapi.serve is off by default, and when it is on it lists only the actions in include.

Do I write the routes by hand? No. Each action's http mapping, or the default POST <base>/v<major>/actions/<name>, becomes a route.

Can I change a shipped version? Not in a way that breaks clients. Add version: 2 and keep the old major until its sunset date.

Reference

api comes from mainsdk/api. The paging and inbound helpers below are in mainsdk.

CallWhat it does
api.openapi(actions, options?)Returns the OpenAPI 3.1 document for the actions, the same one mainsdk api openapi writes. options.include filters by action name glob. The same actions always give the same document
api.handler({ actions, base?, openapi?, idempotency?, identify? })Serves the public routes: checks the input, runs the action with the caller identify returns, checks the output, adds Deprecation, Sunset and Link headers, keeps Idempotency-Key writes safe and serves the document with an ETag when openapi.serve is on. It throws a MainError for a refusal, which your adapter turns into problem details
api.client(document)Returns the source of the typed client for a document: one file, no imports
api.diff(before, after)Lists the changes between two documents as { code: "MAIN-API-0012", severity: "breaking" | "warning", operation, pointer, message }
api.react({ clientModule })Returns the source of the React bindings for a generated client: ApiProvider, useAction, useRead, useList, useSession, useLive, queryOptions and mutationOptions. One file that imports only react and the client. mainsdk api client --react writes it
api.routes(actions)Returns the method, versioned path and action for each public route, so an adapter can mount them. A bad mapping is refused at start with MAIN-API-0001
page(rows, init)Builds a page from up to limit + 1 rows: drops the extra row and makes the opaque next cursor for the last row kept. init is { limit, key, query }. page.read(cursor, { query }) opens a cursor, page.options(req) reads a request's limit and cursor, page.response(page, init) answers with the page and a Link: rel="next" header, and page.configure({ key }) seals every cursor with a key of 16 or more characters
pageLimit(limit, max?)The page size to use: 50 by default, lowered to the maximum (100). A limit that is not a whole number of 1 or more fails with MAIN-PAGE-0002 (400)
paginate(list, opts?)Walks every page of a list({ limit, cursor }) call lazily. Iterate it with for await, or use .pages() and .all(). opts is { limit?, max?, signal? }. A list that returns the cursor it was given fails with MAIN-PAGE-0001
inboundThe inbound protection object: call inbound.handle(req, next) to wrap a handler with secure headers, CSRF, CORS and cookie flags. It is an object with members, so there is no inbound() call.
inboundIssues(config, env, { today? })Checks the inbound section of main.yaml and returns every unknown key, bad value, open CORS rule and unsafe CSP as { code, key, message, fix }. Pure; never throws
originAllowed(origin, origins)True when an Origin header matches an origins list: exact, or https://*.domain for subdomains only. null and * never match

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet