MainSDK
Search⌘K
How-to

Publish your actions as a typed API

Turn existing actions into an OpenAPI document and a typed client, with an idempotent write and a breaking-change check in CI.

Building for 0.3

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

Your app already has actions. This guide publishes them over HTTP as an OpenAPI 3.1 document and a typed client, so a partner can call them without reading your code. It takes about 20 minutes.

Spec app-api, for 0.3. Run npx mainsdk api --help for the commands your version ships.

You need

  • A MainSDK app with at least one action (see Expose an action to agents).
  • An output schema on each action you want to publish. Without one, the document types the output as unknown.

1. Turn the API on

main.yaml
api:
  base: /api
  openapi:
    serve: false
    include: ["orders.*"]         # only these actions are published
    title: Acme API
  snapshot: api/openapi.json
  idempotency:
    require: [destructive]

include is a list of action name globs. Actions outside it stay private.

2. Mark the write that must not repeat

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: "pending" as const }),
});

A destructive action now needs an Idempotency-Key header. A repeat with the same key returns the first answer.

3. Write the document and the client

bash
npx mainsdk api openapi --out api/openapi.json
npx mainsdk api client --out src/acme-api.ts

Commit both files. api/openapi.json is the snapshot that mainsdk check compares against.

4. Call it from a script

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";
await acme.orders.refund({ orderId, reason: "late" }, { idempotencyKey: "refund-8812" });

5. Stop breaking changes in CI

bash
npx mainsdk api diff --base origin/main

Add it to the pull request job. A breaking change in a major version that has shipped fails with MAIN-API-0012. To change it, add version: 2 to the action and keep version 1 until its sunset date.

Check it worked

  • mainsdk check passes, and api/openapi.json matches the code.
  • Calling the refund twice with the same Idempotency-Key returns the same answer and writes once.
  • Calling it without the key returns 400 with MAIN-API-0005.
  • Removing a field from output makes api diff fail.

If it fails

  • MAIN-API-0011: the snapshot is stale. Run api openapi again and commit the result.
  • MAIN-API-0010: a schema has no JSON Schema. Use a schema from mainsdk/schema.

Prompt for your coding agent

Publish the actions under orders.* as an OpenAPI document and a typed client using MainSDK's api interface. Make orders.refund a destructive write that requires Idempotency-Key, keep openapi.serve off, and add mainsdk api diff to the pull request job.

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet