---
title: "Publish your actions as a typed API"
description: "Turn existing actions into an OpenAPI document and a typed client, with an idempotent write and a breaking-change check in CI."
section: "guides"
status: "Building for 0.3"
spec: "app-api"
canonical: "https://mainsdk.dev/docs/guides/typed-api"
updated: "2026-10-28"
---

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

Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/typed-api · 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](https://mainsdk.dev/docs/guides/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

```yaml title="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

```ts title="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

```ts title="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.
