Typed API and OpenAPI
apiTurn the app's actions into REST routes, an OpenAPI 3.1 document and a typed client, with idempotent writes and a breaking-change check.
app-apiFree in your app, Apache-2.0Status 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.
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 cursorsimport { api } from "mainsdk/api";
import { refundOrder } from "./actions/orders";
const doc = api.openapi([refundOrder], { include: ["orders.*"] }); // OpenAPI 3.1app/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);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)
Drivers
1 drivers. Select one to see its line in main.yaml.
api:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-API-0010 | Checkmainsdk check, before deploy | An action's input or output has no JSON Schema (a foreign schema without toJSONSchema()); the document and client type it as unknown |
MAIN-API-0011 | Checkmainsdk check, before deploy | The committed snapshot at api.snapshot differs from what the code generates |
MAIN-API-0012 | Checkmainsdk check, before deploy | mainsdk api diff found a breaking change in a major version that already shipped |
MAIN-API-0020 | Checkmainsdk check, before deploy | CORS lets any origin call the API (*, origin: true or a bare cors()) |
MAIN-API-0001 | RuntimeError responses and logs | An 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-0002 | RuntimeError responses and logs | An Idempotency-Key was reused with a different input (422) |
MAIN-API-0003 | RuntimeError responses and logs | The first request with this Idempotency-Key is still running (409, retryable) |
MAIN-API-0004 | RuntimeError responses and logs | An action returned a value that fails its output schema; nothing of it is sent (500) |
MAIN-API-0005 | RuntimeError responses and logs | A risk listed under api.idempotency.require was called without Idempotency-Key (400) |
MAIN-API-0006 | RuntimeError responses and logs | The major version was called after its sunset date (410) |
MAIN-API-0007 | RuntimeError responses and logs | The generated client got no answer, or an answer that isn't problem details or JSON (retryable for network failures) |
MAIN-PAGE-0001 | RuntimeError responses and logs | A list cursor is malformed, older than 24 hours, or made by a different query, tenant or user (400) |
MAIN-PAGE-0002 | RuntimeError responses and logs | limit 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.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Output schema checked before anything is sent | Phase 1 | 0.3 | app-api#output |
| Versioned REST routes from the actions | Phase 1 | 0.3 | app-api#routes |
| OpenAPI 3.1 document | Phase 1 | 0.3 | app-api#openapi |
| Idempotency-Key for writes | Phase 1 | 0.3 | app-api#idempotency |
| Typed client, one file, global fetch only | Phase 1 | 0.3 | app-api#client |
| Paged and all-items helpers in the client | Phase 1 | 0.3 | app-api#client-pages |
| Breaking-change diff against the snapshot | Phase 1 | 0.3 | app-api#diff |
| One schema for actions, documents and the client | Phase 1 | 0.3 | app-api#one-schema |
| Tenant and auth on every public route | Phase 1 | 0.3 | app-api#tenant-auth |
| React hooks and TanStack Query options for the generated client | Phase 2 | 0.7 | client-react#generator |
Quick start
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 }),
});npx mainsdk api openapi --out api/openapi.json
npx mainsdk api client --out src/acme-api.tsConfiguration
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| Key | What it does |
|---|---|
base | The path prefix for every public route |
actions | The module that exports your actions, for mainsdk api and mainsdk check. src/actions.ts by default |
openapi.serve | Serves the document at /.well-known/openapi.json. Off by default |
openapi.include | Which actions appear in the document and the client. Others stay unlisted |
snapshot | The committed document. mainsdk check fails when the code no longer matches it |
idempotency.require | Risks 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'shttpmapping. 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
outputschema 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:
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
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
npx mainsdk api diff --base origin/mainapi 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
deprecated: { sunset: "2027-06-30" } // after this date the old major answers 410A call to a major after its sunset date fails with MAIN-API-0006. Until then the old major keeps working.
Errors and checks
| Code | When | Fix |
|---|---|---|
MAIN-API-0001 | An action's HTTP mapping is invalid, for example a GET on a write | Fix the mapping the error names |
MAIN-API-0002 | An Idempotency-Key was reused with a different input | Send a new key for a new request |
MAIN-API-0003 | The first request with this Idempotency-Key is still running | Retry the same request shortly |
MAIN-API-0004 | An action returned a value that fails its output schema; nothing is sent | Return a value that matches output |
MAIN-API-0005 | A risk listed under idempotency.require was called without Idempotency-Key | Send the header |
MAIN-API-0006 | A major version was called after its sunset date | Call the newer major |
MAIN-API-0007 | The client got no answer, or an answer that isn't problem details | Check the URL and the network |
MAIN-API-0010 | A schema has no JSON Schema, so the document types it as unknown | Use a schema from mainsdk/schema |
MAIN-API-0011 | The committed snapshot differs from the code | Run mainsdk api openapi and commit the result |
MAIN-API-0012 | api diff found a breaking change in a shipped major | Add 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.
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
outputis also the list of fields allowed to leave the app. Keep it strict.- A risk you list under
idempotency.requirecannot be retried into a double write. openapi.serveis 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.
| Call | What 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 |
inbound | The 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
- Actions: the single definition every door uses.
- API keys: who may call the routes.
- Schema: the input and output schemas.
- Typed API guide: publish the API and client for a partner.
- Call your API from React: hooks for the generated client.
Was this page helpful?