# MainSDK > MainSDK is the open-source app runtime for apps built by AI coding agents: standard interfaces with swappable drivers, one main.yaml manifest, and checks that keep agent-written code safe, reliable and compliant by default. MainSDK is pre-release. It is in design, no package is published, and these docs describe the planned product. The TypeScript public beta is milestone 0.5; version 1.0 follows an independent audit. It is not yet production-ready. What it is: an SDK (the npm package `mainsdk`), a CLI and an MCP server that live in your repository. Your app calls one standard interface per capability, 40 in all, such as `auth`, `db`, `ai`, `pay`, `email`, `files` and `log`. A driver connects each interface to the provider you choose in `main.yaml`, so switching providers changes one line, not your code. It is not a framework, a hosting platform or a no-code builder, and it needs no sidecar. How it works: `npx mainsdk init` finds your framework, writes `main.yaml` and tells your coding agent the rules. You name a driver per capability, such as GitHub for sign-in or Amazon Bedrock for AI. `mainsdk check` runs on your laptop and in CI, fails a change that leaks a key, skips a permission or calls an undeclared host, and hands the fix back to the agent. Runs on: TypeScript on Node.js 22 or later, with adapters for Next.js (App Router), Hono, Express and Node workers, and Prisma or Drizzle for data. Expo and React Native follow in phase 3; Python, Go, Bun, Deno and Cloudflare Workers in phase 5. Works with any MCP-capable coding agent, including Claude Code, Cursor, Codex and GitHub Copilot. AI providers: the `ai` interface has drivers for Amazon Bedrock, Anthropic and OpenAI, plus an AI gateway. Keys stay out of code, personal data is redacted before the model sees it, providers that train on or keep prompts are refused, and spend limits apply per user and feature. Licence and price: everything that runs inside your app is free and open source under Apache-2.0, including every check, driver, the MCP server and the policy engine. MainSDK Cloud has paid plans for hosted history, team workflow and company-wide governance. An app protecting itself is never a paid feature. Reliability: MainSDK is designed never to sit in the request path. Policy decisions run inside your app, no user request waits on MainSDK Cloud, and the app keeps its last good policy if the Cloud is unreachable. Data is stored by region: US, EU and India. Made by HubXero (https://github.com/hubxero). Site: https://mainsdk.dev. Docs: https://mainsdk.dev/docs. Source: https://github.com/hubxero/mainsdk. Security: https://mainsdk.dev/security. Every page below is Markdown. Interface pages include the catalog facts: status, calls, main.yaml, drivers, guarantees, features and check codes. ## Frequently asked questions ### What is MainSDK? MainSDK is the open-source app runtime for apps built by AI coding agents. Your app calls standard interfaces for sign-in, access, data, AI, payments and more, and a driver connects each one to the provider you choose in one main.yaml file. The mainsdk check command verifies every change before it ships. MainSDK is made by HubXero and is free under Apache-2.0. ### Who is MainSDK for? MainSDK is for people who ship apps with AI coding agents. That includes founders and builders with little coding background, freelancers and agencies who rescue and ship client apps, teams who build together, and security and platform teams who govern many apps. Builders start free, and teams and companies pay only for what MainSDK Cloud runs for them. ### Is MainSDK free? Yes. Everything that runs inside your app is free and open source under Apache-2.0: the SDK, the CLI, every check and guarantee, every driver, the MCP server and the policy engine. MainSDK Cloud has paid plans for hosted history, team workflow and company-wide governance. An app protecting itself is never a paid feature. ### Is MainSDK open source? Yes. MainSDK is licensed under Apache-2.0, and everything that runs inside your app is open source: the TypeScript SDK and core, the CLI, the checks, the drivers, the MCP server and the policy engine. Contributions carry a DCO sign-off. MainSDK Cloud, the hosted service for teams and companies, is where the paid plans apply. ### Is MainSDK production-ready? No, not yet. MainSDK is pre-release: it is in design, and no package is published. Work runs in milestones from 0.1 Foundation to 0.5 Pilot ready, which is the public beta of the TypeScript SDK. Version 1.0 follows an independent audit. Until then, read the docs as the planned design and watch the GitHub repository for releases. ### How does MainSDK work? MainSDK works in three steps. First, npx mainsdk init finds your framework, writes main.yaml and tells your coding agent the rules. Second, you name a driver for each capability, such as GitHub for sign-in or Amazon Bedrock for AI, one line each. Third, mainsdk check runs the same checks on your laptop and in CI, and a change ships only when it passes. ### How is MainSDK different from a security scanner? A scanner reports problems after the code exists; MainSDK puts the safeguards inside the building blocks your agent uses. Keys stay out of code because interfaces take secret references, and every action checks permissions by default. The check then verifies rules, intent and outcomes before a change reaches main, and sends failures back to the agent with the fix. MainSDK bundles open-source scanners and runs alongside yours. ### Does MainSDK work with Next.js? Yes. Next.js is a first target, with an App Router adapter that covers route handlers, server actions and the proxy. Hono, Express and Node workers have adapters too, and Prisma or Drizzle plug in as the data layer. You need TypeScript and Node.js 22 or later. The adapters are planned for milestone 0.2, before the public beta. ### Which frameworks and runtimes does MainSDK support? MainSDK targets TypeScript on Node.js 22 or later, with adapters for Next.js, Hono, Express and Node workers, and Prisma or Drizzle for data. An Expo and React Native adapter for mobile follows in phase 3. Bun, Deno and Cloudflare Workers as runtimes, and native Rust, Python and Go SDKs, follow in phase 5. ### Which AI providers does MainSDK support? The ai interface has drivers for Amazon Bedrock, Anthropic and OpenAI, plus an AI gateway driver and a local fake for tests. You pick one in main.yaml and can switch later without changing code. Every call keeps provider keys out of code, redacts personal data before the model sees it, refuses providers that train on or keep prompts, and enforces spend limits per user. ### Which AI coding agents work with MainSDK? Any agent that speaks the Model Context Protocol (MCP) works with MainSDK, including Claude Code, Cursor, Codex and GitHub Copilot. mainsdk init writes AGENTS.md rules for your agent, and the MCP server exposes your app's actions as tools. Apps started in Lovable, Bolt, v0 or Replit come over through mainsdk check and the migration skill. ### Can I use MainSDK on an app I already built? Yes. Run mainsdk check on an existing TypeScript app to see what to fix, then use mainsdk adopt and the migration skill to move it onto MainSDK one pull request at a time, with no rewrite. This is the intended first step for apps built with Lovable, Bolt, v0 or Cursor, and the goal is a useful result in under five minutes. ### What is main.yaml? main.yaml is the one file that declares what a MainSDK app is, needs and may do, like a mobile app's manifest. It names the driver for each interface, the hosts the app may call, its data labels and its permissions. Overlays such as main.in.yaml hold what differs by market, segment or environment, and the most specific file wins. ### What does mainsdk check do? mainsdk check verifies an app before it ships, on your laptop and in CI. It fails on problems such as a key in code, an action without a permission or a call to an undeclared host, and explains the fix. It runs in observe, warn or enforce mode and writes SARIF for CI. Every check is free and runs on your own machine. ### Can I switch providers without rewriting code? Yes. Your code calls an interface such as pay or email, and the provider is one line in main.yaml. Moving from Stripe to Razorpay, or from Amazon Bedrock to Anthropic, changes that line, not your code. Shadow mode for the new driver and a guided job that moves stored state are planned and not specified yet. Anything not covered can call the provider directly through raw(). ### Does MainSDK lock me in? No. Every provider sits behind a contract you can swap, everything inside your app is Apache-2.0, and mainsdk eject leaves MainSDK for one interface or all of them. Eject writes plain provider code in place of MainSDK calls and keeps your tests, so leaving is a supported path, not a rewrite. ### Does my app go down if MainSDK Cloud goes down? No. MainSDK is designed never to sit in the request path: policy decisions run inside your app, no user request waits on MainSDK Cloud, and telemetry never blocks. If the Cloud is slow or unreachable, your app keeps its last good policy, packs and config and keeps serving. Plan limits affect only the Cloud, never your running app. ### Where does MainSDK store data? Data is stored by region: US, EU and India. The db interface keeps one store per region and writes each record to its owner's region, and mainsdk check refuses queries that join across regions. MainSDK Cloud runs in the same three regions. Personal data is labelled in the schema, so one call can export or erase a person everywhere. ### Is MainSDK a framework or a hosting platform? Neither. MainSDK is a library, a CLI and an MCP server that live in your repository and run inside the framework you already use, such as Next.js, Hono or Express. You deploy wherever you deploy today. It is not a no-code builder either, and it needs no sidecar or extra service: you import it like any other package. ### How is MainSDK different from using provider SDKs directly? Provider SDKs such as Stripe's or Clerk's give you one provider's API; MainSDK uses them as drivers behind a standard interface. Your code makes the same call whichever provider you pick, plus what MainSDK adds around it: policy checks, audit logs, redaction, verified webhooks and region rules. Providers are partners, and raw() reaches any provider feature MainSDK does not cover. ### Why not ask my coding agent to write auth and payments itself? Because an agent can write a SAML integration or a payment flow, but it cannot promise the code is correct, kept up to date and ready for a security review. With MainSDK the agent writes one interface call, such as auth.sso.enable() or pay.checkout(), and MainSDK supplies the work behind it: metadata exchange, certificate rotation, verified webhooks and audit logs. ### What is Main 27? Main 27 is the named, versioned minimum every MainSDK app meets: modern methods only, such as passkeys, TLS 1.3 and Argon2id, with older methods retired rather than supported. It is named for the year it serves, like Wi-Fi 6, and Main 28 follows in autumn 2027. The Main mark is the signed proof that a release holds it. ### Who makes MainSDK? MainSDK is made by HubXero, a lab that generates tomorrow's ideas and executes them. HubXero builds a family of products that share capabilities: MainSDK's sharing, uploads and QR codes go through GetQR ID, another HubXero product. MainSDK is built in the open under Apache-2.0, with a public repository, RFCs and roadmap planned for phase 1. ### Does MainSDK support Rust, Python or Go? Not yet. MainSDK ships TypeScript only through phase 4, on Node.js 22 or later. In phase 5 a native Rust SDK comes first, for apps, services and endpoint agents, then Python and Go. Each is its own idiomatic library with the same interfaces, main.yaml, error codes and checks as TypeScript. They share contracts and test data, not code: no shared core, no FFI and no sidecar. Rust may arrive sooner if the owner decides. --- Source: https://mainsdk.dev/docs/getting-started/five-minutes # Five-minute path > From an empty folder to an app with sign-in, a database and a protected route, in four commands and twelve lines you write. Status: Planned for 0.3 · Canonical: https://mainsdk.dev/docs/getting-started/five-minutes · Updated: 2026-10-28 Four commands and one file take you from an empty folder to a running app where every note belongs to the person who wrote it. You need Node 22.18 or later. No account, no keys, and nothing leaves your machine. ## 1. Set up the project ```bash npx mainsdk init ``` ```text $ npx mainsdk init mainsdk init · notes Found empty folder · npm · Claude Code Wrote main.yaml project prj_01JC8Q4N7V3X9B2D5F6G8H0JKM app.ts a fetch handler that says hello package.json mainsdk as a dependency .gitignore .mainsdk/ CLAUDE.md MainSDK rules, between markers agent context .claude/ · .cursor/ · AGENTS.md .mcp.json mainsdk mcp for Claude Code Every interface runs on a local driver. No account, no keys. Next 1 npx mainsdk add auth db sign-in and a database 2 npx mainsdk dev app.ts run it on 127.0.0.1:3000 3 npx mainsdk check see what to fix before you ship ``` `init` writes `main.yaml` for you. You never write it by hand on this path. ## 2. Add sign-in and a database ```bash npx mainsdk add auth db ``` ## 3. Write the app Replace `app.ts` with this. It is the only file you write, and it is 12 lines. ```ts import { auth, models } from "mainsdk" import { s } from "mainsdk/schema" const { notes } = models({ notes: s.object({ text: s.string() }) }) export default async function fetch(req: Request) { const { user } = await auth.require(req) const mine = notes.for(user) if (req.method === "POST") return Response.json(await mine.create(await req.json())) return Response.json(await mine.list()) } ``` ## 4. Run it ```bash npx mainsdk dev app.ts ``` ```text $ npx mainsdk dev app.ts mainsdk dev · notes · env dev auth local dev sign-in on db local .mainsdk/db/app.sqlite log terminal No cloud calls. No provider keys. Network observe, loopback only. Ready http://127.0.0.1:3000 · 0.4s Sign in as a test user: -H "authorization: Bearer dev_ada" Logs: mainsdk logs --follow · Stop: Ctrl-C 12:04:31 GET / 401 2ms MAIN-AUTH-0001 not signed in 12:04:40 POST / 200 6ms ada 12:04:42 GET / 200 3ms ada ``` In a second terminal, ask without signing in. You get `401` with a code and the one thing to do: ```bash curl -i http://127.0.0.1:3000/ ``` Sign in as the test user `ada`, write a note and read it back. The `dev_` token works only from this machine and only under `mainsdk dev`: ```bash curl -X POST http://127.0.0.1:3000/ -H "authorization: Bearer dev_ada" -H "content-type: application/json" -d '{"text":"hello"}' curl http://127.0.0.1:3000/ -H "authorization: Bearer dev_ada" curl http://127.0.0.1:3000/ -H "authorization: Bearer dev_bob" ``` `ada` sees her note. `bob` sees an empty page, because every query belongs to its owner. ## 5. Check it ```bash npx mainsdk check ``` ```text $ npx mainsdk check mainsdk check · notes · env dev · Main 27 ✓ config ✓ secrets ✓ policy ✓ network ✓ auth ✓ data Passed · 6 gates · 0 findings · 1.2s ``` ## Next - [Existing app](https://mainsdk.dev/docs/getting-started/existing-app): add MainSDK to a project you already have. - [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes): the longer route, with the three ideas explained. - [Interfaces](https://mainsdk.dev/docs/interfaces): everything else the app can use, each with a one-line happy path. --- Source: https://mainsdk.dev/docs/getting-started/installation # Install MainSDK > Install the mainsdk package, CLI and testing kit in a Node.js 22+ TypeScript project and wire it into Next.js, Hono, Express or a worker. Status: Building for 0.2 · Canonical: https://mainsdk.dev/docs/getting-started/installation · Updated: 2026-10-28 MainSDK is a library, a CLI and an MCP server that live in your repo. There is nothing to host and no sidecar to run: you import it like any other package. Nothing is published yet: MainSDK is pre-release, and these steps are the planned design. What runs from the repository today, and how to run it, is in the [README](https://github.com/hubxero/mainsdk#quick-start). The Next.js, Hono, Express, worker, Prisma and Drizzle adapters are planned for milestone 0.2. Starting from an empty folder? Skip to the [quickstart](https://mainsdk.dev/docs/getting-started/quickstart); it creates a Next.js app and runs `init` for you. Read on to add MainSDK to a project you already have, or to wire in a framework other than Next.js. ## Requirements | You need | Version | | --- | --- | | Node.js | 22 or later, with ESM (`"type": "module"`) | | Language | TypeScript (phases 1 to 4 are TypeScript only) | | Operating system | Linux x64 or arm64 (glibc and musl), macOS (arm64, x64) or Windows x64 | | Account | None. Everything runs locally until you choose to run `mainsdk login` | MainSDK is pure TypeScript with zero runtime dependencies: no native binary, no WebAssembly core and no install scripts. ## Install Run `init` in the root of your project. It installs the packages, writes a minimal `main.yaml`, adds `.mainsdk/` to `.gitignore` and connects your coding agent. It needs no account and no network. npm: ```bash npx mainsdk init ``` pnpm: ```bash pnpm dlx mainsdk init ``` bun: ```bash bunx mainsdk init ``` > **Note:** Bun works here as a package manager. Running your app on the Bun runtime, Deno or Cloudflare Workers is phase 5. Until then, run the app on Node.js 22+. To add the packages by hand instead: npm: ```bash npm install mainsdk npm install --save-dev @mainsdk/cli @mainsdk/testing ``` pnpm: ```bash pnpm add mainsdk pnpm add -D @mainsdk/cli @mainsdk/testing ``` bun: ```bash bun add mainsdk bun add -d @mainsdk/cli @mainsdk/testing ``` | Package | What it holds | | --- | --- | | `mainsdk` | The SDK: every interface, one subpath per adapter (`mainsdk/next`, `mainsdk/hono` …) | | `@mainsdk/cli` | The `mainsdk` command: `init`, `sync`, `check`, `info`, `logs`, `dev` and more | | `@mainsdk/testing` | In-memory fakes, assertions, time control and chaos drills for your tests | The packages are ES modules only (ADR-0045) and ship built files with source maps. `import` them, or `require()` them on Node.js 22.12 or later. ## Wire in your framework One import puts every request and job behind MainSDK: each gets a trace, its user and market context, the lifecycle hooks, and errors returned as problem details. Next.js: ```ts // proxy.ts (middleware.ts on Next.js 15) import { mainsdk } from "mainsdk/next"; export const proxy = mainsdk.proxy(); // app/api/orders/route.ts import { withMainSDK } from "mainsdk/next"; import { auth, db } from "mainsdk"; export const GET = withMainSDK(async (req) => { const session = await auth.require(req); return Response.json(await db.orders.for(session.user).list({ limit: 20 })); }); ``` Hono: ```ts import { Hono } from "hono"; import { mainsdk } from "mainsdk/hono"; const app = new Hono(); app.use("*", mainsdk()); export default app; ``` Express: ```ts import express from "express"; import { mainsdk } from "mainsdk/express"; const app = express(); app.use(mainsdk()); // … your routes app.use(mainsdk.errors()); // last: turns errors into problem details app.listen(3000); ``` Node worker: ```ts import { worker } from "mainsdk/worker"; import { log } from "mainsdk"; export default worker(async (job, ctx) => { log.info("job started", { job: job.name }); // carries the producer's trace and user }); ``` Next.js support covers the App Router (route handlers, server actions and `proxy.ts` or `middleware.ts`). The Pages Router is not supported. ### Prisma and Drizzle Wrap your existing client so every query goes through the policy engine and the audit log. `mainsdk check` reports any query made with the unwrapped client (`MAIN-DATA-0001`). ```ts title="lib/db.ts" import { PrismaClient } from "@prisma/client"; import { withPrisma } from "mainsdk/prisma"; export const prisma = withPrisma(new PrismaClient()); ``` ```ts title="lib/db.ts" import { drizzle } from "drizzle-orm/node-postgres"; import { withDrizzle } from "mainsdk/drizzle"; import { pool } from "./pool"; export const db = withDrizzle(drizzle(pool)); ``` > **Note:** The adapters ship as beta until each has three production users. Expo and React Native adapters arrive in phase 3. ## Check the install ```bash npx mainsdk info ``` `info` reads `main.yaml` and prints the app, its interfaces and drivers, network destinations, data labels and regions, and open exceptions. Add `--json` for a versioned document. If it prints your app, you're set. ## What runs when Nothing is published yet. Milestone 0.2: `init`, `check`, `sync`, `info`, `logs`, `dev` and local `config`, `secrets`, `policy` and `log`. Milestone 0.3: `init --preset`, `add`, `doctor`, `check --json --agent` and local `auth`, `db` and `ai`. Milestone 0.4: the testing kit. Milestone 0.5: public beta, the migration skill and `adopt`. ## Next - [Quickstart](https://mainsdk.dev/docs/getting-started/quickstart): build a checked app from an empty folder - [Interfaces and drivers](https://mainsdk.dev/docs/concepts/interfaces-and-drivers) - [CLI reference](https://mainsdk.dev/docs/reference/cli) --- Source: https://mainsdk.dev/docs/getting-started/thirty-minutes # Thirty-minute path > The shortest route from an empty folder to a passing mainsdk check, with only three ideas to learn. Status: Planned for 0.3 · Canonical: https://mainsdk.dev/docs/getting-started/thirty-minutes · Updated: 2026-10-28 This is the short path. You learn three ideas, write no `main.yaml` by hand and finish with a passing `mainsdk check`. The [quickstart](https://mainsdk.dev/docs/getting-started/quickstart) builds a larger app once you are through. ## The three ideas - **Interface.** One standard way to do a job, such as sign-in (`auth`), data (`db`) or model calls (`ai`). Your code calls the interface, never the provider. - **Driver.** The provider behind an interface. In development it is a local one that needs no account or key. You swap it later by editing one line. - **The check.** `mainsdk check` reads your project and fails on mistakes, such as a key in code or an action anyone can call. It says how to fix each one. Everything else is optional and has a name in the [glossary](https://mainsdk.dev/docs/reference/glossary). ## 1. Create an app and initialise ```bash npx create-next-app@latest hello --ts --app cd hello npx mainsdk init ``` `init` writes a `main.yaml` with the local `auth`, `db` and `files` drivers, logging and a deny-by-default network (the `indie` preset, the default). It asks no questions. For the bare four-key file, run `init --preset minimal`. ## 2. Add what you need ```bash npx mainsdk add auth npx mainsdk add ai ``` Each `add` uses a local driver, writes one example test and runs the check. ## 3. Check ```bash npx mainsdk doctor npx mainsdk check ``` `doctor` looks at your machine and project. `check` looks at your code. Both name the fix for every problem. ## Let your agent do it Point your agent at `mainsdk check --json --agent`. It gets the file and the fix for each finding. After three tries on the same finding it is told to ask you instead of weakening the check. ## What runs when Nothing is published to npm yet. You can run the parts marked Building from the repository today with `pnpm install` and `pnpm mainsdk `; the rest is the planned design. Each row says the milestone that makes it run. | Milestone | What runs | Spec | | --- | --- | --- | | Building now | `init --preset`, `check --json --agent`, `info`, `network export --to k8s` or `aws-sg` and `doctor`. The `network`, `files` (memory, local, s3), `log`, `access` and agent budget interfaces, and `@mainsdk/testing`. | `network`, `files-share`, `log-telemetry`, `agent-governance`, `cli-core` | | 0.2 | `mainsdk init`, `check`, `sync`, `info`, `logs` and `dev`. Local mode for `config`, `secrets`, `policy` and `log`, so `dev` runs offline with no account. | `cli-core` | | 0.3 | `init --preset`, `add`, `doctor` and `check --json --agent`. Local drivers for `auth`, `db` and `ai`, and the other interfaces specified for 0.3, such as `files` and `share`. | `fast-start` and the interface specs | | 0.4 | The testing kit: `@mainsdk/testing` with a fake of every interface. | `testing-kit` | | 0.5 | Public beta, the migration skill and `mainsdk adopt` for an existing app. | `public-beta`, `migration-skill` | The [installation page](https://mainsdk.dev/docs/getting-started/installation) has the setup for each framework. --- Source: https://mainsdk.dev/docs/getting-started/quickstart # Quickstart > Go from an empty folder to a Next.js app with sign-in, Postgres-style data and AI that passes mainsdk check, offline, adding one main.yaml key at a time. Status: Planned for 0.2 · Canonical: https://mainsdk.dev/docs/getting-started/quickstart · Updated: 2026-10-28 You'll build Pinecart, a small Next.js orders app: people sign in, see only their own orders, and get a one-line AI summary of an order. You start from a four-key `main.yaml`, add one key at a time, run it offline, and finish with a passing `mainsdk check`. Then you swap in real providers and the production settings. > **Note:** Nothing is published yet: MainSDK is pre-release, and the commands below are the planned design. What runs from the repository today, and how to run it, is in the [README](https://github.com/hubxero/mainsdk#quick-start). The table under [What runs when](#what-runs-when) says which milestone makes each step run. You need Node.js 22+ and git. You don't need a MainSDK account or any provider keys. **Before you start.** Three ideas are enough for this page: - **Interface:** one standard way to do a job, such as `auth`, `db` or `ai`. Your code calls it, never the provider. - **Driver:** the provider behind an interface. In development it is a local one with no account or key. - **The check:** `mainsdk check` fails on mistakes, such as a key in code, and says how to fix each one. Anything else has a one-line entry in the [glossary](https://mainsdk.dev/docs/reference/glossary). The shortest route is the [thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes). ## 1. Create the app ```bash npx create-next-app@latest pinecart --ts --app cd pinecart git init ``` ## 2. Run init with the minimal preset npm: ```bash npx mainsdk init --preset minimal ``` pnpm: ```bash pnpm dlx mainsdk init --preset minimal ``` bun: ```bash bunx mainsdk init --preset minimal ``` `init` asks no questions and works offline. The default `indie` preset also writes the local `auth`, `db` and `files` drivers, logging and a deny-by-default network; this page uses `minimal` so the whole file fits in four lines. It writes a `main.yaml`, git-ignores `.mainsdk/` (local events and local secrets live there), adds MainSDK rules to your agent files and registers the MCP server for your agent. ```yaml title="main.yaml" schema: 1 project: prj_01JC8Q4N7V3X9B2D5F6G8H0JKM baseline: main27 overlays: { order: [env] } ``` That is the whole file. `schema` is the file format, `project` names your project and is never a credential, `baseline: main27` names the rules your app is checked against, and `overlays.order` says how per-environment files layer. The `minimal` preset only chose these keys; you can widen or tighten any of them later. ## 3. Add sign-in ```bash npx mainsdk add auth ``` Why: your orders need an owner, and an owner needs a signed-in user. `add` uses the local driver for `auth`, writes one example test, runs the check and changes nothing else. `main.yaml` gains one key: ```yaml title="main.yaml" auth: { driver: local } ``` ## 4. Add data ```bash npx mainsdk add db ``` Why: Pinecart stores orders. The local `db` driver is SQLite, so there is nothing to run. ```yaml title="main.yaml" db: { driver: sqlite } ``` ## 5. Add AI ```bash npx mainsdk add ai ``` Why: the order summary calls a model. The local `ai` driver needs no key. ```yaml title="main.yaml" ai: { driver: local } ``` ## 6. Write a route and an action A route that needs a signed-in user and returns only that user's orders: ```ts title="app/api/orders/route.ts" import { withMainSDK } from "mainsdk/next"; import { auth, db } from "mainsdk"; export const GET = withMainSDK(async (req) => { const session = await auth.require(req); // 401 with a fix if not signed in const orders = await db.orders.for(session.user).list({ limit: 20 }); // no .for(), no compile return Response.json(orders); }); ``` An action that summarises one order. You write it once, and it becomes an HTTP endpoint, a server action, a CLI command and an audit event, all behind the same checks: ```ts title="app/actions/summarise-order.ts" import { action, ai, db } from "mainsdk"; import { s } from "mainsdk/schema"; export const summariseOrder = action({ name: "orders.summarise", intent: "Summarise an order the signed-in user owns", input: s.object({ orderId: s.string() }), risk: "read", run: async ({ input, user }) => { const order = await db.orders.for(user).get(input.orderId); return ai.generate({ model: "anthropic/claude-sonnet-4-5", prompt: `Summarise order ${order.id} in one sentence.`, feature: "order-summary", // spend limits and audit per feature user, // and per user }); }, }); ``` [Actions and MCP](https://mainsdk.dev/docs/concepts/actions-and-mcp) shows how to expose it to agents as well. ## 7. Run it offline ```bash npx mainsdk dev -- npm run dev ``` `mainsdk dev` gives every interface a local driver: SQLite for `db`, secrets from `.mainsdk/secrets.json`, logs in your terminal. It makes no calls to GitHub, AWS or a database host. ## 8. Check ```text $ npx mainsdk check mainsdk check · pinecart · env dev · Main 27 ✓ secrets ✓ config ✓ policy ✓ network ✓ data all gates passed · exit 0 ``` You have an app that signs people in, scopes every query to its owner, calls AI through one interface and passes the check, with a `main.yaml` of four keys. [Your first check](https://mainsdk.dev/docs/getting-started/first-check) breaks it on purpose and shows how to fix what the check finds. ## What runs when Nothing is published yet: MainSDK is pre-release and these commands are the planned design. Each row says the milestone that makes it run. | Milestone | What runs | Spec | | --- | --- | --- | | 0.2 | `mainsdk init`, `check`, `sync`, `info`, `logs` and `dev`. Local mode for `config`, `secrets`, `policy` and `log`, so `dev` runs offline with no account. | `cli-core` | | 0.3 | `init --preset`, `add`, `doctor` and `check --json --agent`. Local drivers for `auth`, `db` and `ai`, and the other interfaces specified for 0.3, such as `files` and `share`. | `fast-start` and the interface specs | | 0.4 | The testing kit: `@mainsdk/testing` with a fake of every interface. | `testing-kit` | | 0.5 | Public beta, the migration skill and `mainsdk adopt` for an existing app. | `public-beta`, `migration-skill` | ## Make it production-ready Everything above runs on local drivers. `npx mainsdk check --env prod` fails with `MAIN-CHECK-0026 local driver in production` until each interface has a real driver. Do this when you are ready to ship, not before. Each key below has a reason and a page. Point the drivers at real providers. Database URLs are `secret:` references, resolved on the server; Bedrock uses the cloud role your app runs with: ```yaml title="main.env.prod.yaml" auth: { driver: github, methods: [passkeys] } ai: driver: bedrock models: [anthropic/claude-*] data: { training: never, retention: zero } db: driver: postgres regions: { us: secret:DATABASE_URL_US, eu: secret:DATABASE_URL_EU } ``` Then add the keys that make the app compliant for your users: | Key | Why you add it | Read | | --- | --- | --- | | `data.residency` | Keep each user's records in their own region (`us`, `eu`) | [Data and regions](https://mainsdk.dev/docs/concepts/data-and-regions) | | `db.fields` with `label` | Mark fields such as `user.email` as `personal` so they are handled and redacted correctly | [Data and regions](https://mainsdk.dev/docs/concepts/data-and-regions) | | `policy` | Choose the policy engine and where decisions run | [Policy](https://mainsdk.dev/docs/concepts/policy) | | `network` | Allow outbound hosts beyond what each driver declares; the default is deny | [Security model](https://mainsdk.dev/docs/concepts/security-model) | | `users.audience` and `regions` | Say who your users are and where, so the check applies the right rules | [The main.yaml file](https://mainsdk.dev/docs/concepts/main-yaml) | | `overlays.order` | Apply small per-market, segment or environment files in a fixed order | [The main.yaml file](https://mainsdk.dev/docs/concepts/main-yaml) | For example, the data keys for Pinecart: ```yaml title="main.yaml" data: { store: postgres, residency: { default: us, by-market: { eu: eu } } } db: fields: user.email: { label: personal } ``` Run `npx mainsdk check --env prod` until it exits 0. Every key is in the [main.yaml reference](https://mainsdk.dev/docs/reference/main-yaml). To apply changes as a reviewed pull request, run `npx mainsdk sync --plan`, then `npx mainsdk sync`: it pins driver versions in `main.lock`, commits on a new branch, runs `check` and opens a pull request. With no git remote it leaves the branch and prints the plan; `--no-pr` skips the pull request. ## Next - [Your first check](https://mainsdk.dev/docs/getting-started/first-check) - [The main.yaml file](https://mainsdk.dev/docs/concepts/main-yaml) - [Deploy on Vercel](https://mainsdk.dev/docs/guides/deploy-vercel) --- Source: https://mainsdk.dev/docs/getting-started/existing-app # Adopt an existing app > Bring a Lovable, Bolt, v0 or Cursor app onto MainSDK with mainsdk check and mainsdk adopt, one module and one pull request at a time, no rewrite. Status: Building for 0.5 · Canonical: https://mainsdk.dev/docs/getting-started/existing-app · Updated: 2026-10-28 The quickstart started from an empty folder. Most apps already exist, and you don't rewrite yours to use MainSDK: you check it, see the gaps, lock what works, and close the gaps one pull request at a time. Your coding agent can do the work through the migration skill, or you can run `mainsdk adopt` yourself. Phase 1 covers Next.js and Node apps, including those generated by Lovable, Bolt and v0. Other stacks are reported as unsupported, never half-migrated. MainSDK is pre-release; these commands are the planned design and do not run yet. `init`, `check` and the framework adapters are planned for 0.2; `mainsdk adopt` and the migration skill are planned for 0.5. ## Start with a check Run it in the app's root. No account, nothing sent anywhere. ```bash npx mainsdk check ``` On a typical generated app, the first run finds things like a payment key in client code or a raw database client: ```text $ npx mainsdk check mainsdk check · my-lovable-app · env dev · Main 27 ✗ secrets 1 finding MAIN-SECRET-0002 critical src/lib/stripe.ts:4 fix: Move the key to secrets and use secret:STRIPE_SECRET_KEY ✗ data 1 finding MAIN-DATA-0001 high src/lib/db.ts:12 fix: Route queries through MainSDK (withPrisma, withDrizzle or db..for(user)) ✓ config ✓ network 2 gates failed · exit 1 ``` ## Adopt, step by step Plain `npx mainsdk adopt` runs the next step each time and is safe to re-run. The steps below run each one by name. Progress is kept in `.mainsdk/adopt.json` (git-ignored); `main.yaml` stays the source of truth. ### 1. Scan ```bash npx mainsdk adopt scan ``` A deterministic scan maps sign-in, data access, secrets and outbound calls, and writes a `main.yaml` describing the app as it is. It changes no code; the same repo always gives the same file. It keeps what you already run. An app on Clerk and OpenFGA stays on them: ```yaml title="main.yaml (written by adopt scan)" schema: 1 project: prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY baseline: main27 auth: { driver: clerk } # byo-identity: users, sessions and MFA stay in Clerk access: { driver: openfga } # byo-policy: your model and tuples stay in place pay: { driver: stripe } overlays: { order: [market, segment, env] } exceptions: [] ``` ### 2. Report ```bash npx mainsdk adopt report ``` A plain-language gap report against Main 27, ordered by risk: secrets first, then access, then data, then the rest. Each item names the rule and the fix. Add `--json` for tools. ### 3. Lock key flows ```bash npx mainsdk adopt lock ``` Records the app's key flows, such as sign-in and checkout, as locked flows before anything changes. `adopt next` refuses to run until they exist (`MAIN-ADOPT-0001 lock flows first`), so migration can't silently change what your users see. ### 4. Migrate one module per pull request ```bash npx mainsdk adopt next --module secrets ``` Each step is one branch and one pull request, limited to one module, that must pass `mainsdk check` and every locked flow. If a step breaks a locked flow it's marked failed, the branch is kept for review, and your main branch is untouched. A secrets step, before and after: ```ts title="src/lib/stripe.ts (before)" import Stripe from "stripe"; export const stripe = new Stripe("sk_live_…"); ``` ```ts title="src/lib/checkout.ts (after)" import { pay } from "mainsdk"; export function checkout(orderId: string, amountInCents: number) { return pay.checkout({ order: orderId, amount: amountInCents, currency: "USD", successUrl: "/orders/done", cancelUrl: "/cart" }); } ``` Code that can't move to an interface yet stays behind that interface's escape hatch, `raw()`, and `check` lists every use. ### 5. Finish ```bash npx mainsdk adopt finish ``` Produces the launch report: the app meets Main 27, or the report lists exactly what remains, with file and line. ## Hono, Express and Node If your app is a Hono or Express server rather than Next.js, three commands put it under the check: ```bash npx mainsdk init ``` ```ts title="src/server.ts" import express from "express"; import { mainsdk } from "mainsdk/express"; // Hono: import { mainsdk } from "mainsdk/hono"; app.use("*", mainsdk()) const app = express(); app.use(mainsdk()); // … your routes app.use(mainsdk.errors()); // last ``` ```bash npx mainsdk check ``` The adapters give every request a trace, its user and market context, and errors as problem details (see [installation](https://mainsdk.dev/docs/getting-started/installation)). What is and isn't specified for adopting these apps: - The adapters for Hono, Express and Node workers, and `withPrisma` and `withDrizzle` for the database, are specified for 0.2. - The adopt steps are scoped to "Next.js and Node apps". Hono and Express servers are Node apps, but the migration spec doesn't name them or say that `adopt scan` recognises their routes: [TBD]. - Until that is settled, wire the adapter and run `check` yourself, and use `adopt` for Next.js apps. ## Let your agent do it The migration skill runs the same five steps through the MainSDK MCP server (`adopt.scan`, `adopt.report`, `adopt.lock`, `adopt.plan_module`, `adopt.verify_module`, `adopt.finish`). It works in Claude Code, Cursor and any MCP-capable agent, and intent checks run on your own agent and keys. ```text Bring this app onto MainSDK with the mainsdk-adopt skill. Start with secrets. ``` See [Use MainSDK with your coding agent](https://mainsdk.dev/docs/getting-started/with-your-agent). > **Note:** `mainsdk migrate ` with guides and codemods for Supabase, Firebase, Clerk, Auth0, Auth.js, Better Auth and Stripe direct arrives in phase 2. Adopting across many repos at once is also phase 2. ## Next - [Migrate from raw provider SDKs](https://mainsdk.dev/docs/guides/migrate-from-raw-providers) - [Interfaces and drivers](https://mainsdk.dev/docs/concepts/interfaces-and-drivers) - [Main 27 and the Main mark](https://mainsdk.dev/docs/concepts/main27) --- Source: https://mainsdk.dev/docs/getting-started/first-check # Your first check > Read mainsdk check output, fix a finding with a secret reference, record an expiring exception, check prod settings and run the check in CI with SARIF. Status: Planned for 0.2 · Canonical: https://mainsdk.dev/docs/getting-started/first-check · Updated: 2026-10-28 At the end of the [quickstart](https://mainsdk.dev/docs/getting-started/quickstart), `mainsdk check` passed. This page reads that output, breaks it on purpose so you see a finding, then fixes the finding properly and covers the cases you'll meet next: a gap you can't fix yet, production settings, and CI. The check runs on your machine, free, with no account. MainSDK is pre-release; the commands here are the planned design and ship from 0.2 (see the [quickstart](https://mainsdk.dev/docs/getting-started/quickstart)). ## Run it on the clean app ```text $ npx mainsdk check mainsdk check · pinecart · env dev · Main 27 ✓ secrets ✓ config ✓ policy ✓ network ✓ data all gates passed · exit 0 ``` Every gate that ran has one line, and exit code 0 means a pass. Run it before every pull request. ## Break it on purpose Paste a live-looking key into a helper: ```ts title="app/lib/pay.ts" export const stripeKey = "sk_live_51H…"; ``` ```text $ npx mainsdk check mainsdk check · pinecart · env dev · Main 27 ✗ secrets 1 finding MAIN-SECRET-0002 critical app/lib/pay.ts:1 fix: Move the key to secrets and use secret:STRIPE_SECRET_KEY ✓ config ✓ policy ✓ network ✓ data 1 gate failed · exit 1 ``` | Part | What it tells you | | --- | --- | | Header | The project, the environment it resolved (`dev` unless you pass `--env`) and the baseline | | Failed gates | Listed first. Each finding has a code, a severity, the file and line, and a fix | | `MAIN-SECRET-0002` | A stable code. `npx mainsdk explain MAIN-SECRET-0002` prints the cause and fix offline; every code is listed in [check codes](https://mainsdk.dev/docs/reference/check-codes) | | Passed gates | One line, so you can see what ran | | Exit code | `0` passed, `1` a gate failed, `2` a usage or configuration error (with a `MAIN-CONFIG-` code) | `check` resolves and tests every overlay combination you use, so a problem that exists only in `main.in.yaml` or `main.env.prod.yaml` still shows up. ## Fix the finding Pinecart takes payments with Stripe. Put the key behind a `secret:` reference in `main.yaml` and let the `pay` interface use it on the server: ```yaml title="main.yaml" payments: { driver: stripe } pay: stripe: { key: secret:STRIPE_SECRET_KEY, webhook: secret:STRIPE_WEBHOOK_SECRET } ``` ```ts title="app/lib/pay.ts" import { pay } from "mainsdk"; export function startCheckout(orderId: string, amountInCents: number) { return pay.checkout({ order: orderId, amount: amountInCents, currency: "USD", successUrl: "/orders/done", cancelUrl: "/cart" }); } ``` For local runs, the `local` secrets driver reads values from `.mainsdk/secrets.json`, which `init` git-ignored. In production, your secrets driver (Vault, a cloud secret manager, Doppler or Infisical) supplies them. Run `npx mainsdk check` again; it exits 0, the same as before you broke it. If the key was ever committed, rotate it with your provider: it is in your git history. See [Payments](https://mainsdk.dev/docs/interfaces/pay) and [Secrets](https://mainsdk.dev/docs/interfaces/secrets). > **Tip:** When an agent wrote the code, let it fix it. Run `npx mainsdk check --json --agent`: it prints a stable output with the file and the fix for every finding, and a hint for how many repair rounds to allow. After three tries on the same finding, the agent is told to ask a person instead of weakening the check. See [Use MainSDK with your coding agent](https://mainsdk.dev/docs/getting-started/with-your-agent). ## When you can't fix it yet Some gaps take time, such as a partner API that still offers only TLS 1.1. Record an exception in `main.yaml` with the rule, a reason, an owner and an expiry date: ```yaml title="main.yaml" exceptions: - rule: MAIN-NET-0004 reason: Partner gateway upgrade booked for November owner: "@pinecart/platform" expires: 2026-11-30 ``` While the exception is open, `check` passes and your mark reads "Main 27 · 1 exception". The day after it expires, `check` fails again. See [Main 27 and the Main mark](https://mainsdk.dev/docs/concepts/main27). ## Check the production settings ```bash npx mainsdk check --env prod ``` This resolves `main.env.prod.yaml` and fails with `MAIN-CHECK-0026 local driver in production: secrets` if, say, `secrets.driver` is still `local`. Set a real driver for every interface in `main.env.prod.yaml`, and the same command passes. Run it before you ship and in CI. ## Machine-readable output ```bash npx mainsdk check --json --sarif mainsdk.sarif ``` ```json { "version": 1, "project": "prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY", "env": "dev", "passed": false, "gates": [{ "id": "secrets", "passed": false, "findings": [ { "code": "MAIN-SECRET-0002", "severity": "critical", "file": "app/lib/pay.ts", "line": 1, "rule": "secrets.no_literal", "fix": "Move the key to secrets and use secret:STRIPE_SECRET_KEY" } ] }] } ``` The JSON is versioned and has a published schema. The SARIF file is SARIF 2.1.0, so GitHub code scanning shows each finding inline on the pull request. ## Run it in CI Gates in your own CI are free on every plan. Check the production settings on every pull request: ```yaml title=".github/workflows/mainsdk.yml" name: mainsdk on: [pull_request] permissions: contents: read security-events: write jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22 } - run: npm ci - run: npx mainsdk check --env prod --sarif mainsdk.sarif - uses: github/codeql-action/upload-sarif@v3 if: always() with: { sarif_file: mainsdk.sarif } ``` To fail only on serious findings while you adopt, add `--fail-on `, for example `--fail-on critical`. ## Next - [Use MainSDK with your coding agent](https://mainsdk.dev/docs/getting-started/with-your-agent) - [The check](https://mainsdk.dev/docs/concepts/the-check) - [Check codes](https://mainsdk.dev/docs/reference/check-codes) --- Source: https://mainsdk.dev/docs/getting-started/quickstart-nextjs # Quick start for Next.js > Add MainSDK to a Next.js App Router app, guard one route with auth.require, run it in local mode with a test user and pass mainsdk check. Status: Planned for 0.5 · Canonical: https://mainsdk.dev/docs/getting-started/quickstart-nextjs · Updated: 2026-10-28 You will add MainSDK to a Next.js app, guard one route and run it with no account or key. It takes about 10 minutes. Next.js support covers the App Router. ## 1. Initialise Run this in the root of a Next.js app. It writes `main.yaml` with local drivers and tells your coding agent the rules. ```bash npx mainsdk init ``` ## 2. Mount MainSDK ```ts title="proxy.ts" import { mainsdk } from 'mainsdk/next' export const proxy = mainsdk.proxy() ``` ## 3. Guard a route ```ts title="app/api/me/route.ts" import { auth } from 'mainsdk' import { withMainSDK } from 'mainsdk/next' // One route that needs a signed-in user: anyone else gets a 401 problem with a fix. export const GET = withMainSDK(async (req) => { const session = await auth.require(req) return Response.json({ user: session.user }) }) ``` ## 4. Run it in local mode In local mode the bearer token `dev_ada` stands for a test person called `ada`. It works only on loopback, under `mainsdk dev`. ```bash npx mainsdk dev -- npm run dev curl -i localhost:3000/api/me curl -i -H "Authorization: Bearer dev_ada" localhost:3000/api/me ``` The first call answers `401` with a problem body that carries `MAIN-AUTH-0001` and a fix. The second answers `200` with the user. ## Check it ```bash npx mainsdk check ``` The check passes when it prints `Passed`. It fails on a key in code, an undeclared network call or an unguarded action, and names the fix for each. Run it in CI too. ## What the sample app is The finished app is in the repository under `apps/examples/nextjs`. CI installs it, starts it in local mode, requests the signed-in route and runs `mainsdk check`, so these steps cannot drift from the code. MainSDK is pre-release and no package is published yet: until the first beta, run the sample from a checkout. --- Source: https://mainsdk.dev/docs/getting-started/quickstart-hono # Quick start for Hono > Add MainSDK to a Hono app with one middleware, guard one route with auth.require, run it in local mode with a test user and pass mainsdk check. Status: Planned for 0.5 · Canonical: https://mainsdk.dev/docs/getting-started/quickstart-hono · Updated: 2026-10-28 You will add MainSDK to a Hono app on Node.js 22 or later, guard one route and run it with no account or key. It takes about 10 minutes. ## 1. Initialise ```bash npx mainsdk init ``` ## 2. Write the route The route is a plain `Request` in and `Response` out, so Hono and a test call it the same way. ```ts title="src/routes.ts" import { auth } from 'mainsdk' // The route's logic is a plain Request in, Response out, so Hono, Express and a test all call it the same way. export async function me(req: Request): Promise { const session = await auth.require(req) // 401 with a fix when nobody is signed in return Response.json({ user: session.user }) } ``` ## 3. Mount MainSDK ```ts title="src/app.ts" import { Hono } from 'hono' import { mainsdk } from 'mainsdk/hono' import { me } from './routes.ts' export const app = new Hono() app.use('*', mainsdk()) app.get('/me', (c) => me(c.req.raw)) ``` ```ts title="src/server.ts" import { serve } from '@hono/node-server' import { app } from './app.ts' serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) }) ``` ## 4. Run it in local mode In local mode the bearer token `dev_ada` stands for a test person called `ada`. It works only on loopback, under `mainsdk dev`. ```bash npx mainsdk dev -- node --experimental-strip-types src/server.ts curl -i localhost:3000/me curl -i -H "Authorization: Bearer dev_ada" localhost:3000/me ``` The first call answers `401` with a problem body that carries `MAIN-AUTH-0001` and a fix. The second answers `200` with the user. ## Check it ```bash npx mainsdk check ``` The check passes when it prints `Passed`. It fails on a key in code, an undeclared network call or an unguarded action, and names the fix for each. Run it in CI too. ## What the sample app is The finished app is in the repository under `apps/examples/hono`. CI installs it, starts it in local mode, requests the signed-in route and runs `mainsdk check`, so these steps cannot drift from the code. MainSDK is pre-release and no package is published yet: until the first beta, run the sample from a checkout. --- Source: https://mainsdk.dev/docs/getting-started/quickstart-express # Quick start for Express > Add MainSDK to an Express app with one middleware and one error handler, guard a route with auth.require, run it in local mode and pass mainsdk check. Status: Planned for 0.5 · Canonical: https://mainsdk.dev/docs/getting-started/quickstart-express · Updated: 2026-10-28 You will add MainSDK to an Express 5 app on Node.js 22 or later, guard one route and run it with no account or key. It takes about 10 minutes. ## 1. Initialise ```bash npx mainsdk init ``` ## 2. Write the route ```ts title="src/routes.ts" import { auth } from 'mainsdk' // The route's logic is a plain Request in, Response out, so Express and a test call it the same way. export async function me(req: Request): Promise { const session = await auth.require(req) // 401 with a fix when nobody is signed in return Response.json({ user: session.user }) } // Express speaks Node requests, so hand the route a standard Request with the same headers. export function toRequest(req: { headers: Record originalUrl: string }): Request { const headers = new Headers() for (const [name, value] of Object.entries(req.headers)) { if (typeof value === 'string') headers.set(name, value) } return new Request(`http://${req.headers.host ?? 'localhost'}${req.originalUrl}`, { headers }) } ``` ## 3. Mount MainSDK `mainsdk()` goes first and `mainsdk.errors()` goes last, so a thrown error becomes a problem response. ```ts title="src/app.ts" import express from 'express' import { mainsdk } from 'mainsdk/express' import { me, toRequest } from './routes.ts' export const app = express() app.use(mainsdk()) app.get('/me', async (req, res) => { const out = await me(toRequest(req)) res .status(out.status) .type('application/json') .send(await out.text()) }) app.use(mainsdk.errors()) // last: turns an error into a problem response ``` ```ts title="src/server.ts" import { app } from './app.ts' app.listen(Number(process.env.PORT ?? 3000)) ``` ## 4. Run it in local mode In local mode the bearer token `dev_ada` stands for a test person called `ada`. It works only on loopback, under `mainsdk dev`. ```bash npx mainsdk dev -- node --experimental-strip-types src/server.ts curl -i localhost:3000/me curl -i -H "Authorization: Bearer dev_ada" localhost:3000/me ``` The first call answers `401` with a problem body that carries `MAIN-AUTH-0001` and a fix. The second answers `200` with the user. ## Check it ```bash npx mainsdk check ``` The check passes when it prints `Passed`. It fails on a key in code, an undeclared network call or an unguarded action, and names the fix for each. Run it in CI too. ## What the sample app is The finished app is in the repository under `apps/examples/express`. CI installs it, starts it in local mode, requests the signed-in route and runs `mainsdk check`, so these steps cannot drift from the code. MainSDK is pre-release and no package is published yet: until the first beta, run the sample from a checkout. --- Source: https://mainsdk.dev/docs/getting-started/with-your-agent # Use MainSDK with your coding agent > Use MainSDK from Claude Code, Cursor, Codex or any MCP agent, with AGENTS.md rules, the MCP server, your actions as tools and the migration skill. Canonical: https://mainsdk.dev/docs/getting-started/with-your-agent · Updated: 2026-10-28 > **Get the MainSDK skill.** `SKILL.md` plus a reference of every interface teaches your agent the MainSDK rules. Install it by copying the folder to `.claude/skills/mainsdk/`; later, `npx mainsdk init` will do it (planned). Download: [SKILL.md](https://mainsdk.dev/skill/SKILL.md) and [references/interfaces.md](https://mainsdk.dev/skill/references/interfaces.md). You now have an app that passes `mainsdk check`. Most of its next changes will probably come from a coding agent. MainSDK is built for that: your agent calls short interfaces instead of writing provider code, reads every failure with its fix, and works through the same gates you do. Free checks run on your own agent and keys. MainSDK is pre-release; the commands on this page are the planned design and do not run yet. The CLI and `init` are planned for 0.2, the MCP server's `check` and `logs` tools with them, and the migration skill for 0.5. ## What init sets up `mainsdk init` detects your agent and writes three things: | File or setting | What it does | | --- | --- | | `AGENTS.md` | A MainSDK section, between markers, with the rules: call interfaces, keep keys as `secret:` references, run `mainsdk check` before a pull request | | `CLAUDE.md` | The same section for Claude Code | | MCP server registration | Connects your agent to the MainSDK MCP server, so it can run checks, read logs and drive adoption | If those files already exist, `init` appends its section between markers and leaves the rest alone. ## Skills and rules: what lives where There are two different things, in two different places. | What | Path | Purpose | Agents | | --- | --- | --- | --- | | The MainSDK skill (general) | `.claude/skills/mainsdk/SKILL.md` | How to build with interfaces: call shapes, rules, what to run | Claude Code. Other agents reach it from the `AGENTS.md` section | | The migration skill | `.claude/skills/mainsdk-adopt/SKILL.md` | Bringing an existing app onto MainSDK, one pull request at a time | Claude Code | | The migration skill, as a rule | `.cursor/rules/mainsdk-adopt.mdc` | The same steps | Cursor | | The `AGENTS.md` section | `AGENTS.md` | The short rules, and a pointer to the skill | Cursor, Codex and any agent that reads `AGENTS.md` | If the general skill isn't in `.claude/skills/mainsdk/`, copy its folder there. Everything else is written by `mainsdk init` or fetched by the agent. ## Per agent Claude Code: ```text Reads: CLAUDE.md and AGENTS.md MCP: registered by mainsdk init Skills: .claude/skills/mainsdk/ (general), .claude/skills/mainsdk-adopt/ (migration) ``` Cursor: ```text Reads: AGENTS.md MCP: registered by mainsdk init Rules: .cursor/rules/mainsdk-adopt.mdc (migration) ``` Codex: ```text Reads: AGENTS.md MCP: add the MainSDK MCP server like any MCP server (below) Skill: the AGENTS.md section ``` Any MCP-capable agent works the same way: it reads `AGENTS.md` and connects to the MCP server. ### Register the server by hand For agents `init` doesn't detect, register the server yourself. The command that starts the server is `mainsdk mcp`. This is the planned command; no flags are specified yet. Claude Code: ```bash claude mcp add mainsdk -- npx mainsdk mcp ``` Cursor and other agents that read a JSON config (in Cursor, `.cursor/mcp.json`): ```json title=".cursor/mcp.json" { "mcpServers": { "mainsdk": { "command": "npx", "args": ["mainsdk", "mcp"] } } } ``` For Codex or any other client, use that client's MCP settings with the same command (`npx`) and arguments (`mainsdk mcp`). Run it from your project root so the server reads your `main.yaml`. ## The MCP server The MainSDK MCP server gives your agent the same tools you have on the command line, with structured results it can act on: - **Check:** run the gates and get findings with code, file, line and fix. An agent that gets `MAIN-DATA-0007` reads the fix (`Scope the query with db.orders.for(session.user)`) and applies it. - **Logs and errors:** read local logs and errors in RFC 9457 shape with the trace id, so it can find the cause without guessing. - **Adopt:** `adopt.scan`, `adopt.report`, `adopt.lock`, `adopt.plan_module`, `adopt.verify_module` and `adopt.finish`, used by the migration skill. Everything runs locally. Nothing is sent to MainSDK Cloud unless you run `mainsdk login` and opt in. A typical loop, in your words to the agent: ```text You: Add an endpoint that lists the signed-in user's orders. Agent: (writes app/api/orders/route.ts with db.orders.for(session.user).list()) Agent: (runs check through MCP) all gates passed Agent: Done. The route requires sign-in and only returns the user's own orders. ``` ## Prompts that work Ten copy-paste prompts, each ending with the check to run and what a pass looks like, are on [Prompts that work](https://mainsdk.dev/docs/agents/prompts). ## Your app's actions as agent tools Your own app can serve agents too. Mark an action with `mcp: true` and it becomes an MCP tool on your app, behind the same identity, owner scope, rate limits and policy as the HTTP route. ```ts title="app/actions/refund-order.ts" import { action, db, pay } 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(), reason: s.string() }), risk: "destructive", // an agent must get the person's yes first mcp: true, run: async ({ input, user }) => { const order = await db.orders.for(user).get(input.orderId); return pay.refund(order.paymentId, { reason: input.reason }); }, }); ``` ```yaml title="main.yaml" mcp: expose: [orders.refund, orders.list] confirm: { write: true, destructive: true } auth: { issuer: auth, scopes: [orders:read, orders:refund], token-ttl: 10m } ``` Only marked actions are exposed, and destructive ones always wait for a person's yes. To limit an agent that calls your app, give it a budget in `main.yaml`: ```yaml title="main.yaml" agents: support-bot: budget: { spend: $5/day, calls: 500/day } approve: { risk: destructive, spend-over: $1 } ``` A call over budget fails before it runs, and a step over `spend-over` waits for a person. See the [glossary](https://mainsdk.dev/docs/reference/glossary) for budget. [Actions and MCP](https://mainsdk.dev/docs/concepts/actions-and-mcp) covers confirmation, signed tool descriptions and agent tokens; the API is on [Actions and agent tools](https://mainsdk.dev/docs/interfaces/action). ## The migration skill For an app you already have, the migration skill lets your agent bring it onto MainSDK one pull request at a time, with MainSDK's gates verifying every step. Ask your agent: ```text Bring this app onto MainSDK with the mainsdk-adopt skill. ``` The migration skill lives at `.claude/skills/mainsdk-adopt/` (see the table above). It scans the app, reports the gaps by risk, locks your key flows, migrates one module per pull request and finishes with a launch report. The same steps run without an agent as `mainsdk adopt`, which the next page walks through. ## Next - [Adopt an existing app](https://mainsdk.dev/docs/getting-started/existing-app) - [Actions and MCP](https://mainsdk.dev/docs/concepts/actions-and-mcp) - [Errors and problem details](https://mainsdk.dev/docs/reference/errors) --- Source: https://mainsdk.dev/docs/guides/index # How-to guides > Step-by-step guides by use case - build, store files, add AI, secure and govern, ship and hand over - each ending in a passing mainsdk check. Canonical: https://mainsdk.dev/docs/guides/index · Updated: 2026-10-28 Each guide solves one use case from start to finish: what you need, the steps, how to verify, what to do if it fails, and a prompt for your coding agent. Pick the row that matches what you want to do. ## I want to | Use case | Guide | Interfaces | | --- | --- | --- | | Check whether an interface covers my need | [What is covered](https://mainsdk.dev/docs/guides/what-is-covered) | all | | Build a SaaS, an AI chat, a marketplace or a wallet app | [Stack recipes](https://mainsdk.dev/docs/guides/stack-recipes) | `auth`, `db`, `ai`, `pay`, `files` | | Move an app off raw provider SDKs | [Migrate from raw providers](https://mainsdk.dev/docs/guides/migrate-from-raw-providers) | all | | Move an app off Supabase, Firebase, Clerk, Auth0, Auth.js, Better Auth or Stripe | [Migrate from Supabase](https://mainsdk.dev/docs/guides/migrate-from-supabase), [Firebase](https://mainsdk.dev/docs/guides/migrate-from-firebase), [Clerk](https://mainsdk.dev/docs/guides/migrate-from-clerk), [Auth0](https://mainsdk.dev/docs/guides/migrate-from-auth0), [Auth.js](https://mainsdk.dev/docs/guides/migrate-from-authjs), [Better Auth](https://mainsdk.dev/docs/guides/migrate-from-better-auth), [Stripe](https://mainsdk.dev/docs/guides/migrate-from-stripe) | `auth`, `db`, `pay` | | Start from a tested, production-shaped app | [SaaS web](https://mainsdk.dev/docs/guides/reference-saas-web), [API service](https://mainsdk.dev/docs/guides/reference-api-service), [Payments checkout](https://mainsdk.dev/docs/guides/reference-payments-checkout), [AI agent](https://mainsdk.dev/docs/guides/reference-ai-agent) | all | | Use MainSDK in a monorepo | [Monorepos](https://mainsdk.dev/docs/guides/monorepos) | `config` | | Translate my app | [Translate your app](https://mainsdk.dev/docs/guides/i18n) | `i18n` | | Let users upload files to S3, Uploadthing, Vercel Blob or getqr.id | [Upload files](https://mainsdk.dev/docs/guides/file-uploads) | `files` | | Share a record, file or screen as a link and QR code, print QR codes, or send invite, deep and sign-in links | [Share anything](https://mainsdk.dev/docs/guides/share-anything) | `share`, `links` | | Turn an email or a file into typed data, with a pattern first and a model only when it misses | [Extract structured data](https://mainsdk.dev/docs/guides/extract-structured-data) | `schema`, `ai` | | Add wallet sign-in, token gates or agent payments | [Web3 and agent payments](https://mainsdk.dev/docs/guides/web3-and-agent-payments) | `auth`, `access`, `pay` | | Give an app agent a budget and approvals | [Govern app agents](https://mainsdk.dev/docs/agents/governance) | `ai`, `action` | | Allow only certain hosts, IPs and ranges | [Restrict network access](https://mainsdk.dev/docs/guides/restrict-network) | `network` | | Declare the hosts, folders, facts and programs my app uses | [Declare what your app uses](https://mainsdk.dev/docs/guides/declare-what-your-app-uses) | `network`, `disk`, `system` | | See every call, decision and change, in my own tools | [See what your app does](https://mainsdk.dev/docs/guides/see-what-your-app-does) | `log`, `policy` | | Give enterprise customers SSO, SCIM and an audit log | [Make your app enterprise ready](https://mainsdk.dev/docs/guides/enterprise-ready) | `auth`, `access`, `log`, `privacy` | | Change a setting or roll out a feature without a deploy | [Change config without a deploy](https://mainsdk.dev/docs/guides/change-config-without-a-deploy) | `config`, `flags` | | Create a project, add an interface and check my machine | [Start a project](https://mainsdk.dev/docs/guides/start-a-project) | `config`, `log` | | Give each customer a tenant, API keys, plans and invites | [Build a multi-tenant app](https://mainsdk.dev/docs/guides/build-a-multi-tenant-app) | `tenant`, `auth`, `apikeys`, `entitlements` | | Decide who can do what, and refuse the rest | [Protect a route with permissions](https://mainsdk.dev/docs/guides/protect-a-route-with-permissions) | `access` | | Keep policy files in git and explain every decision | [Keep policies in git](https://mainsdk.dev/docs/guides/keep-policies-in-git) | `policy` | | Call a model with limits and audit | [Call a model](https://mainsdk.dev/docs/guides/call-a-model) | `ai` | | Take a payment on a hosted checkout | [Take a payment](https://mainsdk.dev/docs/guides/take-a-payment) | `pay` | | Expose an action as an endpoint and an MCP tool | [Expose an action to agents](https://mainsdk.dev/docs/guides/expose-an-action-to-agents) | `action` | | Give an agent a budget and approvals in a few lines | [Give an agent a budget](https://mainsdk.dev/docs/guides/govern-an-app-agent) | `agents` | | Let an agent remember each customer and forget them on request | [Give an app agent memory](https://mainsdk.dev/docs/guides/give-an-agent-memory) | `memory`, `ai`, `agents` | | Roll a feature out to some users | [Roll out with flags](https://mainsdk.dev/docs/guides/roll-out-with-flags) | `flags` | | Deploy to Vercel | [Deploy on Vercel](https://mainsdk.dev/docs/guides/deploy-vercel) | `config`, `secrets` | | Use MainSDK from Rust | [Use MainSDK from Rust](https://mainsdk.dev/docs/guides/use-mainsdk-from-rust) | `kv`, `files`, `log` | | Use MainSDK from Python | [Use MainSDK from Python](https://mainsdk.dev/docs/guides/use-mainsdk-from-python) | `db`, `files`, `log` | | Use MainSDK from Go | [Use MainSDK from Go](https://mainsdk.dev/docs/guides/use-mainsdk-from-go) | `db`, `files`, `log` | | Test offline with fakes | [Testing](https://mainsdk.dev/docs/guides/testing) | `testing` | | Find out why something failed | [Debugging](https://mainsdk.dev/docs/guides/debugging) | `log` | | Change provider without changing code | [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver) | all | | Leave MainSDK and keep my code | [Eject](https://mainsdk.dev/docs/guides/eject) | all | | Hand a finished app to a client | [Client handover](https://mainsdk.dev/docs/guides/client-handover) | all | | Invite a team, sign in with my identity provider and understand the bill | [Set up a team on Teams](https://mainsdk.dev/docs/guides/set-up-a-team) | all | | Call my typed API from React components | [Call your API from React](https://mainsdk.dev/docs/guides/use-react-hooks) | `api` | | Try a policy on a request and see what a change would flip | [Try a policy in the playground](https://mainsdk.dev/docs/guides/try-a-policy-in-the-playground) | `policy` | | Publish my actions as a typed API and OpenAPI document | [Publish your actions as a typed API](https://mainsdk.dev/docs/guides/typed-api) | `api` | | Follow, block and mute, with a home timeline that ends and trending per region | [Build a feed with an end](https://mainsdk.dev/docs/guides/build-a-feed-with-an-end) | `feeds` | | Import a customer's spreadsheet, then pull from a partner on a schedule | [Import a customer's spreadsheet](https://mainsdk.dev/docs/guides/import-data) | `imports`, `files` | | Let an agent act in a customer's Gmail or GitHub, with consent | [Let an agent act in a customer's account](https://mainsdk.dev/docs/guides/connect-accounts) | `connections` | | Seed demo data and give each git branch a preview database | [Seed demo data and make previews](https://mainsdk.dev/docs/guides/seed-and-preview) | `testing`, `db` | | Track product events without identifying people | [Track product events](https://mainsdk.dev/docs/guides/track-events) | `analytics` | | Send reminders that bring people back, within limits | [Send engagement reminders](https://mainsdk.dev/docs/guides/engagement-messages) | `engagement`, `notify` | | Send a receipt, a newsletter with unsubscribe and an order update on the channel a person allows | [Send email and notifications](https://mainsdk.dev/docs/guides/send-email-and-notifications) | `email`, `notify` | | Make, read and convert PDFs and Office documents | [Make, read and convert documents](https://mainsdk.dev/docs/guides/documents) | `documents`, `pdf`, `files` | | Add health probes, SLOs and resource limits | [Watch your app's health and resource pressure](https://mainsdk.dev/docs/guides/alerts-and-health) | `status` | | Find out about errors before users do, grouped and alerted | [Know when your app breaks](https://mainsdk.dev/docs/guides/know-when-your-app-breaks) | `log`, `alerts` | | Page the right person once, with routes, grouping and acknowledgement | [Tell the right person once with alerts](https://mainsdk.dev/docs/guides/raise-and-route-alerts) | `alerts` | | Stop a route, an agent or all writes during an incident, without a deploy | [Switch things off in an incident](https://mainsdk.dev/docs/guides/switch-things-off-in-an-incident) | `incident` | | Record consent, export a person's data and erase it with a signed record | [Honour consent, export and erasure requests](https://mainsdk.dev/docs/guides/honour-consent-export-and-erasure) | `privacy` | | Sign people in from a web page | [Add sign-in to a page](https://mainsdk.dev/docs/guides/sign-in-page) | `adapters`, `auth` | | Run CPU-heavy work off the request thread | [Run CPU work on workers](https://mainsdk.dev/docs/guides/run-cpu-work-on-workers) | `workers` | | Use every core for HTTP | [Run one instance per core](https://mainsdk.dev/docs/guides/run-one-instance-per-core) | `cluster` | | Limit, meter and gate by plan | [Limits, metering and entitlements](https://mainsdk.dev/docs/guides/limits-and-metering) | `limits`, `meter`, `entitlements` | | Sell my app with signed licence files that switch features on, checked offline | [License your app](https://mainsdk.dev/docs/guides/license-your-app) | `licence`, `entitlements` | | Issue licences from MainSDK Cloud, with a root, a CA and a second approver | [Issue licences from the Cloud](https://mainsdk.dev/docs/guides/issue-licences-from-the-cloud) | `licence-cloud` | | Sign in to the Cloud, send events, read usage and set a billing cap | [Use MainSDK Cloud](https://mainsdk.dev/docs/guides/cloud-login-usage-and-billing) | `usage-billing` | | Add delete with undo, safe edits and non-blocking migrations | [Change your database safely](https://mainsdk.dev/docs/guides/change-your-database-safely) | `db` | | Keep a draft or count events with an expiry | [Keep short-lived values](https://mainsdk.dev/docs/guides/keep-short-lived-values) | `kv` | | Make pages fast without sharing one customer's data | [Cache safely](https://mainsdk.dev/docs/guides/cache-safely) | `cache` | | Run work in the background with retries | [Run work in the background](https://mainsdk.dev/docs/guides/run-background-jobs) | `jobs`, `events` | | Verify incoming webhooks and send your own | [Send and receive webhooks](https://mainsdk.dev/docs/guides/send-and-receive-webhooks) | `webhooks`, `jobs` | | Sleep, wait and ask for approval in one function | [Build a durable workflow](https://mainsdk.dev/docs/guides/build-a-durable-workflow) | `workflows`, `events`, `agents` | | Show untrusted text safely and send secure headers | [Show untrusted text safely and protect web responses](https://mainsdk.dev/docs/guides/sanitize-and-protect-inbound) | `sanitize`, `inbound` | | Protect a file so only the right people open it | [Protect a file so only the right people open it](https://mainsdk.dev/docs/guides/protect-an-app) | `protect`, `share` | | Share a person's settings or progress between your apps, with consent | [Share context between your apps](https://mainsdk.dev/docs/guides/share-context-between-your-apps) | `context` | | Use behaviour norms in your app | [Use behaviour norms](https://mainsdk.dev/docs/guides/use-norms) | `norms` | | Check a release's checksums, signatures, provenance and SBOM before you install it | [Verify a release](https://mainsdk.dev/docs/guides/verify-a-release) | `release` | | Subscribe to network, access and action events, assert on them in a test, and print them while you debug | [Watch what your app decides](https://mainsdk.dev/docs/guides/watch-what-your-app-decides) | `observe` | | Move an existing app onto MainSDK one pull request at a time | [Adopt an existing app](https://mainsdk.dev/docs/guides/adopt-an-existing-app) | `adopt` | | Resize uploaded images and transcode video in a background job | [Process uploaded images and video](https://mainsdk.dev/docs/guides/process-uploaded-media) | `media`, `files` | | Search your records as the person searching | [Add search to your app](https://mainsdk.dev/docs/guides/add-search) | `search` | | Run a GPU job with a cost limit and keep the result in files | [Run a GPU job](https://mainsdk.dev/docs/guides/run-gpu-jobs) | `compute`, `files` | | Find where your app fails a standard, see owners and deadlines, and keep signed evidence | [Check your app against a standard](https://mainsdk.dev/docs/guides/assure-your-app) | `assure` | | Take reports, decide with a statement of reasons and export the transparency report | [Moderate user content](https://mainsdk.dev/docs/guides/moderate-user-content) | `moderate` | | Detect brute force and probing and send security events to your SIEM | [Watch for security events](https://mainsdk.dev/docs/guides/watch-for-security-events) | `security` | | Give a coding agent the docs, types, next action and fixes | [Work with a coding agent](https://mainsdk.dev/docs/guides/work-with-a-coding-agent) | `mcp`, `docs` | | Rotate a secret without a restart | [Rotate a secret](https://mainsdk.dev/docs/guides/rotate-a-secret) | `secrets` | | Catch made-up, vulnerable and badly licensed packages before they ship | [Check your dependencies](https://mainsdk.dev/docs/guides/check-your-dependencies) | `deps` | | Sign my build, serve its record and check the files at boot | [Seal your build](https://mainsdk.dev/docs/guides/seal-your-build) | `deploy`, `apps` | | Move my app to a new MainSDK release and learn about security releases | [Upgrade MainSDK](https://mainsdk.dev/docs/guides/upgrade-mainsdk) | `upgrade` | | Respond to an attack or CPU pressure | [Respond to an attack or CPU pressure](https://mainsdk.dev/docs/guides/respond-to-attacks-and-pressure) | `signals`, `playbooks` | | Link two apps on one machine and ask a neighbour for relief | [Link two apps on one machine](https://mainsdk.dev/docs/guides/link-two-apps) | `apps`, `neighbours` | | Let a coding agent ask my running app about its state, with every change waiting for me | [Operate your app with a coding agent](https://mainsdk.dev/docs/guides/operate-your-app-with-an-agent) | `ops`, `playbooks` | | Give my customers API keys with scopes, rotation and instant revoke | [Give my customers API keys with scopes, rotation and instant revoke](https://mainsdk.dev/docs/guides/give-your-customers-api-keys) | `apikeys` | | Block paid features and limits by plan | [Block paid features and limits by plan](https://mainsdk.dev/docs/guides/gate-features-by-plan) | `entitlements` | | Record and show what each tenant uses | [Record and show what each tenant uses](https://mainsdk.dev/docs/guides/meter-usage-per-tenant) | `meter` | | Seal data and erase one person everywhere | [Seal data and erase one person everywhere](https://mainsdk.dev/docs/guides/erase-a-user-with-keys) | `keys`, `crypto` | | Send a link that opens the right screen | [Send a link that opens the right screen](https://mainsdk.dev/docs/guides/link-to-a-screen) | `links` | | Require sign-in on my routes | [Require sign-in on my routes](https://mainsdk.dev/docs/guides/require-sign-in) | `auth` | | Run a user's or an agent's code safely | [Run a user's or an agent's code safely](https://mainsdk.dev/docs/guides/run-untrusted-code-in-a-sandbox) | `sandbox` | | Read what Fleet pushed and prove it to an auditor | [Read your Fleet records](https://mainsdk.dev/docs/guides/read-your-fleet-records) | `fleet-prove` | | Emit and react to signals from my app | [Emit and react to signals from my app](https://mainsdk.dev/docs/guides/react-to-signals) | `signals` | | Share a person's settings between your apps, with their consent | [Share context between your apps](https://mainsdk.dev/docs/guides/share-context-between-your-apps) | `context` | | Share attack and pressure signals between apps on one machine | [Share attack and pressure signals between apps on one machine](https://mainsdk.dev/docs/guides/coordinate-apps-on-one-machine) | `neighbours` | | Mount MainSDK in Next.js, Hono, Express or Workers | [Mount MainSDK in Next.js, Hono, Express or Workers](https://mainsdk.dev/docs/guides/mount-mainsdk-in-your-framework) | `adapters` | | Use tcp and udp sockets in your app | [Use tcp and udp sockets](https://mainsdk.dev/docs/guides/use-net) | `net` | | Enrol my devices, see their health and receive their telemetry | [Enrol and watch your devices](https://mainsdk.dev/docs/guides/enrol-and-watch-your-devices) | `device-identity`, `device-attest`, `device-health`, `device-telemetry` | | Send commands and policy to my devices | [Command and configure your devices](https://mainsdk.dev/docs/guides/command-your-devices) | `device-commands`, `device-policy` | | See every app I run and what is exposed | [See every app you run](https://mainsdk.dev/docs/guides/see-your-whole-estate) | `fleet-enrolment`, `fleet-discover`, `fleet-observe` | | Use deploy config and serverless schedules in your app | [Use deploy config and serverless schedules](https://mainsdk.dev/docs/guides/ship-with-deploy-config) | `deploy` | | Use offline-first replication in your app | [Use offline-first replication](https://mainsdk.dev/docs/guides/use-sync) | `sync` | | Use realtime updates in your app | [Use realtime updates](https://mainsdk.dev/docs/guides/use-realtime) | `realtime` | | Use exfiltration guard and canaries in your app | [Use exfiltration guard and canaries](https://mainsdk.dev/docs/guides/use-canary) | `canary` | | Use bot defence in your app | [Use bot defence](https://mainsdk.dev/docs/guides/use-bots) | `bots` | | Use backups and restore drills in your app | [Use backups and restore drills](https://mainsdk.dev/docs/guides/use-backup) | `backup` | | Build a mobile app with Expo | [Build a mobile app with Expo](https://mainsdk.dev/docs/guides/build-a-mobile-app-with-expo) | `targets`, `notify` | | Sign in on a phone | [Sign in on a phone](https://mainsdk.dev/docs/guides/sign-in-on-a-phone) | `auth` | | Ask for an age or an ID | [Ask for an age or an ID](https://mainsdk.dev/docs/guides/ask-for-age-or-id) | `digital-ids` | | Prepare for post-quantum and FIPS | [Prepare for post-quantum and FIPS](https://mainsdk.dev/docs/guides/prepare-for-post-quantum-and-fips) | `crypto` | | Run on Workers, Deno and Bun | [Run on Workers, Deno and Bun](https://mainsdk.dev/docs/guides/run-on-edge-runtimes) | `targets` | | Keep your keys in an HSM or in your customer's own key service | [Keep your keys in an HSM or in your customer's own key service](https://mainsdk.dev/docs/guides/use-hsm-keys) | `keys` | | Keep personal data in the person's own country | [Keep personal data in the person's own country](https://mainsdk.dev/docs/guides/store-personal-data-in-country) | `keys` | | Confirm sensitive actions with a signed approval | [Confirm sensitive actions with a signed approval](https://mainsdk.dev/docs/guides/confirm-actions-with-signed-approvals) | `action` | | Add an industry pack to your app | [Add an industry pack to your app](https://mainsdk.dev/docs/guides/use-industry-packs) | `privacy` | | Publish a driver to the marketplace | [Publish a driver to the marketplace](https://mainsdk.dev/docs/guides/publish-a-driver) | `adapters` | | Send assure results to your GRC, vulnerability and ticket tools | [Connect your security and ticket tools](https://mainsdk.dev/docs/guides/use-connectors) | `connectors` | | Add calendars with recurrence and free/busy | [Use calendars in your app](https://mainsdk.dev/docs/guides/use-calendar) | `calendar` | | Receive a support inbox and thread replies | [Receive and thread email](https://mainsdk.dev/docs/guides/use-mail) | `mail` | | Generate a video as a durable job | [Generate a video](https://mainsdk.dev/docs/guides/generate-a-video) | `ai`, `files` | | Answer phone calls with an AI agent | [Build a voice agent](https://mainsdk.dev/docs/guides/build-a-voice-agent) | `ai`, `agents`, `privacy` | | Let a model propose a playbook for a person to approve | [Propose playbooks with the advisor](https://mainsdk.dev/docs/guides/propose-playbooks-with-the-advisor) | `advisor`, `playbooks` | | Use end-to-end encryption in your app | [Use end-to-end encryption](https://mainsdk.dev/docs/guides/encrypt-end-to-end) | `e2e` | | Use phone, email and identity checks in your app | [Use phone, email and identity checks](https://mainsdk.dev/docs/guides/use-verify) | `verify` | | Use location in your app | [Use location](https://mainsdk.dev/docs/guides/use-geo) | `geo` | | Use app updates and version blocking in your app | [Use app updates and version blocking](https://mainsdk.dev/docs/guides/use-updates) | `updates` | Interface reference pages say what each call does. Integration pages set up one provider. Architecture pages explain how it works inside. These guides join them into a result. --- Source: https://mainsdk.dev/docs/guides/what-is-covered # What MainSDK covers > A map from the needs of a modern app to the MainSDK interfaces, drivers and status that cover them, plus what MainSDK leaves out on purpose. Canonical: https://mainsdk.dev/docs/guides/what-is-covered · Updated: 2026-10-28 Use this page to find which interface covers a need, which drivers sit behind it and how far along it is. It is built from the interface catalog, so every row names a real interface and its real drivers. Where a need has no catalog row yet, the row says so. > **Note:** MainSDK is pre-release. Every interface below is Planned: specified, not built. "Planned for 0.N" means the spec ships it at milestone 0.N. "Milestone [TBD]" means the interface has no milestone yet. Nothing here is Building or Available. ## Identity and sign-in | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Sign-in with passkeys, social, company SSO or WhatsApp codes, and safe sessions | [`auth`](https://mainsdk.dev/docs/interfaces/auth) | `github`, `clerk`, `auth0`, `better-auth`, `workos`, `supabase`, `local` | Planned for 0.3 | Passkeys by default. SAML, OIDC and SCIM are in the same interface. Native passkeys are planned for a later milestone [TBD]. | | Confirm a phone, an email or an identity | [`verify`](https://mainsdk.dev/docs/interfaces/verify) | `bird`, `twilio-verify`, `digilocker`, `onfido`, `persona` | Planned (milestone [TBD]) | No spec yet. | | Wallet sign-in | `auth` with a wallet method | Wallet driver [TBD] | Planned for 0.7 | Spec `web3-ready`. The catalog has no wallet driver yet. See [Wallets and agent payments](https://mainsdk.dev/docs/guides/web3-and-agent-payments). | ## Access and policy | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Decide who can do what to which record | [`access`](https://mainsdk.dev/docs/interfaces/access) | `cedar`, `opa`, `openfga`, `spicedb`, `cerbos`, `permit`, `oso`, `avp` | Planned for 0.3 | Deny by default. Lists are filtered in the database. | | Decide sensitive calls in process from policies in git | [`policy`](https://mainsdk.dev/docs/interfaces/policy) | `cedar`, `opa` | Planned for 0.2 | Observe, warn and enforce modes. | ## Data | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Private records, stored in the owner's region | [`db`](https://mainsdk.dev/docs/interfaces/db) | `postgres`, `neon`, `supabase`, `sqlite` | Planned for 0.3 | `sqlite` is for local use only. Prisma and Drizzle work through adapters. | | Small values and cached results with an expiry | [`kv`](https://mainsdk.dev/docs/interfaces/kv) | `upstash`, `redis`, `vercel-runtime-cache`, `cloudflare-kv` | Planned (milestone [TBD]) | No spec yet. | | Search and vector search | [`search`](https://mainsdk.dev/docs/interfaces/search) | `typesense`, `meilisearch`, `algolia`, `pgvector` | Planned (milestone [TBD]) | No spec yet. Results are limited to the person searching. | | Runtime settings without a deploy | [`config`](https://mainsdk.dev/docs/interfaces/config) | `files`, `vercel-global-config`, `aws-appconfig`, `cloudflare-kv` | Planned for 0.2 | Typed against a schema. | ## Files and uploads | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Upload files, private to their owner and scanned | [`files`](https://mainsdk.dev/docs/interfaces/files) | `getqr`, `s3`, `uploadthing`, `vercel-blob`, `local` | Planned for 0.3 | `s3` also covers R2, MinIO and B2. See [Upload files](https://mainsdk.dev/docs/guides/file-uploads). | | Private, expiring share links and QR codes | [`share`](https://mainsdk.dev/docs/interfaces/share) | `getqr` | Planned for 0.3 | Always getqr.id (ADR-0009). | | Links that open the right screen on web, Android and iOS | [`links`](https://mainsdk.dev/docs/interfaces/links) | `getqr` | Planned (milestone [TBD]) | No spec yet. | | Resize images, transcode video | [`media`](https://mainsdk.dev/docs/interfaces/media) | `getqr`, `cloudinary`, `mux` | Planned (milestone [TBD]) | No spec yet. | | Render and sign PDFs | [`pdf`](https://mainsdk.dev/docs/interfaces/pdf) | `builtin`, `docraptor`, `documenso` | Planned (milestone [TBD]) | No spec yet. | | Protected files that can be revoked | [`protect`](https://mainsdk.dev/docs/interfaces/protect) | `taps-node`, `taps-hosted`, `sovedocs` | Planned for 1.6 | Stability experimental. | ## Payments | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Cards and subscriptions on a hosted checkout | [`pay`](https://mainsdk.dev/docs/interfaces/pay) | `stripe`, `razorpay`, `paddle`, `local` | Planned for 0.3 | No card data in your app. Routed by market. | | UPI in India | `pay` | `razorpay` | Planned for 0.3 | `payments: { driver: razorpay, methods: [upi, cards] }` in the India overlay. | | Agent payments inside an agent budget | `pay` with an agent-payment driver | Driver [TBD] | Planned for 0.7 | Spec `web3-ready`. Not yet a catalog call. | | Usage metering | [`meter`](https://mainsdk.dev/docs/interfaces/meter) | `stripe-meters`, `lago` | Planned (milestone [TBD]) | No spec yet. | ## Email, notifications and WhatsApp | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Transactional and marketing email | [`email`](https://mainsdk.dev/docs/interfaces/email) | `resend`, `ses`, `local`, `postmark`, `bird`, `smtp` | Planned for 0.3 | One-click unsubscribe, no tracking pixels by default. | | Push, WhatsApp, SMS and email with preferences | [`notify`](https://mainsdk.dev/docs/interfaces/notify) | `whatsapp-cloud`, `bird`, `msg91`, `twilio`, `local`, `fcm`, `apns`, `expo`, `resend`, `ses`, `onesignal` | Planned for 0.3 | WhatsApp only with recorded consent and approved templates. Push through FCM, APNs and Expo is planned for milestone 1.1. | ## Realtime | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Live updates to connected clients | [`realtime`](https://mainsdk.dev/docs/interfaces/realtime) | `pusher`, `ably`, `supabase-realtime` | Planned (milestone [TBD]) | No spec yet. Collaboration and presence come later [TBD]. | ## AI and LLMs | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Call a model with keys out of code, spend limits per user and an audit | [`ai`](https://mainsdk.dev/docs/interfaces/ai) | `bedrock`, `anthropic`, `openai`, `gateway`, `local` | Planned for 0.3 | Streaming, typed output, spend limits and the prompt-injection check are in the spec. | | Embeddings | `ai` | Same drivers | Planned for 0.3 | `ai.embed()`. | | Tools a model may call | [`action`](https://mainsdk.dev/docs/interfaces/action) | `mcp`, `http`, `server-action`, `cli` | Planned for 0.3 | `ai.tool()` only takes actions declared with `action()`. | | Budgets, approvals and a kill switch for app agents | Spec `agent-governance` | None | Planned for 0.3 | A `main.yaml` key, not an interface in the catalog. See [Agents with an identity, a budget and approvals](https://mainsdk.dev/docs/agents/governance). | ## MCP server and client | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Expose actions as MCP tools | [`action`](https://mainsdk.dev/docs/interfaces/action) | `mcp`, `http`, `server-action`, `cli` | Planned for 0.3 | OAuth 2.1 on the endpoint, signed tool descriptions, a person's yes for destructive actions. | | Call other MCP servers from your app | Spec `mcp-client` | None | Planned for 0.3 | `mcp.client(name)`, stability experimental. No catalog row yet. | ## Background jobs, workflows and webhooks | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Events and background jobs that retry and never run twice | [`jobs`](https://mainsdk.dev/docs/interfaces/jobs) | `inngest`, `vercel-queues`, `local`, `trigger-dev`, `sqs` | Planned for 0.3 | `trigger-dev` and `sqs` are deferred to the driver kit. Multi-step workflows as a separate interface are not in the catalog. | | Send and receive signed webhooks | [`webhooks`](https://mainsdk.dev/docs/interfaces/webhooks) | `builtin`, `svix` | Planned for 0.3 | Replays older than 5 minutes are refused. | ## Feature flags, analytics and i18n | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Feature flags per user or market | [`flags`](https://mainsdk.dev/docs/interfaces/flags) | `openfeature`, `posthog`, `vercel-flags` | Planned (milestone [TBD]) | No spec yet. Targeting stays in your tool. | | Product analytics with consent | [`analytics`](https://mainsdk.dev/docs/interfaces/analytics) | `posthog`, `plausible`, `google-analytics`, `segment`, `mainsdk` | Planned (milestone [TBD]) | No spec yet. | | Active users by country and version | [`analytics`](https://mainsdk.dev/docs/interfaces/analytics) (`analytics.query`) | `builtin` | Planned for 0.3 (spec `analytics`) | Folded into analytics (ADR-0031). | | Translation and locale formats | [`i18n`](https://mainsdk.dev/docs/interfaces/i18n) | `files`, `tolgee`, `crowdin`, `lokalise`, `ai-draft` | Planned (milestone [TBD]) | No spec yet. See [Languages and formats](https://mainsdk.dev/docs/guides/i18n). | | Coarse location hint | [`geo`](https://mainsdk.dev/docs/interfaces/geo) | `platform-headers`, `mapbox`, `google-maps` | Planned (milestone [TBD]) | Never used for access. | | Update prompts and version blocking | [`updates`](https://mainsdk.dev/docs/interfaces/updates) | `expo-updates`, `app-store`, `google-play`, `tauri` | Planned (milestone [TBD]) | No spec yet. | | Feedback and support tickets | [`feedback`](https://mainsdk.dev/docs/interfaces/feedback) | `builtin`, `intercom`, `zendesk`, `plain` | Planned (milestone [TBD]) | No spec yet. | ## Observability and audit | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Logs with redaction, a tamper-evident audit log, OpenTelemetry export | [`log`](https://mainsdk.dev/docs/interfaces/log) | `terminal`, `otel`, `mainsdk` | Planned for 0.2 | Nothing goes to MainSDK Cloud without login and opt-in. | | Health checks and a status page | [`status`](https://mainsdk.dev/docs/interfaces/status-page) | `builtin`, `provider` | Planned (milestone [TBD]) | No spec yet. | | Backups and timed restore drills | [`backup`](https://mainsdk.dev/docs/interfaces/backup) | `neon-pitr`, `supabase-pitr`, `pgbackrest`, `aws-backup`, `object-lock` | Planned for 1.2 | The provider runs backups, not MainSDK. | ## Secrets, keys and security | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Provider keys from your secrets manager, never from code | [`secrets`](https://mainsdk.dev/docs/interfaces/secrets) | `vault`, `aws-secrets-manager`, `gcp-secret-manager`, `azure-key-vault`, `doppler`, `infisical`, `env`, `local` | Planned for 0.2 | A key in code fails the check. | | Encryption keys, per-user keys and crypto-erasure | [`keys`](https://mainsdk.dev/docs/interfaces/keys) | `aws-kms`, `gcp-kms`, `azure-keyvault`, `vault-transit`, `local` | Planned for 0.3 | Chain signing profiles are spec `web3-ready`, planned for 0.7. | | Encrypt fields, sign, hash passwords | [`crypto`](https://mainsdk.dev/docs/interfaces/crypto) | `cloud-kms`, `vault-transit`, `libsodium` | Planned (milestone [TBD]) | No spec yet. | | Allow outbound calls only to declared hosts | [`network`](https://mainsdk.dev/docs/interfaces/network) | `builtin` | Planned for 0.3 | Deny by default. | | Rate limits, locks and quotas | [`limits`](https://mainsdk.dev/docs/interfaces/limits) | `builtin`, `upstash`, `arcjet` | Planned (milestone [TBD]) | No spec yet. | ## Privacy and erasure | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Export or erase a person's data, record consent | [`privacy`](https://mainsdk.dev/docs/interfaces/privacy) | `builtin` | Planned for 0.3 | Erasure is confirmed by every driver, with a signed record. | ## Testing | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Test against fakes of every interface, control time, inject outages | [`testing`](https://mainsdk.dev/docs/interfaces/testing) | `fakes` | Planned for 0.4 | `createTestApp()` from `@mainsdk/testing`. See [Testing](https://mainsdk.dev/docs/guides/testing). | ## Deploy on Vercel MainSDK is a library, not a host, so there is no deploy interface. Vercel is covered by guidance and by drivers: `vercel-queues` for `jobs`, `vercel-global-config` for `config`, `vercel-runtime-cache` for `kv`, `vercel-flags` for `flags` and `vercel-blob` for `files`. See [Deploy on Vercel](https://mainsdk.dev/docs/guides/deploy-vercel). ## Mobile (Expo) | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | An Expo app with sign-in, consent and push | Spec `expo-adapter` | `expo`, `fcm`, `apns` (for `notify`), `expo-updates` (for `updates`) | Planned for 1.1 | Imports from `mainsdk/expo`. An adapter, not an interface. | ## Web3 | Need | Interface | Drivers | Status | Notes | | --- | --- | --- | --- | --- | | Wallet sign-in, token gates, chain signing keys, agent payments | Spec `web3-ready` across `auth`, `access`, `keys` and `pay` | Chain and wallet drivers [TBD] | Planned for 0.7 | Stability experimental. No Web3 interface in the catalog. Chains are drivers (ADR-0011). See [Wallets and agent payments](https://mainsdk.dev/docs/guides/web3-and-agent-payments). | ## Not covered, by design - **Hosting.** MainSDK runs inside your app. You deploy on Vercel or anywhere else. - **UI components.** MainSDK ships no design system. The only components are ``, `` and `` from `@getqr/react`. - **ORM design.** `db` enforces owner scope, labels and regions. You keep Prisma or Drizzle through adapters; MainSDK does not replace them. - **A CMS.** There is no content-management interface. - **Chain RPC, indexing and contract tooling.** Use your own library through `raw()` (ADR-0011). - **Custody and token issuance.** MainSDK never holds funds or private keys and never issues tokens. - **Running your backups.** The provider does that; `backup` only declares and proves it. ## Covered by escape hatch When a need falls outside an interface, `raw()` returns the provider's own client: `auth.raw()`, `db.raw()`, `files.raw()`, `pay.raw()`, `email.raw()`, `ai.raw()` and `access.raw()`. Every use is logged and listed by `mainsdk check`, so you always know where your app depends on one provider. ## Next - [Recipes by app type](https://mainsdk.dev/docs/guides/stack-recipes) - [Interfaces and drivers](https://mainsdk.dev/docs/concepts/interfaces-and-drivers) - [Quickstart](https://mainsdk.dev/docs/getting-started/quickstart) --- Source: https://mainsdk.dev/docs/guides/stack-recipes # Recipes by app type > Seven end-to-end MainSDK recipes for SaaS, AI chat, marketplace, realtime, Expo, wallet and AI with uploads, each with main.yaml, code and a verify command. Canonical: https://mainsdk.dev/docs/guides/stack-recipes · Updated: 2026-10-28 Pick the recipe closest to your app, copy its `main.yaml` keys and adapt the code. Each recipe lists the interfaces it uses, a minimal `main.yaml`, the key code and the command that verifies it. They use Pinecart, the Next.js orders app from the [quickstart](https://mainsdk.dev/docs/getting-started/quickstart), and the reference stack: GitHub sign-in, Anthropic for AI (Amazon Bedrock if you run on AWS), Postgres for data, Cedar for access and getqr.id for files and links. > **Note:** MainSDK is pre-release. Every interface here is planned, so the commands do not run yet. Each recipe says which milestone it needs. Where no spec defines a call shape or a field, the recipe says [TBD] instead of guessing. [What MainSDK covers](https://mainsdk.dev/docs/guides/what-is-covered) lists every status. Every recipe verifies the same way, and adds the test it names: ```bash npx mainsdk check ``` ```text $ npx mainsdk check mainsdk check · notes · env dev · Main 27 ✓ config ✓ secrets ✓ policy ✓ network ✓ auth ✓ data Passed · 6 gates · 0 findings · 1.2s ``` Add `--env prod` to check the production layer before you deploy. ## a. SaaS with sign-in, orders, payments, email and a background job Planned for milestone 0.3 (specs `auth`, `db`, `pay`, `email-notify`, `webhooks-jobs`, `access`). **Interfaces:** [`auth`](https://mainsdk.dev/docs/interfaces/auth), [`db`](https://mainsdk.dev/docs/interfaces/db), [`access`](https://mainsdk.dev/docs/interfaces/access), [`pay`](https://mainsdk.dev/docs/interfaces/pay), [`email`](https://mainsdk.dev/docs/interfaces/email), [`jobs`](https://mainsdk.dev/docs/interfaces/jobs), [`secrets`](https://mainsdk.dev/docs/interfaces/secrets). ```yaml title="main.yaml" schema: 1 project: prj_01JC8Q4N7V3X9B2D5F6G8H0JKM baseline: main27 secrets: { driver: env } auth: { driver: github, methods: [passkeys] } data: { store: postgres, residency: { default: eu } } db: driver: postgres regions: { eu: secret:DATABASE_URL_EU } access: { driver: cedar, models: [rbac, abac] } payments: { driver: stripe } pay: stripe: { key: secret:STRIPE_SECRET_KEY, webhook: secret:STRIPE_WEBHOOK_SECRET } email: { driver: resend, key: secret:RESEND_API_KEY } jobs: { driver: inngest, dead-letter: true } idempotency: { store: db, ttl: 24h } overlays: { order: [env] } ``` Create the order, start hosted checkout, then react to the provider's verified webhook: ```ts title="app/api/orders/route.ts" import { auth, models, pay, s } from "mainsdk"; const { orders } = models({ orders: s.object({ item: s.string(), total: s.int() }), }); // declared once in lib/orders.ts export async function POST(req: Request) { const session = await auth.require(req); const input = (await req.json()) as { item: string; total: number; cartId: string }; const order = await orders .for(session.user) .create({ item: input.item, total: input.total }, { idempotencyKey: input.cartId }); const checkout = await pay.checkout({ order: order.id, amount: 125000, // minor units currency: "INR", successUrl: "/orders/done", cancelUrl: "/cart", }); return Response.json(checkout); // { url } or { clientSecret } } ``` ```ts title="lib/payments.ts" import { email, jobs, pay } from "mainsdk"; pay.on("payment.succeeded", async (event) => { // the fields of event are [TBD]; the order id and customer email come from it await email.send({ to: "[TBD]", template: "receipt", data: { orderId: "[TBD]" } }); await jobs.run("orders.fulfil", { orderId: "[TBD]" }); // retries with backoff, dead-letter queue on }); ``` Where you register the handler for a job name is [TBD]. A repeated idempotency key never writes or charges twice. **Verify:** `npx mainsdk check --env prod`, then a test with `createTestApp()` that asserts `mailbox(app).toHaveSent({ template: "receipt" })` (see [Testing kit](https://mainsdk.dev/docs/interfaces/testing); the kit is planned for milestone 0.4). Name the test with the criterion id of the spec it covers. ## b. AI chat with a per-user spend limit, streaming and typed output Planned for milestone 0.3 (spec `ai`). A RAG variant needs `search`, which is planned for roadmap phase 2 with no spec and no milestone [TBD]. **Interfaces:** [`ai`](https://mainsdk.dev/docs/interfaces/ai), [`auth`](https://mainsdk.dev/docs/interfaces/auth), [`limits`](https://mainsdk.dev/docs/interfaces/limits) for request rates, and for RAG [`search`](https://mainsdk.dev/docs/interfaces/search). ```yaml title="main.yaml" ai: driver: anthropic # bedrock needs an AWS account; gateway is the alternative key: secret:ANTHROPIC_API_KEY models: [anthropic/claude-*] data: { training: never, retention: zero } redact: [personal, sensitive, child, phi] limits: { per-user: $1/day, per-feature: { chat: $200/month }, total: $500/month } label: true ``` Stream a reply. The per-user spend limit is checked before the call and stops the stream when reached, and one audit event is written when the stream ends or aborts. `prompt` is one instruction as a string; `messages` is a conversation of role and content turns. The route writes NDJSON, one `Chunk` per line: ```ts title="app/api/chat/route.ts" import { ai, auth, MainError } from "mainsdk"; export async function POST(req: Request) { const session = await auth.require(req); const { messages } = (await req.json()) as { messages: { role: "user" | "assistant"; content: string }[] }; const stream = ai.stream({ model: "anthropic/claude-sonnet-4-5", feature: "chat", user: session.user, messages, maxCost: "$0.05", }); const encoder = new TextEncoder(); const body = new ReadableStream({ async start(controller) { try { for await (const chunk of stream) controller.enqueue(encoder.encode(JSON.stringify(chunk) + "\n")); // { type: "text", text } or { type: "done", usage, cost } } catch (err) { if (err instanceof MainError && err.code === "MAIN-AI-0007") { controller.enqueue(encoder.encode(JSON.stringify({ type: "limit" }) + "\n")); // this app's own signal } else { throw err; } } controller.close(); }, }); return new Response(body, { headers: { "content-type": "application/x-ndjson" } }); } ``` The browser reads the stream, appends each text chunk and stops on `done`: ```ts title="lib/read-chat.ts" export async function readChat( messages: { role: string; content: string }[], onText: (text: string) => void, ) { const res = await fetch("/api/chat", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ messages }), }); if (!res.body) throw new Error("no stream"); const reader = res.body.pipeThrough(new TextDecoderStream()).getReader(); let buffer = ""; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += value; const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; for (const line of lines) { if (!line) continue; const chunk = JSON.parse(line); if (chunk.type === "text") onText(chunk.text); if (chunk.type === "done") return chunk; // { usage, cost } if (chunk.type === "limit") throw new Error("limit reached"); } } } ``` Ask for typed output with `schema`. The reply is checked against it, retried once, and fails with `MAIN-AI-0011` if it still does not match. `value` holds the typed result. Treat model output as untrusted input to your app either way. ```ts title="app/api/tag/route.ts" import { ai, auth } from "mainsdk"; import { s } from "mainsdk/schema"; export async function POST(req: Request) { const session = await auth.require(req); const { text } = (await req.json()) as { text: string }; const result = await ai.generate({ model: "anthropic/claude-sonnet-4-5", feature: "tagging", user: session.user, prompt: `Tag this support message: ${text}`, schema: s.object({ tags: s.array(s.string()) }), }); return Response.json(result.value); } ``` **RAG variant (planned, [TBD]).** Embed documents with `ai.embed`, index them in `search` with the `pgvector` driver, query for the signed-in person, and pass the hits to the model. `search` has no spec yet, so `search.index()` arguments and whether a query takes text or a vector are [TBD]. ```yaml title="main.yaml" search: driver: pgvector ``` ```ts title="lib/rag.ts" import { ai, search } from "mainsdk"; export async function answer(question: string, user: { type: "user"; id: string }) { const hits = await search.query({ index: "notes", q: question, for: user, limit: 5 }); // results are limited to what this person may see return ai.generate({ model: "anthropic/claude-sonnet-4-5", feature: "support-answer", user, prompt: `Answer from these notes only:\n${JSON.stringify(hits)}\n\nQuestion: ${question}`, }); } ``` **Verify:** `npx mainsdk check --env prod`, plus a test with `createTestApp()` on the local fake: a zero limit (`limits: { per-user: $0/day }`) makes `ai.AC4` deterministic, as on [AI calls](https://mainsdk.dev/docs/interfaces/ai). ## c. Marketplace or creator app with uploads, payments, notifications and share links Planned for milestone 0.3 (specs `files-share`, `pay`, `email-notify`). `links` has no spec yet. **Interfaces:** [`files`](https://mainsdk.dev/docs/interfaces/files), [`share`](https://mainsdk.dev/docs/interfaces/share), [`pay`](https://mainsdk.dev/docs/interfaces/pay), [`notify`](https://mainsdk.dev/docs/interfaces/notify), [`db`](https://mainsdk.dev/docs/interfaces/db), [`auth`](https://mainsdk.dev/docs/interfaces/auth). ```yaml title="main.yaml" files: { driver: getqr, key: secret:GETQR_API_KEY, scan: true, max-size: 25MB } share: { default-expiry: 7d, max-expiry: 90d } payments: { in: razorpay, default: stripe } pay: stripe: { key: secret:STRIPE_SECRET_KEY, webhook: secret:STRIPE_WEBHOOK_SECRET } razorpay: { key: secret:RAZORPAY_KEY, webhook: secret:RAZORPAY_WEBHOOK_SECRET, methods: [upi, cards] } notify: channels: [push, whatsapp, sms, email] sms: msg91 whatsapp: whatsapp-cloud quiet: { from: "22:00", to: "08:00" } ``` A creator uploads a work, shares a private link and a buyer is notified: ```ts title="app/api/works/[id]/share/route.ts" import { auth, models, notify, s, share } from "mainsdk"; const { works } = models({ works: s.object({ title: s.string() }), }); // declared once in lib/works.ts export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const session = await auth.require(req); const work = await works.for(session.user).get(id); const { url, qr } = await share({ url: `https://app.example.com/works/${work.id}`, title: work.title }, { expiresIn: "7d", access: "read" }); // a getqr.id link await notify(session.user, { template: "work.shared", data: { workId: work.id }, category: "orders" }); return Response.json({ url, qr }); } ``` For the upload itself, follow [Upload files with S3, Uploadthing or Vercel Blob](https://mainsdk.dev/docs/guides/file-uploads); for the checkout, recipe a. Links are private, expiring and read-only, and a share sheet shows "Powered by getqr.id". **Verify:** `npx mainsdk check --env prod`. Test the link as spec `files-share` AC5 does: the host is `getqr.id`, the path holds no personal data, and the link stops working after seven days or after `share.revoke`. ## d. Realtime collaboration `realtime` is planned with no milestone yet and no spec [TBD]. Real-time collaboration and presence comes later, in roadmap phase 5 (spec `platforms`). Treat this recipe as the planned shape only. **Interfaces:** [`realtime`](https://mainsdk.dev/docs/interfaces/realtime), [`auth`](https://mainsdk.dev/docs/interfaces/auth), [`access`](https://mainsdk.dev/docs/interfaces/access). ```yaml title="main.yaml" realtime: driver: pusher ``` ```ts title="app/api/boards/[id]/move/route.ts" import { auth, realtime } from "mainsdk"; export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; await auth.require(req); const move = (await req.json()) as { card: string; column: string }; await realtime.publish(`board:${id}`, "moved", move); // channel access is checked per user and tenant return Response.json({ ok: true }); } ``` On the client, `realtime.subscribe()` joins a channel. Its arguments are [TBD]. Presence and conflict handling are not in the catalog [TBD]. **Verify:** `npx mainsdk check --env prod`. The kit's fake for `realtime` and its assertions are [TBD]. ## e. Mobile app on Expo Planned for milestone 1.1 (spec `expo-adapter`, stability beta). **Interfaces:** [`notify`](https://mainsdk.dev/docs/interfaces/notify) with push through Expo, FCM and APNs, [`auth`](https://mainsdk.dev/docs/interfaces/auth), [`updates`](https://mainsdk.dev/docs/interfaces/updates). ```yaml title="main.yaml" targets: web: { framework: nextjs } mobile: { framework: expo, platforms: [ios, android], bundleId: dev.acme.orders } notify: push: { drivers: [fcm, apns], via: expo } # or fcm and apns directly ``` ```text title="app/_layout.tsx (planned API)" import { MainProvider } from "mainsdk/expo"; // wraps the app; loads the core export default function Layout({ children }: { children: React.ReactNode }) { return {children}; } ``` ```text title="lib/push.ts (planned API)" import { notify } from "mainsdk"; await notify.push.register(); // asks permission, stores the token await notify(user, { template: "order.shipped", category: "transactional" }); ``` `mainsdk/expo` also exports `useSession()` and `useConsent()`. The overlay dimension `target` (web, mobile) resolves at build time. How the `MainProvider` props look is [TBD]. **Verify:** `npx mainsdk check --env prod`. Device tests are [TBD]. ## f. Wallet and onchain app Planned for milestone 0.7 (spec `web3-ready`, ADR-0011, stability experimental). Chains are drivers, and core has no chain code. MainSDK holds no funds or keys. **Interfaces:** [`auth`](https://mainsdk.dev/docs/interfaces/auth) with a wallet method, [`access`](https://mainsdk.dev/docs/interfaces/access) with an external attribute, [`keys`](https://mainsdk.dev/docs/interfaces/keys), [`pay`](https://mainsdk.dev/docs/interfaces/pay), and an agent budget (spec `agent-governance`, milestone 0.3). ```yaml title="main.yaml" auth: { methods: [passkeys, wallet], wallet: { driver: siwe } } access: { attributes: { token.balance: { driver: evm-balance } } } agents: trader-bot: budget: { spend: $5/day, calls: 500/day } approve: { spend-over: $1 } pay: { payees: { weather-api: { host: api.weather.example, max: $0.05/call } } } ``` ```ts title="app/api/auth/wallet/verify/route.ts" import { auth } from "mainsdk"; export async function POST(req: Request) { const { message, signature } = (await req.json()) as { message: string; signature: string }; const session = await auth.challenge.verify({ message, signature }); // MAIN-AUTH-0040 if the challenge was used before return Response.json({ ok: true }); } ``` An agent pays with `pay.agent({ agent: "trader-bot", amount: "$0.04", to: "weather-api" })`: the payee must be declared, its host allowed by `network`, and a payment above `spend-over` waits for a person. The whole flow, the token gate, chain key profiles and agent payments are in [Wallets, token gates and agent payments](https://mainsdk.dev/docs/guides/web3-and-agent-payments). **Verify:** `npx mainsdk check --env prod`, then tests named `web3-ready.AC1` to `web3-ready.AC5`. ## g. AI chat with an avatar upload, one complete main.yaml Planned for milestone 0.3 (specs `auth`, `db`, `ai`, `files-share`). Sign-in with GitHub and passkeys, Postgres, Anthropic for chat and an S3 bucket for avatars, in one file. **Interfaces:** [`auth`](https://mainsdk.dev/docs/interfaces/auth), [`db`](https://mainsdk.dev/docs/interfaces/db), [`ai`](https://mainsdk.dev/docs/interfaces/ai), [`files`](https://mainsdk.dev/docs/interfaces/files), [`secrets`](https://mainsdk.dev/docs/interfaces/secrets). ```yaml title="main.yaml" schema: 1 project: prj_01JC8Q4N7V3X9B2D5F6G8H0JKM baseline: main27 secrets: { driver: env } auth: { driver: github, methods: [passkeys] } db: driver: postgres regions: { eu: secret:DATABASE_URL_EU } ai: driver: anthropic key: secret:ANTHROPIC_API_KEY models: [anthropic/claude-*] data: { training: never, retention: zero } limits: { per-user: $1/day, total: $500/month } files: driver: s3 bucket: acme-avatars region: eu-west-1 access-key-id: AKIA… # an identifier, not a secret; on AWS omit it and key to use the runtime's IAM role key: secret:S3_SECRET_ACCESS_KEY scan: true overlays: { order: [env] } ``` The chat route is the streaming route from recipe b: `POST /api/chat` calls `ai.stream({ ..., feature: "chat", user })` and the browser reads the NDJSON with `readChat`. The upload takes two routes. The first issues a signed, single-use upload target: ```ts title="app/api/uploads/route.ts" import { auth, files } from "mainsdk"; export async function POST(req: Request) { const session = await auth.require(req); const upload = await files.uploadUrl({ owner: session.user, maxSize: "2MB", types: ["image/png", "image/jpeg"], }); return Response.json(upload); // { method, url, headers?, fields?, ref } } ``` The browser sends the file to `upload.url` with `upload.method` (for `PUT`, the bytes as the body with `headers`; for `POST`, a form with `fields` first and the file last), then asks the second route to finish: ```ts title="app/api/uploads/[id]/complete/route.ts" import { auth, files } from "mainsdk"; export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const session = await auth.require(req); const ref = await files.complete(id, { owner: session.user }); // only the uploader can finish it const url = await files.url(ref, { for: session.user, expiresIn: "1h" }); return Response.json({ url }); } ``` **Verify:** `npx mainsdk check --env prod`. A passing run prints `all gates passed · exit 0`. **In `main.env.dev.yaml`** the same app runs offline on local drivers, and `mainsdk check --env prod` refuses them if they reach production (`MAIN-CHECK-0026`): ```yaml title="main.env.dev.yaml" secrets: { driver: local } auth: { driver: local } db: { driver: sqlite } ai: { driver: local } files: { driver: local } ``` The local driver options (file paths and so on) are [TBD]. See [Upload files with S3, Uploadthing or Vercel Blob](https://mainsdk.dev/docs/guides/file-uploads) for the upload flow in full. ## Next - [What MainSDK covers](https://mainsdk.dev/docs/guides/what-is-covered) - [Deploy on Vercel](https://mainsdk.dev/docs/guides/deploy-vercel) - [Testing](https://mainsdk.dev/docs/guides/testing) --- Source: https://mainsdk.dev/docs/guides/start-a-project # Start a project and add interfaces > Create main.yaml, add an interface with its local driver and a test, and check the machine and project. Status: Planned for 0.3 · Canonical: https://mainsdk.dev/docs/guides/start-a-project · Updated: 2026-10-28 You will create a MainSDK project, add the interfaces you need one at a time, and check that the machine and the project are healthy. It takes about 10 minutes. ## You need - Node 22.18 or later and a repository for your app. ## 1. Create the project ```bash npx mainsdk init ``` This writes `main.yaml` with a project id and the local `auth`, `db` and `files` drivers, logging and a deny-by-default network (the default `indie` preset; `--preset minimal` writes only the four required keys), and adds `.mainsdk/` to `.gitignore`. Running it again in the same repository exits 2 unless you pass `--force` or `--new-id`. ## 2. Add an interface ```bash npx mainsdk add auth ``` `add` works for `auth`, `db`, `files`, `network`, `log`, `ai` and `pay`. It sets the interface's local driver in `main.yaml`, writes one example test and runs `check`. It never weakens a key that is already set. ## 3. Check the machine and the project ```bash npx mainsdk doctor ``` `doctor` checks the Node version, that `main.yaml` loads and that `.mainsdk/` is ignored. It prints one `problem` line for each failure and exits 1. ## 4. Find a command ```bash npx mainsdk --help ``` `mainsdk --help` (or `mainsdk -h`, or `mainsdk` alone) prints every command by group; `mainsdk help ` prints the usage, flags, examples and exit codes of one. ## Check it worked `npx mainsdk doctor` prints `mainsdk doctor: all good` and `npx mainsdk check` passes. ## If it fails | Output | Fix | | --- | --- | | `Node ... is too old` | Install Node 22.18 or later | | `No main.yaml` | Run `npx mainsdk init` | | `.mainsdk/ is not in .gitignore` | Add `.mainsdk/` to `.gitignore` | ## Next - [Declare what your app uses](https://mainsdk.dev/docs/guides/declare-what-your-app-uses) - [CLI reference](https://mainsdk.dev/docs/reference/cli) --- Source: https://mainsdk.dev/docs/guides/build-a-multi-tenant-app # Build a multi-tenant app > Give every customer a tenant, issue API keys, gate features by plan and invite teammates, with isolation enforced for you. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/build-a-multi-tenant-app · Updated: 2026-10-28 You will turn on tenancy, let people create organisations and invite teammates, issue API keys for customer code, and gate a feature by plan. It takes about 30 minutes. The calls run on the in-memory fakes in tests, with the tenant directory held in memory. ## You need - A MainSDK app with `auth` and `db` (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)). - A `pay` subscription, if you gate by plan. ## 1. Turn on tenancy ```yaml title="main.yaml" tenancy: isolation: shared resolve: [apikey, session, subdomain] personal: true roles: [owner, admin, member, viewer] system: { allow: [src/admin/**, scripts/**] } ``` Each person gets a personal tenant at first sign-in. Every request now has a tenant, and every `db` query is scoped to it. Never pass a tenant id by hand. Organisations and invites also need `auth` configured (a key and a method, see [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth)). ## 2. Create an organisation and invite a teammate ```ts title="app/api/orgs/route.ts" import { auth } from "mainsdk"; const org = await auth.orgs.create({ name: "Acme", region: "eu" }); // the creator becomes owner await auth.invites.create({ email: "jane@acme.com", roles: ["member"] }); ``` `auth.invites.create()` acts on the current tenant, so the caller must be an owner or admin of an organisation (a personal tenant refuses invites). The invite is a getqr.id link, bound to Jane's email address, single use, and expires in 7 days. ## 3. Read the tenant in your code ```ts title="app/api/orders/route.ts" import { auth, models, s, tenant } from "mainsdk"; const { orders } = models({ orders: s.object({ item: s.string(), total: s.int() }), }); // declared once in lib/orders.ts, shown inline so the example stands alone export async function GET(req: Request) { const { user } = await auth.require(req); const t = tenant.current(); // { id, kind, region, plan, roles, ... } const page = await orders.for(user).list({ limit: 20 }); // only this tenant's rows return Response.json({ tenant: t.id, items: page.items }); } ``` In a job, wrap the work: `tenant.run(id, fn)`. Use `tenant.system(reason, fn)` only in the admin paths you listed under `tenancy.system.allow`. ## 4. Issue an API key Creating a key needs a role that holds `apikeys.manage`, and the scopes you grant must be declared under `apikeys.scopes`. ```yaml title="main.yaml" access: roles: owner: [apikeys.manage, orders.read] apikeys: scopes: [orders.read] ``` ```ts title="app/api/keys/route.ts" import { apikeys } from "mainsdk"; const { secret } = await apikeys.create({ name: "CI", scopes: ["orders.read"] }); // shown once ``` Add `apikey` first under `tenancy.resolve`, and a request carrying the key gets that tenant. Never hand-roll key storage. ## 5. Gate a feature by plan ```yaml title="main.yaml" plans: free: { features: [basic], limits: { projects: 5 } } pro: { features: [basic, export], limits: { projects: 50 }, prices: [price_pro_monthly] } entitlements: { default: free } ``` ```ts title="app/api/export/route.ts" import { entitlements } from "mainsdk"; entitlements.require("export"); // throws MAIN-PLAN-0001 (403) with an upgrade hint on the free plan ``` ## Check it worked `npx mainsdk check` passes. A request with tenant A's key cannot read tenant B's orders: it gets a 404. ## If it fails | Code | Fix | | --- | --- | | `MAIN-TENANT-0001` | A job ran with no tenant. Wrap it in `tenant.run(id, fn)` | | `MAIN-TENANT-0004` | `tenant.system()` ran in a file outside `tenancy.system.allow`. Move it, or list the path (`mainsdk check` reports the same as `MAIN-TENANT-0011`) | | `MAIN-PLAN-0001` | The tenant's plan lacks the feature. Move it to a plan that has it | | `MAIN-PLAN-0010` | A feature no plan declares. Add it under `plans` | ## Next - [Tenants](https://mainsdk.dev/docs/interfaces/tenant) - [API keys for your customers](https://mainsdk.dev/docs/interfaces/apikeys) - [Plans and entitlements](https://mainsdk.dev/docs/interfaces/entitlements) --- Source: https://mainsdk.dev/docs/guides/deploy-vercel # Deploy on Vercel > Ship a MainSDK app on Vercel with secrets as references, a production overlay, mainsdk check on every pull request and a second network wall. Canonical: https://mainsdk.dev/docs/guides/deploy-vercel · Updated: 2026-10-28 Deploy Pinecart, a Next.js app on MainSDK, to Vercel with no key in your code and a check that blocks unsafe changes before they ship. MainSDK is a library, not a host, so Vercel still deploys it; [Ship with deploy config](https://mainsdk.dev/docs/guides/ship-with-deploy-config) writes `vercel.json` from `main.yaml`. This guide adds secrets as references, a production overlay, `mainsdk check` in CI and a second network wall. **Before you start:** - A Next.js App Router app with a `main.yaml` (see [Adopt an existing app](https://mainsdk.dev/docs/getting-started/existing-app) if you don't have one yet). - A Vercel project linked to the repo, and the Vercel CLI. - `npx mainsdk check` passing locally (see [Your first check](https://mainsdk.dev/docs/getting-started/first-check)). ## 1. Keep keys as references `main.yaml` never holds a key. It names one with `secret:NAME`, and the `secrets` driver resolves it on the server at startup. On Vercel, the `env` driver reads Vercel environment variables. ```yaml title="main.yaml" schema: 1 project: prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY baseline: main27 targets: { web: { framework: nextjs } } secrets: driver: env # reads Vercel environment variables auth: driver: github db: driver: postgres regions: { us: secret:DATABASE_URL_US, eu: secret:DATABASE_URL_EU } ai: driver: bedrock # IAM role, no key in code config: driver: vercel-global-config # settings change without a deploy schema: ./config.schema.json jobs: driver: vercel-queues overlays: { order: [market, segment, env] } ``` Add the values in Vercel, per environment: ```bash vercel env add DATABASE_URL_US production vercel env add DATABASE_URL_EU production ``` > **Warning:** A key in source, a committed `.env` file or a secret value in the browser bundle fails the check (`MAIN-SECRET-0002`, `MAIN-SECRET-0003`, `MAIN-SECRET-0004`). `.env.example` is allowed. If your keys live in Vault, AWS, Google Cloud, Azure, Doppler or Infisical, change `secrets.driver` and keep the same `secret:` references. See [Secrets](https://mainsdk.dev/docs/interfaces/secrets). ## 2. Add a production overlay Put production-only values in `main.env.prod.yaml`. Environment resolves at build time, so the production build carries only that layer. See [main.yaml and overlays](https://mainsdk.dev/docs/concepts/main-yaml). ```yaml title="main.env.prod.yaml" network: default: deny allow: ["*.pinecart.dev"] ``` ## 3. Check the production config `mainsdk check --env prod` refuses any interface that still resolves to a local driver. If an earlier setup left `secrets.driver: local`, you see: ```bash npx mainsdk check --env prod ``` ```text mainsdk check · pinecart · env prod · Main 27 ✗ config 1 finding MAIN-CHECK-0026 critical main.yaml fix: local driver in production: secrets. Set secrets.driver to env or a key service. ✓ secrets ✓ policy ✓ network ✓ data 1 gate failed · exit 1 ``` Set `secrets.driver: env` as in step 1 and run it again until it ends with `all gates passed · exit 0`. ## 4. Check every pull request Run the check in CI on each pull request, before Vercel promotes anything. The check is free and runs on your own runner. ```yaml title=".github/workflows/mainsdk.yml" name: mainsdk on: [pull_request] jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22 } - run: npm ci - run: npx mainsdk check --env prod --sarif mainsdk.sarif - uses: github/codeql-action/upload-sarif@v3 if: always() with: { sarif_file: mainsdk.sarif } ``` SARIF puts each finding inline on the pull request. Make the job a required status check, so a failed gate blocks the merge and the deploy that follows it. Exit codes are explained in [The check](https://mainsdk.dev/docs/concepts/the-check). ## 5. Add a second network wall `network.allow` is enforced in the SDK for every outbound call. Export the same list as a firewall rule for an independent wall. The CLI writes Kubernetes rules with `--to k8s` and AWS security-group rules with `--to aws-sg`. Vercel and Cloudflare exports are planned, so on Vercel the SDK check is the wall for now: ```bash npx mainsdk network export --to k8s ``` See [Network permissions](https://mainsdk.dev/docs/interfaces/network). ## 6. Verify the deploy Open a pull request. The `mainsdk` job runs, and a red check blocks the merge. Once it is green and merged, Vercel deploys as usual. To confirm nothing secret reached the browser, run the check against the production build output: ```bash npx mainsdk check --env prod ``` A secret value in a client chunk fails with `MAIN-SECRET-0004` and names the chunk. ## Optional: OIDC instead of a stored key > **Note:** Sending telemetry to MainSDK Cloud is phase 2 (milestone 0.6). Nothing leaves your app before `mainsdk login`. When you send telemetry to MainSDK Cloud, each environment normally needs an ingest key in `MAINSDK_KEY` (`msk_live_…` for prod, `msk_test_…` for dev and stage). On Vercel and in GitHub Actions the platform's OIDC token for the linked project or repo is accepted in place of the key. ```yaml title="main.yaml" telemetry: to: [terminal, mainsdk] sample: 0.2 ``` The Vercel-side setup for that OIDC trust (which audience and issuer to register) is not decided yet. Until it is, store `MAINSDK_KEY` as a sensitive Vercel variable. > **Tip:** The app never waits on MainSDK Cloud. If it is down, telemetry is dropped and requests are served as usual. See [Fail static](https://mainsdk.dev/docs/concepts/fail-static). ## Next - [Environment variables](https://mainsdk.dev/docs/reference/environment) - [Secrets](https://mainsdk.dev/docs/interfaces/secrets) - [Security model](https://mainsdk.dev/docs/concepts/security-model) --- Source: https://mainsdk.dev/docs/guides/ship-with-deploy-config # Ship with deploy config > Generate Vercel, Cloudflare, Fly or Docker files from main.yaml, run schedules on a serverless platform, fail the build on a missing secret and review a staging to production change. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/ship-with-deploy-config · Updated: 2026-10-28 You will generate the platform's deploy files from `main.yaml`, wake a schedule through the tick route, and review what a promotion to production changes. It takes about 20 minutes. MainSDK writes and checks the files; the platform's Git integration or CLI still deploys. ## You need - A MainSDK app with a `main.yaml` and a production overlay `main.env.prod.yaml` (see [Deploy on Vercel](https://mainsdk.dev/docs/guides/deploy-vercel)). - The app's data regions declared in `db.regions`. ## 1. Pick a target and a schedule Set the target in the production overlay and declare the schedule in `main.yaml`: ```yaml title="main.env.prod.yaml" deploy: target: vercel vercel: { plan: pro } ``` ```yaml title="main.yaml" jobs: schedules: nightly-report: "0 3 * * *" ``` ## 2. Write the files ```bash npx mainsdk deploy config --env prod ``` This writes `vercel.json` (regions, one cron for `nightly-report`, a build command that runs the preflight), `deploy/env.prod.example` and `mainsdk.deploy.json`. Commit all three. Keys you added to `vercel.json` yourself are not touched. Run it again after any change to `main.yaml`; `mainsdk deploy config --check` and `mainsdk check` fail when a generated block is stale. ## 3. Serve the tick route On `vercel` and `cloudflare` the platform's cron calls one route. Mount it before your own routes and set `CRON_SECRET` in the platform: ```ts title="app/api/mainsdk/route.ts" import { app } from "mainsdk"; import { createTick } from "mainsdk/deploy"; const tick = createTick(app()); export const POST = (req: Request) => tick.handle(req); ``` On Cloudflare Workers export `scheduled: (event) => tick.scheduled(event)` next to `fetch`, and the same job is queued in process with no HTTP call. ## 4. Let the build refuse a missing secret The generated build command runs `mainsdk deploy preflight --env prod` first. Try it with a name unset: ```bash CRON_SECRET=x npx mainsdk deploy preflight --env prod ``` It exits 1 with `MAIN-DEPLOY-0003` and lists the missing names, never a value. ## 5. Review the promotion ```bash npx mainsdk deploy diff --from staging --to prod --fail-on widen ``` It lists changed config, env names, schedules, regions and grants, and exits 1 with `MAIN-DEPLOY-0012` for each change that lets production do more than staging. ## Check it worked Run `npx mainsdk check --env prod`. It passes when the generated files match `main.yaml`, the regions sit inside the data regions and the schedules fit the plan. --- Source: https://mainsdk.dev/docs/guides/file-uploads # Upload files with S3 or getqr.id > Add private avatar uploads to a Next.js app on S3 or getqr.id, test them offline, and swap stores by changing one line of main.yaml. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/file-uploads · Updated: 2026-10-28 You will add an avatar upload to Pinecart, a Next.js app on MainSDK: the browser sends the file straight to the store, `files.complete()` scans it, and only its owner can read it. You pick a store, write two routes and one browser function, test it offline, and finish with a passing `mainsdk check`. It takes about 20 minutes, and you can change the store later by editing one line. > **Note:** `files` (spec `files-share`, ADR-0013) runs from the repository and is not published to npm yet. The `getqr`, `s3`, `local` and `memory` drivers are built; `uploadthing` and `vercel-blob` are planned and fail with `MAIN-FILES-0014` today. ## You need - A Next.js App Router app with a `main.yaml`, `auth` and `db` set up (see [Quickstart](https://mainsdk.dev/docs/getting-started/quickstart)). - An account and secret for the store you pick: a getqr.id API key, or an S3 bucket and access key. - Secrets stored as references in your secrets manager, never in code (see [Secrets](https://mainsdk.dev/docs/interfaces/secrets)). ## Pick a driver `files` has one contract and several drivers. Every driver keeps the same guarantees (ADR-0013): owner-scoped reads, short-lived signed URLs, a scan before a file is readable, resumable uploads and an idempotency key. A driver that cannot meet one declares the gap, and `mainsdk check` names it. | Driver | Pick it when | Private files | Notes | | --- | --- | --- | --- | | `getqr` | You want the default, with uploads, share links and QR codes in one place. HubXero products use it. | Owner-scoped, signed URLs | Needs a getqr.id API key. getqr.id scans server side, and its upload target is a tus endpoint (PATCH), so the browser needs a tus-aware client such as `` from `@getqr/react`. [Setup](https://mainsdk.dev/docs/integrations/getqr-id) | | `s3` | You already have a bucket, need its region, or want files in your own cloud account. Also R2, MinIO and Backblaze B2. | Owner-scoped, signed URLs. Keep Block all public access on. | Browser uploads need a CORS rule on the bucket. On AWS you can use the runtime's IAM role and set no keys. [Setup](https://mainsdk.dev/docs/integrations/amazon-s3) | | `uploadthing` | Planned. | Not built yet (`MAIN-FILES-0014`). | [Setup](https://mainsdk.dev/docs/integrations/uploadthing) | | `vercel-blob` | Planned. | Not built yet (`MAIN-FILES-0014`). | [Setup](https://mainsdk.dev/docs/integrations/vercel-blob) | | `local` | Development and tests, with no account. | Owner-scoped | Not for production. | Sharing is separate from storage. `share()`, links and QR codes always use getqr.id, whichever driver holds the file (ADR-0009). See [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share). ## 1. Declare the driver in main.yaml Pick one block. Each `key` is a `secret:` reference, so no secret is in the file. A literal value fails with `MAIN-CONFIG-0004`. For S3, `access-key-id` is an identifier, not a secret, so it may be a literal. ```yaml title="main.yaml (getqr.id)" files: { driver: getqr, key: secret:GETQR_API_KEY, scan: true, max-size: 25MB } ``` ```yaml title="main.yaml (Amazon S3)" files: { driver: s3, bucket: acme-uploads, region: eu-west-1, access-key-id: AKIA…, key: secret:S3_SECRET_ACCESS_KEY, scan: true } # on AWS, omit access-key-id and key to use the runtime's IAM role # R2, MinIO and B2 also set: endpoint: https:// ``` The S3 `region` is the bucket's own region: pick a bucket in the region your users' data must stay in. For S3, create the bucket with Block all public access on and add the CORS rule from [Amazon S3](https://mainsdk.dev/docs/integrations/amazon-s3), or the browser cannot upload. You should see: `npx mainsdk check` passes, or names a missing secret. ## 2. Use the local driver in development A dev overlay means you need no account to build. See [main.yaml and overlays](https://mainsdk.dev/docs/concepts/main-yaml). ```yaml title="main.env.dev.yaml" files: driver: local ``` You should see: `npx mainsdk dev -- npm run dev` stores files on disk with no network call. ## 3. Issue an upload on the server `files.uploadUrl()` returns an `Upload`: the URL, the HTTP method, the headers or form fields to send, and a pending `FileRef` in `upload.ref`. It is signed, single-use, limited in size and type, and expires in 15 minutes. The bytes never pass through your server. ```ts title="app/api/uploads/route.ts" import { auth, files } from "mainsdk"; export async function POST(req: Request) { const session = await auth.require(req); // 401 with a fix if not signed in const upload = await files.uploadUrl({ owner: session.user, maxSize: "5MB", types: ["image/png", "image/jpeg"], }); return Response.json(upload); // { method, url, headers?, fields?, ref } } ``` You should see: a signed-in `POST /api/uploads` returns `method`, `url` and `ref` with `ref.status` set to `"pending"`, plus `headers` (for `PUT`) or `fields` (for `POST`). ## 4. Upload from the browser This is the one browser function for every driver. The other pages link here. For `PUT` it sends the bytes as the body with `upload.headers`. For `POST` it sends a form with `upload.fields` first and the file last. ```ts title="app/profile/upload-file.ts" import type { Upload } from "mainsdk/files"; export async function uploadFile(file: File) { const res = await fetch("/api/uploads", { method: "POST" }); const upload = (await res.json()) as Upload; if (upload.method === "PUT") { await fetch(upload.url, { method: "PUT", headers: upload.headers, body: file }); } else { const form = new FormData(); for (const [name, value] of Object.entries(upload.fields ?? {})) form.append(name, value as string); form.append("file", file); // the file goes last in a form upload await fetch(upload.url, { method: "POST", body: form }); } const done = await fetch(`/api/uploads/${upload.ref.id}/complete`, { method: "POST" }); return (await done.json()) as { url: string }; } ``` The store refuses a file over `maxSize` or outside `types` (`MAIN-FILES-0006`, `MAIN-FILES-0007`), and the signed URL works once (`MAIN-FILES-0004` after that). With the getqr.id driver you can use `` from `@getqr/react` instead of this function (see [File uploads](https://mainsdk.dev/docs/interfaces/files)). You should see: a 6 MB file or a `text/html` file is refused by the store, and a small PNG goes through. ## 5. Complete the upload and read the file `files.complete()` runs the scan and only then makes the file readable. `files.url()` returns a short-lived URL for its owner. Pass it `upload.ref.id`, which arrives here as the route's `id`. ```ts title="app/api/uploads/[id]/complete/route.ts" import { auth, files } from "mainsdk"; export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { const session = await auth.require(req); const { id } = await params; const ref = await files.complete(id, { owner: session.user }); // only the uploader can finish it const url = await files.url(ref, { for: session.user, expiresIn: "1h" }); return Response.json({ url }); } ``` Save `ref` on your own record here if the file belongs to one, for example the user's avatar. You should see: the response holds a URL that stops working after an hour. A reader that `access` denies gets `MAIN-FILES-0002` with status 403. Another user who sends someone else's upload id to the complete route also gets `MAIN-FILES-0002`, and the upload stays pending. An infected file makes `files.complete()` fail with `MAIN-FILES-0005`: the file is quarantined and no URL exists. ## 6. Test it offline `createTestApp()` puts `files` on its in-memory fake, with the same owner scope and scanning as production. `testApp.files.put(upload, bytes, { type })` plays the browser's part, so the test covers the direct flow with no network. It covers a denied reader (`files-share AC1`) and the EICAR test file (`files-share AC2`). ```ts title="app/api/uploads/uploads.test.ts" import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { createTestApp } from "@mainsdk/testing"; import { files } from "mainsdk"; const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); const eicar = new TextEncoder().encode( "X5O!P%@AP[4\\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*", ); describe("uploads", () => { it("files-share AC1 refuses a reader access denies", async () => { const testApp = createTestApp({ main: "./main.yaml" }); const upload = await files.uploadUrl({ owner: { id: "u1" }, maxSize: "5MB", types: ["image/png"] }); await testApp.files.put(upload, png, { type: "image/png" }); const ref = await files.complete(upload.ref.id, { owner: { id: "u1" } }); await assert.rejects(files.url(ref, { for: { id: "u2" } }), { code: "MAIN-FILES-0002" }); }); it("files-share AC2 refuses an infected file", async () => { const testApp = createTestApp({ main: "./main.yaml" }); const upload = await files.uploadUrl({ owner: { id: "u1" }, maxSize: "5MB", types: ["text/plain"] }); await testApp.files.put(upload, eicar, { type: "text/plain" }); await assert.rejects(files.complete(upload.ref.id, { owner: { id: "u1" } }), { code: "MAIN-FILES-0005" }); }); }); ``` Use `clock.advance("61m")` to expire a one-hour signed URL without waiting. ## Verify ```bash npx mainsdk check ``` Add `--env prod` to check the production overlay. A passing run looks like this: ```text $ npx mainsdk check mainsdk check · notes · env dev · Main 27 ✓ config ✓ secrets ✓ policy ✓ network ✓ auth ✓ data Passed · 6 gates · 0 findings · 1.2s ``` Import `@aws-sdk/client-s3`, `uploadthing` or `@vercel/blob` in a route and the check fails with `MAIN-FILES-0010` and names the file. Replace the import with `files.upload()` or `files.uploadUrl()`. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-FILES-0002` | `files.url()` was called for a reader `access` denies (403). | Read as the owner, or add an `access` rule for the reader. | | `MAIN-FILES-0005` | The scan in `files.complete()` found an infected file. The upload fails, the file is quarantined and no URL exists. | Ask the user for a clean file. Nothing to fix in code. | | `MAIN-FILES-0010` | `mainsdk check` found a storage SDK imported in route code. | Use `files.upload()` or `files.uploadUrl()`, or set that store as the `files` driver. | | `MAIN-CONFIG-0004` | `key` is a literal value, not a `secret:` reference. | Move the value to your secrets manager and reference it. | | `MAIN-CHECK-0026` | `files.driver` is still `local` in production. | Set a real driver in `main.yaml` and keep `local` only in `main.env.dev.yaml`. | Run `npx mainsdk explain MAIN-FILES-0005` for the cause and fix offline. ## Swap drivers later Change `driver` and its keys. `files.uploadUrl()`, `files.complete()` and `files.url()` stay the same, and so do owner scope, scanning and audit. ```yaml title="main.yaml" files: { driver: s3, bucket: acme-uploads, region: eu-west-1, access-key-id: AKIA…, key: secret:S3_SECRET_ACCESS_KEY, scan: true } # was getqr ``` Existing files stay where they are until you migrate them, and old `FileRef` values keep pointing at the old store. How a migration moves them is [TBD]. See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver). ## For agents ```text Goal: add owner-scoped avatar upload to this Next.js app with the files interface. Read: docs/specs/files-share.md, the driver's integration page, main.yaml. Touch only: main.yaml, main.env.dev.yaml, app/api/uploads/**, app/profile/upload-file.ts, one test file. Do: declare files.driver with a secret: reference; add a dev overlay with driver local; issue the upload with files.uploadUrl({ owner, maxSize, types }) in POST /api/uploads; send the file to upload.url with upload.method (the browser function in the guide); call files.complete(upload.ref.id, { owner }) as the same user before files.url(ref, { for, expiresIn }). Never import @aws-sdk/client-s3, uploadthing or @vercel/blob in route code. Never put a key in code. Never weaken, skip or loosen a check to make it pass. Verify: npx mainsdk check --json --agent exits 0, and the test names its criterion (files-share AC1). ``` ## Next - [File uploads](https://mainsdk.dev/docs/interfaces/files) - [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share) - [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver) --- Source: https://mainsdk.dev/docs/guides/share-anything # Share anything > Share a record, a file or a screen as a private, expiring getqr.id link and QR code, print QR codes for posters, count opens with consent, send invite, deep and sign-in links, and do it all offline with the local driver. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/share-anything · Updated: 2026-10-28 You will add sharing to Pinecart, a Next.js app on MainSDK: a customer shares an order as a link and QR code, the shop prints QR codes for its counter and flyers, and the team sends invite and sign-in links. Every link goes through one call, `share`, and lands on getqr.id, so it expires, can be revoked, carries nothing personal in its URL and shows "Powered by getqr.id". You build it offline with the local driver, switch to getqr.id for production, and finish with a passing `mainsdk check`. It takes about 30 minutes. > **Note:** `share` (spec `files-share`, ADR-0056) and `links` (spec `links`) run from the repository on the `local` and `getqr` drivers. Nothing is published to npm yet. Steps that need other interfaces that are not built (`privacy` consent, sending email or WhatsApp) say so. ## You need - A Next.js App Router app with a `main.yaml`, `auth` and `db` set up (see [Quickstart](https://mainsdk.dev/docs/getting-started/quickstart)). - For production only: a getqr.id account and a `gq_live_` secret key, stored as a reference in your secrets manager (see [Secrets](https://mainsdk.dev/docs/interfaces/secrets)). A `gq_test_` key uses the getqr.id sandbox, where links always expire in 24 hours. ## Why one call `share` is the only way a MainSDK app hands out a link, QR code or share page (ADR-0056). `auth` invites, `files`, `feeds`, `imports` reports, the seal record page and engagement messages all call it inside the SDK, so the same rules hold everywhere. `mainsdk check` fails on any other shortener or QR library (`MAIN-SHARE-0001`) and on calls to their hosts (`MAIN-SHARE-0007`). ## 1. Declare share in main.yaml Use the local driver while you build. It mints links on your dev server, keeps them in process and needs no account or network. ```yaml title="main.yaml" share: driver: local default-expiry: 7d max-expiry: 30d ``` For production, point an overlay at getqr.id. The key is a `secret:` reference; a literal fails with `MAIN-CONFIG-0004`. ```yaml title="main.env.prod.yaml" share: driver: getqr key: secret:GETQR_API_KEY ``` The local driver refuses to start in production (`MAIN-SHARE-0008`), and `mainsdk check --env prod` reports `MAIN-CHECK-0026` if the overlay is missing. ## 2. Share a record Share a read-only page of the record, never the record's data. The page is your own route; the link to it is a share. ```ts title="app/api/orders/[id]/share/route.ts" import { auth, models, s, share } from "mainsdk"; const { orders } = models({ orders: s.object({ item: s.string(), total: s.int() }) }); export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const session = await auth.require(req); const order = await orders.for(session.user).get(id); const link = await share({ url: `https://pinecart.in/orders/${order.id}/view`, title: "Your Pinecart order" }, { expiresIn: "7d" }); return Response.json(link); // { url, qr, id, expiresAt } } ``` The URL holds nothing from the order. A `title` or `metadata` with an email or phone number is refused with `MAIN-SHARE-0013`, because getqr.id shows it in lists and webhooks. Share links are read-only: asking for any other access fails with `MAIN-SHARE-0003`. To share an uploaded file, pass the `FileRef` from [files](https://mainsdk.dev/docs/interfaces/files) directly: `share(fileRef, { expiresIn: "1d" })`. ## 3. Show the QR code and a story image ```ts title="app/api/shares/[id]/qr/route.ts" import { share } from "mainsdk"; export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const qr = await share.qr(id, { format: "png", size: 1024 }); return new Response(qr.bytes, { headers: { "content-type": qr.type } }); } ``` `share.story(id)` returns a 1080×1920 image with the QR, title and link, sized for Instagram and WhatsApp Status. QR codes are always dark on white with a quiet zone, so they scan; MainSDK never draws one itself. In the browser, `` and `` from `@getqr/react` add the caption and "Powered by getqr.id". ## 4. List, change and revoke ```ts title="app/shares/actions.ts" "use server"; import { share } from "mainsdk"; export async function myShares(cursor?: string) { return share.list({ status: "live", cursor, limit: 20 }); // only this tenant's shares } export async function rename(id: string) { return share.update(id, { title: "Order 1042", extend: "24h" }); // same link, same QR } export async function stop(id: string) { await share.revoke(id); // the link stops opening } ``` An `extend` past `max-expiry` fails with `MAIN-SHARE-0002`. Share links always expire; asking to keep one forever fails with `MAIN-SHARE-0010`. ## 5. Count opens, with consent Counts are plain numbers from getqr.id's own domain (time, country, device type, placement), never anything about the person who opened the link. Read them only after the person who shared has agreed. ```ts title="app/shares/stats.ts" import { share } from "mainsdk"; // `analyticsConsent` is the person's recorded analytics consent from your own consent store. export async function opens(id: string, analyticsConsent: boolean) { return share.stats(id, { consent: analyticsConsent, range: "30d" }); // MAIN-SHARE-0004 without consent } ``` `share.usage()` returns this billing period's shares, opens and storage against your getqr.id plan, and logs a warning once when a meter passes its alert level. ## 6. Print QR codes for posters and counters A campaign holds one kept link and QR code per placement: a flyer, a counter card, a newspaper insert. Placements are the only links that never expire, and they point at a public page or a WhatsApp chat, never at a record. ```ts title="app/marketing/diwali.ts" import { share } from "mainsdk"; export async function diwaliCampaign() { const campaign = await share.campaigns.create({ name: "Diwali boxes", handle: "pinecart" }); const { placements, printSheet } = await share.placements.create(campaign.id, [ { slug: "counter", name: "Shop counter", medium: "counter", kind: "link", url: "https://pinecart.in/diwali" }, { slug: "flyer-kothrud", name: "Kothrud flyers", medium: "print", kind: "link", url: "https://pinecart.in/diwali" }, ]); return { placements, printSheet }; // printSheet: the URL of a printable sheet of the QR codes } ``` If any row fails getqr.id's checks, nothing is created and you get `MAIN-SHARE-0011` with each row listed. Read scan counts with `share.placements.list(campaign.id, { consent })`, with the campaign owner's consent, as in step 5. ## 7. Receive getqr.id events Declare the inbound endpoint and register it once. ```yaml title="main.yaml" webhooks: inbound: getqr: { preset: getqr, secret: secret:GETQR_WEBHOOK_SECRET, path: /hooks/getqr } share: webhooks: events: [share.opened, share.removed, upload.scanned, placement.scanned] track: { analytics: true } ``` ```bash npx mainsdk share webhooks add https://pinecart.in/hooks/getqr ``` It prints the signing secret once; store it as `GETQR_WEBHOOK_SECRET`. Then handle events: ```ts title="app/hooks/getqr.ts" import { share, webhooks } from "mainsdk"; // Route handler for /hooks/getqr: the declared endpoint verifies the signature, then share.on handlers run. export async function POST(req: Request) { return (await webhooks.handle(req)) ?? new Response("Not found", { status: 404 }); } share.on("share.removed", async (event) => { console.info("share removed", event.id); }); ``` Each delivery is verified before your handler runs (a bad signature is refused with a 400 problem), runs once even if getqr.id retries, and runs in the tenant that made the share. `upload.scanned` marks a getqr.id upload ready without polling. Opens of shares made with `track: true` feed the `share.opens` metric with no person attached. ## 8. Send invite, deep and sign-in links `links` builds on `share`. Declare your routes once. ```yaml title="main.yaml" links: base: https://pinecart.in routes: order: { web: "/orders/:id", app: "order/:id", params: [id] } list: { web: "/lists/:id", app: "list/:id", params: [id] } auth: methods: [passkeys, link] ``` ```ts title="app/links/actions.ts" "use server"; import { links } from "mainsdk"; export async function orderLink(orderId: string) { return links.create({ screen: "order", params: { id: orderId } }); // opens the app when installed } export async function inviteToList(listId: string, email: string) { return links.invite({ screen: "list", params: { id: listId } }, { email, uses: 1, expiresIn: "7d" }); } export async function emailSignIn(email: string) { return links.signIn({ email }, { next: { screen: "order", params: { id: "latest" } } }); // { sent: true } } ``` A deep link grants nothing: the person still signs in and passes `access`. An invite accepted with another email fails with `MAIN-LINK-0001` and stays usable. A sign-in link is never returned to your code, and the answer is the same whether or not the address has an account. `links.signIn` and `links.accept` need `auth` to provide its link method and sessions, and delivery by email or WhatsApp needs a sender; until those interfaces are built they fail with `MAIN-LINK-0005` and `MAIN-AUTH-0001`. With `links.apps` set, `links.route(req)` serves the Universal Links and App Links files; call it from a route handler (see [App links](https://mainsdk.dev/docs/interfaces/links)). ## 9. Test it offline ```ts title="app/shares/share.test.ts" import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { createTestApp } from "@mainsdk/testing"; import { share } from "mainsdk"; describe("sharing an order", () => { it("expires after 7 days", async () => { const testApp = createTestApp({ main: "./main.yaml" }); // share.driver: local const link = await share({ url: "https://pinecart.in/orders/ord_1/view" }, { expiresIn: "7d" }); assert.ok(link.url.includes("/_mainsdk/s/")); testApp.clock.advance("8d"); const [found] = (await share.list({ status: "expired" })).items; assert.equal(found?.id, link.id); }); }); ``` ## Verify ```bash npx mainsdk check npx mainsdk check --env prod ``` Both pass with no `MAIN-SHARE-` or `MAIN-LINK-` finding. Revoke a share link and open it again: it no longer opens (on the local driver it answers 410 with `MAIN-SHARE-0009`). ## If it fails | Code | What happened | Fix | | --- | --- | --- | | `MAIN-SHARE-0001` | A shortener or QR library is imported in app code | Remove it and use `share()` or `links` | | `MAIN-SHARE-0007` | Code calls a shortener or QR service, or `network.allow` lists one | Remove the call and the entry | | `MAIN-SHARE-0004` | Counts were read without consent | Pass the person's `analytics` consent | | `MAIN-SHARE-0008` | The local driver in production | Add the `main.env.prod.yaml` overlay with `driver: getqr` | | `MAIN-SHARE-0013` | Personal data in a title or metadata | Use record ids your app resolves after sign-in | | `MAIN-LINK-0002` | A screen or param isn't declared | Add it under `links.routes` | | `MAIN-LINK-0005` | `links.signIn` without the `link` method | Add `link` to `auth.methods` | ## For agents ```text Add sharing to this app with MainSDK. Use share() for every link, QR code and share page, and links for invite, deep and sign-in links; never add a shortener or QR library. Use share.driver: local in main.yaml and getqr with secret:GETQR_API_KEY in main.env.prod.yaml. Share read-only pages of records, never their data, and keep emails and phone numbers out of titles and metadata. Read counts only with the person's analytics consent. Finish with a passing npx mainsdk check and npx mainsdk check --env prod. ``` ## Next - [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share): every call and option. - [Invite, deep and sign-in links](https://mainsdk.dev/docs/interfaces/links): routes and association files. - [Upload files](https://mainsdk.dev/docs/guides/file-uploads): the files a share can point at. - [getqr.id](https://mainsdk.dev/docs/integrations/getqr-id): keys, sandbox and plans. --- Source: https://mainsdk.dev/docs/guides/sign-in-page # Add sign-in to a page > Mount the sign-in routes with an adapter, then sign people in from the browser with passkeys, WhatsApp codes or an emailed link using the small client. Status: Planned for 0.4 · Canonical: https://mainsdk.dev/docs/guides/sign-in-page · Updated: 2026-10-28 You will let someone sign in from a web page with a passkey or a WhatsApp code, show who is signed in across tabs, and sign out. It takes about 20 minutes. The server side is an adapter; the page uses a client under 6 KB with no dependencies. ## You need - A MainSDK app run from the repository, on Next.js, Hono or Express. - `AUTH_KEY` in your secrets. ## 1. Turn on the methods ```yaml title="main.yaml" auth: key: secret:AUTH_KEY methods: [passkeys, whatsapp] passkeys: origins: ["https://app.example.com"] ``` ## 2. Mount the adapter ```ts title="server.ts" import { Hono } from "hono"; import { mainsdk } from "mainsdk/hono"; const app = new Hono(); app.use("*", mainsdk()); ``` You should see `GET /_mainsdk/auth/session` answer 401 with `MAIN-AUTH-0001` and `Cache-Control: no-store`. ## 3. Sign in from the page ```ts title="sign-in.ts" import { createAuthClient } from "mainsdk/auth/client"; declare function render(session: unknown): void; // your own declare const codeFromTheInput: string; // from your own const client = createAuthClient(); client.session.watch((s) => render(s)); // this tab and every other tab // Passkey, with browser autofill on await client.passkeys.signIn({ mediation: "conditional" }); // WhatsApp code const { challengeId } = await client.whatsapp.send({ phone: "+919876543210" }); await client.whatsapp.verify({ challengeId, code: codeFromTheInput }); await client.signOut(); ``` `render` is your own function. A session never holds a token: the browser keeps the cookie and the client holds a CSRF value it sends on every change. ## 4. Handle step-up When a sign-in needs a second check, the client throws `StepUpRequired`. Finish it: ```ts import { createAuthClient, StepUpRequired } from "mainsdk/auth/client"; declare const challengeId: string; declare const code: string; const client = createAuthClient(); try { await client.whatsapp.verify({ challengeId, code }); } catch (err) { if (err instanceof StepUpRequired) { await client.stepUp.verify(err); // passkey prompt // or: await client.stepUp.send(err); await client.stepUp.verify(err, { code }); } else throw err; } ``` ## Verify Open the page, sign in, reload: `client.session.get()` returns the same user. Sign out in one tab and the other tab's `watch` callback receives `null`. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-AUTH-0081` | The person cancelled the passkey prompt | Ask again from a click | | `MAIN-AUTH-0082` | The routes answered 404 or HTML | Mount the adapter or set `base` | | `MAIN-AUTH-0083` | This browser has no WebAuthn or conditional mediation | Offer the WhatsApp code | | `MAIN-AUTH-0084` | The method is not in `auth.methods` | Add it to `main.yaml` | | `MAIN-WEB-0002` | A cross-site POST | Serve the page from the same origin as the app | ## For agents ```text Goal: add sign-in to this page using the MainSDK client. Touch: main.yaml (auth.methods), the server entry (adapter), one client module. Do not: store a session token in JavaScript, or write your own sign-in endpoint. Verify: GET /_mainsdk/auth/session answers 401 signed out and 200 after sign-in. Stop and ask if: no adapter exists for the framework. ``` --- Source: https://mainsdk.dev/docs/guides/extract-structured-data # Extract structured data from text > Turn an inbound email into a typed invoice. Try a pattern first, fall back to a model when it misses, and check both with one schema. Status: Building for 0.2 · Canonical: https://mainsdk.dev/docs/guides/extract-structured-data · Updated: 2026-10-28 An invoice email usually follows a pattern, and a model is slow and costs money. This guide reads each inbound email with a regular expression first. When the pattern misses, it asks `ai.extract` for the same typed invoice. Both paths check the result against one schema, and the caller gets the invoice, the path it came from (`via`) and the model cost, if any. It takes about 25 minutes. ## You need - A MainSDK app with a `main.yaml` (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)). - A model key for the `anthropic` driver, or the `local` driver for development. The `local` driver needs no key. - Three sample emails: one that follows the template, one that does not, and one longer than 20 000 characters. ## 1. Declare the model and its limit Add an `ai` block with a per-feature spend limit. The model is only called when the pattern misses, so the limit is the cost you accept for that case. ```yaml title="main.yaml" ai: driver: anthropic key: secret:ANTHROPIC_API_KEY # a secret reference, never a literal models: [anthropic/claude-*] data: { training: never, retention: zero } limits: per-feature: { invoice-intake: $50/month } total: $200/month ``` For development, set `driver: local` in `main.env.dev.yaml`. The fake replies without a key. ## 2. Describe the invoice once Write the invoice as a schema. Both paths use it, so the pattern and the model cannot disagree about the shape. ```ts title="lib/invoice.ts" import { s, type Infer } from "mainsdk/schema"; export const Invoice = s.object({ number: s.string().min(1).max(40), vendor: s.string().min(1).max(80), currency: s.enum(["USD", "EUR", "INR"]), total: s.number().min(0), due: s.date(), // ISO 8601 date, returned as the same string }).refine((i) => i.total > 0, "Total must be above zero"); export type Invoice = Infer; ``` ## 3. Read the email and try the pattern first Limit the text first with `parse.text`, so a huge email is refused before any pattern runs. Then read the named groups of the template with `parse.regex`. Its groups arrive as strings, and the schema coerces them, so `total` becomes a number. ```ts title="lib/read-invoice.ts" import { ai, MainError } from "mainsdk"; import { parse } from "mainsdk/parse"; import { s, type Infer } from "mainsdk/schema"; // The schema from lib/invoice.ts const Invoice = s.object({ number: s.string().min(1).max(40), vendor: s.string().min(1).max(80), currency: s.enum(["USD", "EUR", "INR"]), total: s.number().min(0), due: s.date(), }).refine((i) => i.total > 0, "Total must be above zero"); type Invoice = Infer; // Template: "Invoice INV-2026-0042 from Acme Ltd for USD 1250.00 due 2026-11-30." const TEMPLATE = /Invoice (?[A-Z0-9-]{1,40}) from (?[^\n.]{1,80}) for (?[A-Z]{3}) (?\d+(?:\.\d{1,2})?) due (?\d{4}-\d{2}-\d{2})/; export type Found = { invoice: Invoice; via: "pattern" | "model"; cost?: string }; function noMatch(err: unknown): boolean { return err instanceof MainError && (err.code === "MAIN-SCHEMA-0004" || err.code === "MAIN-SCHEMA-0001"); } export async function readInvoice(email: { body: string }): Promise { const body = parse.text(email.body, { maxLength: 20_000 }); // trims and normalises; MAIN-SCHEMA-0002 above the limit try { const invoice = parse.regex(body, TEMPLATE, Invoice); // MAIN-SCHEMA-0004 if the template misses return { invoice, via: "pattern" }; // no model call, so no cost } catch (err) { if (!noMatch(err)) throw err; // any other error stops here } // Fall back to the model with the same schema. const found = await ai.extract({ input: body, schema: Invoice, model: "anthropic/claude-sonnet-4-5", feature: "invoice-intake", repairs: 1, // one repair after an invalid reply; the default }); return { invoice: found.value, via: found.via, cost: found.cost }; } ``` The regex has no repeated group inside another repeat, so it passes the check at definition. A pattern such as `(\d+)+` would be refused with `MAIN-SCHEMA-0003`. ## 4. Validate once, in the schema Both paths return a value that the schema has already checked. A model reply that does not match is repaired, and the call fails with `MAIN-AI-0011` if it still does not match, so `readInvoice` never returns an invalid invoice. The `refine` rule runs on the pattern path too, because `parse.regex` checks its groups with the same schema. Keep business rules in the schema, not after the call, so both paths obey them. ## 5. Use it in a route ```ts title="app/api/inbound-invoice/route.ts" declare function readInvoice(email: { body: string }): Promise<{ invoice: unknown; via: "pattern" | "model"; cost?: string }>; // lib/read-invoice.ts export async function POST(req: Request) { // Check the mail provider's signature before you read the body. See the webhooks interface. const email = (await req.json()) as { body: string }; const { invoice, via, cost } = await readInvoice(email); return Response.json({ invoice, via, cost: cost ?? null }); // via says which path ran } ``` `via` is `pattern` when the template matched, with no cost, and `model` when the model read the email. `cost` is the Money string of the model call. ## Verify Send the three sample emails through the route, or call `readInvoice` from a script: - The template email returns `via: "pattern"` and no cost. - The free-text email returns `via: "model"` and a cost, and its invoice passes the schema. - The long email is refused with `MAIN-SCHEMA-0002` before any pattern or model call. Then run the check: ```bash npx mainsdk check --json --agent ``` It exits 0 when every interface is declared and the `ai` block passes its rules. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-SCHEMA-0002` | The email is longer than the `maxLength` you set (413). Nothing ran | Reject or truncate long emails upstream, or raise `maxLength` on purpose | | `MAIN-SCHEMA-0003` | The template has a nested quantifier and is refused when defined (500) | Rewrite it so no repeated group sits inside another repeat | | `MAIN-AI-0011` | The model's reply still does not match after its repairs; no value is returned | Loosen the schema or the prompt, and never use the raw reply | | `MAIN-AI-0007` | The `invoice-intake` limit or the total limit is reached; nothing reached the model (403) | Raise the limit on purpose, or let the email wait for the period to reset | | `MAIN-AI-0002` | The model is not in `ai.models` | Add it to `ai.models`, or call an allowed model | | `MAIN-AI-0014` | `repairs` is not 0, 1 or 2 (400); no request was sent | Pass 0, 1 or 2 | ## For agents ```text Goal: turn inbound invoice emails into typed invoices. Try the template with parse.regex first; use ai.extract only when it misses. Read: docs/specs/schema.md, docs/specs/ai.md (ai.AC11 to ai.AC12), this page, main.yaml. Touch only: main.yaml (the ai block), lib/invoice.ts, lib/read-invoice.ts, the inbound route. Do: define Invoice with s; keep one schema for both paths; catch only MAIN-SCHEMA-0001 and MAIN-SCHEMA-0004 around parse.regex; return via and cost. Verify: npx mainsdk check --json --agent, and the three sample emails in Verify. Never parse model text by hand, never skip the schema on the model path, never raise a limit to pass a test. ``` ## Next - [Schema and parsers](https://mainsdk.dev/docs/interfaces/schema): every schema type, every parser and the guards. - [AI calls](https://mainsdk.dev/docs/interfaces/ai): `ai.extract`, `ai.classify`, `ai.summarize` and `ai.extractor`. - [Declare what your app uses](https://mainsdk.dev/docs/guides/declare-what-your-app-uses): the hosts and programs the inbound route touches. --- Source: https://mainsdk.dev/docs/guides/swap-a-driver # Swap a driver > Change the provider behind an interface by editing main.yaml, prove it with tests and mainsdk check, and fail over or route by market. Canonical: https://mainsdk.dev/docs/guides/swap-a-driver · Updated: 2026-10-28 Move Pinecart's email from Amazon SES to Resend without changing any code. Your code calls an interface, such as `email.send()`; the driver named in `main.yaml` talks to the provider. Changing provider is a config change. See [Interfaces and drivers](https://mainsdk.dev/docs/concepts/interfaces-and-drivers). **Before you start:** an account and an API key with the new provider, and tests for the code that sends email (see [Test your app](https://mainsdk.dev/docs/guides/testing)). How seamless a swap is depends on whether the provider holds state: - **Stateless** (email, SMS, push, webhooks out, realtime, AI): change the driver and you are done. This guide covers that case. - **Stateful** (auth, payments, data): users, customers and rows have to move; see the section on stateful swaps below. ## 1. Store the new key Add `RESEND_API_KEY` to your secrets driver (for example `.mainsdk/secrets.json` locally, or your host's environment variables in production). See [Secrets](https://mainsdk.dev/docs/interfaces/secrets). ## 2. Change the driver ```yaml title="main.yaml" email: driver: resend # was ses: this is the only change key: secret:RESEND_API_KEY ``` The Resend driver declares its own host (`api.resend.com`), so your `network.allow` list doesn't change. ## 3. Verify it worked Run your tests, then the check: ```bash node --test npx mainsdk check ``` ```text $ npx mainsdk check mainsdk check · pinecart · env dev · Main 27 ✓ config ✓ secrets ✓ policy ✓ network ✓ data all gates passed · exit 0 ``` Every listed driver passes the same conformance suite for its interface version, so a swap between listed drivers keeps the same guarantees. If the new driver lacks a capability your app uses (for example attachments), `mainsdk check` names the gap before production instead of failing silently. The product requirements also describe a `mainsdk swap` command that runs the contract tests against the new driver and sends in shadow mode before switching. It has no spec yet, so its phase, flags and output are not decided. ## Fail over instead of swapping List more than one driver to fail over when the first is down: ```yaml title="main.yaml" fallback: email: [resend, ses] ``` Prove the failover in CI with a chaos drill; see [Test your app](https://mainsdk.dev/docs/guides/testing). ## Route by market One deployment can use a different driver per market. Market resolves per request. ```yaml title="main.yaml" payments: { in: razorpay, default: stripe } ``` ## Stateful swaps: auth, payments, data Users in an auth provider, customers in a payment provider and rows in a database need a migration. MainSDK runs it as a guided job with a rollback, never a silent switch. Moving a user between data regions is likewise an audited job. > **Warning:** Plan a stateful move like any migration: lock your key flows first, run it on a branch, and keep the old provider until the job reports done. How the guided job is started, and its rollback steps, are not specified yet. ## Run two major versions side by side When a driver needs a new major version of an interface, run both during the move and upgrade one call site at a time: ```ts import { email } from "mainsdk/email"; // email@1 import { email as emailV2 } from "mainsdk/email/v2"; ``` A deprecated field keeps working for at least 12 months after it is deprecated, and every breaking change names its codemod. ## Next - [Interfaces and drivers](https://mainsdk.dev/docs/concepts/interfaces-and-drivers) - [Test your app](https://mainsdk.dev/docs/guides/testing) - [Eject](https://mainsdk.dev/docs/guides/eject) --- Source: https://mainsdk.dev/docs/guides/eject # Eject > Leave MainSDK for one interface or all of them. mainsdk eject writes plain provider SDK code in place of MainSDK calls and keeps your tests passing. Status: Planned for 0.3 · Canonical: https://mainsdk.dev/docs/guides/eject · Updated: 2026-10-28 Replace MainSDK calls for one interface, or all of them, with the provider's own SDK. `mainsdk eject` keeps your tests passing, writes a report of the guarantees you give up, runs offline and sends nothing anywhere. No lock-in, by design. > **Note:** Eject ships in phase 1, milestone 0.3, with recipes for the reference architecture drivers first. Still open: which interfaces have recipes in 0.3 (the proposal is email, pay, auth and db), and whether interfaces with a built-in driver vendor its library code or target a named outside provider. **Before you start:** - A clean git working tree, so you can review and revert the change. - Your test suite passing, so you can compare the count after eject. ## 1. See the plan Eject always plans first. `--dry-run` lists every file it would change and changes none. ```bash npx mainsdk eject email --dry-run ``` ```text $ npx mainsdk eject email --dry-run eject email · driver resend app/lib/receipts.ts email.send() → resend.emails.send() app/lib/newsletter.ts email.marketing() → resend.emails.send() tests/receipts.test.ts @mainsdk/testing fake → plain mock main.yaml email.key secret:RESEND_API_KEY → process.env.RESEND_API_KEY no files changed (dry run) ``` ## 2. Eject one interface ```bash npx mainsdk eject email ``` Before: ```ts title="app/lib/receipts.ts" import { email } from "mainsdk"; export async function sendReceipt(to: string, orderId: string) { await email.send({ to, template: "receipt", data: { orderId } }); } ``` After, with the Resend driver: ```js title="app/lib/receipts.js" import { Resend } from "resend"; const resend = new Resend(process.env.RESEND_API_KEY); export async function sendReceipt(to: string, orderId: string) { await resend.emails.send({ from: "orders@pinecart.dev", to, subject: "Your receipt", html: renderReceipt({ orderId }), }); } ``` How the template is rendered (`renderReceipt` here) depends on each driver's recipe. What eject changes: - Every call to that interface becomes idiomatic code for the provider SDK named by the driver in `main.yaml`. - `secret:` references become plain environment variable reads. - Tests that used `@mainsdk/testing` fakes are rewritten to the provider's test mode or a plain mock. They are never deleted. - `EJECT-REPORT.md` lists each guarantee that no longer holds. A partial eject keeps every other interface working. ## 3. Verify it worked Run your tests. They pass, with the same test count as before. ```bash node --test ``` Then read the report and decide how you replace each guarantee: ```md title="EJECT-REPORT.md" # Eject report: email (resend) No longer enforced by MainSDK: - Unsubscribe link on marketing email (was enforced by types) - Suppression list checked before every send - Idempotency keys on sends (retries may send twice) ``` ## Eject everything ```bash npx mainsdk eject --all --dry-run npx mainsdk eject --all ``` After the last interface, `main.yaml`, the gates and every `mainsdk` and `@mainsdk/*` dependency are removed from the project. Interfaces with a built-in driver, such as Cedar for `policy` or built-in `webhooks`, eject to the open-source library code they use. > **Tip:** Want a different provider rather than no MainSDK? Swap the driver instead and keep the guarantees. See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver). ## Next - [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver) - [Interfaces and drivers](https://mainsdk.dev/docs/concepts/interfaces-and-drivers) - [CLI reference](https://mainsdk.dev/docs/reference/cli) --- Source: https://mainsdk.dev/docs/guides/give-an-agent-memory # Give an app agent memory > Let a support agent remember each customer's preferences, recall them into the next model call as data, keep tool-supplied facts quarantined, and forget a person with a signed receipt. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/give-an-agent-memory · Updated: 2026-10-28 Your support agent will remember what each customer tells it, such as how they like to be contacted, and use it in the next reply. Memories stay inside the customer's tenant, reach the model only as data, and are forgotten for real when the customer asks, with a receipt to show for it. It takes about 20 minutes. > **Note:** `memory` is planned for 0.4 (spec `memory`) and has no code yet, so the steps below are the target shape and cannot be run today. Like everything in the app, it is free. ## You need - A MainSDK app with `ai` set up and an agent under `agents` (see [Give an agent a budget](https://mainsdk.dev/docs/guides/govern-an-app-agent)). - Sign-in, so each request has a person and a tenant. ## 1. Declare memory and the agent's grant ```yaml title="main.yaml" memory: driver: db # local in development works too embed: { model: openai/text-embedding-3-small, feature: memory-embed } ttl: { event: 90d } from-tools: quarantine agents: support-bot: budget: { spend: $5/day, calls: 500/day } memory: { read: own, write: true, forget: approve, recalls: 500/day, writes: 100/day } ``` `read: own` means the agent sees only what it wrote. `forget: approve` means a forget by the agent waits for a person. ## 2. Remember what the customer says ```ts title="lib/remember.ts" import { memory } from "mainsdk/experimental/memory"; export async function rememberPreference(text: string) { return memory.remember(text, { kind: "preference" }); // about the person the agent acts for, in their tenant } ``` Call it when the customer states a preference, for example from a `support.remember` action the agent can call. ## 3. Recall into the next reply, as data ```ts title="lib/reply.ts" import { ai } from "mainsdk"; import { memory } from "mainsdk/experimental/memory"; export async function reply(user: { id: string }, message: string) { const ctx = await memory.context({ query: message, budget: 800 }); const out = await ai.generate({ model: "anthropic/claude-sonnet-4-5", messages: [ { role: "user", content: ctx.text }, // one escaped block, never system text { role: "user", content: message }, ], feature: "support-chat", agent: "support-bot", user, }); return out.text; } ``` If the store is down, `ctx.text` is an empty block and `ctx.degraded` is `true`; the reply still goes out. ## 4. Let a person confirm what a tool supplied Facts the agent picked up from a tool or a web page are quarantined to their session. Show them to the customer and confirm the ones they agree with. ```ts title="app/api/memory/confirm/route.ts" import { auth } from "mainsdk"; import { memory } from "mainsdk/experimental/memory"; export async function POST(req: Request) { await auth.require(req); const { id } = (await req.json()) as { id: string }; return Response.json(await memory.revise(id, { trust: "confirmed" }, { reason: "customer confirmed" })); } ``` ## 5. Forget on request ```ts title="app/api/memory/forget/route.ts" import { auth } from "mainsdk"; import { memory } from "mainsdk/experimental/memory"; export async function POST(req: Request) { const session = await auth.require(req); const receipt = await memory.forget({ about: session.user }, { reason: "customer request" }); return Response.json({ receipt }); // signed by your project key; ids only, no content } ``` ## Verify ```text $ pnpm mainsdk check ✓ memory: driver db, erase hard, regions eu, us ✓ agents.support-bot.memory: read own, write, forget approve 0 findings ``` In a test, remember a preference as one tenant and recall as another: the second gets nothing. Forget, then read the receipt: its signature verifies with your project's public key. ## If it fails | You see | Do this | | --- | --- | | `MAIN-MEM-0005` | The agent has no grant. Add `memory` under `agents.` | | `MAIN-MEM-0001` | No person in context. Call inside a signed-in request, or pass `about` | | `MAIN-MEM-0003` | The text looked like instructions. Store what the person said, not what a tool returned | | `MAIN-MEM-0022` | You passed recalled text as `system`. Put it in a user message | | `MAIN-MEM-0023` | A driver in `main.yaml` is named `memory`. Rename it `local` | ## For agents ```text Give support-bot memory with MainSDK: add memory (driver db) and agents.support-bot.memory (read own, write true, forget approve) to main.yaml. Store preferences with memory.remember, pass memory.context() text as a user message (never system), confirm tool-supplied memories with memory.revise(id, { trust: "confirmed" }) from a person, and add a forget route that returns the memory.forget receipt. Run pnpm mainsdk check until it reports 0 findings. ``` ## Next - [Agent memory, the concept](https://mainsdk.dev/docs/concepts/agent-memory) - [Agent memory reference](https://mainsdk.dev/docs/interfaces/memory) - [Data and regions](https://mainsdk.dev/docs/concepts/data-and-regions) --- Source: https://mainsdk.dev/docs/guides/require-sign-in # Require sign-in on your routes > Turn on passkeys and WhatsApp codes, guard a route with auth.require, read the session, and test the signed-out and signed-in cases. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/require-sign-in · Updated: 2026-10-28 You will turn on two sign-in methods, guard one route and test it. It takes about 15 minutes. To build the sign-in page itself, see [add sign-in to a page](https://mainsdk.dev/docs/guides/sign-in-page). ## You need - A MainSDK app run from the repository. - `AUTH_KEY` in your secrets. It signs sessions and is required in production. ## 1. Turn on the methods ```yaml title="main.yaml" auth: key: secret:AUTH_KEY methods: [passkeys, whatsapp] passkeys: origins: ["https://app.example.com"] ``` ## 2. Guard a route ```ts title="app/api/me/route.ts" import { auth, problem } from "mainsdk"; export async function GET(req: Request) { try { const session = await auth.require(req); // throws MAIN-AUTH-0001 (401) if not signed in return Response.json({ user: session.user, aal: session.aal }); } catch (err) { return problem(err); // RFC 9457 body with code and fix } } ``` Where signing in is optional, call `auth.session(req)`: it returns the session or `null`. ## 3. Test both cases ```ts title="app/api/me/route.test.ts" import { it } from "node:test"; import assert from "node:assert/strict"; import { createTestApp } from "@mainsdk/testing"; import { auth, problem } from "mainsdk"; // the handler under test, as in app/api/me/route.ts async function GET(req: Request) { try { const session = await auth.require(req); return Response.json({ user: session.user }); } catch (err) { return problem(err); } } it("auth AC1 refuses a request with no session and accepts one", async () => { createTestApp({ yaml: "schema: 1\nproject: shop\nauth:\n methods: [password]\n" }); const none = await GET(new Request("http://localhost/api/me")); assert.equal(none.status, 401); const issued = await auth.passwords.register(new Request("http://localhost/"), { email: "jane@x.com", password: "correct horse battery", }); const cookie = issued.cookies.map((c) => c.split(";")[0]).join("; "); const ok = await GET(new Request("http://localhost/api/me", { headers: { cookie } })); assert.equal(ok.status, 200); }); ``` ## Check it worked Without a cookie the route answers 401 with `MAIN-AUTH-0001` and a `fix`. With a valid session it answers 200. The test passes. ## Troubleshooting - 401 on every call in production: check that `AUTH_KEY` resolves and that the browser sends the session cookie. - `MAIN-AUTH-0084`: you called a sign-in route for a method `auth.methods` does not list. - A passkey fails on one domain: add its origin under `auth.passkeys.origins`. See the [auth interface](https://mainsdk.dev/docs/interfaces/auth) for step-up, organisations and single sign-on. --- Source: https://mainsdk.dev/docs/guides/client-handover # Hand a project to a client > End an agency job cleanly. Eject to plain provider code, transfer the project to the client's organisation on free view-only seats, and hand over evidence. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/client-handover · Updated: 2026-10-28 At the end of a job you give the client one of two things: code that no longer needs MainSDK, or a project they can read in their own organisation, plus evidence that the app meets the standards you agreed. This page shows the three steps and what each one needs. Plan about an hour for the eject or the transfer, and longer if the client wants an assurance bundle. > **Note:** Eject is planned for milestone 0.3. Project transfer, view-only seats and `mainsdk assure` are planned for milestone 0.7. Until then the commands below do not run; the steps are the planned design (specs `eject`, `teams-plan`, `assure`). ## What you hand over | What you hand over | Command | Milestone | Status | | --- | --- | --- | --- | | Plain provider code, no MainSDK dependency | `mainsdk eject --all` | 0.3 | Planned, beta. Recipes for email, pay, auth and db are proposed; the final list is not decided. | | The project in the client's organisation, with history, policy and launch reports | `mainsdk project transfer` | 0.7 | Planned | | Client people reading the project on free view-only seats | `mainsdk org invite --role viewer` | 0.7 | Planned | | Signed evidence an assessor can verify | `mainsdk assure`, `mainsdk assure verify` | 0.7 | Planned, beta | ## You need - A clean git working tree and a passing test suite. - For the transfer: a Teams organisation, and the client's organisation name. - For the evidence: the standards or industry pack the client asked for (for example `pci-dss-4.0.1`). ## 1. Leave MainSDK (optional) Choose this when the client wants no dependency on MainSDK. Plan first, then run it. See [Eject](https://mainsdk.dev/docs/guides/eject). ```bash npx mainsdk eject --all --dry-run npx mainsdk eject --all ``` You should see: your tests pass with the same count as before, `main.yaml`, the gates and every `mainsdk` and `@mainsdk/*` dependency are gone, and `EJECT-REPORT.md` lists each guarantee the client no longer gets. Give the client that report. Be honest with the client about coverage. The spec says eject recipes cover the reference architecture drivers first, and the interfaces with recipes in 0.3 are still an open question. The proposal is email, pay, auth and db. Any other interface has no recipe yet [TBD], so plan to rewrite those calls by hand, or use step 2 instead. ## 2. Transfer the project to the client Choose this when the client keeps using MainSDK. The transfer moves the project with its history, policy and launch reports in one step and keeps the project ID. The client's people read it on free view-only seats; client viewers never count as active developers (see [Free in the app, paid in the Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud)). ```bash npx mainsdk org invite ana@client.example --role viewer npx mainsdk project transfer prj_01JC8Q… --to client-co --keep-maintainer ``` - `--keep-maintainer` keeps one maintainer seat for your agency, so you can still fix things. - `--bill-addons-to client` puts any Teams add-ons on the client's invoice instead of yours. You should see: the project in `client-co` with its history, the same project ID, and the client's people able to read it without a charge. ## 3. Hand over evidence Run `mainsdk assure` against the standards the client asked for, before you eject or transfer. Running it and reading the results is free. ```bash npx mainsdk assure --standard pci-dss-4.0.1 npx mainsdk assure verify .mainsdk/assure/latest.bundle ``` You should see: the failing controls with owners and fixes, then a verified signature on the bundle, which holds the results, SBOM, VEX, policy test results, drill records and exceptions. Hand the client the bundle. Whether `mainsdk assure` still runs after a full eject is [TBD]. ## Verify Before the handover, run the check and save its output with the evidence. ```bash npx mainsdk check --env prod ``` A pass exits 0. After an eject, run your test suite instead, since the check is gone with MainSDK. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-ASSURE-0003` | `mainsdk assure` pointed at a URL found a tampered `/.well-known/mainsdk.json` (signature invalid, exit 2). | Do not hand it over. Rebuild the record from your own repo and run assure again. | | `MAIN-CONFIG-0004` | A literal secret in `main.yaml`, found before you hand the repo over. | Move it to a `secret:` reference and rotate the secret. | Run `npx mainsdk explain ` for the cause and fix offline. ## For agents ```text Goal: prepare this project for handover to a client. Read: docs/guides/eject.md, docs/specs/eject.md, docs/specs/teams-plan.md, docs/specs/assure.md. Touch only: files that eject rewrites, EJECT-REPORT.md, and .mainsdk/assure/. Do: run mainsdk assure and keep the bundle; run mainsdk eject --all --dry-run and show the plan to the owner; run the real eject only after the owner approves; never run project transfer without the owner. Verify: the test suite passes with the same count, and npx mainsdk assure verify .mainsdk/assure/latest.bundle succeeds. Never weaken, skip or loosen a check to make it pass. ``` ## Next - [Eject](https://mainsdk.dev/docs/guides/eject) - [Free in the app, paid in the Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud) - [Make your app enterprise ready](https://mainsdk.dev/docs/guides/enterprise-ready) --- Source: https://mainsdk.dev/docs/guides/mount-mainsdk-in-your-framework # Mount MainSDK in your framework > Add the adapter for Next.js, Hono, Express, Cloudflare Workers or a job worker so every request carries a trace, a context and a problem response. Status: Building for 0.2 · Canonical: https://mainsdk.dev/docs/guides/mount-mainsdk-in-your-framework · Updated: 2026-10-28 You will mount one adapter and see a trace id on every log record. It takes about 10 minutes. ## You need - A MainSDK app on Next.js, Hono, Express or Cloudflare Workers. ## 1. Mount it ```ts title="server.ts (Hono)" import { Hono } from "hono"; import { mainsdk } from "mainsdk/hono"; const app = new Hono(); app.use("*", mainsdk()); app.get("/hello", (c) => c.json({ ok: true })); ``` Other frameworks: ```ts title="app/api/orders/route.ts (Next.js App Router)" import { withMainSDK } from "mainsdk/next"; export const GET = withMainSDK(async (req, ctx) => Response.json({ trace: ctx.traceId })); ``` ```ts title="server.ts (Express)" import express from "express"; import { mainsdk } from "mainsdk/express"; const app = express(); app.use(mainsdk()); app.use(mainsdk.errors()); // last: turns a thrown error into a problem ``` ## 2. Read the context Inside any handler, `requestContext()` from `mainsdk/adapters` returns the trace id, user and tenant. Call `/hello` with a `traceparent` header and every log record carries that trace id. Without it the adapter starts a new one. ## 3. Add hooks if you need them ```ts import { Hono } from "hono"; import { mainsdk } from "mainsdk/hono"; declare const metrics: { done(traceId: string): void }; declare function warmCaches(): Promise; declare function flushQueues(): Promise; const app = new Hono(); app.use("*", mainsdk({ hooks: { finally: (ctx) => metrics.done(ctx.traceId) }, startup: async () => warmCaches(), shutdown: async () => flushQueues(), })); ``` `metrics`, `warmCaches` and `flushQueues` are your own. ## 4. Run a job worker ```ts title="jobs/report.ts" import { worker } from "mainsdk/worker"; declare function buildReport(orderId: string): Promise; export default worker(async (input: { orderId: string }, ctx) => { await buildReport(input.orderId); // ctx.traceId is the producer's trace }); ``` ## Check it worked ```bash curl -i -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" localhost:3000/hello npx mainsdk logs --trace 4bf92f3577b34da6a3ce929d0e0e4736 ``` You should see the request's records under that trace. ## Troubleshooting - `MAIN-CORE-0500`: a handler threw an error that is not a `MainError`. Read the log record with the same trace id. - A health probe answers 503: the app is draining, which is expected during shutdown. - A sign-in route answers 404 `MAIN-AUTH-0084`: the method is not in `auth.methods`. See the [adapters interface](https://mainsdk.dev/docs/interfaces/adapters). --- Source: https://mainsdk.dev/docs/guides/set-up-a-team # Set up a team on Teams > Create an organisation, invite people, sign in with your identity provider, sync groups with SCIM, understand active developers and the yearly cap, and push audit evidence. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/set-up-a-team · Updated: 2026-10-28 Teams is the paid plan for working together in MainSDK Cloud. This page shows how a team is set up and what it is billed for. The app itself stays free on every plan. Plan about 30 minutes, longer if your identity provider needs a ticket. ## You need - A MainSDK Cloud account, signed in with a passkey, WhatsApp or a social login. - For SSO and SCIM: an Okta, Entra or Google Workspace admin. ## 1. Invite people An admin invites by email as `admin`, `developer` or `viewer`. An invite works once and for seven days, for the person who owns that email. A developer can create and read keys; a viewer reads projects and nothing else. ## 2. Sign in with your identity provider SSO uses OIDC or SAML through a driver (WorkOS first). SCIM keeps people in step: a person added in the provider becomes a member, and a SCIM group such as `payments-team` becomes a team with the same members. Removing a person in the provider ends their console access within five minutes. SSO and SCIM are never a separate charge. ## 3. Know what you are billed for A seat costs $20 for each active developer in a month: someone who pushed, ran a gate that reported to the Cloud, or used the console. Viewers and auditors never count. A year is capped at $30,000; past it, more developers cost nothing. You can switch to one annual invoice with a purchase order number. Events pool across all your seats and stay searchable for 30 days. An approved startup pays $0 for 12 months. Billing starts in month 13, and you get a notice 30 days before. ## 4. Add-ons Add-ons are optional and apply to your own projects only: longer history, more replays, hosted triage, questionnaire packs, deep review and audit evidence. None gives access to other organisations' projects, discovery, SIEM streaming, detections, egress control or company-wide evidence; those are Fleet. An add-on with no price set is not on sale yet. ## 5. Push audit evidence With the audit evidence add-on and a connected Vanta, Drata or Secureframe account, push evidence for one project. The tool receives a bundle signed with Ed25519 and mapped to SOC 2 or ISO 27001 criteria. Check it offline with `mainsdk assure verify ` (planned with spec `assure`). ## 6. Hand a project to a client Follow [Hand a project to a client](https://mainsdk.dev/docs/guides/client-handover). Use `--keep-maintainer` to keep one maintainer seat and `--bill-addons-to client` to put add-ons on the client's invoice. ## Check it worked Invite yourself with a second email as a viewer: the viewer can open the project and cannot create a key. Remove a person in your identity provider and confirm their console session ends within five minutes. --- Source: https://mainsdk.dev/docs/guides/migrate-from-raw-providers # Migrate from raw provider SDKs > Move Stripe, Auth0 or Clerk, and OpenAI SDK calls onto MainSDK pay, auth and ai, one module per pull request, with before and after code. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/migrate-from-raw-providers · Updated: 2026-10-28 Move direct Stripe, Clerk or Auth0, and OpenAI SDK calls onto MainSDK interfaces, so you can swap, audit and check them. Your provider accounts stay; the driver talks to them. **Before you start:** - A `main.yaml` in your app. If you don't have one, start with [Adopt an existing app](https://mainsdk.dev/docs/getting-started/existing-app). - Your provider keys stored as `secret:` references (see [Secrets](https://mainsdk.dev/docs/interfaces/secrets)). Move one module per pull request, and run `mainsdk check` on each. ## 1. Stripe to `pay` Before: the key is read in your code, and nothing stops a retry from charging twice. ```ts title="app/api/checkout/route.ts (before)" import Stripe from "stripe"; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); export async function POST(req: Request) { const { orderId, amount } = await req.json(); const session = await stripe.checkout.sessions.create({ mode: "payment", line_items: [{ price_data: { currency: "usd", unit_amount: amount, product_data: { name: `Order ${orderId}` } }, quantity: 1 }], success_url: "https://orders.pinecart.dev/orders/done", cancel_url: "https://orders.pinecart.dev/cart", }); return Response.json({ url: session.url }); } ``` After: hosted checkout, an idempotency key on the call, the key in `secrets`. ```ts title="app/api/checkout/route.ts (after)" import { auth, models, pay, s } from "mainsdk"; const { orders } = models({ orders: s.object({ total: s.int() }), }); // declared once in lib/orders.ts export async function POST(req: Request) { const session = await auth.require(req); const { orderId } = (await req.json()) as { orderId: string }; const order = await orders.for(session.user).get(orderId); const checkout = await pay.checkout({ order: order.id, amount: order.total, // integer minor units currency: "USD", successUrl: "/orders/done", cancelUrl: "/cart", idempotencyKey: `checkout:${order.id}`, }); return Response.json(checkout); } ``` ```yaml title="main.yaml" payments: { driver: stripe } pay: stripe: { key: secret:STRIPE_SECRET_KEY, webhook: secret:STRIPE_WEBHOOK_SECRET } ``` See [Payments](https://mainsdk.dev/docs/interfaces/pay). ## 2. Auth0 or Clerk to `auth` Your users, sessions and MFA stay in your Auth0 or Clerk tenant. Set the driver to the provider you run (`byo-identity`). ```ts title="app/api/orders/route.ts (before, Clerk)" import { auth } from "@clerk/nextjs/server"; export async function GET() { const { userId } = await auth(); if (!userId) return new Response("Unauthorized", { status: 401 }); const orders = await sql`select * from orders where user_id = ${userId}`; return Response.json(orders); } ``` ```ts title="app/api/orders/route.ts (after)" import { auth, models, s } from "mainsdk"; const { orders } = models({ orders: s.object({ total: s.int() }), }); // declared once in lib/orders.ts export async function GET(req: Request) { const session = await auth.require(req); // 401 problem details with a fix const page = await orders.for(session.user).list({ limit: 20 }); return Response.json(page); } ``` ```yaml title="main.yaml" auth: driver: clerk # or auth0 methods: [passkeys, sso] ``` See [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth) and [Data and records](https://mainsdk.dev/docs/interfaces/db). ## 3. OpenAI SDK to `ai` ```ts title="app/lib/summary.ts (before)" import OpenAI from "openai"; const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); export async function summarise(orderId: string) { const res = await openai.chat.completions.create({ model: "gpt-4o", messages: [{ role: "user", content: `Summarise order ${orderId}` }], }); return res.choices[0].message.content; } ``` ```ts title="app/lib/summary.ts (after)" import { ai } from "mainsdk"; export async function summarise(user: { id: string }, orderId: string) { return ai.generate({ model: "anthropic/claude-sonnet-4-5", prompt: `Summarise order ${orderId}`, feature: "order-summary", // spend limits and audit per feature user, // and per user }); } ``` ```yaml title="main.yaml" ai: driver: bedrock # or anthropic, openai, gateway models: [anthropic/claude-*] ``` Personal data is redacted before the call, spend limits apply, and the model allowlist is checked. See [AI calls](https://mainsdk.dev/docs/interfaces/ai). ## 4. Keep what can't move behind `raw()` Every interface has `raw()`, which returns the provider's own client. Use it for a provider-only feature: ```ts import { pay } from "mainsdk"; const stripe = pay.raw(); // logged on every use, listed by mainsdk check ``` Each use is marked as bypassing that interface's guarantees, so you always know where you depend on one provider. ## 5. Verify each pull request ```text $ npx mainsdk check mainsdk check · notes · env dev · Main 27 ✓ config ✓ secrets ✓ policy ✓ network ✓ auth ✓ data Passed · 6 gates · 0 findings · 1.2s ``` Your existing tests should pass unchanged. To test the migrated code without provider keys, see [Test your app](https://mainsdk.dev/docs/guides/testing). ## Later: `mainsdk migrate` > **Note:** `mainsdk migrate` and the step-by-step migration guides ship in phase 2 (milestone 0.7). In phase 1, use `mainsdk adopt` and the migration skill. `mainsdk migrate ` reports what maps to which interface, what stays behind `raw()`, and what needs a person, then opens the change as a pull request. `` is one of `supabase`, `firebase`, `clerk`, `auth0`, `authjs`, `better-auth`, `stripe` or `raw`. ```bash npx mainsdk migrate stripe --report-only --json ``` ```json { "from": "stripe", "mapped": [{ "file": "app/api/checkout/route.ts", "line": 7, "call": "stripe.checkout.sessions.create()", "to": "pay.checkout()" }], "raw": [], "person": [], "endsAt": "mainsdk/payments-checkout" } ``` `--report-only` changes nothing. Moving stored state (users, customers, rows) is a guided job with a rollback, never a silent switch. See [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver). ## Next - [Adopt an existing app](https://mainsdk.dev/docs/getting-started/existing-app) - [Interfaces and drivers](https://mainsdk.dev/docs/concepts/interfaces-and-drivers) - [Eject](https://mainsdk.dev/docs/guides/eject) ## Where you end You end at the reference architecture [`mainsdk/api-service`](https://mainsdk.dev/docs/guides/reference-api-service). `npx mainsdk migrate raw` runs these moves as one pull request, and `npx mainsdk init --template mainsdk/api-service` starts the finished shape. --- Source: https://mainsdk.dev/docs/guides/migrate-from-supabase # Migrate from Supabase > Move Supabase database, auth, storage and realtime calls onto MainSDK db, auth, files and events, then keep what has no interface behind raw(). Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/migrate-from-supabase · Updated: 2026-10-28 Move Supabase database, auth, storage and realtime calls onto MainSDK db, auth, files and events, then keep what has no interface behind raw(). Your Supabase account stays; nothing here deletes it. **Before you start:** - A git repository with a clean working tree. `mainsdk migrate` works on a branch and never touches yours. - Node 22 and `npx mainsdk`. No `main.yaml` yet? `migrate` writes one; or start with [Adopt an existing app](https://mainsdk.dev/docs/getting-started/existing-app). - Your Supabase keys stay where they are. Move them to `secret:` references when the driver is chosen (see [Secrets](https://mainsdk.dev/docs/interfaces/secrets)). ## 1. Read the report ```text npx mainsdk migrate supabase --report-only ``` The report writes no file. It sorts every Supabase call in your code into three lists: **mapped** (an interface call replaces it), **raw** (it stays behind `raw()`, with the reason) and **needs a person** (a policy, a rule file or a call with no interface). Add `--json` for a machine-readable report. For the sample app of this guide (`packages/cli/test/fixtures/migrate/supabase`), the report finds 6 mapped, 1 raw and 1 for a person. ## 2. What the codemod changes | Where | Before | After | | --- | --- | --- | | `lib/auth.ts:4` | `return supabase.auth.signInWithPassword({ email, password })` | `auth.signIn()` | | `lib/auth.ts:8` | `return supabase.auth.getUser()` | `auth.require(req)` | | `lib/db.ts:4` | `return supabase.from('orders').select('*')` | `db.orders.for(user).list()` | | `lib/db.ts:8` | `return supabase.from('orders').insert(row)` | `db.orders.for(user).create()` | | `lib/db.ts:12` | `return supabase.from('orders').delete().eq('id', id)` | `db.orders.for(user).remove()` | | `lib/files.ts:4` | `return supabase.storage.from('avatars').upload(path, file)` | `files.upload()` | Each rewrite is mechanical and one line at a time. Imports of Supabase go when no call needs them, and `mainsdk` is added to `package.json`. ## 3. What stays behind raw() - `lib/realtime.ts:3`: no broadcast driver capability: presence A call behind `raw()` keeps working through the `Supabase` host on your network allow list, and `mainsdk check` still sees it. Each one has a marker comment, `// mainsdk migrate: ...`, so you can find it later. ## 4. What needs a person - `supabase/policies.sql:1`: row-level security policy needs a data label owner The codemod leaves these lines alone and marks them. No tool should guess a policy. ## 5. Stateful moves are guided jobs Data does not move with the code. Each stateful move is a job with steps and a rollback, listed in the pull request and never run by the codemod. ### users: Move auth.users to the new auth driver 1. Export users from the Supabase dashboard or admin API into users.jsonl (ids and emails; password hashes only where Supabase exports them). 2. Run the import against a staging project and compare the counts. 3. Switch auth.driver for 1% of sign-ins, then all. Rollback: Set auth.driver back to supabase in main.yaml; Supabase still holds every user until you delete the project. ### rows: Copy each table to the app database 1. Take pg_dump of each table named in the report. 2. Restore it into the new database and run the row-count check. 3. Dual-write for one week, then read from the new database. Rollback: Point db.driver back at the Supabase connection string; the dual-write kept it current. ## 6. Open the pull request ```text npx mainsdk migrate supabase ``` This records your sign-in and checkout flows as locked flows first, then creates the branch `migrate/supabase`, applies the codemods, adds `.github/workflows/mainsdk-migrate.yml`, runs `mainsdk check` and the locked flows, commits, pushes and opens one pull request with `gh`. If a gate fails, the branch is kept and no pull request opens (`MAIN-MIGRATE-0001`). `--no-pr` stops after the commit. Locked flows run again in CI: `npx mainsdk migrate flows`. ## Where you end You end at the reference architecture [`mainsdk/saas-web`](https://mainsdk.dev/docs/guides/reference-saas-web): the same shape, with its tests, benchmark and chaos drills. ```text npx mainsdk init --template mainsdk/saas-web ``` --- Source: https://mainsdk.dev/docs/guides/migrate-from-firebase # Migrate from Firebase > Move Firestore, Firebase Auth and Storage calls onto MainSDK db, auth and files, with the codemod, the report and a pull request. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/migrate-from-firebase · Updated: 2026-10-28 Move Firestore, Firebase Auth and Storage calls onto MainSDK db, auth and files, with the codemod, the report and a pull request. Your Firebase account stays; nothing here deletes it. **Before you start:** - A git repository with a clean working tree. `mainsdk migrate` works on a branch and never touches yours. - Node 22 and `npx mainsdk`. No `main.yaml` yet? `migrate` writes one; or start with [Adopt an existing app](https://mainsdk.dev/docs/getting-started/existing-app). - Your Firebase keys stay where they are. Move them to `secret:` references when the driver is chosen (see [Secrets](https://mainsdk.dev/docs/interfaces/secrets)). ## 1. Read the report ```text npx mainsdk migrate firebase --report-only ``` The report writes no file. It sorts every Firebase call in your code into three lists: **mapped** (an interface call replaces it), **raw** (it stays behind `raw()`, with the reason) and **needs a person** (a policy, a rule file or a call with no interface). Add `--json` for a machine-readable report. For the sample app of this guide (`packages/cli/test/fixtures/migrate/firebase`), the report finds 4 mapped, 1 raw and 1 for a person. ## 2. What the codemod changes | Where | Before | After | | --- | --- | --- | | `lib/auth.ts:5` | `signInWithEmailAndPassword(auth, email, password)` | `auth.signIn()` | | `lib/orders.ts:4` | `export const listOrders = () => getDocs(collection(db, 'orders'))` | `db.orders.for(user).list()` | | `lib/orders.ts:6` | `export const addOrder = (row: unknown) => addDoc(collection(db, 'order` | `db.orders.for(user).create()` | | `lib/orders.ts:8` | `export const removeOrder = (id: string) => deleteDoc(doc(db, 'orders',` | `db.orders.for(user).remove(id)` | Each rewrite is mechanical and one line at a time. Imports of Firebase go when no call needs them, and `mainsdk` is added to `package.json`. ## 3. What stays behind raw() - `lib/live.ts:3`: no realtime listener capability: onSnapshot A call behind `raw()` keeps working through the `Firebase` host on your network allow list, and `mainsdk check` still sees it. Each one has a marker comment, `// mainsdk migrate: ...`, so you can find it later. ## 4. What needs a person - `firestore.rules:1`: security rules need a data label owner The codemod leaves these lines alone and marks them. No tool should guess a policy. ## 5. Stateful moves are guided jobs Data does not move with the code. Each stateful move is a job with steps and a rollback, listed in the pull request and never run by the codemod. ### users: Move Firebase Authentication users 1. Run firebase auth:export users.json (password hashes come with the project hash parameters). 2. Import into the new auth driver on staging and compare counts. 3. Switch auth.driver for 1% of sign-ins, then all. Rollback: Set auth.driver back to firebase; Firebase keeps its users until the project is deleted. ### documents: Copy Firestore collections to the app database 1. Export each collection named in the report with gcloud firestore export. 2. Load it into the new database and run the document-count check. 3. Dual-write for one week, then read from the new database. Rollback: Point db.driver back at Firestore; the dual-write kept it current. ## 6. Open the pull request ```text npx mainsdk migrate firebase ``` This records your sign-in and checkout flows as locked flows first, then creates the branch `migrate/firebase`, applies the codemods, adds `.github/workflows/mainsdk-migrate.yml`, runs `mainsdk check` and the locked flows, commits, pushes and opens one pull request with `gh`. If a gate fails, the branch is kept and no pull request opens (`MAIN-MIGRATE-0001`). `--no-pr` stops after the commit. Locked flows run again in CI: `npx mainsdk migrate flows`. ## Where you end You end at the reference architecture [`mainsdk/saas-web`](https://mainsdk.dev/docs/guides/reference-saas-web): the same shape, with its tests, benchmark and chaos drills. ```text npx mainsdk init --template mainsdk/saas-web ``` --- Source: https://mainsdk.dev/docs/guides/migrate-from-clerk # Migrate from Clerk > Move Clerk sign-in, route protection and sign-in tokens onto the MainSDK auth interface and switch drivers when you choose. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/migrate-from-clerk · Updated: 2026-10-28 Move Clerk sign-in, route protection and sign-in tokens onto the MainSDK auth interface and switch drivers when you choose. Your Clerk account stays; nothing here deletes it. **Before you start:** - A git repository with a clean working tree. `mainsdk migrate` works on a branch and never touches yours. - Node 22 and `npx mainsdk`. No `main.yaml` yet? `migrate` writes one; or start with [Adopt an existing app](https://mainsdk.dev/docs/getting-started/existing-app). - Your Clerk keys stay where they are. Move them to `secret:` references when the driver is chosen (see [Secrets](https://mainsdk.dev/docs/interfaces/secrets)). ## 1. Read the report ```text npx mainsdk migrate clerk --report-only ``` The report writes no file. It sorts every Clerk call in your code into three lists: **mapped** (an interface call replaces it), **raw** (it stays behind `raw()`, with the reason) and **needs a person** (a policy, a rule file or a call with no interface). Add `--json` for a machine-readable report. For the sample app of this guide (`packages/cli/test/fixtures/migrate/clerk`), the report finds 4 mapped, 0 raw and 1 for a person. ## 2. What the codemod changes | Where | Before | After | | --- | --- | --- | | `app/api/orders/route.ts:4` | `const { userId } = auth()` | `auth.require(req)` | | `app/sign-in/route.ts:6` | `const token = await clerkClient.signInTokens.createSignInToken({ userI` | `auth.signIn()` | | `middleware.ts:3` | `const isPublic = createRouteMatcher(['/sign-in(.*)', '/'])` | `auth.routes()` | | `middleware.ts:5` | `export default clerkMiddleware(async (guard, req) => {` | `auth.middleware()` | Each rewrite is mechanical and one line at a time. Imports of Clerk go when no call needs them, and `mainsdk` is added to `package.json`. ## 3. What stays behind raw() - Nothing in the sample stays behind raw(). A call behind `raw()` keeps working through the `Clerk` host on your network allow list, and `mainsdk check` still sees it. Each one has a marker comment, `// mainsdk migrate: ...`, so you can find it later. ## 4. What needs a person - `app/layout.tsx:4`: no interface call for Move Auth0 sessions, the auth route handler and the Management API onto MainSDK auth, with a guided user move and a rollback. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/migrate-from-auth0 · Updated: 2026-10-28 Move Auth0 sessions, the auth route handler and the Management API onto MainSDK auth, with a guided user move and a rollback. Your Auth0 account stays; nothing here deletes it. **Before you start:** - A git repository with a clean working tree. `mainsdk migrate` works on a branch and never touches yours. - Node 22 and `npx mainsdk`. No `main.yaml` yet? `migrate` writes one; or start with [Adopt an existing app](https://mainsdk.dev/docs/getting-started/existing-app). - Your Auth0 keys stay where they are. Move them to `secret:` references when the driver is chosen (see [Secrets](https://mainsdk.dev/docs/interfaces/secrets)). ## 1. Read the report ```text npx mainsdk migrate auth0 --report-only ``` The report writes no file. It sorts every Auth0 call in your code into three lists: **mapped** (an interface call replaces it), **raw** (it stays behind `raw()`, with the reason) and **needs a person** (a policy, a rule file or a call with no interface). Add `--json` for a machine-readable report. For the sample app of this guide (`packages/cli/test/fixtures/migrate/auth0`), the report finds 3 mapped, 0 raw and 1 for a person. ## 2. What the codemod changes | Where | Before | After | | --- | --- | --- | | `app/api/auth/[auth0]/route.ts:3` | `export const GET = handleAuth()` | `auth.handlers()` | | `app/api/me/route.ts:4` | `const session = await getSession()` | `auth.require(req)` | | `lib/users.ts:9` | `export const allUsers = () => management.users.getAll()` | `auth.users.list()` | Each rewrite is mechanical and one line at a time. Imports of Auth0 go when no call needs them, and `mainsdk` is added to `package.json`. ## 3. What stays behind raw() - Nothing in the sample stays behind raw(). A call behind `raw()` keeps working through the `Auth0` host on your network allow list, and `mainsdk check` still sees it. Each one has a marker comment, `// mainsdk migrate: ...`, so you can find it later. ## 4. What needs a person - `lib/users.ts:4`: no interface call for auth0.com; a person decides The codemod leaves these lines alone and marks them. No tool should guess a policy. ## 5. Stateful moves are guided jobs Data does not move with the code. Each stateful move is a job with steps and a rollback, listed in the pull request and never run by the codemod. ### users: Move Auth0 users 1. Run the Auth0 bulk user export (Dashboard, Extensions, User Import / Export). 2. Password hashes come only through an Auth0 support request; without them users reset on first sign-in. 3. Import on staging, compare counts, then switch sign-in for 1%, then all. Rollback: Keep auth.driver: auth0 until the new driver has served a week. ## 6. Open the pull request ```text npx mainsdk migrate auth0 ``` This records your sign-in and checkout flows as locked flows first, then creates the branch `migrate/auth0`, applies the codemods, adds `.github/workflows/mainsdk-migrate.yml`, runs `mainsdk check` and the locked flows, commits, pushes and opens one pull request with `gh`. If a gate fails, the branch is kept and no pull request opens (`MAIN-MIGRATE-0001`). `--no-pr` stops after the commit. Locked flows run again in CI: `npx mainsdk migrate flows`. ## Where you end You end at the reference architecture [`mainsdk/saas-web`](https://mainsdk.dev/docs/guides/reference-saas-web): the same shape, with its tests, benchmark and chaos drills. ```text npx mainsdk init --template mainsdk/saas-web ``` --- Source: https://mainsdk.dev/docs/guides/migrate-from-authjs # Migrate from Auth.js > Move Auth.js (NextAuth) sessions and adapters onto the MainSDK auth and db interfaces, with the codemod and a pull request. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/migrate-from-authjs · Updated: 2026-10-28 Move Auth.js (NextAuth) sessions and adapters onto the MainSDK auth and db interfaces, with the codemod and a pull request. Your Auth.js account stays; nothing here deletes it. **Before you start:** - A git repository with a clean working tree. `mainsdk migrate` works on a branch and never touches yours. - Node 22 and `npx mainsdk`. No `main.yaml` yet? `migrate` writes one; or start with [Adopt an existing app](https://mainsdk.dev/docs/getting-started/existing-app). - Your Auth.js keys stay where they are. Move them to `secret:` references when the driver is chosen (see [Secrets](https://mainsdk.dev/docs/interfaces/secrets)). ## 1. Read the report ```text npx mainsdk migrate authjs --report-only ``` The report writes no file. It sorts every Auth.js call in your code into three lists: **mapped** (an interface call replaces it), **raw** (it stays behind `raw()`, with the reason) and **needs a person** (a policy, a rule file or a call with no interface). Add `--json` for a machine-readable report. For the sample app of this guide (`packages/cli/test/fixtures/migrate/authjs`), the report finds 2 mapped, 0 raw and 1 for a person. ## 2. What the codemod changes | Where | Before | After | | --- | --- | --- | | `app/api/auth/[...nextauth]/route.ts:4` | `const handler = NextAuth({ providers: [GitHub] })` | `auth.handlers()` | | `app/dashboard/page.tsx:4` | `const session = await getServerSession()` | `auth.require(req)` | Each rewrite is mechanical and one line at a time. Imports of Auth.js go when no call needs them, and `mainsdk` is added to `package.json`. ## 3. What stays behind raw() - Nothing in the sample stays behind raw(). A call behind `raw()` keeps working through the `Auth.js` host on your network allow list, and `mainsdk check` still sees it. Each one has a marker comment, `// mainsdk migrate: ...`, so you can find it later. ## 4. What needs a person - `lib/db.ts:3`: no interface call for PrismaAdapter; a person decides The codemod leaves these lines alone and marks them. No tool should guess a policy. ## 5. Stateful moves are guided jobs Data does not move with the code. Each stateful move is a job with steps and a rollback, listed in the pull request and never run by the codemod. ### sessions-and-accounts: Move the user, account and session tables the adapter owns 1. Read the adapter tables (User, Account, Session) with the same database connection. 2. Copy users and linked accounts to the new auth driver; sessions are not copied, people sign in again. 3. Switch for 1% of sign-ins, then all. Rollback: Keep the adapter tables untouched; set auth.driver back to authjs. ## 6. Open the pull request ```text npx mainsdk migrate authjs ``` This records your sign-in and checkout flows as locked flows first, then creates the branch `migrate/authjs`, applies the codemods, adds `.github/workflows/mainsdk-migrate.yml`, runs `mainsdk check` and the locked flows, commits, pushes and opens one pull request with `gh`. If a gate fails, the branch is kept and no pull request opens (`MAIN-MIGRATE-0001`). `--no-pr` stops after the commit. Locked flows run again in CI: `npx mainsdk migrate flows`. ## Where you end You end at the reference architecture [`mainsdk/saas-web`](https://mainsdk.dev/docs/guides/reference-saas-web): the same shape, with its tests, benchmark and chaos drills. ```text npx mainsdk init --template mainsdk/saas-web ``` --- Source: https://mainsdk.dev/docs/guides/reference-saas-web # SaaS web app reference > Next.js on Vercel with Postgres, sign-in, organisations, Stripe checkout and email, on the SOC 2 baseline. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/reference-saas-web · Updated: 2026-10-28 Start from it: `npx mainsdk init --template mainsdk/saas-web` The reference architecture for a SaaS web app: sign-in, organisations with invites, hosted checkout and email, on MainSDK interfaces only. Pack: SOC 2 baseline. | | Offline (`mainsdk dev app.ts`) | Production (`main.env.prod.yaml`) | | --- | --- | --- | | Database | local | Postgres on Neon | | Sign-in | local dev sign-in (`Bearer dev_ada`) | passkeys and magic links, or Better Auth or Clerk as the driver | | Email | local mailbox | Resend with an SES fallback | | Payments | local test cards | Stripe hosted checkout | | Share links and files | local | getqr.id | Runs with no account and no provider key: `npm run dev`. `npm test` runs the tests and the locked flows (sign-in, organisation invite, checkout, email). Every change and every MainSDK release runs `.github/workflows/reference.yml`: `mainsdk check`, `mainsdk drill restore --to scratch` and `mainsdk assure --standard soc2`, `npm run bench` and `npm run chaos` (a 60 second Resend outage must send all 20 emails through SES, none lost, none twice). `npm run verify-ci` fails when a job is removed. Moving from Supabase, Firebase or Auth.js? `mainsdk migrate ` ends here. ## Performance The benchmark harness fails a change when p95 goes over 25 ms on local drivers (`npm run bench`). ## main.yaml ```yaml schema: 1 project: prj_template_saas_web baseline: main27 auth: driver: local methods: [passkeys, link] admin: { require: phishing-resistant } db: driver: local files: driver: local max-size: 25MB share: driver: local default-expiry: 7d email: driver: local from: "Pinecart " templates: welcome: subject: "Welcome to {{org}}" text: "Your organisation {{org}} is ready." invite: subject: "Join {{org}}" text: "Join {{org}}: {{link}}" pay: driver: local logging: audit: true retention: 12m network: allow: [] default: deny mode: observe # Evidence for `mainsdk assure --standard soc2`: change control is on (a pull request and a green gate before every merge), # and the providers the app runs on are registered. compliance: change-control: true providers: [local, ./backup.ts] backup: rpo: 15m rto: 1h keep: 7d drill: { every: 30d, to: scratch } driver: ./backup.ts ``` ## app.ts ```ts // The SaaS web app reference architecture: sign-in, organisations, invites, hosted checkout and email. Every outside // service is an interface (auth, share, email, pay), so the same code runs on local drivers offline and on Neon, // Resend with an SES fallback, Stripe and getqr.id in production (main.env.prod.yaml). import { randomBytes, randomUUID } from 'node:crypto' import { auth, email, MainError, pay, share } from 'mainsdk' type Role = 'owner' | 'admin' | 'member' type Org = { id: string; name: string; members: Map } /** Build the handler and its state; a test makes its own so nothing is shared. */ export function createSaasWeb(): (req: Request) => Promise { const orgs = new Map() const invites = new Map() const json = (status: number, body: unknown): Response => Response.json(body, { status }) /** Answer a request; a refusal from an interface is its own problem answer, never a crash. */ const answer = async (req: Request): Promise => { const url = new URL(req.url) const path = url.pathname if (req.method === 'GET' && path === '/health') return json(200, { ok: true }) const session = await auth.require(req) const user = session.user if (req.method === 'GET' && path === '/me') return json(200, { user }) if (req.method === 'POST' && path === '/orgs') { const body = (await req.json()) as { name?: string; email?: string } const org: Org = { id: randomUUID(), name: String(body.name ?? 'My organisation'), members: new Map([[user, 'owner']]), } orgs.set(org.id, org) if (body.email) await email.send({ to: body.email, template: 'welcome', data: { org: org.name }, idempotencyKey: `welcome:${org.id}`, }) return json(201, { id: org.id, name: org.name }) } const m = /^\/orgs\/([^/]+)\/(invites|checkout)$/.exec(path) if (m && req.method === 'POST') { const org = orgs.get(m[1] as string) const role = org?.members.get(user) if (!org || !role) return json(404, { error: 'not found' }) if (m[2] === 'invites') { if (role === 'member') return json(403, { error: 'owners and admins invite' }) const body = (await req.json()) as { email: string; role?: Role } const token = randomBytes(24).toString('base64url') invites.set(token, { org: org.id, email: body.email, role: body.role ?? 'member' }) // Share links go through getqr.id (the share interface): private, expiring, read-only. const link = await share( { url: `${url.origin}/invites/${token}`, title: `Join ${org.name}` }, { access: 'read' }, // expires after share.default-expiry (7d) in main.yaml ) await email.send({ to: body.email, template: 'invite', data: { org: org.name, link: link.url }, idempotencyKey: `invite:${token}`, }) return json(201, { invite: token, link: link.url, expiresAt: link.expiresAt }) } const checkout = await pay.checkout({ order: org.id, amount: '$29.00', currency: 'USD', successUrl: `${url.origin}/billing/done`, cancelUrl: `${url.origin}/billing`, idempotencyKey: `checkout:${org.id}`, }) return json(200, checkout) } const accept = /^\/invites\/([^/]+)\/accept$/.exec(path) if (accept && req.method === 'POST') { const invite = invites.get(accept[1] as string) if (!invite) return json(404, { error: 'no such invite' }) invites.delete(accept[1] as string) orgs.get(invite.org)?.members.set(user, invite.role) return json(200, { org: invite.org, role: invite.role }) } return json(404, { error: 'not found' }) } return async (req) => { try { return await answer(req) } catch (e) { if (e instanceof MainError) return json(e.status, { code: e.code, title: e.title, fix: e.fix }) throw e } } } const handler = createSaasWeb() /** The entry `mainsdk dev app.ts` serves. */ export default handler ``` --- Source: https://mainsdk.dev/docs/guides/verify-a-release # Verify a release > Check a MainSDK release before you install it: checksums, Sigstore signatures, SLSA provenance and the SBOM, with one command. Status: Planned for 0.5 · Canonical: https://mainsdk.dev/docs/guides/verify-a-release · Updated: 2026-10-28 Every MainSDK release candidate ships with checksums, a Sigstore signature per package, SLSA build provenance and a CycloneDX SBOM. Check them before you install. ## Check a downloaded release Download the release assets into a folder, then run the verifier from a clone of the repository: ```bash python3 tools/verify_release.py ./release --require-cosign --commit ``` It checks that: - every package matches its line in `SHA256SUMS` - every package has a Sigstore bundle that signs the same digest (and, with [cosign](https://docs.sigstore.dev/) installed, that the signature verifies) - the provenance names every package and the commit the release was built from - the SBOM is a CycloneDX document It exits 0 when all pass and 1 naming each file that fails. Without cosign it still checks the structure and says so; `--require-cosign` turns that gap into a failure. ## What publishing needs No package is published until the owner approves its name in `.github/release-approvals.yaml`. Until then the release workflow runs a dry run only and says that publishing is blocked pending owner approval. --- Source: https://mainsdk.dev/docs/guides/reference-api-service # API service reference > A Node API with signed webhooks, jobs with a dead-letter queue and an outbound partner call, on the SOC 2 baseline. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/reference-api-service · Updated: 2026-10-28 Start from it: `npx mainsdk init --template mainsdk/api-service` The reference architecture for an HTTP API service: a verified inbound webhook, background jobs that retry and then land in a dead-letter queue, and sign-in for the routes people call. Pack: SOC 2 baseline. The handler is a plain `fetch` function, so Hono or Express can sit in front of it. The interfaces (`webhooks`, `jobs`, `auth`, `http`) stay the same. Runs with no account and no provider key: `npm run dev` serves it on local drivers. `npm test` runs the tests: a delivery with a bad signature is refused before the handler, a job that fails three times lands in the dead-letter queue. Every change and every MainSDK release runs `.github/workflows/reference.yml`: `mainsdk check`, `mainsdk drill restore --to scratch` and `mainsdk assure --standard soc2`, `npm run bench` and `npm run chaos` (the partner down, slow or answering nonsense must never lose a delivery). `npm run verify-ci` fails when a job is removed. Moving off Express with raw provider SDKs? `mainsdk migrate raw` ends here. ## Performance The benchmark harness fails a change when p95 goes over 25 ms on local drivers (`npm run bench`). ## main.yaml ```yaml schema: 1 project: prj_template_api_service baseline: main27 auth: driver: local methods: [passkeys, link] admin: { require: phishing-resistant } db: driver: local jobs: driver: local dead-letter: true webhooks: inbound: github: { preset: github, secret: secret:GITHUB_WEBHOOK_SECRET, path: /webhooks/github } logging: audit: true retention: 12m network: allow: [status.partner.dev] default: deny mode: observe http: timeout: 2s retries: { attempts: 1, backoff: 100ms, max: 1s } # Evidence for `mainsdk assure --standard soc2`: change control is on (a pull request and a green gate before every merge), # and the providers the app runs on are registered. compliance: change-control: true providers: [local, ./backup.ts] backup: rpo: 15m rto: 1h keep: 7d drill: { every: 30d, to: scratch } driver: ./backup.ts ``` ## app.ts ```ts // The API service reference architecture: an HTTP service with a verified inbound webhook, a background job that // retries and then lands in the dead-letter queue, and sign-in for the routes people call. Hono or Express can // replace the fetch handler; the interfaces (webhooks, jobs, auth) stay the same. import { auth, http, jobs, MainError, webhooks } from 'mainsdk' const synced: string[] = [] /** * The job the webhook queues: ask the partner whether it is ready, then record the sync. A partner that is down, slow * or answering nonsense fails the attempt; the queue retries it and then keeps it in the dead-letter queue, so no * delivery is lost. A repository named `broken` always fails, to show the retries. */ async function syncRepo(input: { repo: string }): Promise { if (input.repo === 'broken') throw new MainError('MAIN-APP-0001', { detail: `Could not sync ${input.repo}`, status: 502 }) const res = await http.fetch('https://status.partner.dev/v1/ready') const body = (await res.json()) as { ready?: boolean } if (!res.ok || body.ready !== true) throw new MainError('MAIN-APP-0002', { detail: 'The partner is not ready', status: 503 }) synced.push(input.repo) } /** The inbound webhook: the signature is checked over the raw body before this runs; a bad one never gets here. */ async function onDelivery(body: unknown, meta: { id: string }): Promise { const repo = String((body as { repository?: { name?: string } }).repository?.name ?? '') // One job per delivery: the provider's delivery id is the idempotency key, so a duplicate queues nothing. await jobs.run('repo.sync', { repo }, { idempotencyKey: meta.id, retries: 2, backoff: 'fixed' }) } /** Register the handlers on the running app. It is cheap and safe to repeat, so every request does it. */ function wire(): ReturnType { jobs.on('repo.sync', syncRepo) return webhooks.receive('github', onDelivery) } const json = (status: number, body: unknown): Response => Response.json(body, { status }) /** The service: its routes, with every interface refusal answered as a problem and never a crash. */ export default async function fetch(req: Request): Promise { try { const receive = wire() const { pathname } = new URL(req.url) if (req.method === 'GET' && pathname === '/health') return json(200, { ok: true }) if (req.method === 'POST' && pathname === '/webhooks/github') return await receive(req) await auth.require(req) if (req.method === 'GET' && pathname === '/repos') return json(200, { synced }) if (req.method === 'GET' && pathname === '/jobs/dead') return json(200, await jobs.dead.list()) return json(404, { error: 'not found' }) } catch (e) { if (e instanceof MainError) return json(e.status, { code: e.code, title: e.title, fix: e.fix }) throw e } } ``` --- Source: https://mainsdk.dev/docs/guides/migrate-from-better-auth # Migrate from Better Auth > Move Better Auth server and client sign-in calls onto the MainSDK auth interface, keeping your users until you choose to move them. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/migrate-from-better-auth · Updated: 2026-10-28 Move Better Auth server and client sign-in calls onto the MainSDK auth interface, keeping your users until you choose to move them. Your Better Auth account stays; nothing here deletes it. **Before you start:** - A git repository with a clean working tree. `mainsdk migrate` works on a branch and never touches yours. - Node 22 and `npx mainsdk`. No `main.yaml` yet? `migrate` writes one; or start with [Adopt an existing app](https://mainsdk.dev/docs/getting-started/existing-app). - Your Better Auth keys stay where they are. Move them to `secret:` references when the driver is chosen (see [Secrets](https://mainsdk.dev/docs/interfaces/secrets)). ## 1. Read the report ```text npx mainsdk migrate better-auth --report-only ``` The report writes no file. It sorts every Better Auth call in your code into three lists: **mapped** (an interface call replaces it), **raw** (it stays behind `raw()`, with the reason) and **needs a person** (a policy, a rule file or a call with no interface). Add `--json` for a machine-readable report. For the sample app of this guide (`packages/cli/test/fixtures/migrate/better-auth`), the report finds 2 mapped, 0 raw and 1 for a person. ## 2. What the codemod changes | Where | Before | After | | --- | --- | --- | | `app/api/me/route.ts:4` | `const session = await auth.api.getSession({ headers: req.headers })` | `auth.require(req)` | | `lib/client.ts:6` | `authClient.signIn.email({ email, password })` | `auth.signIn()` | Each rewrite is mechanical and one line at a time. Imports of Better Auth go when no call needs them, and `mainsdk` is added to `package.json`. ## 3. What stays behind raw() - Nothing in the sample stays behind raw(). A call behind `raw()` keeps working through the `Better Auth` host on your network allow list, and `mainsdk check` still sees it. Each one has a marker comment, `// mainsdk migrate: ...`, so you can find it later. ## 4. What needs a person - `lib/auth.ts:3`: no interface call for betterAuth(; a person decides The codemod leaves these lines alone and marks them. No tool should guess a policy. ## 5. Stateful moves are guided jobs Data does not move with the code. Each stateful move is a job with steps and a rollback, listed in the pull request and never run by the codemod. ### users: Keep Better Auth as the auth driver; move nothing 1. Set auth.driver: better-auth in main.yaml; it reads the same tables. 2. Replace calls one module at a time and run mainsdk check after each. Rollback: Revert the pull request; no data moved. ## 6. Open the pull request ```text npx mainsdk migrate better-auth ``` This records your sign-in and checkout flows as locked flows first, then creates the branch `migrate/better-auth`, applies the codemods, adds `.github/workflows/mainsdk-migrate.yml`, runs `mainsdk check` and the locked flows, commits, pushes and opens one pull request with `gh`. If a gate fails, the branch is kept and no pull request opens (`MAIN-MIGRATE-0001`). `--no-pr` stops after the commit. Locked flows run again in CI: `npx mainsdk migrate flows`. ## Where you end You end at the reference architecture [`mainsdk/saas-web`](https://mainsdk.dev/docs/guides/reference-saas-web): the same shape, with its tests, benchmark and chaos drills. ```text npx mainsdk init --template mainsdk/saas-web ``` --- Source: https://mainsdk.dev/docs/guides/try-a-policy-in-the-playground # Try a policy in the playground > Run a Cedar or Rego policy on a request in the browser or the terminal, see the line that decided, run its tests, replay recorded decisions and share the state as a link. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/try-a-policy-in-the-playground · Updated: 2026-10-28 Before a policy change reaches your app, try it on a request. The playground shows the decision, the line that decided it, which tests pass, and which recorded decisions the change would flip. It takes about 10 minutes. The browser and the terminal use the same engine, so they print the same result. ## You need - A policy in `policies/` (see [Keep policies in git](https://mainsdk.dev/docs/guides/keep-policies-in-git)). - A request as JSON, for example `request.json`. ```json title="request.json" { "subject": { "type": "User", "id": "u_ana", "role": "editor" }, "action": { "name": "orders.update" }, "resource": { "type": "Order", "id": "o_1" }, "context": { "amount": 12000 } } ``` ## 1. Decide in the terminal ```bash npx mainsdk policy play policies --request request.json ``` You should see `decision: deny` and `decided by: … at policy.cedar:7`, the line of the rule that decided. ## 2. Run the tests Put cases in `policies/orders.test.json` as `{ "tests": [{ "name": "…", "input": { … }, "expect": "allow" }] }`. The command prints each failing test and exits 1 when one fails. ## 3. See what a change would flip ```bash npx mainsdk policy play policies --request request.json --replay .mainsdk/decisions.jsonl ``` Each line starting `FLIP allow -> deny` is a recorded decision the new policy would answer differently. A replay can hold up to 100,000 decisions. ## 4. Use the browser page Open the playground in the docs. Paste the policy and request, press Run, and read the same result. The page runs entirely in your browser; nothing is sent until you press Share. ## 5. Share a state Share makes a short getqr.id link that opens your policy, request and tests read-only. The state travels in the link itself. Open it and press Fork to get an editable copy; the original link never changes. ## Check it worked The JSON from `mainsdk policy play --json` and the browser's result for the same input are identical. A tampered or unreadable link shows `MAIN-PLAY-0003` and leaves the page empty. --- Source: https://mainsdk.dev/docs/guides/migrate-from-stripe # Migrate from Stripe > Move Stripe checkout, refunds and webhooks onto MainSDK pay with hosted checkout, idempotency keys and verified webhooks. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/migrate-from-stripe · Updated: 2026-10-28 Move Stripe checkout, refunds and webhooks onto MainSDK pay with hosted checkout, idempotency keys and verified webhooks. Your Stripe account stays; nothing here deletes it. **Before you start:** - A git repository with a clean working tree. `mainsdk migrate` works on a branch and never touches yours. - Node 22 and `npx mainsdk`. No `main.yaml` yet? `migrate` writes one; or start with [Adopt an existing app](https://mainsdk.dev/docs/getting-started/existing-app). - Your Stripe keys stay where they are. Move them to `secret:` references when the driver is chosen (see [Secrets](https://mainsdk.dev/docs/interfaces/secrets)). ## 1. Read the report ```text npx mainsdk migrate stripe --report-only ``` The report writes no file. It sorts every Stripe call in your code into three lists: **mapped** (an interface call replaces it), **raw** (it stays behind `raw()`, with the reason) and **needs a person** (a policy, a rule file or a call with no interface). Add `--json` for a machine-readable report. For the sample app of this guide (`packages/cli/test/fixtures/migrate/stripe`), the report finds 3 mapped, 0 raw and 0 for a person. ## 2. What the codemod changes | Where | Before | After | | --- | --- | --- | | `app/api/checkout/route.ts:5` | `const session = await stripe.checkout.sessions.create({` | `pay.checkout()` | | `app/api/refund/route.ts:5` | `return Response.json(await stripe.refunds.create({ charge }))` | `pay.refund()` | | `app/api/webhook/route.ts:4` | `const event = stripe.webhooks.constructEvent(` | `pay.webhook()` | Each rewrite is mechanical and one line at a time. Imports of Stripe go when no call needs them, and `mainsdk` is added to `package.json`. ## 3. What stays behind raw() - Nothing in the sample stays behind raw(). A call behind `raw()` keeps working through the `Stripe` host on your network allow list, and `mainsdk check` still sees it. Each one has a marker comment, `// mainsdk migrate: ...`, so you can find it later. ## 4. What needs a person - Nothing in the sample needs a person. The codemod leaves these lines alone and marks them. No tool should guess a policy. ## 5. Stateful moves are guided jobs Data does not move with the code. Each stateful move is a job with steps and a rollback, listed in the pull request and never run by the codemod. ### webhooks: Point the Stripe webhook endpoint at the verified MainSDK route 1. Add the new endpoint in the Stripe dashboard with its own signing secret, stored as a secret: reference. 2. Send test events and compare the audit log with the old endpoint. 3. Keep both endpoints for seven days, then remove the old one. Rollback: Re-enable the old endpoint; Stripe delivers to both, handlers are idempotent. ### customers: Customers and subscriptions stay in Stripe; the driver reads them 1. Set pay.driver: stripe with the live key as a secret: reference. 2. Run one test-mode checkout end to end. Rollback: Revert the pull request; no Stripe data moved. ## 6. Open the pull request ```text npx mainsdk migrate stripe ``` This records your sign-in and checkout flows as locked flows first, then creates the branch `migrate/stripe`, applies the codemods, adds `.github/workflows/mainsdk-migrate.yml`, runs `mainsdk check` and the locked flows, commits, pushes and opens one pull request with `gh`. If a gate fails, the branch is kept and no pull request opens (`MAIN-MIGRATE-0001`). `--no-pr` stops after the commit. Locked flows run again in CI: `npx mainsdk migrate flows`. ## Where you end You end at the reference architecture [`mainsdk/payments-checkout`](https://mainsdk.dev/docs/guides/reference-payments-checkout): the same shape, with its tests, benchmark and chaos drills. ```text npx mainsdk init --template mainsdk/payments-checkout ``` --- Source: https://mainsdk.dev/docs/guides/reference-payments-checkout # Payments checkout reference > Hosted card fields, one charge per order and keys in KMS, in SAQ A scope for PCI DSS 4.0.1. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/reference-payments-checkout · Updated: 2026-10-28 Start from it: `npx mainsdk init --template mainsdk/payments-checkout` The reference architecture for taking payments: a page that mounts the provider's hosted fields, a checkout route that charges a stored total once per order, and a verified webhook. Pack: Payments (PCI DSS 4.0.1, SAQ A). Card data goes from the buyer's browser to Stripe and never reaches this app, so the page has no card field and the app stays in SAQ A scope. Stripe's keys are `secret:` references read from AWS Secrets Manager in production, and record keys are wrapped by AWS KMS (`main.env.prod.yaml`). Runs with no account and no key: `npm run dev` uses the local test-card driver. `npm test` runs the tests. Every change and every MainSDK release runs `.github/workflows/reference.yml`: `mainsdk check`, `mainsdk assure --standard pci-dss-4.0.1`, `npm run bench` and `npm run chaos` (Stripe down, slow or answering nonsense must never charge an order twice). `npm run verify-ci` fails when a job is removed. Moving off Stripe called directly? `mainsdk migrate stripe` ends here. ## Performance The benchmark harness fails a change when p95 goes over 25 ms on local drivers (`npm run bench`). ## main.yaml ```yaml schema: 1 project: prj_template_payments_checkout baseline: main27 industry: [payments] auth: driver: local methods: [passkeys, link] admin: { require: phishing-resistant } db: driver: local pay: driver: local keys: driver: local webhooks: inbound: stripe: { preset: stripe, secret: secret:STRIPE_WEBHOOK_SECRET, path: /webhooks/stripe } logging: audit: true retention: 12m redact: [personal, sensitive] network: # js.stripe.com is loaded by the buyer's browser from the checkout page, never fetched by this server; it is listed # because the page source names it, and the PCI script inventory below records why. allow: [js.stripe.com] default: deny mode: observe compliance: pages: [checkout-page.ts] scripts: - { host: js.stripe.com, reason: Stripe hosted fields, owner: payments-team } ``` ## app.ts ```ts // The payments checkout reference architecture: a page that mounts the provider's hosted fields (card data goes from // the buyer's browser to Stripe and never touches this app, so the scope is PCI DSS 4.0.1 SAQ A), a checkout route // that charges a server-side total once per order, and a verified webhook that marks the order paid. import { app, auth, MainError, pay } from 'mainsdk' import { CHECKOUT_PAGE } from './checkout-page.ts' export { CHECKOUT_PAGE } type Order = { id: string user: string amountMinor: number currency: string status: 'open' | 'paid' } const orders = new Map() const byCheckout = new Map() const sessions = new Map() const paidEarly = new Set() const attempts = new Map() const wired = new WeakSet() /** Register the payment handlers once per running app. */ function wire(): void { const current = app() if (wired.has(current)) return wired.add(current) pay.on('payment.succeeded', (event) => { const sid = String((event.data as { id?: string }).id) const order = orders.get(byCheckout.get(sid) ?? '') // A test driver can confirm before checkout() returns; remember it until the session is known. if (order) order.status = 'paid' else paidEarly.add(sid) }) } const json = (status: number, body: unknown): Response => Response.json(body, { status }) /** The service: every refusal from an interface is answered as a problem and never a crash. */ export default async function fetch(req: Request): Promise { try { wire() const { pathname, origin } = new URL(req.url) if (req.method === 'GET' && pathname === '/') return new Response(CHECKOUT_PAGE, { headers: { 'content-type': 'text/html; charset=utf-8' }, }) if (req.method === 'GET' && pathname === '/health') return json(200, { ok: true }) if (req.method === 'POST' && pathname === '/webhooks/stripe') { const { status, body } = await pay.handleWebhook(await req.text(), req.headers) return new Response(body, { status }) } const { user } = await auth.require(req) if (req.method === 'POST' && pathname === '/orders') { const body = (await req.json()) as { amountMinor?: number; currency?: string } const amountMinor = Number(body.amountMinor) if (!Number.isInteger(amountMinor) || amountMinor <= 0) throw new MainError('MAIN-APP-0003', { detail: 'The amount must be a positive whole number of minor units', status: 400, }) const order: Order = { id: crypto.randomUUID(), user, amountMinor, currency: body.currency ?? 'USD', status: 'open', } orders.set(order.id, order) return json(201, order) } const m = /^\/orders\/([^/]+)(\/checkout)?$/.exec(pathname) const order = m ? orders.get(m[1] as string) : undefined if (m && (!order || order.user !== user)) return json(404, { error: 'not found' }) if (order && !m?.[2] && req.method === 'GET') return json(200, order) if (order && m?.[2] && req.method === 'POST') { // One session per order: a second click returns the first, so the order is charged once. const known = sessions.get(order.id) if (known) return json(200, known) // The amount is the stored total, never a number from the browser; the order id is the idempotency key. const session = await pay.checkout({ order: order.id, amount: order.amountMinor, currency: order.currency, successUrl: `${origin}/orders/${order.id}`, cancelUrl: `${origin}/`, idempotencyKey: `checkout:${order.id}:${attempts.get(order.id) ?? 0}`, }) attempts.set(order.id, (attempts.get(order.id) ?? 0) + 1) // a bad answer must not be replayed on retry const sid = (session as { id?: unknown }).id if (typeof sid !== 'string' || !/^cs_/.test(sid)) throw new MainError('MAIN-APP-0003', { detail: 'The payment provider answered without a checkout session', status: 502, }) byCheckout.set(sid, order.id) if (paidEarly.delete(sid)) order.status = 'paid' sessions.set(order.id, session) return json(200, session) } return json(404, { error: 'not found' }) } catch (e) { if (e instanceof MainError) return json(e.status, { code: e.code, title: e.title, fix: e.fix }) throw e } } ``` ## checkout-page.ts ```ts title="checkout-page.ts" // The payment page, the one file the PCI DSS script inventory and page integrity controls watch (compliance.pages). /** The page. It holds no card field: the provider's script puts its own iframe into the empty container. */ export const CHECKOUT_PAGE = ` Pay

Pay

` ``` --- Source: https://mainsdk.dev/docs/guides/reference-ai-agent # AI agent app reference > MCP tools from actions, agent identity and budget, human confirmation for destructive calls, EU AI Act disclosure. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/reference-ai-agent · Updated: 2026-10-28 Start from it: `npx mainsdk init --template mainsdk/ai-agent` The reference architecture for an agent that acts: app actions become MCP tools, the agent has its own identity and daily budget, and a destructive call waits for a person. Standard: EU AI Act (disclosure, human oversight, logging). - `orders.read` is a read tool and runs. `orders.refund` is destructive: an MCP client or agent gets `MAIN-MCP-0005` until a person confirms, and the confirmed call is held as a pending approval that `agents.approve` releases. - Every audit record of an agent call names the agent as the actor (`actor.kind: agent`). - Models are called through `ai`, never a model SDK, so price, budget and logging apply. Every answer to a person carries the disclosure line (`disclose()`). Runs with no account and no key: `npm run dev` uses the scripted model. `npm test` runs the tests. Every change and every MainSDK release runs `.github/workflows/reference.yml`: `mainsdk check`, `mainsdk assure --standard eu-ai-act-2024-1689`, `npm run bench` and `npm run chaos`. `npm run verify-ci` fails when a job is removed. Coming from direct model SDK calls? `mainsdk migrate raw` ends here when most calls are model calls. ## Performance The benchmark harness fails a change when p95 goes over 25 ms on local drivers (`npm run bench`). ## main.yaml ```yaml schema: 1 project: prj_template_ai_agent baseline: main27 access: roles: support: [orders.*] member: [orders.read] agents: support-bot: budget: { spend: $5/day, calls: 500/day } approve: { risk: destructive, spend-over: $1 } mcp: confirm: { write: false, destructive: true } ai: models: ['fake/*'] logging: audit: true retention: 12m redact: [personal, sensitive] network: allow: [] default: deny mode: observe ``` ## app.ts ```ts // The AI agent reference architecture: app actions become MCP tools, an agent acts under its own identity and budget, // a destructive call waits for a person, and every audit record names the agent. Models are called through `ai`, never // a model SDK directly, and the answer carries the AI disclosure text. import { action, app, s } from 'mainsdk' /** The line every agent answer carries: the person knows they talk to an AI (EU AI Act, article 50). */ export const DISCLOSURE = 'You are talking to an AI assistant.' const orderId = s.object({ orderId: s.string() }) /** Read one order: allowed for a member, no confirmation. */ export const readOrder = action({ name: 'orders.read', intent: 'Read one order', input: orderId, risk: 'read', mcp: true, resource: (i) => ({ type: 'orders', id: i.orderId, owner: i.orderId.split(':')[0] ?? '' }), run: ({ input }) => ({ order: input.orderId, status: 'shipped' }), }) /** Refund one order: destructive, so an agent or an MCP client waits for a person's yes. */ export const refundOrder = action({ name: 'orders.refund', intent: 'Refund one order', input: orderId, risk: 'destructive', mcp: true, spend: () => '$0.50', run: ({ input }) => ({ refunded: input.orderId }), }) /** The MCP endpoint of the running app: tools are the actions marked `mcp: true`. */ export function mcpHandler( identify: Parameters['mcpServer']>[0]['identify'], ) { const a = app() return a.mcpServer({ actions: [readOrder, refundOrder] as never, ...(identify ? { identify } : {}), // The person's answer to "may the agent do this?"; without a person present the answer is no. elicit: (c) => Promise.resolve((c.user as { confirm?: boolean } | undefined)?.confirm === true), log: a.log, }) } /** Add the disclosure to an answer the agent gives a person. */ export function disclose(text: string): string { return `${text}\n\n${DISCLOSURE}` } ``` --- Source: https://mainsdk.dev/docs/guides/testing # Test your app > Test Pinecart with createTestApp from @mainsdk/testing - in-memory fakes, assertions, time control, chaos drills and policy tests, with no provider keys. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/testing · Updated: 2026-10-28 Write tests for code that uses MainSDK and run them with no network and no provider keys. `createTestApp` from `@mainsdk/testing` puts every interface on an in-memory fake generated from the same contracts as the SDK, so a fake can't drift from production. The full API is on [Testing kit](https://mainsdk.dev/docs/interfaces/testing); this guide walks through the common tasks. > **Note:** The testing kit ships in phase 1, milestone 0.4. **Before you start:** a MainSDK app with a `main.yaml`, and a test runner. Node's built-in runner (`node:test`) is the example; any runner works. ## 1. Install the kit npm: ```bash npm install --save-dev @mainsdk/testing ``` pnpm: ```bash pnpm add -D @mainsdk/testing ``` bun: ```bash bun add -d @mainsdk/testing ``` ## 2. Write a test with fakes `createTestApp({ main: "./main.yaml" })` reads your `main.yaml` and puts every interface on its fake. Fakes enforce the same guarantees as production: `email.marketing()` without an unsubscribe link fails in a test too. ```ts title="tests/checkout.test.ts" import { describe, it } from "node:test"; import { createTestApp, expect, mailbox, type TestApp } from "@mainsdk/testing"; // Your own code, from app/lib/orders.ts: it takes the app so a test can pass the fake one. declare function placeOrder(app: TestApp, order: { email: string; amount: number; currency: string }): Promise; describe("checkout", () => { it("charges once and sends a receipt", async () => { const app = createTestApp({ main: "./main.yaml" }); await placeOrder(app, { email: "a@pinecart.dev", amount: 4900, currency: "INR" }); expect(app.pay).toHaveCharged({ amount: 4900, currency: "INR", times: 1 }); mailbox(app).toHaveSent({ to: "a@pinecart.dev", template: "receipt" }); }); }); ``` ## 3. Run it and verify npm: ```bash node --test tests/checkout.test.ts ``` pnpm: ```bash pnpm exec node --test tests/checkout.test.ts ``` The test passes with no provider account. To see a useful failure, change `times: 1` to `times: 2`: the assertion fails and lists the one charge that did happen. ## Control time Expiring links, TTLs and retries run without real waiting: ```ts title="tests/share.test.ts" import { it } from "node:test"; import { createTestApp, clock } from "@mainsdk/testing"; import { share } from "mainsdk"; it("share links expire after 24 hours", async () => { const app = createTestApp({ main: "./main.yaml" }); const { url } = await share({ url: "https://pinecart.dev/orders/o_1" }, { expiresIn: "24h" }); clock.advance("25h"); // resolving url now reports it expired }); ``` ## Test policies ```ts title="tests/policy.test.ts" import { it } from "node:test"; import { policyTest } from "@mainsdk/testing"; it("only owners update orders", () => { policyTest("policies/") .expect({ subject: { type: "user", id: "u_2" }, action: "orders.update", resource: { type: "order", id: "o_1", owner: "u_1" }, }) .toDecide("deny") .byRule("orders.owner_only"); }); ``` From the terminal, `mainsdk test policy` runs your policy tests and replays recorded decisions, listing every decision a change would flip. See [Policy as code](https://mainsdk.dev/docs/concepts/policy). ## Prove your fallbacks with chaos drills Inject an outage, added latency or a malformed response into one driver and check that the fallback in `main.yaml` takes over. ```yaml title="main.yaml" fallback: email: [resend, ses] ``` ```ts title="tests/fallback.test.ts" import { it } from "node:test"; import { createTestApp, chaos, mailbox, type TestApp } from "@mainsdk/testing"; // Your own code, from app/lib/receipts.ts. declare function sendReceipt(app: TestApp, orderId: string): Promise; it("fails over to ses when resend is down", async () => { const app = createTestApp({ main: "./main.yaml" }); chaos.on("email:resend", { outage: true }); await sendReceipt(app, "o_1"); mailbox(app).toHaveSent({ template: "receipt", times: 1 }); }); ``` With `{ latency: "3s" }` the call times out per contract and fails over; with `{ respond: "malformed" }` it raises a typed error. ## Build and test your own driver The driver kit lets anyone build a driver for an interface. `mainsdk driver test` runs exactly the conformance suite first-party drivers pass, against a local harness with fake traffic. ```ts title="relaymail/index.ts" import { defineDriver, type EmailMessage } from "mainsdk"; export default defineDriver({ implements: "email@1", id: "relaymail", manifest: { regions: ["us", "eu", "in"], data: ["email-address", "message-body"], subprocessors: ["Relaymail Inc."], }, async send(msg: EmailMessage, ctx: { http: { post(path: string, body: unknown): Promise<{ id: string }> } }) { const res = await ctx.http.post("/v1/send", msg); return { id: res.id }; }, }); ``` ```bash npx mainsdk driver test ./relaymail --interface email@1.4 ``` ```text email@1.4 conformance: 86 passed, 0 failed · capability profile: attachments=no · idempotency=sdk ``` A driver that calls a host it didn't declare fails with `MAIN-NET-0002`. `defineDriver` comes from `mainsdk` today. Whether the driver kit becomes its own package or part of `@mainsdk/testing` is still open. ## Run another implementation against the golden vectors The golden test vectors are plain data, so a port to another language can prove it behaves the same: ```bash npx mainsdk conformance export --out vectors/ npx mainsdk conformance run --impl "./my-port --vectors" --only config --json ``` `export` writes the vectors, and with `--check` it fails when the committed copy is stale. `run` feeds them to the command you name and exits 1 when a vector fails. ## Next - [Testing kit](https://mainsdk.dev/docs/interfaces/testing) - [Swap a driver](https://mainsdk.dev/docs/guides/swap-a-driver) - [The check](https://mainsdk.dev/docs/concepts/the-check) --- Source: https://mainsdk.dev/docs/guides/call-a-model # Call a model > Call a model through ai with limits and audit, and get typed output. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/call-a-model · Updated: 2026-10-28 You will configure a driver, call a model, and get output checked against a schema. It takes about 15 minutes. ## You need - A provider key in your secrets manager (for example `secret:ANTHROPIC_API_KEY`). For a first run with no key, use `driver: local`: it needs no account and no network and prices every call at `$0`. ## 1. Configure ```yaml title="main.yaml" ai: driver: anthropic models: [anthropic/claude-*] limits: { per-user: $1/day, total: $500/month } prices: anthropic/claude-sonnet-4-5: { input: $3, output: $15 } # a model with no price cannot be called ``` ## 2. Generate text ```ts title="app/api/summary/route.ts" import { ai } from "mainsdk"; const user = { id: "u_1" }; const order = { id: "o_1" }; // your own order record const text = await ai.generate({ model: "anthropic/claude-sonnet-4-5", prompt: `Summarise order ${order.id}`, feature: "order-summary", user, }); ``` `feature` and `user` are what spend limits and the audit log group by. The audit record holds the model, tokens and cost, never the prompt. ## 3. Get typed data ```ts title="lib/extract.ts" import { ai } from "mainsdk"; import { s } from "mainsdk/schema"; const user = { id: "u_1" }; const emailText = "Invoice 42, total $120, due 1 Dec"; // the email body const Invoice = s.object({ total: s.string(), due: s.string() }); const found = await ai.extract({ input: emailText, schema: Invoice, model: "anthropic/claude-sonnet-4-5", feature: "invoice-extract", user, }); console.log(found.value.total); ``` Invalid output is repaired once (up to twice with `repairs`), then refused with `MAIN-AI-0011`. It is never returned. ## Check it worked A call over the per-user limit fails with `MAIN-AI-0007` before anything reaches the provider. With `driver: local` and `per-user: $0/day` you can see it with no account. ## Fail a change that makes a feature worse Put an eval set next to the feature, then run it before every merge: ```bash npx mainsdk eval summarise --json npx mainsdk eval summarise --update-baseline ``` `mainsdk eval` exits 1 when a score drops below its threshold or below the saved baseline. `--update-baseline` writes the baseline only when the run passes, and `--max-cost` stops the run before it spends more than you allow. `mainsdk eval samples summarise --export samples.json` exports the recorded samples for review. ## Next - [AI calls](https://mainsdk.dev/docs/interfaces/ai) - [Extract structured data](https://mainsdk.dev/docs/guides/extract-structured-data) --- Source: https://mainsdk.dev/docs/guides/monorepos # Monorepos > Run several MainSDK apps in one repository with a shared main.workspace.yaml, one main.lock, and mainsdk check only on what a change touches. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/monorepos · Updated: 2026-10-28 Share policies, drivers and overlays across several apps in one repository, and check only what a change touches. Each app keeps its own `main.yaml`; one optional `main.workspace.yaml` at the root holds what they share. It is configuration only and adds no runtime code to your bundles. > **Note:** Monorepo support ships in phase 2, milestone 0.7. In phase 1, `main.workspace.yaml` is read as a file with inheritance only; per-package `check` and `sync` come in 0.7. **Before you start:** a pnpm, npm or Yarn workspace, or a Turborepo or Nx repo, with at least one app that has a `main.yaml`. ## 1. Add the workspace file ```text pinecart/ main.workspace.yaml # shared policies, drivers, overlays main.lock # one per workspace policies/ apps/web/main.yaml # Pinecart web app, Next.js apps/api/main.yaml # Hono API packages/ui/ ``` ```yaml title="main.workspace.yaml" schema: 1 packages: auto # from pnpm, npm or Yarn workspaces, Turborepo or Nx policies: [policies/] providers: { email: resend } overlays: { order: [market, segment, env] } network: { default: deny, allow: ["*.pinecart.dev"] } ``` ```yaml title="apps/web/main.yaml" schema: 1 project: prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY baseline: main27 targets: { web: { framework: nextjs } } auth: { driver: github } ``` The web app inherits `providers.email: resend`, the policies and the network list. Packages are discovered from pnpm, npm and Yarn workspaces, Turborepo and Nx. Inheritance runs org, workspace, app, overlays; see [main.yaml and overlays](https://mainsdk.dev/docs/concepts/main-yaml). ## 2. Tighten, never weaken An app may tighten what the workspace sets. Weakening it needs an exception with an owner and an expiry. ```yaml title="apps/api/main.yaml" network: { default: allow } # weakens the workspace exceptions: - rule: net.default_deny reason: Partner VPN gateway until the move to a fixed host owner: "@pinecart/platform" expires: 2026-11-30 ``` Without the exception, `check` fails and names the weakened key. An expired exception fails like no exception. ## 3. Verify where each value comes from ```bash npx mainsdk explain config --package apps/web ``` ```text $ npx mainsdk explain config --package apps/web schema = 1 (apps/web/main.yaml:1) packages = auto (main.workspace.yaml:2) providers.email = resend (main.workspace.yaml:4) network.default = deny (main.workspace.yaml:6) project = web (apps/web/main.yaml:2) auth.driver = github (apps/web/main.yaml:5) ``` Each value names the file and line it came from. Add `--json` for tools. ## 4. Check only what changed ```bash npx mainsdk check --affected --base origin/develop ``` `--affected` checks the packages a change touches, the packages that depend on them, and every app when `main.workspace.yaml` or `main.lock` changes. Use `--package apps/web` for one app or `--all` for everything. `sync` takes the same flags. ```yaml title=".github/workflows/mainsdk.yml" name: mainsdk on: [pull_request] jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 0 } - uses: pnpm/action-setup@v4 - run: pnpm install --frozen-lockfile - run: pnpm mainsdk check --affected --base origin/develop ``` ## 5. Keep one lock for every app ```bash npx mainsdk sync --all ``` `mainsdk sync --all` writes one `main.lock` at the root, so every app resolves the same contract versions, with hashes. No app folder has its own lock. ```yaml title="main.lock" lock: 1 interfaces: email: { range: "^1.2", resolved: "1.4.0" } drivers: resend: version: 2.3.1 implements: ["email@^1.2"] sha256: "9f2c…" ``` Org-wide defaults for repos outside this workspace live in `main.org.yaml`. ## Next - [main.yaml and overlays](https://mainsdk.dev/docs/concepts/main-yaml) - [main.yaml reference](https://mainsdk.dev/docs/reference/main-yaml) - [CLI reference](https://mainsdk.dev/docs/reference/cli) --- Source: https://mainsdk.dev/docs/guides/generate-a-video # Generate a video > Start a video job with ai.video, let the host finish it, and get a labelled file back. Status: Planned for 0.6 · Canonical: https://mainsdk.dev/docs/guides/generate-a-video · Updated: 2026-10-28 You will start a video as a durable job, wait for it to finish, and read the file it wrote. It takes about 10 minutes and needs no provider account for the first run. ## You need - `files` set up, because the finished clip is written there (set `files.maxSize` large enough for a clip). - For a real clip, a video provider key as a secret, such as `secret:OPENAI_API_KEY`. ## 1. Configure ```yaml title="main.yaml" ai: models: [openai/*] video: { driver: openai, max-seconds: 8 } # openai | google | gateway prices: openai/sora-2: { video: { "1280x720": "$0.10/s" } } # a model with no price cannot be called ``` ## 2. Start a job ```ts title="app/api/clip/route.ts" import { ai } from "mainsdk"; export async function startClip(user: { id: string }) { const job = await ai.video.generate({ model: "openai/sora-2", prompt: "A paper boat on a stream", seconds: 4, feature: "promo", user, maxCost: "$1", // refused before anything is submitted when the worst case is higher }); return Response.json({ id: job.id, state: job.state }); // queued; nothing waits for the provider } ``` The request returns at once. A durable workflow submits the job, waits, fetches the output into `files` and finishes, even across a restart. ## 3. Read the result ```ts title="app/api/clip/[id]/route.ts" import { ai } from "mainsdk"; export async function readClip(id: string) { const job = await ai.video.get(id); // queued, running, ready, failed or cancelled return job.state === "ready" ? Response.json({ file: job.file, cost: job.cost }) : Response.json({ state: job.state }); } ``` `ai.video.cancel(id, { reason })` stops the job at the provider and records any cost already billed. ## Check it worked Run with `driver: fake` and ask for more seconds than `max-seconds`: the call fails with `MAIN-AI-0050` and nothing is sent. A job id from another tenant fails with `MAIN-AI-0051`. A finished clip carries the `ai-generated` label, and with `ai.credentials.signer` set it also carries a signed content credential that `ai.credentials.verify` reports as `verified`. ## Next - [AI calls](https://mainsdk.dev/docs/interfaces/ai) - [Call a model](https://mainsdk.dev/docs/guides/call-a-model) --- Source: https://mainsdk.dev/docs/guides/build-a-voice-agent # Build a voice agent > Answer phone calls with an AI agent that says it is AI, runs your actions as tools, and never records without consent. Status: Planned for 0.6 · Canonical: https://mainsdk.dev/docs/guides/build-a-voice-agent · Updated: 2026-10-28 You will route a phone number to a voice session for one of your agents. It takes about 30 minutes and needs a telephony account and a speech-to-speech provider key for a live call. Tests run with no account, against local room servers and a real WebSocket on the stream route. ## You need - An agent declared under `agents` in `main.yaml`, with a budget. - A Twilio, Telnyx or SIP trunk account, and an OpenAI or Google key for the realtime model. - The provider hosts in `network.allow`. `mainsdk check` reports any that are missing as `MAIN-VOICE-0011`. ## 1. Configure ```yaml title="main.yaml" agents: support: { budget: { spend: $5/day, calls: 500/day } } ai: driver: openai models: [openai/*] voice: driver: openai-realtime disclosure: You are speaking with an AI assistant for Pinecart. max-duration: 15m telephony: driver: twilio account-sid: secret:TWILIO_SID auth-token: secret:TWILIO_TOKEN webhook-url: https://app.example.com/voice stream-url: wss://app.example.com/voice/stream stream-secret: secret:VOICE_STREAM numbers: { "+15550100": support } sessions: support: { voice: alloy, instructions: "Help callers with their orders.", feature: voice-support, agent: support } ``` A number routed to a session with an empty `disclosure` fails `mainsdk check` with `MAIN-VOICE-0010`. ## 2. Answer calls ```ts title="app/voice/route.ts" import { ai } from "mainsdk"; export async function POST(req: Request) { return ai.voice.calls.handle(req); // verifies the signature, picks the session by the number called } ``` An unsigned or forged webhook fails with `MAIN-VOICE-0005` and nothing runs. The call state is read back from the provider, never taken from the webhook body. ## 3. Serve the media stream The webhook answer tells the carrier to stream the call's audio to `stream-url`, with a token for that one call in the path. Your HTTP server hands the upgrade to the session: ```ts title="server.ts" import { createServer } from "node:http"; import { ai } from "mainsdk"; const server = createServer(/* your request handler */); server.on("upgrade", (req, socket, head) => { if (req.url?.startsWith("/voice/stream/")) { void ai.voice.calls.upgrade({ req, socket, head }); // runs until hangup or a limit } }); ``` A stream without a valid token for a call that is waiting gets a `401` with `MAIN-VOICE-0005` before the socket opens. MainSDK runs the WebSocket itself: no SIP stack and no SFU. On a runtime with its own upgrade API, accept the socket there and pass it to `ai.voice.calls.attach(callId, socket)`. ## 4. Put the agent in a room (optional) For a LiveKit or Daily room, add `ai.voice.rooms` with the provider keys and a `bridge`: the `wss://` URL of the media bridge you run next to the room, which carries 16 kHz PCM audio (the providers use WebRTC, which MainSDK does not run). ```ts title="app/voice/room.ts" import { ai, type AiUser } from "mainsdk"; declare const options: { voice: string; instructions: string; feature: string; agent: string }; declare const user: AiUser; const session = await ai.voice.rooms.join({ room: "r1", session: { ...options, user } }); const token = await ai.voice.rooms.token({ room: "r1", user }); // for the client SDK, 10 minutes ``` A join with no bridge fails with `MAIN-VOICE-0002`, and a bridge that never confirms fails with `MAIN-VOICE-0006`. ## Check it worked The first event of every session is a disclosure that the caller is talking to AI. In a test, `ai.driver: local` and a fake voice driver give the whole conversation offline. A session that reaches `max-duration` or its cost limit says a closing line and ends with `MAIN-VOICE-0004`. Recording is off unless you set `record: consent` and the caller's `call-recording` consent is recorded; otherwise it fails with `MAIN-VOICE-0003` and the call goes on unrecorded. ## Next - [AI calls](https://mainsdk.dev/docs/interfaces/ai) - [Give an agent a budget](https://mainsdk.dev/docs/guides/govern-an-app-agent) --- Source: https://mainsdk.dev/docs/guides/take-a-payment # Take a payment > Send a customer to a hosted checkout, handle the verified result and refund an order, with no card data in your app. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/take-a-payment · Updated: 2026-10-28 You will configure the payment driver, create a checkout, handle the verified webhook and refund an order. You can finish it on the local test card driver without a provider account; switching to Stripe is a config change. It takes about 15 minutes. ## You need - A project with `main.yaml` (see [Start a project](https://mainsdk.dev/docs/guides/start-a-project)). - For Stripe: an account in test mode, with its secret key and webhook signing secret in your secrets manager. Not needed for the local driver. ## 1. Configure With no `pay.driver`, `pay` uses the local `test-cards` driver: no network, no account. ```yaml title="main.yaml" pay: driver: stripe key: secret:STRIPE_SECRET_KEY webhookSecret: secret:STRIPE_WEBHOOK_SECRET ``` Keep `driver: test-cards` (or leave the section out) while you build. ## 2. Start a checkout ```ts title="app/api/checkout/route.ts" import { pay } from "mainsdk"; declare function loadOrder(id: string): Promise<{ id: string; totalMinor: number }>; // your code export async function POST(req: Request) { const { orderId } = (await req.json()) as { orderId: string }; const order = await loadOrder(orderId); // your code: read the total on the server const session = await pay.checkout({ order: order.id, amount: order.totalMinor, // integer minor units, never from the browser currency: "INR", successUrl: "/orders/done", cancelUrl: "/cart", }); return Response.json(session); // { id, url }, or { id, clientSecret } for hosted fields } ``` Redirect the customer to `session.url`. Card data never touches your app. A repeat of the same call returns the same session, so a double-click does not start two checkouts. ## 3. Handle the result Point your provider's webhook at a route that hands the raw body and headers to `pay.handleWebhook()`. It verifies the signature and timestamp, then runs your handlers once per event. ```ts title="app/api/pay/webhook/route.ts" import { pay } from "mainsdk"; declare function markPaid(paymentId: string): Promise; // your code pay.on("payment.succeeded", async (event) => { await markPaid(event.data.id as string); // your code }); export async function POST(req: Request) { const { status, body } = await pay.handleWebhook(await req.text(), req.headers); return new Response(body, { status }); } ``` With the local driver, `payment.succeeded` fires straight after `pay.checkout()`, so you can test this handler without a webhook. ## 4. Refund ```ts title="app/api/refund/route.ts" import { pay } from "mainsdk"; declare const order: { paymentId: string }; // loaded by your code declare const ticketId: string; const refund = await pay.refund(order.paymentId, { reason: "damaged" }); // full refund // partial: { amount: 500, reason: "damaged", idempotencyKey: `refund:${ticketId}` } ``` Checkout, subscribe and refund take idempotency keys, so a retry never charges or refunds twice. Pass your own key when you have a natural one. ## Verify Run your test with the local driver and the check: ```bash pnpm mainsdk check ``` A pass shows no findings for `pay`. In a test, a checkout returns a URL, your handler runs once, and a retried refund returns the same refund: ```ts import { pay } from "mainsdk"; const session = await pay.checkout({ order: "o1", amount: 2000, successUrl: "/ok", cancelUrl: "/no" }); const a = await pay.refund("pi_1", { reason: "dup", idempotencyKey: "r1" }); const b = await pay.refund("pi_1", { reason: "dup", idempotencyKey: "r1" }); // session.url is set; a.id === b.id ``` With Stripe in test mode, the test card `4242 4242 4242 4242` completes a checkout and your webhook handler runs after the signature is verified. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-CONFIG-0004` | `pay.key` is a literal value | Write it as `secret:STRIPE_SECRET_KEY` | | `MAIN-PAY-0002` | The amount is not a positive integer in minor units | Pass `125000`, or money such as `"$1250.00"` | | `MAIN-PAY-0010` | The webhook failed verification (you get 400) | Use the right `pay.webhookSecret`, and pass the raw, unparsed body | | `MAIN-PAY-0020` | Stripe refused the request | Read the request in your Stripe dashboard logs | | `MAIN-SDK-0003` | `pay.driver` names a provider that is not built yet | Use `local`, `stripe` or `razorpay` | | `MAIN-DATA-0020` | A data model has a `pan` field | Drop it; hosted checkout keeps card data out of your app | ## For agents ```text Goal: add a hosted checkout, a verified webhook route and a refund call using mainsdk `pay`. Touch: main.yaml (pay section only), the checkout route, one webhook route, the refund route. Rules: the amount comes from a server-side order, in integer minor units. Provider keys are `secret:` references. Never add a card-number field or store card data. Register pay.on() handlers and call pay.handleWebhook(rawBody, headers) with the raw body. Verify: pnpm mainsdk check && pnpm test Never weaken or skip a check; fix the finding it names. ``` ## Next - [Payments](https://mainsdk.dev/docs/interfaces/pay) - [Plans and entitlements](https://mainsdk.dev/docs/interfaces/entitlements) - [Limits, metering and entitlements](https://mainsdk.dev/docs/guides/limits-and-metering) --- Source: https://mainsdk.dev/docs/guides/web3-and-agent-payments # Wallets, token gates and agent payments > Add wallet sign-in, token-gated access and agent payments inside a budget, with chains kept in drivers and no custody, token issuance or chain RPC in MainSDK. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/web3-and-agent-payments · Updated: 2026-10-28 You will add wallet sign-in, a token gate and a budgeted agent payment to Pinecart, a Next.js app on MainSDK. Core only verifies a signed challenge, reads an outside attribute and signs with a key; every chain and wallet is a driver. Reading it takes about 15 minutes. > **Note:** Wallet, token-gate and agent-payment support is pre-release and planned for milestone 0.7 (spec `web3-ready`, ADR-0011, stability experimental). The steps are the planned design and the commands do not run yet. The agent budget it relies on (spec `agent-governance`) is planned for milestone 0.3. ## What core does, what drivers do, what MainSDK will not do | | What | | --- | --- | | Core does | Verify a single-use signed challenge as a sign-in method. Read an external attribute inside `access`. Sign with a `secp256k1` or `ed25519` profile through `keys`. Run an agent-payment kind of `pay` driver behind an agent budget. | | Drivers do | Every chain, wallet, signature format and settlement network. A driver is installed and conformance-tested like any other. | | MainSDK will not | Hold customer funds or private keys (no custody). Issue tokens. Author smart contracts. Ship chain RPC or an indexer. Core has no chain package (ADR-0011). | For contract calls and RPC, use your own library through `raw()`. Which wallet driver ships first, SIWE for Ethereum or a chain-agnostic profile, is an open question in the spec [TBD]. The driver ids below, `siwe` and `evm-balance`, are the ones the spec's example uses. ## You need - A Next.js app with `auth`, `access`, `keys`, `pay` and an agent budget set up. - A wallet driver and an attribute driver for your chain [TBD: no driver is published yet]. - A user-side wallet library that signs the challenge message. MainSDK does not ship one. ## 1. Turn on wallet sign-in ```yaml title="main.yaml" auth: { methods: [passkeys, wallet], wallet: { driver: siwe } } ``` You should see: `npx mainsdk check` passes. The method is `passkeys`, as in [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth). ## 2. Run the challenge flow A challenge is single-use, bound to your app's domain, and expires in five minutes or less. The wallet signs `message` in the browser; your server verifies the signature and gets the same session as any other method. ```ts title="app/api/auth/wallet/start/route.ts" import { auth } from "mainsdk"; export async function POST() { const { nonce, message } = await auth.challenge.start({ method: "wallet" }); return Response.json({ nonce, message }); } ``` ```ts title="app/api/auth/wallet/verify/route.ts" import { auth } from "mainsdk"; export async function POST(req: Request) { const { message, signature } = (await req.json()) as { message: string; signature: string }; const session = await auth.challenge.verify({ message, signature }); // the driver checks the chain's format return Response.json({ ok: true }); // how the session cookie is set is [TBD] } ``` You should see: a valid signature returns a session. Send the same `message` and `signature` again and it fails with `MAIN-AUTH-0040`, because a challenge works once. ## 3. Ask for a passkey on sensitive actions A wallet session is a session, so sensitive actions still need a stronger step. `auth.require` asks for a fresh passkey. ```ts title="app/api/withdraw/route.ts" import { auth } from "mainsdk"; export async function POST(req: Request) { const session = await auth.require(req, { aal: "aal2", fresh: "5m" }); // 401 or 403 with a fix without a recent passkey return Response.json({ ok: true }); } ``` ## 4. Gate access on a token `access` reads the balance from an external source through the attribute hook, so the gate sits under the same policy and audit as every other decision. ```yaml title="main.yaml" access: { attributes: { token.balance: { driver: evm-balance } } } ``` ```ts title="app/api/members/route.ts" import { access, auth } from "mainsdk"; export async function GET(req: Request) { const session = await auth.require(req); const balance = await access.attribute("token.balance", { subject: session.user, asset: "[TBD]" }); // the asset id format and the shape of balance depend on the driver [TBD] return Response.json({ balance }); } ``` An attribute has a timeout and a declared freshness. How you set both in `main.yaml`, and how a policy reads `token.balance`, are not defined yet [TBD]. When the source is down, `access` fails closed: the request is denied with `MAIN-ACCESS-0020`, and the audit event says the source was down. ## 5. Sign with a chain key Chain keys are used through `keys`, never read by your app, and each use is logged without the key. ```ts title="lib/sign.ts" import { keys } from "mainsdk"; // profile is "secp256k1" or "ed25519"; data is what you sign; purpose goes in the audit log const signature = await keys.sign({ profile: "secp256k1", data: "settle:o_1", purpose: "settle-order" }); ``` You should see: on the local fake the signature verifies, and the audit log holds the use, not the key. Which key services offer these profiles is [TBD]. ## 6. Let an agent pay inside its budget An agent payment is always behind an agent budget, and a person must approve it above `spend-over`. It may only pay a payee you declare under `pay.payees`, and the payee's host must be allowed by `network`. See [Agents with an identity, a budget and approvals](https://mainsdk.dev/docs/agents/governance). ```yaml title="main.yaml" agents: support-bot: budget: { spend: $5/day, calls: 500/day } approve: { risk: destructive, spend-over: $1 } enabled: true # false stops it on the next call pay: { payees: { weather-api: { host: api.weather.example, max: $0.05/call } } } ``` ```ts title="lib/agent-pay.ts" import { pay } from "mainsdk"; // Money is a string; `to` is a payee declared in pay.payees await pay.agent({ agent: "support-bot", amount: "$0.04", to: "weather-api" }); ``` What you should see, with the bot's `budget: { spend: $5/day }`, `approve: { spend-over: $1 }`, `weather-api` at `max: $0.05/call` and a second payee `translator` at `max: $10/call`: | The bot pays | Result | | --- | --- | | `$0.04` to `weather-api` | Within the budget, the payee's `max` and `spend-over`: it is sent. | | `$0.06` to `weather-api` | Above the payee's `max`: refused with `MAIN-PAY-0030`; nothing is sent. | | `$2` to `translator` | Within the budget and the payee's `max`, but above `spend-over: $1`: nothing is sent until a person approves. | | Any amount once `$5` is spent today | Refused with `MAIN-AGENT-0002`; nothing is sent. | | A payee not in `pay.payees` | Fails with `MAIN-PAY-0030`. An amount above the payee's `max` fails with the same code. | The agent cannot approve its own step; that fails with `MAIN-AGENT-0003`. The format of the payee's settlement address, and how the driver sends the payment, are [TBD] until a payment driver exists. ## Verify ```bash npx mainsdk check --env prod ``` You should see every gate pass and the command exit 0. See [Check codes](https://mainsdk.dev/docs/reference/check-codes) for what each finding means. Test with `createTestApp()` and name each test with its criterion, for example `web3-ready.AC1` for a reused challenge (see [Testing kit](https://mainsdk.dev/docs/interfaces/testing)). ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-AUTH-0040` | A challenge was verified a second time, or it expired. | Start a new challenge with `auth.challenge.start` for each sign-in. | | `MAIN-ACCESS-0020` | The attribute source timed out or is down, so `access` denied. | Wait for the source or fix its driver. Never turn the gate off. | | `MAIN-AGENT-0002` | The agent's budget would be exceeded and nothing was sent. | Raise the budget in `agents..budget` if it is right to, or stop the call. | | `MAIN-PAY-0030` | The payee is not in `pay.payees`, or the amount is above its `max`. | Declare the payee with its `host` and `max`, and allow the host under `network`. | | `MAIN-AGENT-0003` | The agent tried to approve its own step. | A person approves it. | | `MAIN-AGENT-0004` | The agent is set to `enabled: false`. | Set `enabled: true` when the agent may run again. | ## For agents ```text Goal: add wallet sign-in and a token gate to this app with the web3-ready calls. Read: docs/specs/web3-ready.md, docs/specs/agent-governance.md, docs/adr/0011. Touch only: main.yaml, app/api/auth/wallet/**, app/api/members/**, one test file. Do: use auth.challenge.start and auth.challenge.verify; read balances with access.attribute; keep every chain format in a driver. Never add a chain package (viem, ethers) to core code. Never hold a key or funds. Route every agent payment through pay.agent({ agent, amount: "$0.04", to }) inside an agent budget. Never weaken, skip or loosen a check to make it pass. Verify: npx mainsdk check --env prod exits 0, and tests name web3-ready.AC1 to AC5. Unknown value: write [TBD] and ask. ``` ## Next - [Agents with an identity, a budget and approvals](https://mainsdk.dev/docs/agents/governance) - [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth) - [Permissions](https://mainsdk.dev/docs/interfaces/access) --- Source: https://mainsdk.dev/docs/guides/expose-an-action-to-agents # Expose an action to agents > Write an action once and expose it as an endpoint and an MCP tool with the same checks. Status: Preview · Canonical: https://mainsdk.dev/docs/guides/expose-an-action-to-agents · Updated: 2026-10-28 You will write one action, expose it over MCP and require a person's yes for a destructive one. It takes about 15 minutes. ## You need - A route or job that already does the work you want to expose. ## 1. Write the action ```ts title="app/actions/refund-order.ts" import { action, models, pay } from "mainsdk"; import { s } from "mainsdk/schema"; const { orders } = models({ orders: s.object({ paymentId: s.string() }) }); export const refundOrder = action({ name: "orders.refund", intent: "Refund an order the signed-in user owns", input: s.object({ orderId: s.string(), reason: s.string() }), risk: "destructive", mcp: true, run: async ({ input, user }) => { const order = await orders.for(user!).get(input.orderId); return pay.refund(order!.paymentId, { reason: input.reason }); }, }); ``` ## 2. Expose it ```yaml title="main.yaml" mcp: expose: [orders.refund] confirm: { write: true, destructive: true } ``` ```ts title="app/api/mcp/route.ts" import { mcpServer, type Action } from "mainsdk"; declare const refundOrder: Action<{ orderId: string; reason: string }, unknown>; // from app/actions/refund-order.ts export const POST = mcpServer({ actions: [refundOrder] }); ``` ## Check it worked An MCP client lists `orders.refund`. Calling it as an agent waits for a person's yes, because it is destructive. ## Next - [Actions and agent tools](https://mainsdk.dev/docs/interfaces/action) - [Govern app agents](https://mainsdk.dev/docs/agents/governance) --- Source: https://mainsdk.dev/docs/guides/limits-and-metering # Limits, metering and entitlements > Rate-limit an endpoint, cap usage with a quota, meter what each tenant uses and gate features and limits by plan. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/limits-and-metering · Updated: 2026-10-28 You will protect an expensive endpoint with a rate limit and a daily quota, record what each tenant uses, and decide what each plan includes. The three interfaces work together: `limits` refuses abuse, `meter` counts usage, and `entitlements` turns a tenant's plan into yes or no. It takes about 20 minutes and needs no account. ## You need - A project with `main.yaml` and tenancy on (see [Build a multi-tenant app](https://mainsdk.dev/docs/guides/build-a-multi-tenant-app)). - For plans set by payments: a [`pay`](https://mainsdk.dev/docs/guides/take-a-payment) driver. Optional here. ## 1. Rate-limit an endpoint Declare a named policy, then enforce it with the person's id as the key. ```yaml title="main.yaml" limits: driver: local single-instance: true # use redis once you run more than one instance policies: export: { max: 5, per: 1m } quotas: exports-daily: { max: 50, per: day } ``` ```ts title="app/api/export/route.ts" import { auth, limits } from "mainsdk"; const buildExport = async () => ({ rows: 0 }); export async function POST(req: Request) { const session = await auth.require(req); await limits.enforce("export", { key: session.user }); // MAIN-LIMIT-0001 (429) past 5 a minute return Response.json(await buildExport()); } ``` A refusal is a 429 problem with `Retry-After` and the `RateLimit` headers. You should see the sixth request in a minute fail. ## 2. Cap usage with a quota A quota counts per UTC window and gives back what you release. ```ts title="app/exports/actions.ts" import { limits } from "mainsdk"; const runExport = async () => {}; export async function startExport(userId: string) { await limits.quota("exports-daily", { key: userId }); // MAIN-LIMIT-0002 when the day's 50 are used try { await runExport(); } catch (err) { await limits.release("exports-daily", { key: userId, add: 1 }); throw err; } } ``` ## 3. Meter what each tenant uses Declare each metric, then record where the usage happens. `meter.record()` returns at once and never fails the request. ```yaml title="main.yaml" meter: driver: local # use db or redis in production metrics: exports: { unit: export, aggregate: sum } ``` ```ts title="lib/usage.ts" import { meter } from "mainsdk"; meter.record("exports", 1, { feature: "export" }); const month = await meter.usage({ metric: "exports", window: "month" }); // { value, unit, from, to, stale } ``` Pass an `idempotencyKey` where a step can retry, so it counts once. ## 4. Say what each plan includes Declare plans. A limit that reads a meter counter needs a window. ```yaml title="main.yaml" plans: free: { features: [basic], limits: { exports: 20/month } } pro: features: [basic, export] limits: { exports: 1000/month } prices: [price_pro_monthly] entitlements: default: free upgrade: /settings/billing usage: exports: meter:exports ``` ```ts title="app/api/export/route.ts" import { entitlements } from "mainsdk"; const buildExport = async () => ({ rows: 0 }); entitlements.require("export"); // MAIN-PLAN-0001 (403) on free await entitlements.within("exports", 1, () => buildExport()); // MAIN-PLAN-0002 (403) at the limit const { limit, used, remaining } = await entitlements.limit("exports"); ``` Every refusal carries an `upgrade` hint with the plans that include it and your `upgrade` link. ## 5. Let a subscription set the plan When `pay` verifies a `subscription.updated` event whose price is listed under a plan's `prices` and whose `metadata.tenant` names the tenant, the tenant moves to that plan. To set a plan by hand: ```ts title="src/admin/plans.ts" import { entitlements, tenant } from "mainsdk"; await tenant.system("sales deal", () => entitlements.set("tnt_acme", "pro", { reason: "annual deal", trial: "14d" }), ); ``` ## Verify ```bash pnpm mainsdk check pnpm test ``` `check` fails on an undeclared policy, quota or metric in code, on a feature or limit no plan declares, and on `driver: local` in production without `single-instance: true` for `limits`. A test with `createTestApp` can prove the behaviour: ```ts import assert from "node:assert/strict"; import { createTestApp } from "@mainsdk/testing"; import { entitlements, limits } from "mainsdk"; const app = createTestApp({ main: "./main.yaml" }); await app.asTenant({ id: "tnt_free" }, async () => { assert.ok(await limits.enforce("export", { key: "u1" })); assert.equal(entitlements.has("export"), false); }); ``` ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-LIMIT-0006` | A policy or quota name is not declared | Declare it under `limits` | | `MAIN-LIMIT-0007` | A policy has a bad `max`, `per`, `algorithm` or `burst` | Fix the key the error names | | `MAIN-CONFIG-0004` | A policy declares `key:`, which the loader reads as a secret field | Remove it and pass `{ key }` on the call | | `MAIN-LIMIT-0008` | `limits` uses `local` in production without `single-instance` | Use `redis`, or set `single-instance: true` | | `MAIN-METER-0001` | A metric is not declared under `meter.metrics` | Declare it | | `MAIN-PLAN-0006` | `plans` is invalid, such as a meter-sourced limit with no window | Write `20/month`, and declare the metric | | `MAIN-PLAN-0008` | A feature or limit name that no plan declares | Declare it under `plans` | ## For agents ```text Goal: protect an endpoint with limits, count usage with meter, and gate features by plan with entitlements. Touch: main.yaml (limits, meter, plans, entitlements sections), the route handlers that enforce and record, tests. Rules: declare every policy, quota, metric, feature and limit in main.yaml before using it. Pass { key } to limits calls. Key by user or tenant id, never an email. A meter-sourced limit needs a window such as 20/month. Never read the plan from the browser. Verify: pnpm mainsdk check && pnpm test Never weaken or skip a check; fix the finding it names. ``` ## Next - [Rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits) - [Usage metering](https://mainsdk.dev/docs/interfaces/meter) - [Plans and entitlements](https://mainsdk.dev/docs/interfaces/entitlements) --- Source: https://mainsdk.dev/docs/guides/roll-out-with-flags # Roll out a feature with a flag > Turn a feature on for some users and change it while the app runs. Status: Preview · Canonical: https://mainsdk.dev/docs/guides/roll-out-with-flags · Updated: 2026-10-28 You will add a flag, check it in code and change it with no deploy. It takes about 10 minutes. ## You need - A config store (see [Change config without a deploy](https://mainsdk.dev/docs/guides/change-config-without-a-deploy)). ## 1. Define the flag Set `flags.new-checkout` in your config store: ```json { "enabled": true, "rollout": 10 } ``` ## 2. Check it ```ts title="app/checkout/choose.ts" import { flags } from "mainsdk"; export function checkoutFor(userId: string) { const rows = flags.value("export.row-limit", 500, { user: userId }); return flags.enabled("new-checkout", { user: userId }) ? { checkout: "new", rows } : { checkout: "classic", rows }; } ``` The check reads memory only, and the same user gets the same answer on every instance. ## 3. Change it live Set `rollout` to 50, or `enabled` to `false`. Every instance applies it with no restart, and the change is audited. ## Check it worked About 10 percent of users see the new checkout, and setting `enabled: false` turns it off for everyone. ## Next - [Feature flags](https://mainsdk.dev/docs/interfaces/flags) - [Plans and entitlements](https://mainsdk.dev/docs/interfaces/entitlements): for features a plan includes. --- Source: https://mainsdk.dev/docs/guides/run-cpu-work-on-workers # Run CPU work on workers > Move CPU-heavy work off the request thread with workers.run, a declared module, a time limit and the tenant carried in. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/run-cpu-work-on-workers · Updated: 2026-10-28 You will move a CPU-heavy step out of a request handler onto a worker thread, declare the module, set a time limit and test it. It takes about 10 minutes and needs no account. ## You need - A MainSDK app with a `main.yaml`. - A function that burns CPU for more than a few milliseconds and needs no network, database or secret. A worker may not call other MainSDK interfaces. ## 1. Declare the module ```yaml title="main.yaml" workers: size: auto timeout: 10s modules: ["src/workers/*.worker.ts"] ``` ## 2. Write the worker module ```ts title="src/workers/score.worker.ts" import { worker } from "mainsdk/workers"; export default worker((rows: number[][], ctx) => { ctx.log.info("scoring", { rows: rows.length }); return rows.map((r) => r.reduce((a, b) => a + b, 0)); }); ``` ## 3. Call it ```ts title="lib/score.ts" import { workers } from "mainsdk"; export function score(rows: number[][], signal?: AbortSignal) { return workers.run("./src/workers/score.worker.ts", rows, { signal }); } ``` Pass plain data in and out. Never pass a secret: it fails with `MAIN-WORKER-0004`. ## 4. Test it `createTestApp()` runs the task inline. Add `workers: "threads"` to prove it on a real thread. ```ts title="test/score.test.ts" import { createTestApp } from "@mainsdk/testing"; const app = createTestApp({ yaml: "schema: 1\nproject: t\nworkers:\n modules: [src/workers/*.worker.ts]\n", workers: "threads" }); const out = await app.workers.run("./src/workers/score.worker.ts", [[1, 2], [3]]); ``` ## Check it worked - `npx mainsdk check` has no `MAIN-WORKER-` finding, and `npx mainsdk discover` lists the module. - `workers.snapshot()` shows `completed` rising and `threads` back to 0 after the idle time. - A task that loops past the timeout fails with `MAIN-WORKER-0005` and the next task still runs. --- Source: https://mainsdk.dev/docs/guides/change-your-database-safely # Change your database safely > Add delete with undo, versioned updates and migrations that never lock a busy table, and watch for slow and repeated queries. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/change-your-database-safely · Updated: 2026-10-28 You will let people delete a record and undo it, stop two editors overwriting each other, find slow and repeated queries before your users do, and run a migration without locking a busy table. It takes about 25 minutes and needs a Postgres database for the last step. ## You need - A project with `main.yaml` and a model from [`db`](https://mainsdk.dev/docs/interfaces/db) (see the interface page for `models`). - For step 4: `db.driver: postgres` and the `DATABASE_URL` secret, or one `secret:` per region. ## 1. Delete with undo Give the model a trash period in `main.yaml`. A deleted record waits that long before it is gone, and a label's retention still caps it. ```yaml title="main.yaml" db: models: invoices: { trash: 30d, versions: true } ``` ```ts title="lib/invoices.ts" import { model, models, s } from "mainsdk"; const { invoices } = models({ invoices: model(s.object({ total: s.int() }), { trash: "30d", versions: true }), }); export async function undoable(user: string, id: string) { const mine = invoices.for(user); await mine.delete(id, { ifVersion: "any" }); // hidden from get and list return mine.restore(id); // MAIN-DATA-0063 once the 30 days have passed } ``` You should see `get(id)` fail as not found after the delete, and `trashed().list()` show it. ## 2. Stop silent overwrites With `versions: true` every read returns `version`, and an update must say which version it saw. A stale one fails with `MAIN-DATA-0060` and nothing is written. ```ts title="app/api/invoices/[id]/route.ts" import { etag, ifMatch, model, models, preconditionFailed, s } from "mainsdk"; const { invoices } = models({ invoices: model(s.object({ total: s.int() }), { trash: "30d", versions: true }), }); export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { const { id } = await params; const patch = (await req.json()) as { total: number }; const row = await invoices .for("u1") // the signed-in user in a real route .update(id, patch, { ifVersion: ifMatch(req) }) // reads the If-Match header .catch((err: unknown) => { throw preconditionFailed(err); }); // 412 instead of 409 return Response.json(row, { headers: { etag: etag(row.version) } }); } ``` A request without `If-Match` gets a 428 (`MAIN-DATA-0066`). ## 3. Find slow and repeated queries Every statement is timed. In development a query slower than `db.slow` and a query run more than `db.repeat` times in one request are logged, with the file and line that made them. Check a handler in a test. ```ts title="app/api/orders/route.test.ts" import { expectQueries } from "@mainsdk/testing"; declare const GET: (req: Request) => Promise; // the route handler under test await expectQueries(() => GET(new Request("http://localhost/api/orders")), { repeat: 10 }); // fails with MAIN-DATA-0054 on an N+1 ``` Fix the repeated query by loading the records with one `where: { id: { in: ids } }` call. `mainsdk check` also reports a query inside a loop over another query's results (`MAIN-DATA-0055`). ## 4. Run migrations without blocking Write the migration with your tool as usual. Before you merge, `mainsdk check` reads the migrations that are new since your base branch and names anything that would lock a busy table, with the safe form. ```bash npx mainsdk check ``` ```text MAIN-DATA-0040 high prisma/migrations/20261027_add_index/migration.sql:1 fix: CREATE INDEX CONCURRENTLY "orders_status_idx" ON "orders" ("status"), in a migration of its own ``` Then apply them with a lock timeout and retries, once per region: ```bash npx mainsdk db migrate --plan npx mainsdk db migrate ``` If a statement can't get its lock after the last retry, the run stops with `MAIN-DATA-0044` and applies nothing after it. Run it again when the lock is free. ## Ask your agent ```text Goal: add undo and safe edits to invoices, and ship the migration without locking. Touch: main.yaml (db.models), the invoice routes, one test with expectQueries, the migration files. Rules: read versions through ETag and If-Match; never loosen db.timeout; write a CREATE INDEX as CONCURRENTLY in its own migration; never drop a column the base branch still reads. Verify: pnpm mainsdk check && pnpm test Never weaken or skip a check; fix the finding it names. ``` ## Next - [Data and records](https://mainsdk.dev/docs/interfaces/db) - [Build a multi-tenant app](https://mainsdk.dev/docs/guides/build-a-multi-tenant-app) - [Run work in the background](https://mainsdk.dev/docs/guides/run-background-jobs) --- Source: https://mainsdk.dev/docs/guides/documents # Make, read and convert documents > Render a PDF from a template, read PDFs and Word, Excel and PowerPoint files into text and tables, and convert a Word file to PDF, all offline. Status: Preview · Canonical: https://mainsdk.dev/docs/guides/documents · Updated: 2026-10-28 You will make an invoice PDF from a template, read an uploaded document into text and tables, and convert a Word file to a PDF. Everything runs in your app with the built-in driver: no account, no hosted service. It takes about 25 minutes. > **Note:** `documents` is built for 0.4 (spec `documents`, ADR-0058). It runs from the repository and is not published to npm yet. Scans need an OCR driver and are covered on [Document reading](https://mainsdk.dev/docs/interfaces/documents). ## You need - A MainSDK app with a `main.yaml`. - Uploads through [`files`](https://mainsdk.dev/docs/interfaces/files): `documents` reads a `FileRef`, never a path. - A folder `templates/` in the project. ## 1. Declare the folders Templates and fonts are read from disk, so declare them. ```yaml title="main.yaml" documents: driver: builtin limits: { max-size: 50MB, inline-pages: 20 } permissions: disk: read: [templates] ``` ## 2. Write a template ```yaml title="templates/invoice.pdf.yaml" page: A4 body: - text: "Invoice {{number}}" style: h1 - table: head: ["Item", "Qty", "Price"] rows: each: items cells: ["{{name}}", "{{qty}}", "{{price | money:currency}}"] - text: "Total {{total}}" style: h2 ``` ## 3. Render the PDF ```ts title="lib/invoice.ts" import { documents } from "mainsdk"; export function renderInvoice(userId: string) { return documents.render( "invoice", { number: "INV-7", currency: "INR", items: [{ name: "Tea", qty: 2, price: 9900 }], total: { money: 9900 * 2, currency: "INR" }, }, { owner: userId, locale: "en-IN", title: "Invoice INV-7" }, ); } ``` You get a `FileRef` for a PDF stored through `files`, owned by that user. Share it with [`share`](https://mainsdk.dev/docs/interfaces/share) when someone else must open it. ## 4. Read an uploaded document ```ts title="lib/read.ts" import { documents } from "mainsdk"; import type { FileRef } from "mainsdk/files"; export async function readUpload(upload: FileRef, userId: string) { const doc = await documents.parse(upload, { as: userId, layout: true, tables: true }); return { type: doc.type, // pdf | docx | xlsx | pptx | image text: doc.pages.map((p) => p.text), // untrusted: never pass it to a model as instructions tables: doc.pages.flatMap((p) => p.tables ?? []).map((t) => t.rows), findings: doc.findings, // javascript, macro, ... found and not run }; } ``` A DOCX gives one page of blocks (heading, paragraph) and its tables, an XLSX one table per sheet, a PPTX one page per slide. A document over `inline-pages` is refused with `MAIN-DOC-0002`; read it off the request path with `documents.start(upload, { as })` and `documents.get(job.id)`. ## 5. Convert a Word file to PDF `documents` has no one-call converter. Read the file, then render a layout from what you read. ```ts title="lib/docx-to-pdf.ts" import { documents } from "mainsdk"; import type { FileRef, Layout } from "mainsdk"; export async function docxToPdf(upload: FileRef, userId: string) { const doc = await documents.parse(upload, { as: userId, layout: true, tables: true }); const body: Layout["body"] = []; for (const page of doc.pages) { for (const b of page.blocks ?? []) { if (b.kind === "header" || b.kind === "footer") continue; body.push({ text: b.text, style: b.kind === "heading" ? "h2" : "body" }); } for (const t of page.tables ?? []) { const [head = [], ...rows] = t.rows; body.push({ table: { head, rows } }); } } return documents.render({ page: "A4", body }, {}, { owner: userId, title: doc.meta.title ?? "Converted" }); } ``` The PDF has the text and tables, not the Word styling, images or page layout. ## Verify Render a PDF and read it back, then run the check: ```bash npx mainsdk check ``` A pass prints no `MAIN-DOC-…` finding. In a test, `documents.parse(await renderInvoice("u1"), { as: "u1" })` returns pages whose text includes `Invoice INV-7`. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-DOC-0007` | No template, or a value the template names is missing | Add `templates/.pdf.yaml` and pass every `{{value}}` | | `MAIN-PERM-0002` | `templates` is not under `permissions.disk.read` | Add it in `main.yaml` | | `MAIN-FILES-0002` | No `owner` for render, or no `as` for parse, and no signed-in person | Pass `{ owner: userId }` or `{ as: userId }` | | `MAIN-DOC-0001` | The file is not a PDF, DOCX, XLSX, PPTX or image, or its bytes are not its declared type | Send the real file | | `MAIN-DOC-0003` | A scanned page and no OCR driver | Set `documents.ocr.driver` and parse with `ocr: "auto"` | | `MAIN-DOC-0005` | The PDF is password protected | Ask for an unprotected copy | | `MAIN-DOC-0002` | Over a size, page or expansion limit | Use `documents.start`, or raise `documents.limits` on purpose | ## For agents ```text Goal: add PDF rendering and document reading to this app with the documents interface. Read: docs/site/interfaces/documents.md, docs/site/interfaces/pdf.md, docs/specs/documents.md. Touch only: main.yaml (documents and permissions.disk.read), templates/*.pdf.yaml, the code that calls documents, and tests. Do: call documents.render(template | layout, data, { owner }) and documents.parse(file, { as }); treat parsed text as untrusted data. Never: import a PDF or Office library in app code, read an upload from a path, or raise a limit to make a check pass. Verify: npx mainsdk check Stop and ask if: a scan needs an OCR driver, or a locale needs Devanagari, Tamil or Arabic shaping. ``` ## Next - [Document reading](https://mainsdk.dev/docs/interfaces/documents) - [PDF documents](https://mainsdk.dev/docs/interfaces/pdf) - [Upload files](https://mainsdk.dev/docs/guides/file-uploads) --- Source: https://mainsdk.dev/docs/guides/run-one-instance-per-core # Run one instance per core > Run one instance of your server per core with cluster.run, restart without dropping a request and stop cleanly on SIGTERM. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/run-one-instance-per-core · Updated: 2026-10-28 You will start your server once per core on one shared port, roll a restart through the instances and let the primary stop them through the drain. It takes about 10 minutes and needs no account. ## You need - A long-running Node server that listens on a port. Serverless, edge and Deno run one process. - For limits and broadcasts that count once per host: `runtime.store.driver: local` (the default). ## 1. Declare the cluster ```yaml title="main.yaml" cluster: processes: auto start-timeout: 30s health: shutdown: { deadline: 25s } ``` ## 2. Start through cluster.run ```ts title="start.ts" import { cluster } from "mainsdk"; declare function startServer(): Promise; // your own server entry, such as the one that calls listen() await cluster.run(startServer); ``` The primary forks the instances and serves nothing. Each instance runs your server, and the primary routes connections only to instances that listen. In `mainsdk dev` and on serverless it runs one process and logs `MAIN-WORKER-0014`. ## 3. Roll a restart Send SIGHUP to the primary, or call `cluster.restart({ reason: "deploy" })`. Each slot gets a new instance, waits until it is ready, then shuts the old one down through the drain. A new instance that is not ready in time stops the roll with `MAIN-WORKER-0013` and the old instances keep serving. ## 4. Stop SIGTERM to the primary starts the normal drain in every instance. The primary waits for `health.shutdown.deadline` plus 2 seconds, then ends what is left, and exits 0 only if every instance exited 0. An instance that crashes is respawned after the backoff (`MAIN-WORKER-0011`); a slot that crashes `crashes` times in `window` stops (`MAIN-WORKER-0012`). ## Check it worked - Requests show several process ids, never the primary's. - `cluster.snapshot()` lists every instance as `ready`. - A restart replaces every pid and no request fails. --- Source: https://mainsdk.dev/docs/guides/keep-short-lived-values # Keep short-lived values > Store drafts and counters under a key with an expiry, scoped to the tenant, on a file in development and Redis in production. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/keep-short-lived-values · Updated: 2026-10-28 You will keep a draft for a day and count events in a fixed window, using `kv` on a local file first and Redis when you deploy. It takes about 10 minutes and needs no account for the local part. ## You need - A project with `main.yaml`. - For production: a Redis URL as a secret, for example `REDIS_URL`. ## 1. Store and read a value ```yaml title="main.yaml" kv: driver: local file: .mainsdk/kv.json # optional: keeps values across restarts ``` ```ts title="lib/draft.ts" import { kv } from "mainsdk"; const userId = "u_1"; // the signed-in user await kv.set(`draft:${userId}`, { step: 2 }, { ttl: "1d" }); const draft = await kv.get<{ step: number }>(`draft:${userId}`); // null once it expires ``` You should see `get` return the value now and `null` after a day. ## 2. Count in a window ```ts title="lib/hits.ts" import { kv } from "mainsdk"; const userId = "u_1"; const n = await kv.incr(`hits:${userId}`, { ttl: "1m" }); // 1, 2, 3 ... then a new window ``` The expiry is set when the key is created, so the window doesn't move. Use [`limits`](https://mainsdk.dev/docs/interfaces/limits) when you want a refusal with `Retry-After`. ## 3. Move to Redis ```yaml title="main.yaml" kv: driver: redis redis: { url: secret:REDIS_URL, prefix: myapp } ``` Add the Redis host to `network.allow`, or let `mainsdk discover` list it. Your calls don't change. Values are not copied from the file. ## Ask your agent ```text Goal: keep onboarding drafts for a day and count invites per hour. Touch: main.yaml (kv section), the draft and invite code, one test with the fake clock. Rules: pass a ttl on every draft and counter; never put personal data in a kv value; read the Redis URL from a secret. Verify: pnpm mainsdk check && pnpm test Never weaken or skip a check; fix the finding it names. ``` ## Next - [Key-value store](https://mainsdk.dev/docs/interfaces/kv) - [Rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits) - [Network permissions](https://mainsdk.dev/docs/interfaces/network) --- Source: https://mainsdk.dev/docs/guides/cache-safely # Cache safely > Memoise slow calls per tenant, share a public read action with a CDN, and see why a signed-in response is never shared. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/cache-safely · Updated: 2026-10-28 You will memoise a slow query per tenant, declare one read action as shareable, and check that nothing personal can reach a shared cache. It takes about 10 minutes and needs no account. ## You need - A project with `main.yaml` and an adapter (`mainsdk/hono`, `mainsdk/next` or `mainsdk/express`). ## 1. Memoise a slow call ```yaml title="main.yaml" cache: driver: kv timeout: 50ms ``` ```ts title="lib/stats.ts" import { cache } from "mainsdk/cache"; declare function loadStats(): Promise<{ orders: number }>; // your slow query export const stats = () => cache.wrap("dashboard-stats", () => loadStats(), { ttl: "1m", swr: "10m", tags: ["stats"] }); ``` You should see `loadStats` run once per tenant a minute, and once more in the background after that while callers keep the old value. The key is stored as `t::r::dashboard-stats`, so no tenant reads another's. ## 2. Drop it when the data changes ```ts title="lib/orders.ts" import { cache } from "mainsdk/cache"; await cache.delete({ tag: "stats" }); // this tenant only, on every instance ``` ## 3. Share one read action Declare `http.cache` on a plain `GET` read action and serve it with `serveRead` (see [Cache](https://mainsdk.dev/docs/interfaces/cache)). Nothing else makes a response shareable: a header you set by hand on a signed-in response is replaced with `private, no-store` and logged as `MAIN-CACHE-0001`. ## 4. Check it ```bash pnpm mainsdk check ``` `MAIN-CACHE-0011` names a public declaration on a write, a non-`GET` or a permissioned action; `MAIN-CACHE-0012` names an app-scoped cache around a `db` read. ## Ask your agent ```text Goal: speed up the products page without leaking one tenant's data to another. Touch: main.yaml (cache section), the products lib, one read action, tests with the fake clock. Rules: cache.wrap with the default tenant scope; scope "app" only for values with no person or tenant data; never set Cache-Control by hand on a signed-in response. Verify: pnpm mainsdk check && pnpm test Never weaken or skip a check; fix the finding it names. ``` ## Next - [Cache](https://mainsdk.dev/docs/interfaces/cache) - [Key-value store](https://mainsdk.dev/docs/interfaces/kv) - [Protect web responses](https://mainsdk.dev/docs/guides/sanitize-and-protect-inbound) --- Source: https://mainsdk.dev/docs/guides/change-config-without-a-deploy # Change config without a deploy > Store runtime settings and flags, read them with config.get, watch changes, and roll a feature out in steps with no deploy. Status: Building for 0.2 · Canonical: https://mainsdk.dev/docs/guides/change-config-without-a-deploy · Updated: 2026-10-28 Change a limit, a banner or a rollout while the app runs. You will type your settings, read them with `config.get`, react to changes with `config.watch`, and roll a feature out to a growing share of users with a flag. It takes about 20 minutes. > **Note:** `config` is being built for 0.2 and `flags` for 0.3 (specs `config-secrets` and `flags`). Both run from the repository and are not published to npm yet. ## You need - A MainSDK app with a `main.yaml` (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)). - The settings you want to change, written down with their types. - For production, a store that several instances share: the `redis` driver. The `files` driver reads JSON from your repository and suits development. ## 1. Type the settings Describe each setting in a JSON schema. A value that doesn't match it is refused, and the last good value stays in place. ```json title="config.schema.json" { "type": "object", "properties": { "cart": { "type": "object", "properties": { "maxItems": { "type": "integer" } } } } } ``` ```yaml title="main.yaml" config: driver: redis schema: ./config.schema.json redis: { url: secret:REDIS_URL } ``` Use `driver: files` in `main.env.dev.yaml` to keep development on the repository. ## 2. Read values with config.get ```ts title="app/api/cart/route.ts" import { config } from "mainsdk"; export function canAdd(count: number) { return count < (config.get("cart.maxItems") as number); // synchronous, from memory } ``` `config.get()` reads memory, so call it inside the request. Don't read `main.yaml` or the store at run time; the value you need is already there. ## 3. React to a change with config.watch ```ts title="server/worker.ts" import { config, log } from "mainsdk"; let batchSize = config.get("worker.batchSize"); const stop = config.watch("worker.batchSize", (value, change) => { batchSize = value; log.info("batch size changed", { from: change.previous, to: value }); }); process.on("SIGTERM", () => stop()); ``` The watcher runs once for each change, after it is applied, outside any request. A watcher that throws is logged and does not stop the others. ## 4. Roll a feature out in steps A flag is a definition in the same store, at `flags.`. Start small and raise `rollout` as you gain confidence: ```json title="flags. in the store" { "enabled": true, "rollout": 5 } ``` ```ts title="app/api/checkout/route.ts" import { flags } from "mainsdk"; export function checkoutFor(userId: string) { return flags.enabled("new-checkout", { user: userId }) ? "new" : "classic"; // no await } ``` Move `rollout` to 25, then 100. Each step applies without a restart. A user who was on stays on, because each user has a fixed place in the rollout. To give a different group of users the early access, change the `salt`. To turn it off at once, set `enabled` to `false`. That wins over every rule. ## 5. Know what a push can change A push from the store can only tighten `main.yaml`. A spend limit can go down, `network.mode` can go up to `enforce`, and a host can be removed. It can't add a host, raise a limit or switch a rule off. A push that loosens a rule is refused with `MAIN-CONFIG-0021`, and a `main.` path that only changes in a reviewed deploy, such as `main.payments.driver`, with `MAIN-CONFIG-0022`. The table of pushable paths is in [Runtime settings](https://mainsdk.dev/docs/interfaces/config#pushing-mainyaml-values). The rule keeps a compromised store from loosening the app. The reason is in the decision: a loosening is a deploy, so a person reviews it. Keep secrets out of the store. A literal key under a secret field, such as `apiKey`, fails with `MAIN-CONFIG-0004`. Put it in [Secrets](https://mainsdk.dev/docs/interfaces/secrets) and refer to it with `secret:NAME`. ## 6. Check it works In a test, use the `memory` driver. Its handle sets a value, the watcher fires, and `config.get()` returns the new value at once. Then check the app: ```bash npx mainsdk check --env prod ``` A pass exits 0. To prove the app survives an outage, make the store unreachable in a test: `config.get()` keeps the last good value and `MAIN-CONFIG-0023` is logged once. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-CONFIG-0004` | A literal secret sits in a config value, or `config.redis.url` is a literal | Use a `secret:` reference and keep the secret in `secrets` | | `MAIN-CONFIG-0020` | A value fails the schema, or a file isn't valid JSON | Correct the value; the last good one stays | | `MAIN-CONFIG-0021` | A pushed `main.` value is looser than `main.yaml` | Push a tighter value, or change `main.yaml` in a reviewed deploy | | `MAIN-CONFIG-0022` | A pushed `main.` path only changes in a reviewed deploy | Change it in `main.yaml` and deploy | | `MAIN-CONFIG-0023` | The store can't be read; the app serves its last good values | Restore the store; no restart is needed | | `MAIN-FLAGS-0001` | A flag was checked with no definition; the fallback was served | Define `flags.`, or check the name | | `MAIN-FLAGS-0002` | A flag definition is invalid and was refused | Fix `enabled`, `rollout` (0 to 100, two decimals at most) or the rule keys | | `MAIN-FLAGS-0003` | A rule's value has another type from the fallback | Serve the same type as the fallback | ## For agents ```text Goal: change a runtime setting or roll out a feature without a deploy. Read: docs/specs/config-secrets.md, docs/specs/flags.md, this page, main.yaml, config.schema.json. Touch only: config.schema.json, the code that reads the setting or the flag, and tests. Do: read values with config.get and flags.enabled, and never await them; never re-read main.yaml at run time; keep secrets in secrets; ask the owner before raising a limit or widening network.allow in main.yaml. Verify: npx mainsdk check --json --agent Never write a push that loosens main.yaml; the store can only tighten it. ``` ## What comes later - A console to edit values, staged rollout that advances by itself, and rollback on error spikes: Cloud (ADR-0007). - The `openfeature` flag driver, so definitions can stay in LaunchDarkly, Flagsmith or PostHog (planned). ## Next - [Runtime settings](https://mainsdk.dev/docs/interfaces/config): the full reference. - [Feature flags](https://mainsdk.dev/docs/interfaces/flags): definitions, rules and rollout. - [Secrets](https://mainsdk.dev/docs/interfaces/secrets): credentials, server only. --- Source: https://mainsdk.dev/docs/guides/debugging # Debug your app > Go from an error to a fix with local logs, error codes and mainsdk logs, then debug production with live tail, debug sessions, replay and triage. Explain, driver tracing and OpenTelemetry export are planned. Status: Building for 0.6 · Canonical: https://mainsdk.dev/docs/guides/debugging · Updated: 2026-10-28 Find out why a request failed and fix it, using only local tools. Every error and log MainSDK writes carries a stable code, the file and line in your code, the `main.yaml` rule involved and a fix, so you or your coding agent can act on it directly. **Before you start:** a MainSDK app that runs locally with `mainsdk dev`. Steps 1 to 4 are free and local; nothing leaves your machine. > **Note:** Built today: `MainError` codes, `log`, `mainsdk logs`, `mainsdk check` and `mainsdk info`. Planned, not built: `mainsdk explain`, `MAINSDK_DEBUG=driver` and OpenTelemetry export. The MainSDK Cloud tools at the end are built. Steps below say where a planned tool would go. ## 1. Find the error in the local logs By default records print to the terminal. To keep them for `mainsdk logs`, add a file sink once; it writes JSON lines to `.mainsdk/events/` (git-ignored). Passing sinks replaces the terminal output. ```ts title="server/index.ts" import { configure, fileSink } from "mainsdk"; configure({ sinks: [fileSink(".mainsdk/events/app.jsonl")] }); ``` ```bash npx mainsdk logs --level error ``` ```text 2026-11-12T10:04:31.208Z ERROR This record belongs to another owner ``` To watch new events, or to see every record from one request (records carry `attributes.trace_id` inside a request with a trace): ```bash npx mainsdk logs --follow npx mainsdk logs --trace 4bf92f3577b34da6a3ce929d0e0e4736 --json ``` `--json` prints the stored record, which includes the `code` and `fix` of a `MainError`. ## 2. Look up the code Find the code in the [error codes](https://mainsdk.dev/docs/reference/errors) reference for its cause and fix; the `fix` is also in the record from step 1. A problem response carries the same `code` and `fix` for your client. `mainsdk explain` (offline lookup by code or trace id) is planned. ```text MAIN-DATA-0007 · This record belongs to another owner · 403 fix: Read orders through db.orders.for(session.user) so users only reach their own. ``` Your coding agent reads the same codes and fixes; see [Use MainSDK with your coding agent](https://mainsdk.dev/docs/getting-started/with-your-agent). ## 3. Fix the code `MAIN-DATA-0007` means the owner scope did its job: the order route refused a record that belongs to someone else. The question is why the user asked for it. Follow the trace back to the list endpoint, which still reads orders through the escape hatch, so it returns every user's orders: ```ts title="app/api/orders/route.ts (before)" import { auth } from "mainsdk"; declare const sql: { query(text: string): Promise }; // your own SQL client export async function GET(req: Request) { await auth.require(req); const orders = await sql.query("select * from orders limit 20"); // no owner scope return Response.json(orders); } ``` Read through the scoped model instead, so the list only holds this user's orders: ```ts title="app/api/orders/route.ts (after)" import { auth, models, s } from "mainsdk"; const { orders } = models({ orders: s.object({ item: s.string(), total: s.int() }), }); export async function GET(req: Request) { const session = await auth.require(req); const page = await orders.for(session.user).list({ limit: 20 }); return Response.json(page); } ``` A query through `models` without `.for(user)` does not compile, so look for other places that read orders through your own SQL client. ## 4. Look at the app's state (when the cause is a driver or a dependency) ```bash npx mainsdk info npx mainsdk status ``` `info` lists each interface with its driver, the network destinations and the health and resource settings. `status` shows dependencies, which are critical, and resource pressure. Tracing each provider call with `MAINSDK_DEBUG=driver` is planned. ## 5. Verify the fix Repeat what the user did while following the error log: ```bash npx mainsdk logs --level error --follow ``` Open the order list again: it shows only your own orders, and no new `MAIN-DATA-0007` line appears. Add a test so it stays fixed; see [Test your app](https://mainsdk.dev/docs/guides/testing). ## Send telemetry to your own OpenTelemetry backend Planned: OTLP export of logs, traces and metrics to any endpoint, redacted in process, through a `telemetry` block in `main.yaml`. It is not built, and `main.yaml` has no `telemetry` key yet. Until then, give `configure()` a sink of your own: a `Sink` is a function `(kind, record) => void` that receives each redacted record. ## Debug production with MainSDK Cloud > **Note:** Built in 0.6 and tested against the in-repo Cloud on an in-memory database. Sending to MainSDK Cloud needs `mainsdk login` and an explicit opt-in. Two parts are still the host's to wire: the GitHub app that opens issues and pull requests, and the screenshot upload through getqr.id. Both are ports with tested fakes. Send logs to the Cloud with the sink from `mainsdk/feedback`. It sends nothing until you are logged in and have set `telemetry.to: [mainsdk]` or `MAINSDK_TELEMETRY=1`, and it replaces emails, bearer tokens and key formats with `[redacted]` before anything leaves the process. ```ts title="server/telemetry.ts" import { cloudSink } from "mainsdk/feedback"; declare const session: { exists(): boolean }; declare const config: { telemetry: { to: string[] } }; declare const client: { emit: Parameters[0]["emit"] }; export const sink = cloudSink({ loggedIn: () => session.exists(), to: () => config.telemetry.to, flag: () => process.env.MAINSDK_TELEMETRY, emit: (event) => client.emit(event), }); ``` | Task | Command | Plan | | --- | --- | --- | | Live tail from production, for one user, trace or level | `mainsdk logs --follow --env prod --user u_8f3` | Free; asks for a fresh passkey | | Raise detail for one user, request or release, for up to 60 minutes; it switches itself off | `mainsdk debug start --user u_8f3 --for 30m` | Teams | | End a debug session early | `mainsdk debug stop ` | Teams | | Replay a failed request locally on fake drivers | `mainsdk replay 4bf92f3577b34da6a3ce929d0e0e4736` | Teams | | Install the GitHub app for the project | `mainsdk triage connect github --installation ` | Free | | Group errors, find the release and commit, open one GitHub issue per group | `mainsdk triage run` | Teams (hosted) | | Open a draft fix pull request that waits for `check --json` to pass | `mainsdk triage fix --head ` | Free | A session names one user, one request or one release and lasts at most 60 minutes, so `--for 90m` runs for 60. The app reads the active sessions with its ingest key and keeps records below its log level only for the target, through the `raise` option of the log: ```ts title="server/log.ts" import { Log } from "mainsdk"; import { raiseFor, type DebugSession } from "mainsdk/feedback"; declare const active: DebugSession[]; // the sessions your app read with its ingest key export const log = new Log({ raise: raiseFor({ sessions: () => active, now: () => Date.now() }) }); ``` Nothing is redeployed. A session ends itself on time, and its start and end are written to an audit record. Records stay redacted. Replay needs a file, `mainsdk.replay.ts`, that exports a function for each action you want to replay. It runs the recorded input on the test app, where every interface is a fake, and any call that tries to reach the network is blocked and counted: ```ts title="mainsdk.replay.ts" declare function refund(input: unknown, app: unknown): unknown; export default { "orders.refund": (input: unknown, app: unknown) => refund(input, app), }; ``` ```bash mainsdk replay 4bf92f3577b34da6a3ce929d0e0e4736 ``` ```text orders.refund: reproduced MAIN-DATA-0007; 0 outbound calls ``` On Free, `mainsdk debug start` and `mainsdk replay` get HTTP 402 from the Cloud with a link to Teams, and your app is unaffected. See [Collect customer reports](https://mainsdk.dev/docs/interfaces/feedback) for the report button that arrives with its trace. > **Warning:** MainSDK never offers a remote shell or a debugger attached to production. Live tail, debug sessions and replay answer the same questions without opening a door into the running app. A test fails the build if any endpoint, command or driver executes code or attaches a debugger. ## See where telemetry goes ```bash npx mainsdk telemetry status --json ``` It shows the destinations telemetry is sent to and whether MainSDK Cloud may receive it. Nothing is sent unless you declared it. ## Next - [Error codes](https://mainsdk.dev/docs/reference/errors) - [Test your app](https://mainsdk.dev/docs/guides/testing) - [Free and Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud) --- Source: https://mainsdk.dev/docs/guides/typed-api # 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. --- Source: https://mainsdk.dev/docs/guides/use-react-hooks # Call your API from React > Generate React hooks next to the typed client, then run an action with field errors, read and list with a cache, show the session and refresh on live events. Status: Planned for 0.7 · Canonical: https://mainsdk.dev/docs/guides/use-react-hooks · Updated: 2026-10-28 You have a typed API client. This guide adds hooks so a component can run an action, read a record and page a list without writing `fetch`, loading state or error mapping. It takes about 15 minutes. The generated file imports only `react` and your client. ## You need - The typed client from [Publish your actions as a typed API](https://mainsdk.dev/docs/guides/typed-api), for example `src/acme-api.ts`. - `react` 18 or 19 in `package.json`. Without it the command exits 1 and writes nothing. ## 1. Generate the hooks ```bash npx mainsdk api client --out src/acme-api.ts --react ``` You should see `src/acme-api-react.ts` next to the client. Use `--react-out ` to put it elsewhere. Run the command again after you change an action; the file is generated, so do not edit it. ## 2. Give the tree one client Make the client once, so every component shares it. ```ts title="acme.ts" import { createClient } from "./acme-api.ts" export const acme = createClient({ baseUrl: "https://acme.app" }) ``` ```tsx import { ApiProvider } from "./acme-api-react.ts" import { acme } from "./acme.ts" export function Providers({ children }: { children: React.ReactNode }) { return {children} } ``` ## 3. Run an action and show field errors ```tsx import { useAction } from "./acme-api-react.ts" import { acme } from "./acme.ts" export function RefundForm({ orderId, reason }: { orderId: string; reason: "damaged" | "late" }) { const refund = useAction(acme.orders.refund, { invalidates: ["orders.*"] }) return (
{ e.preventDefault(); void refund.run({ orderId, reason }) }}> {refund.fieldErrors.reason &&

{refund.fieldErrors.reason}

}
) } ``` `invalidates` names the reads to refetch when the action succeeds. A wrong input is a compile error, because the types come from your client. ## 4. Read and list ```tsx import { useList, useRead } from "./acme-api-react.ts" import { acme } from "./acme.ts" export function Orders({ orderId }: { orderId: string }) { const order = useRead(acme.orders.get, { orderId }, { staleTime: "30s" }) const orders = useList(acme.orders.list, { status: "open" }, { limit: 20 }) return

{order.data?.status} {orders.items.length}

} ``` `orders.items` holds every page loaded so far and `orders.more()` loads the next. Two components that read the same input share one request. ## 5. Session and live updates ```tsx import { useLive, useSession } from "./acme-api-react.ts" export function Header() { const { session, pending } = useSession() useLive(["order:42"], { invalidates: ["orders.get"] }) return

{pending ? "…" : session ? "Signed in" : "Signed out"}

} ``` `useLive` refetches the reads it names when an event arrives, and closes its subscription when the last component using it unmounts. ## 6. Use TanStack Query instead (optional) If the app already uses TanStack Query, take the options and keep its cache. ```tsx import { useMutation, useQuery } from "@tanstack/react-query" import { mutationOptions, queryOptions } from "./acme-api-react.ts" import { acme } from "./acme.ts" export function Order({ orderId }: { orderId: string }) { const q = useQuery(queryOptions(acme.orders.get, { orderId })) const m = useMutation(mutationOptions(acme.orders.refund)) return } ``` ## Check it worked Run the app, open a page that uses `useRead` twice with the same input, and look in the network tab: one request, not two. Submit the form with a bad reason and the field error shows without a page reload. --- Source: https://mainsdk.dev/docs/guides/import-data # Import a customer's spreadsheet > Preview a CSV or Excel file, confirm the column mapping, run a dry run, then write the rows, with a report of every row that failed. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/import-data · Updated: 2026-10-28 A customer uploads a spreadsheet of contacts. You preview the first rows, confirm which column is which, check a dry run, and write the rows. Nothing is written until the mapping is confirmed. It takes about 25 minutes. > **Note:** `imports` is in 0.4 (spec `imports`) and runs from the repository; it is not published to npm. Google Sheets and Airtable sources need a `connections` token, which is not built yet; files and `rest` sources work now. ## You need - A MainSDK app with a `db` model for the rows you import (for example `contacts`). - A schema for the row, with a unique key such as `email`. - An upload, from [`files`](https://mainsdk.dev/docs/interfaces/files). ## 1. Declare the import limits ```yaml title="main.yaml" imports: inline-max: 5MB max-rows: 1000000 ``` ## 2. Preview the file ```ts title="app/admin/import/actions.ts" "use server"; import { imports } from "mainsdk"; import type { FileRef } from "mainsdk/files"; import { s } from "mainsdk/schema"; const Contact = s.object({ email: s.string(), name: s.string() }); export async function previewContacts(file: FileRef) { return imports.preview(file, { schema: Contact, key: "email" }); } ``` The preview returns the columns, the first 20 rows, a suggested mapping and `missing`, the required fields no column maps to. Show these to the person. ## 3. Dry run with the confirmed mapping ```ts import { imports } from "mainsdk"; import type { FileRef } from "mainsdk/files"; import { s } from "mainsdk/schema"; const Contact = s.object({ email: s.string(), name: s.string() }); declare const file: FileRef; // the upload const run = await imports.file(file, { schema: Contact, key: "email", into: "contacts", mapping: { "E-mail": "email", "Full name": "name", "Company": null }, // null ignores a column dryRun: true, }); console.log(run.counts, run.report); ``` `counts` shows what would be inserted, updated, unchanged or failed. `report` is a CSV of the failed rows. ## 4. Write the rows Run the same call without `dryRun`. The `mapping` you pass is the confirmation. Set `maxErrors` if a few bad rows should not stop the run, for example `maxErrors: "2%"`. ## Check it worked - `run.state` is `completed`, and `counts.inserted + counts.updated + counts.unchanged` equals the rows you expect. - Running step 4 a second time writes nothing new. The default idempotency key is the file, target, key and mapping together. - Open `run.report` and find the rows that failed. ## If it fails - `MAIN-IMPORT-0003`: a required field has no column. Map it, or fix the header in the file. - `MAIN-IMPORT-0005`: failed rows passed `maxErrors`. Fix the rows in the report and run again. - `MAIN-IMPORT-0001`: the file is not CSV, XLSX, JSON or NDJSON in UTF-8. Save it again. ## Prompt for your coding agent > Add a spreadsheet import for contacts using MainSDK's `imports` interface. Preview the upload, show the suggested mapping, require the person to confirm it, run a dry run, then write the rows with a report of failed rows. --- Source: https://mainsdk.dev/docs/guides/build-a-feed-with-an-end # Build a feed with an end > Let people follow, block and mute, read a chronological home timeline that fans out in the background and ends with a caught-up marker, and show trending tags per region. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/build-a-feed-with-an-end · Updated: 2026-10-28 You are building a small social app. People follow each other, post, and read a home timeline that stays fast whether an author has ten followers or ten million. The timeline ends: it shows where the person last stopped, pauses after a session, and never inserts items while they read. It takes about 30 minutes. > **Note:** `feeds` is in 0.4 (spec `feeds`) and runs from the repository; it is not published to npm. Status: experimental. ## You need - A MainSDK app with a `db` section and a model for your posts (here `posts`). - Users that have an id. A subject is `{ id }`, and may carry `tenant` and `region`. ## 1. Declare the feed ```yaml title="main.yaml" feeds: driver: db # local keeps everything in process, for tests items: posts page: { end: true, pause_after: 200 } trending: { window: 1h, k: 20, min-count: 25 } ``` ## 2. Follow, block and mute ```ts title="app/people.ts" import { feeds } from "mainsdk"; await feeds.follow({ id: "ana" }, { id: "ben" }); // { state: "following" } await feeds.mute({ id: "ana" }, { id: "ben" }, { until: "2026-12-01T00:00:00Z" }); await feeds.block({ id: "ben" }, { id: "cy" }); // deletes follows both ways const counts = await feeds.counts({ id: "ben" }); // { followers, following } ``` ## 3. Publish and read ```ts title="app/posts.ts" import { feeds } from "mainsdk"; declare const post: { id: string }; await feeds.publish({ id: "ben" }, { id: post.id, visibility: "public" }); const home = await feeds.timeline({ id: "ana" }, { limit: 20 }); ``` `publish` returns once the reference and the fan-out job are accepted. No follower's timeline is written in the request, and the author sees their own post at the top on the next read. The page ends with a `caught-up` or `pause` entry; send `continue: true` only when the person asks for more. ## 4. Show a count, not a push Subscribe the client to `feeds.new` on `user:` when you have a realtime port, and show "3 new posts". Never add the items to the list the person is reading. ## 5. Add trending ```ts import { feeds } from "mainsdk"; feeds.count("tag:launch", { by: { id: "ana" } }); const top = await feeds.trending({ k: 10 }); // { region, window, keys } ``` Keys counted for fewer than `min-count` people are hidden. Count tags, not people. ## Check it worked - `pnpm mainsdk check` passes with no `MAIN-FEED-0010` to `0013` findings. - The home timeline of a follower shows the new post, and a blocked person's posts are gone from it. - Every page you read ends with a `caught-up` or `pause` entry. ## If it fails - `MAIN-FEED-0010`: the feed has no end, or a client requests the next page with no person acting. Keep `page.end` on and wait for a click. - `MAIN-FEED-0011`: ranking in an app with a children's audience. Serve chronological only. - `MAIN-FEED-0001`: one side blocks the other. That is the block working. ## Prompt for your coding agent > Add follows, blocks, mutes and a home timeline to my app with MainSDK's `feeds` interface. Keep the timeline chronological with a caught-up end and a pause, show new posts as a count and never push them in, and count trending tags per region with a minimum group size. --- Source: https://mainsdk.dev/docs/guides/connect-accounts # Let an agent act in a customer's Gmail or GitHub > Connect a person's Google, GitHub or Slack account once, give an app agent only the scopes it needs, and make risky writes wait for a yes. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/connect-accounts · Updated: 2026-10-28 Your app agent needs to read a person's Gmail, or open an issue in their GitHub, and only after they agreed. This guide sets up one provider, one agent with a narrow scope and one risky scope that waits for a yes. It takes about 30 minutes. Spec `connections`, for 0.3. Status: experimental, so import it from `mainsdk/experimental/connections`. ## You need - An OAuth app registered with the provider, with its client ID and secret. Its redirect URL is your app's `connections.callback` path. - A MainSDK app with an agent defined in `main.yaml`. ## 1. Declare the provider and the agent ```yaml title="main.yaml" connections: store: db callback: /api/connections/callback providers: gmail: preset: google client-id: 1234.apps.googleusercontent.com client-secret: secret:GOOGLE_OAUTH_SECRET scopes: [https://www.googleapis.com/auth/gmail.readonly, https://www.googleapis.com/auth/gmail.send] hosts: [gmail.googleapis.com] step-up: [https://www.googleapis.com/auth/gmail.send] # sending waits for a person's yes agents: support-bot: connections: gmail: [https://www.googleapis.com/auth/gmail.readonly] # read only, for this agent ``` `scopes` is the most the app may ever ask for. The agent gets the read scope alone. ## 2. Send the person to the consent screen ```ts title="app/connect/route.ts" import { connections } from "mainsdk/experimental/connections"; export async function GET(req: Request) { return connections.connect(req, "gmail", { returnTo: "/settings/accounts" }); } ``` The person sees one consent screen. After they agree, the callback stores the grant and returns them to `returnTo`. ## 3. Show what they agreed to ```ts title="app/settings/accounts.ts" import { connections } from "mainsdk/experimental/connections"; const info = connections.describe("gmail"); ``` Render `info.scopes` and `info.agents` on your settings page, so the person can see which agent can do what. ## 4. Let the agent read ```ts title="app/agents/support.ts" import { connections } from "mainsdk/experimental/connections"; declare const user: { id: string }; // the signed-in person const res = await connections.fetch("gmail", "https://gmail.googleapis.com/gmail/v1/users/me/messages", { scope: "https://www.googleapis.com/auth/gmail.readonly", agent: "support-bot", user, }); ``` `scope` is required. A call for a scope the agent does not hold fails with `MAIN-CONN-0004` and nothing is sent. ## 5. Make the send wait for a yes A call that needs `gmail.send` returns `Pending` until the person approves it in your app. Handle `Pending` like any other waiting state, and render the approval request. ## Check it worked - After connecting, `connections.list()` shows the grant with `status: "active"`. - `connections.fetch` with the read scope returns a response. - The same call with a scope not listed under `agents.support-bot` fails with `MAIN-CONN-0004`. - Revoking the grant with `connections.revoke(id)` makes the next call fail with `MAIN-CONN-0002` or `MAIN-CONN-0007`. ## If it fails - `MAIN-CONN-0006`: the callback state was missing, expired or used. Start the connect flow again. - `MAIN-CONN-0005`: the URL's host is not in `hosts`. Call the listed host only. - `MAIN-CONN-0021`: a provider has no preset and no `hosts`. Add them. ## Prompt for your coding agent > Connect a customer's Gmail with MainSDK's `connections` interface. Request only the read scope for `support-bot`, keep send behind `step-up`, and show each scope and agent on the account settings page. --- Source: https://mainsdk.dev/docs/guides/seed-and-preview # Seed demo data and make preview databases > Seed the same synthetic demo rows every time, and give each git branch a preview database that expires after its time to live. Status: Planned for 0.4 · Canonical: https://mainsdk.dev/docs/guides/seed-and-preview · Updated: 2026-10-28 You want realistic demo data on your laptop and in preview environments, with no real personal data in it. This guide seeds a database from a number, gives each git branch its own preview database, and removes old previews. It takes about 20 minutes. Planned for 0.4 (spec `seed-preview`). ## You need - A MainSDK app with `db` models. - A database that can be seeded: a local one, or a preview database. Production is refused. ## 1. Describe the demo data ```yaml title="main.yaml" seed: number: 42 # the same number gives the same rows tenants: 3 regions: [eu, in, us] per-tenant: { users: 20 } models: orders: count: 50 fields: status: [paid, shipped, refunded] note: { text: short } preview: driver: local # neon | supabase | local from: main seed: true ttl: 7d ``` Use reserved values for anything that looks like a person. An email must use a reserved domain, such as `example.com`, or `MAIN-SEED-0011` fails the check. ## 2. Seed a local database ```bash npx mainsdk seed --number 42 --tenants 3 ``` The command prints a `digest`. The same number, schema and SDK major always give the same digest. Use `--reset` to clear the seeded rows first. ## 3. Create a preview for the branch ```bash npx mainsdk preview up ``` The preview is named after the current git branch and is seeded with the same number. Running `preview up` again on the same branch returns the same preview. ## 4. Clean up ```bash npx mainsdk preview list npx mainsdk preview prune ``` `prune` deletes previews whose branch is gone or whose `ttl` has passed. It never deletes the parent database. ## Check it worked - `mainsdk seed` prints a digest, and running it twice with `--reset` prints the same digest. - `mainsdk preview up` prints the preview's name and its database secret reference. ## If it fails - `MAIN-SEED-0001`: the environment is production. Seed a local or preview environment. - `MAIN-SEED-0002`: the target database is not seedable. Point at a local or preview database. - `MAIN-PREVIEW-0001`: the db driver cannot branch. Use `preview.driver: local`. - `MAIN-PREVIEW-0002`: the provider failed while making the preview. Check its status and credentials, then run `preview up` again. ## Prompt for your coding agent > Seed this app with the same demo data every time, using MainSDK's `seed` and `preview` interface. Use reserved example emails only, give each git branch a preview database that expires after seven days, and make `preview prune` part of the nightly job. --- Source: https://mainsdk.dev/docs/guides/restrict-network # Restrict which hosts and IPs your app can reach > Allow only named hosts, IPs and CIDR ranges, roll the rule out from observe to enforce, and export it as Kubernetes or AWS security group rules. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/restrict-network · Updated: 2026-10-28 Your app will reach only the hosts, IPs and ranges you name. Every other outbound call is blocked in process and reported with the file and line that made it. You will write an allowlist, run it in observe mode to see what the app really calls, switch it to enforce, and export the same list as a firewall rule for a second, independent wall. It takes about 15 minutes. > **Note:** `network` is in 0.3 (spec `network`) and runs from the repository; it is not published to npm. Enforcement stays free on every plan: an app protecting itself is never a paid action. ## You need - A MainSDK app with a `main.yaml` (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)). - The list of outside services your app calls. You don't need the hosts of your drivers: each driver declares its own. ## 1. Start in observe mode ```yaml title="main.yaml" network: allow: - api.partner.com # exactly this host, not its subdomains - "*.acme.dev" # subdomains of acme.dev only, not acme.dev itself - 10.20.0.0/16 # an IPv4 range, such as your VPC - 8.8.8.8 # one IP - "2001:db8::/32" # an IPv6 range default: deny mode: observe # observe, then warn, then enforce ``` In `observe`, every call still succeeds, and each call that enforce would block is written to the decision log as a would-block. ## 2. Call outside APIs through the SDK ```ts title="lib/rates.ts" import { http } from "mainsdk"; export async function loadRates() { const res = await http.fetch("https://api.partner.com/v1/rates"); return res.json(); } ``` `http.fetch` is the one client every driver and your own code use, so every outbound call passes the same check. `mainsdk check` fails on raw sockets, other HTTP libraries and a bare `fetch` outside the SDK (`MAIN-NET-0001`), so nothing slips past it. The helpers `http.get`, `http.post`, `http.json`, `http.events` and `http.websocket` cover the other common calls, and `http.assert` checks a database URL or git remote before a client connects. Each one is checked the same way. Programs such as `git` or `curl` run through `system.run`, which checks every URL in their arguments. ### What is covered | Kind of call | How to make it | Checked by | | --- | --- | --- | | Web requests (`fetch`, `get`, `post`, `json`) | `http.fetch`, `http.get`, `http.post`, `http.json` | `network.allow` at call time; `json` also refuses non-2xx answers (`MAIN-NET-0008`) | | WebSockets | `http.websocket(url)` | the host, before the socket opens | | Server-sent events | `http.events(url)` | `network.allow` at call time | | Database URLs and git remotes | `http.assert(url)`, then connect | the host, before the client connects | | Programs that reach the network (`git`, `curl`, `wget`) | `system.run(cmd, args)`, declared under `permissions.processes` | every URL in the arguments | | Local files | `disk` (declared under `permissions.disk`) | not the network; see [Declare what your app uses](https://mainsdk.dev/docs/guides/declare-what-your-app-uses) | | User uploads | `files` | the upload driver's own hosts, which the driver declares | Local folders are not network calls, so `network.allow` does not govern them. Read [Declare what your app uses](https://mainsdk.dev/docs/guides/declare-what-your-app-uses) for folders, facts and programs. ## 3. Find every host your code uses ```bash npx mainsdk discover ``` `discover` reads your source and lists every host the code connects to, with the file and line. A host that `network.allow` does not list is marked `MISSING`, and the output ends with the lines to add. Add the hosts you expect; remove the code that calls the ones you don't. `npx mainsdk info --json` shows the same list under `uses`, next to the destinations each driver declares, with each would-block's rule and calling file and line. `npx mainsdk discover --json` prints the full manifest for scripts and agents. To keep the list with the build, write it to a file: ```bash npx mainsdk discover --out build/manifest.json ``` Run it in CI or in your build step. The manifest records every host, folder, fact and program the app uses, so a reviewer or a platform rule can check it without running the app. `mainsdk check` fails with `MAIN-NET-0007` for any host the code uses that `network.allow` doesn't list, so run discovery again after you add a call. ## 4. Enforce ```yaml title="main.yaml" network: mode: enforce ``` A call to any other host now fails with `MAIN-NET-0002`. The SDK also checks: - **DNS answers.** A declared name that resolves to a private range you didn't list, such as `127.0.0.1`, is blocked as rebinding. - **Redirects.** A redirect from an allowed host to an undeclared one is refused with `MAIN-NET-0003`. The decision is made in your app, with no network round trip. If the policy layer fails, the last good list keeps applying (see [Fail static](https://mainsdk.dev/docs/concepts/fail-static)). ## 5. Add a second wall at the platform An in-app check can't stop code that never loads the SDK, such as a native module. Write the same list into your platform's own egress controls: ```bash npx mainsdk network export --to k8s # Kubernetes NetworkPolicy npx mainsdk network export --to aws-sg # AWS security group egress rules ``` Exports for Vercel and Cloudflare are planned. For the list above, `--to k8s` writes egress rules for `10.20.0.0/16` and `8.8.8.8/32`. Platform rules match IPs, so names are resolved when you export; re-export when a provider's IPs change. Add `--discovered` to include hosts the code uses that `network.allow` does not list yet. ## Verify ```bash npx mainsdk check --env prod ``` A pass exits 0 with no `MAIN-NET` finding. In a test, call an undeclared host and expect `MAIN-NET-0002`. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-NET-0001` | Code outside a driver imports `node:net`, `axios` or another client, calls `fetch` directly, opens `new WebSocket` or `EventSource`, or runs `git`, `curl` or `wget` as a child process. | Call through `http` or a driver; run programs with `system.run`. | | `MAIN-NET-0002` | A call went to a host that is neither in `network.allow` nor declared by a driver. | Add the host if you expect the call; otherwise remove the code. | | `MAIN-NET-0003` | An allowed host redirected to an undeclared one. | Declare the target host, or call it directly. | | `MAIN-NET-0004` | A third-party API offers less than TLS 1.2. | Ask the provider to upgrade; meanwhile add an exception with an expiry. | | `MAIN-NET-0005` | An entry in `network.allow` is not an IP, a CIDR range, an exact host or a `*.domain`. | Fix the entry; the error names it. | | `MAIN-NET-0006` | A request was redirected more times than the SDK follows. | Call the final URL directly, or fix the redirect loop. | | `MAIN-NET-0007` | `mainsdk check` found a host the code uses that `network.allow` doesn't list. | Add the host, or remove the call. `npx mainsdk discover` prints the lines. | | `MAIN-NET-0008` | `http.json` or `http.events` got a non-2xx answer, or a body that is not JSON. | Check the URL, the credentials and the service status, or read the response with `http.fetch`. | ## For agents ```text Goal: restrict this app's outbound network to the hosts it needs. Read: docs/specs/network.md, this page, main.yaml. Touch only: main.yaml, files that call outside APIs. Do: add network.allow with mode: observe; replace raw fetch, axios, sockets and child-process curl or git with http helpers or system.run; run npx mainsdk discover and add only the expected hosts; ask the owner before switching to enforce. Run npx mainsdk discover again after every new outbound call. Verify: npx mainsdk check --json --agent Never add a wildcard that covers a whole provider or "*" to make a call pass. ``` ## What comes later - **Across many apps.** Fleet pushes one signed allowlist to every app, can tighten an app's list but never loosen it, and keeps a live map of every destination (phase 4, specs `fleet-control` and `fleet-observe`). - **Inbound traffic.** Secure headers, CSRF and CORS are in [inbound protection](https://mainsdk.dev/docs/guides/sanitize-and-protect-inbound), and rate limits in [`limits`](https://mainsdk.dev/docs/interfaces/limits). This page is outbound only. ## Next - [Declare what your app uses](https://mainsdk.dev/docs/guides/declare-what-your-app-uses): hosts, folders, facts and programs in one place - [See every call, decision and change your app makes](https://mainsdk.dev/docs/guides/see-what-your-app-does) - [Network permissions](https://mainsdk.dev/docs/interfaces/network), the interface reference --- Source: https://mainsdk.dev/docs/guides/track-events # Track product events without identifying people > Declare each product event and its props, send events with consent, and query active users, funnels and retention from the built-in store. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/track-events · Updated: 2026-10-28 You want to know which features people use and where they drop off, without learning who anyone is. This guide declares two events, tracks them, and reads the numbers back. It takes about 20 minutes. Built for 0.3 (spec `analytics`). The built-in store keeps events in the tenant's region; PostHog and an OpenTelemetry collector are optional drivers. ## You need - A MainSDK app with [privacy](https://mainsdk.dev/docs/interfaces/privacy) consent set up for the `analytics` purpose. ## 1. Declare the events ```yaml title="main.yaml" analytics: to: [builtin] without-consent: drop min-group: 10 events: checkout.started: purpose: analytics props: { plan: "enum(free, pro)", items: int, coupon: "string?" } lesson.finished: purpose: analytics props: { subject: "enum(math, science, reading)", minutes: int } ``` Only declared events are accepted. A prop that would identify a person, such as `email` or `ip`, fails the check with `MAIN-ANALYTICS-0011`. ## 2. Track the events ```ts title="app/checkout/actions.ts" "use server"; import { analytics, auth } from "mainsdk"; export async function startCheckout(req: Request, plan: string) { await auth.require(req); analytics.track("checkout.started", { plan, items: 3 }); } ``` The user and tenant come from the request. You never pass them in. ## 3. Read the numbers ```ts title="scripts/funnel.ts" import { analytics } from "mainsdk"; const funnel = await analytics.query({ kind: "funnel", steps: ["checkout.started", "lesson.finished"], within: "7d", from: "2026-10-01", to: "2026-10-31", }); ``` Rows for fewer than `min-group` people are hidden, and the result says how many were hidden. From the terminal, `mainsdk analytics` asks the same questions across every tenant: ```text npx mainsdk analytics --query funnel --steps checkout.started,lesson.finished --within 7d ``` ## Check it worked - `mainsdk check` passes with no `MAIN-ANALYTICS-0010` or `0011` findings. - Tracking an event you did not declare throws `MAIN-ANALYTICS-0001` in development. - Without consent for the purpose, the event is dropped and no row is stored. ## If it fails - `MAIN-ANALYTICS-0002`: props do not match the declared types. Send values that match. - `MAIN-ANALYTICS-0004`: a query names an undeclared event or uses a `by` its kind does not take. - `MAIN-ANALYTICS-0007`: `analytics.query` needs `builtin` in `analytics.to`. ## Prompt for your coding agent > Track `checkout.started` and `lesson.finished` with MainSDK's `analytics` interface. Declare their props with types, keep identifying fields out, and add a funnel query that a non-technical admin can read in the app. --- Source: https://mainsdk.dev/docs/guides/declare-what-your-app-uses # Declare what your app uses > List the hosts, folders, server facts and programs your app uses, declare them in main.yaml like a phone's app privacy page, and keep the list in the build. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/declare-what-your-app-uses · Updated: 2026-10-28 Your app should say what it touches before it runs, the way a phone's app privacy page lists what an app may reach. You will run discovery to list every host, folder, server fact and program the code uses. You will declare them in `main.yaml`, use `disk` and `system` in place of raw file, OS and process calls, check the result with `mainsdk info`, and write the list into your build. It takes about 20 minutes for an app that already calls outside services. > **Note:** `app-permissions` is being built for 0.3 (spec `app-permissions`). Discovery and the `permissions` block run from the repository and are not published to npm yet. Enforcement stays free on every plan. ## You need - A MainSDK app with a `main.yaml` (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)). - Server-side code that reads files, reads server facts or runs programs. A browser-only app needs only the network part, which is in [Restrict which hosts and IPs your app can reach](https://mainsdk.dev/docs/guides/restrict-network). ## 1. Run discovery ```bash npx mainsdk discover ``` Discovery reads your source and `main.yaml` and lists four things, each with the file and line that uses it: - **network**: every host the code connects to, with how it connects (`http`, `websocket`, `event-stream`, `database`, `git`). - **disk**: every folder the code reads or writes. - **system**: every server fact it reads, such as `hostname` or `platform`. - **processes**: every program it runs, such as `git`. Each item is marked `declared` or `MISSING`. The output ends with the `main.yaml` lines that would declare what is missing. Treat it as a first draft: remove the code that uses something you don't want the app to use. ## 2. Declare the permissions Add the lines discovery printed, or write them by hand. Paths are relative to the app folder. ```yaml title="main.yaml" network: allow: - api.partner.com - github.com permissions: mode: observe # observe first, then enforce disk: read: [content] # folders the app may read write: [.cache] # folders the app may write; these are readable too system: [hostname, platform] # facts the app may read processes: [git] # programs the app may run ``` Start with `mode: observe`. Calls succeed, and each use that enforce would refuse is recorded with its file and line. When the list is right, switch to `mode: enforce`. An undeclared use then fails with `MAIN-PERM-0002` (403), and the SDK refuses it before it happens. Declare the narrowest thing you can. A folder such as `.` or a program such as `sh` gives the app far more reach than it needs. ## 3. Use `disk` and `system` in place of raw calls Replace direct `node:fs`, `node:os` and `node:child_process` calls. `mainsdk check` fails on those (`MAIN-PERM-0001`). ```ts title="lib/cache.ts" import { disk } from "mainsdk"; export async function saveRates(rates: unknown) { await disk.write(".cache/rates.json", JSON.stringify(rates)); // .cache is under disk.write } ``` ```ts title="lib/build-info.ts" import { system } from "mainsdk"; export async function currentCommit() { const { stdout } = await system.run("git", ["rev-parse", "HEAD"]); // git is under processes return stdout.trim(); } export function host() { return system.hostname(); // hostname is under system } ``` `system.run` runs the program without a shell, and checks every URL in its arguments against `network.allow`. The rest of the call shapes are in [Local files](https://mainsdk.dev/docs/interfaces/disk) and [Server facts and programs](https://mainsdk.dev/docs/interfaces/system). For a web request, keep using `http`. It is the network side of the same model, covered by [Restrict which hosts and IPs your app can reach](https://mainsdk.dev/docs/guides/restrict-network). ## 4. See what the app uses ```bash npx mainsdk info # human-readable npx mainsdk info --json # for scripts, agents and reviews ``` `info` shows a `uses` section next to what is declared: the hosts, the folders, the facts and the programs the code uses, and which of them `permissions` does not yet declare. Each enforced or observed decision names its calling file and line, so you can see what the app asked for in production as well as in the code. ## 5. Write the list into the build Keep a record of what the app uses with each build: ```bash npx mainsdk discover --out build/manifest.json ``` The manifest is the discovery result as JSON: every host, folder, fact, program and environment variable the code reads, with file and line, and what is undeclared. Store it with the build artifact so that a reviewer, a security team or a platform rule can read what the app uses without running it. Add it to CI next to `mainsdk check`, and diff it on each pull request to catch a new destination or folder. Discovery lists an environment variable the code reads, but it never prints the variable's value. ## Verify ```bash npx mainsdk check --json --agent ``` The check passes with no `MAIN-PERM` or `MAIN-NET-0007` finding. `npx mainsdk info --json` lists nothing in `uses` that you did not mean to add. Switch `mode` to `enforce` once the observe run is clean, and run the check again. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-PERM-0001` | Code imports `node:fs`, `node:os` or `node:child_process` outside a driver. | Use `disk` for files and `system` for facts and programs. | | `MAIN-PERM-0002` | In enforce mode, the app used a folder, fact or program that `permissions` doesn't declare (403). | Declare it if the app needs it; otherwise remove the code that asks. | | `MAIN-PERM-0003` | The check found a folder, fact or program the code uses that `permissions` doesn't declare. | Run `npx mainsdk discover` and add the lines it prints. | | `MAIN-PERM-0004` | A name under `permissions.system` is not a known fact. | Use `user`, `hostname`, `platform` or `network-interfaces`. | | `MAIN-PERM-0005` | A declared program could not start, for example because it is not installed on the server. | Install it on the server, or check its name. | | `MAIN-NET-0007` | The code connects to a host that `network.allow` doesn't list. | Add the host, or remove the call. | ## For agents ```text Goal: declare everything this app uses on the server and in the network, and stop using raw file, OS and process calls. Read: docs/specs/app-permissions.md, this page, main.yaml. Touch only: main.yaml, code that imports node:fs, node:os, node:child_process or calls outside hosts. Do: run npx mainsdk discover; add the lines it prints with mode: observe; replace node:fs with disk, node:os with system, child_process with system.run; run npx mainsdk discover again after every new call; ask the owner before switching to enforce. Verify: npx mainsdk check --json --agent and npx mainsdk info --json Never declare "." for disk, a shell such as sh or bash for processes, or a wildcard host to make a finding pass. ``` ## What comes later - **Across many apps.** Fleet can hold one organisation-wide list for every app (see [Restrict which hosts and IPs your app can reach](https://mainsdk.dev/docs/guides/restrict-network) for the network side). - **Platform rules.** The same network list exports as platform egress rules, as in [Restrict which hosts and IPs your app can reach](https://mainsdk.dev/docs/guides/restrict-network). ## Next - [Restrict which hosts and IPs your app can reach](https://mainsdk.dev/docs/guides/restrict-network) - [Local files](https://mainsdk.dev/docs/interfaces/disk) and [Server facts and programs](https://mainsdk.dev/docs/interfaces/system), the interface references - [See every call, decision and change your app makes](https://mainsdk.dev/docs/guides/see-what-your-app-does) --- Source: https://mainsdk.dev/docs/guides/protect-a-route-with-permissions # Protect a route with permissions > Decide who may do what to which record with access, and refuse the rest with a 403. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/protect-a-route-with-permissions · Updated: 2026-10-28 You will define roles in `main.yaml`, check a request with `access`, and see a refusal explained. It takes about 15 minutes. ## You need - A route that already knows the user (see [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth)). ## 1. Declare roles ```yaml title="main.yaml" access: roles: admin: ["*"] member: [orders.read] owner: [orders.update] ``` An admin may do anything. A member may read orders. The owner of an order may update it, because its `owner` equals the subject's `id`. ## 2. Check before you act ```ts title="app/api/orders/[id]/route.ts" import { access, auth } from "mainsdk"; // your own order store declare function loadOrder(id: string): Promise<{ type: string; id: string; owner: string }>; declare function saveOrder(order: { id: string }, patch: unknown): Promise; export async function PATCH(req: Request, ctx: { params: Promise<{ id: string }> }) { const session = await auth.require(req); const order = await loadOrder((await ctx.params).id); // { type: "order", id, owner, ... } const user = { id: session.user, roles: ["member"] }; // roles come from tenancy membership when you use it access.require(user, "orders.update", order); // throws MAIN-ACCESS-0001 (403) unless allowed return Response.json(await saveOrder(order, await req.json())); } ``` Use `await access.check({ subject, action, resource })` when you want the decision and not a throw. ## 3. Explain a refusal ```ts title="lib/why.ts" import { access } from "mainsdk"; const user = { id: "u_1", roles: ["member"] }; const order = { type: "order", id: "o_1", owner: "u_2" }; const d = access.decide({ subject: user, action: "orders.update", resource: order }); const why = access.explain(d.id); // { decision, request }; decision.reasons names the rule ``` ## Check it worked A member who does not own the order gets 403, and `d.reasons` says `no role or owner rule grants orders.update`. The owner gets 200, with the reason `owner of order may orders.update`. Every decision is in the audit log with the policy version. ## Next - [Permissions](https://mainsdk.dev/docs/interfaces/access) - [Policy decisions](https://mainsdk.dev/docs/interfaces/policy) --- Source: https://mainsdk.dev/docs/guides/see-what-your-app-does # See every call, decision and change your app makes > Get a full picture of a MainSDK app - destinations, drivers, access decisions and a tamper-evident audit log - in your own OpenTelemetry stack or SIEM. Status: Building for 0.2 · Canonical: https://mainsdk.dev/docs/guides/see-what-your-app-does · Updated: 2026-10-28 You will see what your app does, from your own tools: which outside hosts it calls, which driver serves each interface, why each access request was allowed or denied, and who changed what. Everything runs inside your app and goes to the destinations you choose. Nothing is sent to MainSDK until you log in and turn it on. > **Note:** Logs and the audit log are being built for 0.2 (spec `log-telemetry`), and telemetry is still planned; `mainsdk info` is being built for 0.2 (spec `cli-core`); network decisions are being built for 0.3 (spec `network`). They run from the repository and are not published to npm yet. ## You need - A MainSDK app with a `main.yaml`. - Optional: an OpenTelemetry collector or a SIEM that accepts OTLP. ## 1. Ask the app what it is ```bash npx mainsdk info # human-readable npx mainsdk info --json # for scripts, agents and reviews ``` It lists the project, each interface and its driver, every network destination (yours and each driver's declared hosts), the folders, server facts and programs the code uses (see [Declare what your app uses](https://mainsdk.dev/docs/guides/declare-what-your-app-uses)), tracking settings such as `email.tracking: none`, and every open exception with its owner and expiry. Run it in CI and diff it to catch a new destination or driver in a pull request. A running app can serve a signed copy at `/.well-known/mainsdk.json`. `mainsdk info https://app.example.com` checks the signature, so a buyer can verify the record without access to your code. ## 2. Turn on the audit log ```yaml title="main.yaml" logging: audit: true redact: [personal, sensitive, child, phi] retention: 12m ``` Every action and every policy decision is written as an OCSF record with the actor, the action, the resource, the decision and the policy version. Each record carries the hash of the one before it, so an edit, deletion or reorder is detectable: ```ts title="scripts/verify-audit.ts" import { log } from "mainsdk"; const result = log.verifyAudit({ from: "2026-10-01" }); if (!result.ok) throw new Error(`Audit log broken at ${result.brokenAt}`); ``` The audit log is never sampled or dropped. If its sink is down, records buffer to local disk and `MAIN-LOG-0010` is reported. ## 3. Find out why a request was denied Every decision has an id. `policy.explain` returns the policy line that made it: ```ts title="app/admin/why/route.ts" import { auth, policy } from "mainsdk"; export async function GET(req: Request) { await auth.require(req, { role: "admin" }); const id = new URL(req.url).searchParams.get("decision") ?? ""; return Response.json(policy.explain(id)); } ``` Blocked network calls are decisions too: each one names the destination, the rule and the calling file and line (see [Restrict which hosts and IPs your app can reach](https://mainsdk.dev/docs/guides/restrict-network)). ## 4. Send it to your own tools ```yaml title="main.yaml" telemetry: to: [terminal, otel] otel: endpoint: https://otel.acme.dev:4318 headers: { authorization: secret:OTEL_TOKEN } sample: 0.2 # traces only; errors, decisions and audit are never sampled ``` Logs follow the OpenTelemetry data model with W3C trace ids, so they land in Grafana, Datadog, Honeycomb or your SIEM next to the rest of your traces. Export is asynchronous and bounded: if the collector is down, telemetry is dropped and counted, and your requests never wait (ADR-0003). ## What is kept out Redaction runs in your app before anything is written or exported: - Fields labelled `personal`, `sensitive`, `child` or `phi`, anything resolved from a `secret:` reference, and known key formats. - Request and response bodies, cookies and `authorization` headers, unless you turn them on. - User ids are pseudonymised per project, and IP addresses are trimmed (IPv4 to /24, IPv6 to /48). The `telemetry@1` contract lists every field, its purpose and its redaction rule. A field not in it is dropped (`MAIN-LOG-0005`). ## Verify ```bash npx mainsdk check --env prod npx mainsdk info --json ``` `check` passes with no `MAIN-LOG` finding, and `info` lists only the destinations and drivers you expect. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-LOG-0003` | `MAINSDK_DEBUG=driver` was ignored in production. | Use a debug session (phase 2). | | `MAIN-LOG-0005` | An attribute not in `telemetry@1` was dropped before export. | Remove it, or propose it for the contract. | | `MAIN-LOG-0010` | The audit sink is unavailable; records are buffering to local disk. | Restore the sink. | | `MAIN-INFO-0002` | The signed record from a running app doesn't verify. | Treat the record as untrusted and redeploy from a clean build. | ## For agents ```text Goal: give this app an audit log and send its telemetry to the team's collector. Read: docs/specs/log-telemetry.md, this page, main.yaml. Touch only: main.yaml, main.env.prod.yaml. Do: set logging.audit: true with redact labels; set telemetry.to: [terminal, otel] and otel.endpoint; keep the collector token as a secret: reference. Verify: npx mainsdk check --json --agent && npx mainsdk info --json Never turn off redaction or log request bodies to debug. ``` ## What comes later - **Streaming each customer organisation's audit log to its own SIEM** (phase 2). - **One view across every app:** a live destination map that alerts on any new host, and reachable-vulnerability reports, in Fleet (phase 4, spec `fleet-observe`). ## Next - [Declare what your app uses](https://mainsdk.dev/docs/guides/declare-what-your-app-uses): the hosts, folders, facts and programs `info` lists - [Make your app enterprise ready](https://mainsdk.dev/docs/guides/enterprise-ready) - [Logs, audit and telemetry](https://mainsdk.dev/docs/interfaces/log), the interface reference --- Source: https://mainsdk.dev/docs/guides/engagement-messages # Send engagement reminders without dark patterns > Send a short ladder of reminders to people who went quiet, within a weekly cap and quiet hours, that stops when they return or stop reading. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/engagement-messages · Updated: 2026-10-28 You want people who went quiet to come back, without spamming them or using guilt. This guide sends up to three reminders over a week, respects a weekly cap and quiet hours, and stops when the person returns. It takes about 30 minutes. `engagement` is in 0.4 (spec `engagement`) and runs from the repository; it is not published to npm. Status: experimental. ## You need - A MainSDK app with [notifications](https://mainsdk.dev/docs/interfaces/notify) set up for at least one channel. - The event that means a person came back, such as `user.active`. - Two or three approved message templates. ## 1. Set the guardrails ```yaml title="main.yaml" notify: engagement: max-per-week: 4 quiet-hours: "21:00-08:00" stop-after-ignored: 5 active: user.active ignored-after: 48h send-time: { default: "17:00" } categories: [reminders] ``` These are checked at run time. A later change that loosens one is refused, and the last good value stays. ## 2. Write the journey ```ts title="app/engagement/come-back.ts" import { journey, ladder } from "mainsdk/notify"; export const comeBack = journey("come-back", { version: 1, start: "user.inactive", category: "reminders" }, async ({ user }) => ladder(user, [ { after: "1d", template: "reminder.gentle" }, { after: "3d", template: "reminder.gentle-2" }, { after: "7d", template: "reminder.last" }, ], { sendAt: "usual" })); ``` `ladder` waits for each rung, checks whether the person came back, and sends at a time that suits them. ## 3. Make every stop one step Each engagement message carries a stop. Email goes with an RFC 8058 one-click unsubscribe, so set `email.unsubscribe-url`. Push carries a stop action with a `handle`; call it back: ```ts title="app/push/stop.ts" import { app } from "mainsdk"; export const onStop = (handle: string) => app().engagement.stopByHandle(handle, { channel: "push" }); ``` For WhatsApp and SMS pass inbound text to `engagement.keyword(user, text, { channel })`. A stop applies to the category on every channel before the next send, with no confirmation step. ## 4. Link without tracking people Use a placeholder and declare its target. One link is minted per journey, rung and variant, never per person, and its opens come back as counts: ```yaml title="main.yaml" notify: engagement: links: offer: https://app.example.com/offer ``` Write `{{link.offer}}` in the template. Opens never decide a winner and never count toward the ignored signal. ## 5. Check the copy Copy must not use streak-loss or guilt. Social proof must come from a placeholder such as `{{proof.finished-today}}`, backed by a declared query, not a typed number. `mainsdk check` reports `MAIN-ENGAGE-0010` and `0011` when it does not. ## 6. Turn on children's rules if they can use the app If any child can have an account, add the guardian controls and keep the strict copy rules: ```yaml title="main.yaml" notify: engagement: guardian: { controls: [pause, frequency, quiet-hours, view] } ``` Without guardian settings, `mainsdk check` fails with `MAIN-ENGAGE-0013`. ## Check it worked - `engagement.status(user)` shows `sentThisWeek` at most 4. - A message due inside quiet hours is deferred until they end, never sent early. - Five ignored messages in a row stop engagement messages for that person (`MAIN-ENGAGE-0002`). - One POST to the email's unsubscribe link, or `stopByHandle` on a push, stops the category on every channel. - `mainsdk check` passes with no `MAIN-ENGAGE-0010` to `0014` findings. With `check.ai: false` it prints a `note:` that the model pass was skipped. - `mainsdk privacy labels --format web` prints the promise built from your guardrails. ## If it fails - `MAIN-ENGAGE-0001`: the weekly cap was reached. Nothing to fix; the message waits. - `MAIN-ENGAGE-0006`: `urgent: true` is refused. Engagement never skips quiet hours. - `MAIN-ENGAGE-0008`: a guardrail in `main.yaml` is invalid, so start fails. Fix the value. ## Prompt for your coding agent > Add a come-back journey with three reminders using MainSDK's engagement messages. Keep the weekly cap at 4, quiet hours from 21:00 to 08:00, and stop after five ignored messages. Use no guilt or streak copy, and use a proof placeholder for any social proof. --- Source: https://mainsdk.dev/docs/guides/govern-an-app-agent # Give an agent a budget and approvals > Name an agent, give it a budget and an approval rule, and switch it off without a deploy. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/govern-an-app-agent · Updated: 2026-10-28 You will declare an agent, run a step through it and approve a waiting step. It takes about 10 minutes. The full walk-through is in [agents with an identity, a budget and approvals](https://mainsdk.dev/docs/agents/governance). ## You need - An action or model call the agent will make. ## 1. Declare the agent ```yaml title="main.yaml" agents: support-bot: budget: { spend: $5/day, calls: 500/day } approve: { risk: destructive, spend-over: $1 } enabled: true ``` ## 2. Run a step through it ```ts title="lib/refund-step.ts" import { agents } from "mainsdk"; declare function refundOrder(input: { orderId: string }): Promise; // your own action const user = { id: "u_1" }; const input = { orderId: "o_1" }; const out = await agents.run( { agent: "support-bot", action: "orders.refund", user: user.id, spend: "$5.00", risk: "destructive" }, () => refundOrder(input), ); ``` A step over `spend-over`, or at the destructive risk, returns `pending` and waits. ## 3. Approve it ```ts title="app/api/approve/route.ts" import { agents } from "mainsdk"; const approvalId = "apr_1"; // from the pending approval const admin = { id: "u_admin" }; agents.approve(approvalId, { kind: "user", id: admin.id }); ``` An agent approving its own step fails with `MAIN-AGENT-0003`. ## Check it worked Set `enabled: false`. The next call fails with `MAIN-AGENT-0004`, with no deploy. ## Next - [App agents](https://mainsdk.dev/docs/interfaces/agents) --- Source: https://mainsdk.dev/docs/guides/keep-policies-in-git # Keep policies in git > Write decisions as policy files, decide in process and explain every decision. Status: Building for 0.2 · Canonical: https://mainsdk.dev/docs/guides/keep-policies-in-git · Updated: 2026-10-28 You will put policy files in your repository, decide a request in process and explain the result. It takes about 15 minutes. ## You need - The `access` interface set up (see [Protect a route with permissions](https://mainsdk.dev/docs/guides/protect-a-route-with-permissions)). ## 1. Choose the engine ```yaml title="main.yaml" policy: engine: cedar dir: policies/ mode: observe ``` Start in `observe`: decisions are logged but not enforced. Move to `enforce` when the log looks right. ## 2. Decide and explain ```ts title="lib/decide.ts" import { policy } from "mainsdk"; export function canUpdate(user: string, order: { id: string }) { const d = policy.decide({ subject: { type: "user", id: user, roles: ["member"] }, action: { name: "orders.update" }, resource: { type: "order", id: order.id }, }); return { allowed: d.allow, why: policy.explain(d.id) }; } ``` ## 3. Review changes in a pull request Policy files are plain text in git, so a change to who may do what is a reviewed diff with history. ## Check it worked `policy.explain()` names the policy and rule behind a decision, and the audit log holds the policy version. ## Next - [Policy decisions](https://mainsdk.dev/docs/interfaces/policy) --- Source: https://mainsdk.dev/docs/guides/propose-playbooks-with-the-advisor # Propose playbooks with the advisor > Let a model read signals and propose one of your declared playbooks, with a person approving every proposal. Status: Building for 0.6 · Canonical: https://mainsdk.dev/docs/guides/propose-playbooks-with-the-advisor · Updated: 2026-10-28 You will turn on the advisor so that, when signals suggest trouble, a model proposes one of the playbooks you already declared and a person approves it. It takes about 10 minutes. ## You need - At least one playbook rule declared, or a preset turned on, such as `attack.contain`. - The `ai` interface configured with a model (or `driver: local` in tests). ## 1. Declare it ```yaml title="main.yaml" agents: advisor: { budget: { spend: $2/day, calls: 50/day } } advisor: on: [attack.suspected, anomaly.detected] may: [attack.contain, pressure.slim] run: approve ``` `mainsdk check` fails with `MAIN-ADVISOR-0001` when `may` is empty, names a rule that is not declared, or when `run: auto` would apply a rule whose steps are not notice or instance class. ## 2. Read a proposal A wake at medium severity or above makes the advisor ask the model. You can also ask now: ```ts title="ops/ask.ts" import { advisor } from "mainsdk/experimental/advisor"; const p = await advisor.propose(); console.log(p.choice, p.reason, p.evidence); // choice is one of may, or none ``` A proposal with a choice starts a playbook run with source `advisor`, held for a person to approve. The advisor never approves anything itself. ## Test it offline ```ts title="test/advisor.test.ts" import { createTestApp } from "@mainsdk/testing"; const app = await createTestApp({ ai: { replies: [JSON.stringify({ choice: "attack.contain", reason: "Many failed sign-ins", evidence: ["s1"], confidence: 0.8 })] }, }); ``` ## Check it worked A model answer that names a playbook outside `may` is refused and logged as `MAIN-ADVISOR-0002`, and no run starts. When the budget or the hourly limit is reached the advisor stops with `MAIN-ADVISOR-0003` and your playbooks carry on without it. Secrets and personal data never reach the model: a field that matches a detector is dropped and logged as `MAIN-ADVISOR-0004`. ## Next - [Playbook advisor](https://mainsdk.dev/docs/interfaces/advisor) - [Give an agent a budget](https://mainsdk.dev/docs/guides/govern-an-app-agent) --- Source: https://mainsdk.dev/docs/guides/run-background-jobs # Run work in the background > Queue a job that retries with backoff, applies its side effect once, lands in a dead-letter queue when it keeps failing, and runs on a schedule. Status: Preview · Canonical: https://mainsdk.dev/docs/guides/run-background-jobs · Updated: 2026-10-28 You will move a slow task out of a request into a background job, make its side effect safe to retry, deal with a job that keeps failing, and run one on a schedule. It takes about 20 minutes. ## You need - A MainSDK app with `main.yaml` and Node 22 or later. - For production, a `db` section so the queue lives in your database. Development runs the queue in process. ## 1. Declare the queue ```yaml title="main.yaml" jobs: driver: db # the app's own database, shared by every instance dead-letter: true # keep jobs that fail their last attempt idempotency: { ttl: 24h } ``` ```yaml title="main.env.dev.yaml" jobs: driver: local # in-process queue for development ``` `mainsdk check --env prod` fails with `MAIN-CHECK-0026` if `jobs` resolves to `local`, so the in-process queue cannot reach production by accident. ## 2. Register a handler and queue a job A handler is code you register by name with `jobs.on()`. Register it in a module your app imports at start, because only an instance that has registered a name runs that job. A handler that throws fails the attempt and the queue retries it. ```ts title="app/jobs/reports.ts" import { idempotency, jobs } from "mainsdk"; type ExportInput = { userId: string; month: string; email: string }; declare function sendReport(input: ExportInput): Promise; jobs.on("reports.export", async (input: ExportInput, ctx) => { console.log(`export ${input.month}, attempt ${ctx.attempt} of ${ctx.maxAttempts}`); await idempotency.once("email.send", { to: input.email, month: input.month }, () => sendReport(input), // your code; the side effect that must not happen twice ); }); ``` Queue it from a route, an action or a script. `jobs.run()` writes one record and returns at once. ```ts title="app/api/reports/route.ts" import { auth, jobs } from "mainsdk"; import "@/jobs/reports"; export async function POST(req: Request) { const session = await auth.require(req); const { month, email } = (await req.json()) as { month: string; email: string }; const { id, deduped } = await jobs.run( "reports.export", { userId: session.user, month, email }, { idempotencyKey: `export:${session.user}:${month}`, retries: 3 }, ); return Response.json({ id, deduped }, { status: 202 }); } ``` You should see the handler log `attempt 1 of 4` (`retries: 3` means up to four attempts). Calling `jobs.run()` again with the same `idempotencyKey` returns the same `id` with `deduped: true` and does not queue a second job. Check a job with `await jobs.get(id)`; its `state` is `queued`, `running`, `done`, `dead` or `failed`. ## 3. Make the side effect safe to retry A worker can crash after it sends the email and before it tells the queue. The retry then reaches the same line again. `idempotency.once(action, input, fn)` runs `fn` once for that key and returns the first result on a repeat, so the report is sent once. Calls you make through MainSDK, such as `email.send`, already carry a key made from the job and the call; wrap your own side effects with `idempotency.once()`. To see it, make the first attempt throw after the side effect. The log shows `The job reports.export failed attempt 1 of 4; it will run again`, a second `attempt 2 of 4`, and the send happens once. Retries wait about 1s, 2s, 4s and so on (`backoff: "exponential"`, capped at 5 minutes); pass `backoff: "fixed"` for a flat 1s. ## 4. Deal with a job that keeps failing A job that fails its last attempt moves to the dead-letter queue with the code and message of its last failure, and logs `MAIN-JOB-0002`. ```ts title="scripts/dead-letters.ts" import { jobs } from "mainsdk"; const { items } = await jobs.dead.list(); // oldest first, current tenant console.log(items.map((j) => [j.name, j.state, j.attempts, j.error?.code])); await jobs.dead.retry(items[0].id); // back in the queue with a fresh set of attempts ``` You should see `[ 'reports.broken', 'dead', 2, 'MAIN-JOB-0002' ]` for a job that failed both attempts, and `jobs.dead.retry` returns the job with `state: "queued"`. Fix the cause first; a retried job that fails again goes back to the dead-letter queue. ## 5. Run a job on a schedule ```ts title="app/jobs/schedules.ts" import { jobs } from "mainsdk"; declare function buildNightlyReport(region: string): Promise; jobs.on("reports.nightly", async (input: { region: string }) => { await buildNightlyReport(input.region); // your code }); jobs.schedule("reports.nightly", "0 3 * * *", { region: "in" }); // 03:00 UTC every day ``` The schedule is a five-field cron expression in UTC. Every instance may call `schedule`; each firing uses one idempotency key, so the job is queued once however many instances run. ## 6. Test it `createTestApp` gives you the queue on a fake clock. Step the clock in small pieces so the timer each retry arms fires in order. ```ts title="app/jobs/reports.test.ts" import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { createTestApp } from "@mainsdk/testing"; async function pass(app: ReturnType, ms: number) { for (let t = 0; t < ms; t += 500) await app.clock.tick(500); } describe("report export", () => { it("webhooks-jobs AC6 applies the side effect once when an attempt is retried", async () => { const app = createTestApp(); let sent = 0; let attempts = 0; app.jobs.on("reports.export", async () => { attempts++; await app.idempotency.once("email.send", { to: "a@pinecart.dev" }, () => void sent++); if (attempts === 1) throw new Error("crashed before the acknowledgement"); }); await app.jobs.run("reports.export", { month: "2026-10" }); await pass(app, 10_000); assert.equal(attempts, 2); assert.equal(sent, 1); }); it("webhooks-jobs AC5 dead-letters a job that always fails", async () => { const app = createTestApp(); app.jobs.on("reports.export", () => { throw new Error("always fails"); }); await app.jobs.run("reports.export", { month: "2026-10" }, { retries: 3 }); await pass(app, 30_000); const dead = await app.jobs.dead.list(); assert.equal(dead.items.length, 1); assert.equal(dead.items[0]?.attempts, 4); }); }); ``` ## Verify ```bash npx mainsdk check --env prod node --test app/jobs/reports.test.ts ``` A pass prints `mainsdk check: passed (prod)` and both tests pass. With `jobs.driver: local` in `main.yaml`, the prod check fails with `MAIN-CHECK-0026`. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-CHECK-0026` | `jobs` resolves to the local queue in production | Set `jobs.driver: db` in `main.yaml` and keep `local` in `main.env.dev.yaml` | | `MAIN-JOB-0001` | The job name is not lowercase letters, digits, dots, dashes and colons, `retries` is outside 0 to 50, or the input is not JSON or is over 256 KB | Fix the name or `retries`; store large data and pass its id | | `MAIN-JOB-0003` | A job ran with no handler registered for its name | Call `jobs.on(name, handler)` in a module the app imports at start | | `MAIN-JOB-0004` | The cron expression is not five valid fields | Write it like `0 3 * * *` | | `MAIN-JOB-0005` | `jobs.dead.retry` got an id that is not a dead job in the current tenant | List with `jobs.dead.list()` | | `MAIN-JOB-0006` | The job queue could not be reached (503, retryable) | Check the database the `db` driver uses, then retry | ## For agents ```text Goal: move one slow task into a background job that is safe to retry. Touch: main.yaml (jobs, idempotency), main.env.dev.yaml (jobs.driver: local), one module that calls jobs.on(), the caller that calls jobs.run(), one test. Do: pass an idempotencyKey built from the business key; wrap each side effect in idempotency.once() or a MainSDK call; keep job inputs to ids, not personal data. Do not: set jobs.driver to local in main.yaml, build a retry loop or a queue by hand, or loosen a check. Verify: npx mainsdk check --env prod, then node --test on the job's test file. Stop and ask if: the work cannot be repeated safely, or the app has no database for the queue. ``` ## Next - [Events and background jobs](https://mainsdk.dev/docs/interfaces/jobs): every option, error and the instance broadcast. - [Build a durable workflow](https://mainsdk.dev/docs/guides/build-a-durable-workflow): when one job is not enough and the work has to wait. - [Send and receive webhooks](https://mainsdk.dev/docs/guides/send-and-receive-webhooks): hand slow webhook work to a job. --- Source: https://mainsdk.dev/docs/guides/protect-an-app # Protect a file so only the right people open it > Protect a file with protect, release it at a set time, share it through getqr.id, see who opened it and revoke it in seconds, all on the local driver first. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/protect-an-app · Updated: 2026-10-28 You will protect a file so it stays ciphertext until its policy allows an open, hold it until a release time, share it with named people, read who opened it, and revoke it. You build it on the `local` driver, which fakes a TAPS Node in your process, so it needs no account. It takes about 20 minutes. > **Note:** `protect` is in 0.4 (spec `protect-taps`) and experimental. The `local` driver is for development and tests only: `mainsdk check` refuses it in production (`MAIN-CHECK-0026`). A real TAPS Node needs the `@mainsdk/protect-taps` package in this repository; nothing is published to npm. ## You need - A MainSDK app with a `main.yaml` (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)) and a signed-in user (see [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth)). - For real links outside tests, a getqr.id key as `secret:GETQR_API_KEY` (see [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share)). ## 1. Turn on the driver ```yaml title="main.yaml" protect: driver: local # taps-node in production ``` `local` is the default, so a missing `protect` section behaves the same. ## 2. Protect a file with a release time ```ts title="app/api/papers/route.ts" import { auth, protect } from "mainsdk"; export async function POST(req: Request) { await auth.require(req, { role: "exam-admin" }); const form = await req.formData(); const paper = form.get("paper") as File; const asset = await protect(paper, { label: "sensitive", policy: { releaseAt: "2027-03-01T09:00:00Z" }, // no licence before this time, for anyone }); return Response.json({ asset: asset.id }); } ``` You should get an asset with an id such as `urn:taps:asset:01M5…` and `state: "active"`. `label` is one of `public`, `internal`, `confidential`, `restricted`, or `personal`, `sensitive`, `child`, `phi`. Policy is default deny; add `people`, `zones`, `geo`, `time` or `purposes` to narrow it further. ## 3. Open it `protect.open()` asks for a licence for the current user and returns the content only when the policy allows it. ```ts title="app/papers/read.ts" import { protect } from "mainsdk"; export async function readPaper(id: string) { const opened = await protect.open(id); try { return (await opened.text()).text; // the text layer a screen reader uses } finally { await opened.close(); } } ``` Before `releaseAt`, the open fails with `MAIN-PROTECT-0002` and a `retryAfter`, and the attempt is written to the decision log. After it, the open succeeds. Pages are available from `opened.pages()`; `read()` needs `mode: wrapper`. ## 4. Share it ```ts title="app/papers/share.ts" import { protect } from "mainsdk"; export async function sharePaper(assetId: string, email: string) { const link = await protect.share(assetId, { to: [email], expiresIn: "7d", maxOpens: 3 }); return link.url; // a getqr.id link that opens the TAPS Web Reader } ``` Recipients open the file in the Web Reader with nothing to install. The TAPS link token is never in the getqr.id path, your logs or channel data. ## 5. See who opened it, and revoke ```ts title="app/api/assets/[id]/route.ts" import { auth, protect } from "mainsdk"; export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { await auth.require(req, { role: "exam-admin" }); const { id } = await params; return Response.json(await protect.activity(id)); // { items: [{ at, who, action, decision }], next } } export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) { await auth.require(req, { role: "exam-admin", fresh: "5m" }); const { id } = await params; await protect.revoke(id, { reason: "paper leaked" }); return new Response(null, { status: 204 }); } ``` A revoked asset fails the next open and the next licence renewal, so an open session stops within one `renew` interval (10 seconds by default). `protect.revoke(id, { person })` stops one person and leaves the rest. ## 6. Test it on the fake clock ```ts title="app/papers/protect.test.ts" import assert from "node:assert/strict"; import { it } from "node:test"; import { createTestApp } from "@mainsdk/testing"; it("protect-taps releases a paper only after releaseAt", async () => { const app = createTestApp({ yaml: "schema: 1\nproject: papers\n" }); await app.tenancy.as({ user: "u1" }, async () => { const asset = await app.protect(new TextEncoder().encode("Q1 paper"), { label: "sensitive", policy: { releaseAt: "2027-03-01T09:00:00Z" }, }); await assert.rejects(app.protect.open(asset.id), { code: "MAIN-PROTECT-0002" }); app.clock.set(Date.parse("2027-03-01T09:00:01Z")); const opened = await app.protect.open(asset.id); assert.equal((await opened.text()).text, "Q1 paper"); await opened.close(); await app.protect.revoke(asset.id, { reason: "test" }); await assert.rejects(app.protect.open(asset.id), { code: "MAIN-PROTECT-0003" }); }); }); ``` ## Verify ```bash npx mainsdk check --env prod ``` A pass has no `MAIN-PROTECT` finding and no `MAIN-CHECK-0026`. With `driver: local` in production the check fails; switch the driver to a TAPS Node with `protect.url`, `protect.share-domain` and `protect.key: secret:MAINSDK_PROTECT_KEY` first (see [TAPS Node and SoveDocs](https://mainsdk.dev/docs/integrations/taps)). ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-PROTECT-0001` | The policy does not allow this open. | Check with `protect.policy.check(asset, { as })`. | | `MAIN-PROTECT-0002` | Opened before `releaseAt`. | Wait for the release time; `retryAfter` says how long. | | `MAIN-PROTECT-0003` | The asset was revoked or erased. | Protect and share a new copy. | | `MAIN-PROTECT-0005` | The change needs a second person. | Pass `approvals` from another person. | | `MAIN-PROTECT-0015` | No user in the context, or an unknown party or agent. | Run inside a signed-in request or `tenant.as({ user }, fn)`; name parties under `protect.threshold`. | | `MAIN-CHECK-0026` | The `local` driver in production. | Use `taps-node`, `taps-hosted` or `sovedocs`. | ## For agents ```text Goal: protect files this app shares, with a release time and revocation. Read: docs/specs/protect-taps.md, this page, main.yaml. Touch only: main.yaml (protect section), files that store or share documents. Do: replace plain share of sensitive files with protect() then protect.share(); set label and policy; add a test on the fake clock. Verify: pnpm mainsdk check --env prod --json; node --test on the new test. Never use the local driver in production, put a TAPS link token in a log, or approve a change on behalf of another person. ``` ## Next - [Protected files](https://mainsdk.dev/docs/interfaces/protect): every call, policy key, the ledger and the driver notes. - [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share): how protected files are shared through getqr.id. - [Show untrusted text safely and protect web responses](https://mainsdk.dev/docs/guides/sanitize-and-protect-inbound): headers, CSRF, CORS and sanitized HTML. --- Source: https://mainsdk.dev/docs/guides/send-and-receive-webhooks # Send and receive webhooks > Verify GitHub or Stripe webhooks before your code runs, run each delivery once, and send your own signed webhooks with retries and a delivery log. Status: Preview · Canonical: https://mainsdk.dev/docs/guides/send-and-receive-webhooks · Updated: 2026-10-28 You will receive a signed webhook so that forged and replayed requests never reach your code, hand its slow work to a job, and send your own signed webhook to a partner with retries. It takes about 20 minutes. ## You need - A MainSDK app with `main.yaml`. - A signing secret from the sender (for GitHub, the secret you set on the webhook) in your secrets manager. In development, MainSDK reads `secret:NAME` from the environment variable `NAME`. - A signing key of your own for outbound webhooks, such as `whsec_` and 32 random bytes in base64. ## 1. Declare the endpoints ```yaml title="main.yaml" network: allow: [hooks.partner.dev] # the hosts webhooks.emit may call webhooks: secret: secret:WEBHOOK_SIGNING_KEY # signs the webhooks you send retries: { max: 8, window: 24h } inbound: github: { preset: github, secret: secret:GITHUB_WEBHOOK_SECRET, path: /api/webhooks/github } ``` The `preset` says how the sender signs (`stripe`, `github`, `slack`, `shopify`, `twilio`, `razorpay`, `standard`, `mailgun`, `ses`, `postmark`). A secret is always a `secret:` reference; a literal value fails `mainsdk check` with `MAIN-CONFIG-0004`. To rotate without downtime, write `secret: [secret:NEW, secret:OLD]`: a signature that matches either passes. ## 2. Receive a webhook ```ts title="app/api/webhooks/github/route.ts" import { jobs, webhooks } from "mainsdk"; export const POST = webhooks.receive("github", async (body, meta) => { // body is the parsed JSON; meta.id is GitHub's delivery id await jobs.run("deploys.record", { delivery: meta.id, payload: body }, { idempotencyKey: meta.id }); }); ``` `webhooks.receive()` returns a route handler that takes a standard `Request`. The signature and timestamp are checked over the raw body before the handler runs. A forged request gets 400 with `MAIN-HOOK-0002` and is logged without its body. Keep the handler short: record or queue the work and return, so the sender gets its 200 quickly. Here the slow work goes to a [job](https://mainsdk.dev/docs/guides/run-background-jobs) keyed by the delivery id. ## 3. Check that duplicates run once Senders retry, and sometimes send the same event twice. MainSDK passes the provider's delivery id (for GitHub, `X-GitHub-Delivery`) to the idempotency record, so the second delivery gets 200 without calling your handler. You do not need your own dedupe table. If your handler throws, the sender gets a 500, nothing is marked done, and its retry runs the handler again. ## 4. Send a signed webhook ```ts title="app/orders/notify.ts" import { webhooks } from "mainsdk"; export async function notifyPartner(order: { id: string }) { return webhooks.emit( "https://hooks.partner.dev/in", { type: "order.shipped", data: { orderId: order.id } }, { idempotencyKey: `order.shipped:${order.id}` }, ); } ``` `emit` queues the delivery and returns its id; it does not wait for the partner. Each request carries `webhook-id`, `webhook-timestamp` and `webhook-signature`, so the partner can verify it with any Standard Webhooks library. If the endpoint answers anything but 2xx, MainSDK sends the same `webhook-id` again after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours, up to `retries.max` attempts inside `retries.window`. Read a delivery with `webhooks.delivery(id)`: it lists each attempt with its status and response status, never a body. ## 5. Replay a delivery in development Under `mainsdk dev`, every inbound delivery is recorded in `.mainsdk/` for 7 days or the last 500. Replay one to rerun your handler without going back to the sender: ```bash npx mainsdk webhooks list --json npx mainsdk webhooks replay npx mainsdk webhooks replay --same-id # keep the delivery id, to test the duplicate check ``` Without `--same-id` the handler runs again with a new delivery id. With it, you get 200 and the handler does not run, which is the duplicate check working. Outside dev, or with no dev server running, the command exits 2 with `MAIN-HOOK-0012`. ## 6. Test it `createTestApp` gives you a fake network and clock. Sign a request yourself to test the receiver, and read what `emit` sent from `app.sent`. ```ts title="app/webhooks.test.ts" import { describe, it } from "node:test"; import { createHmac } from "node:crypto"; import assert from "node:assert/strict"; import { createTestApp } from "@mainsdk/testing"; const secrets = { WEBHOOK_SIGNING_KEY: `whsec_${Buffer.from("signing-key-bytes").toString("base64")}`, GITHUB_WEBHOOK_SECRET: "gh-secret", }; describe("webhooks", () => { it("webhooks-jobs AC3 and AC7: refuses a forged delivery and runs a duplicate once", async () => { const app = createTestApp({ main: "./main.yaml", secrets }); let ran = 0; const POST = app.webhooks.receive("github", () => void ran++); const body = JSON.stringify({ action: "opened" }); const post = (key: string) => POST(new Request("http://localhost/api/webhooks/github", { method: "POST", body, headers: { "x-hub-signature-256": `sha256=${createHmac("sha256", key).update(body).digest("hex")}`, "x-github-delivery": "gh-1", }, })); assert.equal((await post("wrong")).status, 400); assert.equal(ran, 0); assert.equal((await post("gh-secret")).status, 200); assert.equal((await post("gh-secret")).status, 200); assert.equal(ran, 1); }); it("webhooks-jobs AC2: retries a failing endpoint with the same webhook-id", async () => { let calls = 0; const app = createTestApp({ main: "./main.yaml", secrets, respond: () => new Response(null, { status: ++calls < 4 ? 500 : 200 }), }); const id = await app.webhooks.emit("https://hooks.partner.dev/in", { type: "order.shipped", data: { orderId: "o1" } }); for (const wait of ["10s", "6m", "31m"]) await app.clock.tick(wait); // the retry schedule const ids = new Set(app.sent.map((r) => r.headers.get("webhook-id"))); assert.equal(app.sent.length, 4); assert.equal(ids.size, 1); const delivery = await app.webhooks.delivery(id); assert.equal(delivery?.status, "delivered"); assert.equal(delivery?.attempts?.length, 4); }); }); ``` ## Verify ```bash npx mainsdk check node --test app/webhooks.test.ts ``` A pass prints `mainsdk check: passed (dev)` and both tests pass: the forged request got 400 and never reached your handler, the duplicate ran it once, and the failing endpoint was retried until it delivered, with one `webhook-id` across four attempts. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-HOOK-0002` | Wrong signature, a timestamp outside the window, or a body over 1 MiB | Check the sender uses the secret you configured and that its clock is correct | | `MAIN-HOOK-0003` | `webhooks.receive` names neither a `webhooks.inbound` entry nor a preset | Declare it under `webhooks.inbound` with one of the presets | | `MAIN-HOOK-0011` | A `twilio` entry has no `public-url` | Set `public-url` to your app's public address | | `MAIN-HOOK-0001` | `webhooks.emit` has no signing secret, or the type or URL is invalid | Set `webhooks.secret`, use a type like `order.shipped` and a full https URL | | `MAIN-HOOK-0012` | `mainsdk webhooks replay` outside dev, with no dev server, or with an unknown id | Run it under `mainsdk dev` with a recorded id | | `MAIN-CONFIG-0004` | A literal secret in `main.yaml` | Write `secret:NAME` and move the value to your secrets manager | ## For agents ```text Goal: receive webhooks safely and send signed webhooks to . Touch: main.yaml (webhooks.inbound, webhooks.secret, network.allow), one route file that exports webhooks.receive(...), the module that calls webhooks.emit(), one test. Do: use the provider's preset; keep every secret as a secret: reference; hand slow work to jobs.run() keyed by meta.id. Do not: parse or verify signatures by hand, add your own dedupe table, put a secret literal in main.yaml, or add the partner's host to network.allow without telling the owner. Verify: npx mainsdk check, then node --test on the webhook test file. Stop and ask if: the provider is not one of the presets, or its signing secret is not available yet. ``` ## Next - [Webhooks](https://mainsdk.dev/docs/interfaces/webhooks): every preset, the delivery log and customer-managed endpoints. - [Run work in the background](https://mainsdk.dev/docs/guides/run-background-jobs): what to hand the slow work to. - [Take a payment](https://mainsdk.dev/docs/guides/take-a-payment): typed payment webhooks with `pay.on()`. --- Source: https://mainsdk.dev/docs/guides/sanitize-and-protect-inbound # Show untrusted text safely and protect web responses > Sanitize user HTML and model Markdown, find raw HTML sinks, and send secure headers, CSRF checks, CORS rules and safe cookies from the inbound section. Status: Planned for 0.2 · Canonical: https://mainsdk.dev/docs/guides/sanitize-and-protect-inbound · Updated: 2026-10-28 You will make two things safe: the text users and models give you, and the responses your app sends back. `sanitize` turns untrusted HTML or Markdown into HTML that cannot run script. `inbound` wraps your handler with a strict Content Security Policy, CSRF checks, a CORS allowlist, safe cookie flags and `security.txt`, from one `inbound` section of `main.yaml`. Neither has its own interface page: both are in `mainsdk` and run in your app, with no dependency and no network call. It takes about 25 minutes. > **Note:** `sanitize` (spec `sanitize`) and `inbound` (spec `inbound-protection`) are in 0.2 and run from the repository; they are not published to npm. There is no Next.js, Hono or Express adapter yet: today you wrap a Web-standard handler with `inbound.handle()`, which is the code every adapter will call. Both specs are drafts. Bot defence and rate limits are separate: see [rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits). ## You need - A MainSDK app with a `main.yaml` (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)). - A handler that takes a Web `Request` and returns a `Response`. ## 1. Sanitize HTML a user wrote ```ts title="lib/comments.ts" import { sanitize } from "mainsdk"; export function renderComment(html: string) { return sanitize.html(html); // the "basic" profile } // sanitize.html('

Hi therelink

') // =>

Hi therelink

``` You should see the script, the `onclick` and the `style` gone, and the link kept with a safe `rel`. The output is rebuilt from an allowlist and escaped, so sanitizing it again gives the same string. Choose a profile and narrow what links and images may point to: ```ts title="lib/posts.ts" import { sanitize } from "mainsdk"; export function renderPost(html: string) { return sanitize.html(html, { profile: "rich", // text | basic | rich images: { hosts: ["cdn.acme.dev"] }, // rich only; "self" is the page's origin links: { hosts: ["acme.dev"], target: "_blank" }, // other hosts become plain text ids: "prefix", // id="user-content-" instead of dropping ids }); } ``` | Profile | Keeps | | --- | --- | | `text` | Nothing: all markup removed, text kept and escaped | | `basic` (default) | `p br b strong i em u s code pre blockquote ul ol li a` | | `rich` | `basic` plus headings, tables, `img`, `figure`, `details`, `span`, `div` and a few more | Never allowed, whatever you pass: `script`, `style`, `iframe`, `object`, `svg`, `math`, `form`, any `on*` attribute, `style` and `srcdoc` attributes, and the schemes `javascript:`, `vbscript:` and `data:` for links. Asking for one throws `MAIN-SANITIZE-0002`. Input over 1 MB (`maxBytes`) or nested deeper than 100 elements throws `MAIN-SANITIZE-0001` and returns nothing. ## 2. Render model output as Markdown Model output is untrusted input. `sanitize.markdown()` renders it to safe HTML and, with `source: "ai"`, draws no images and links only to hosts you name, so a prompt-injected answer cannot send data out through an image URL. ```ts title="lib/answer.ts" import { sanitize } from "mainsdk"; export function renderAnswer(markdown: string) { return sanitize.markdown(markdown, { source: "ai", links: { hosts: ["docs.acme.dev"] } }); } ``` You should see a link to `docs.acme.dev` as an anchor, any other link as its text followed by the URL in plain text, raw ``, { headers: { "content-type": "text/html" }, }); } export default { fetch: (req: Request) => inbound.handle(req, app) }; ``` `inbound.handle(req, next)` runs every protection around `next`. Outside a request, `inbound.nonce()` and `inbound.csrfToken()` throw `MAIN-WEB-0013`. What every response gets, in production over HTTPS: | Header | Default | | --- | --- | | `Content-Security-Policy` | `default-src 'self'`; scripts by a fresh nonce plus `'strict-dynamic'`; `object-src 'none'`, `base-uri 'none'`, `frame-ancestors 'self'`, `upgrade-insecure-requests` | | `Strict-Transport-Security` | `max-age=63072000; includeSubDomains`; never in `dev` or over HTTP | | `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy` | `nosniff`, `SAMEORIGIN`, `strict-origin-when-cross-origin`, camera, microphone and geolocation off, `same-origin` | `X-Powered-By` is removed. A handler that sets one of these headers itself is overridden, and `MAIN-WEB-0004` is logged once per route and header. A route that needs a different value gets an entry under `inbound.routes`: ```yaml title="main.yaml" inbound: routes: "/embed/*": { frame-ancestors: ["https://*.partner.com"] } ``` Check what is in force for any path: ```ts title="scripts/show-policy.ts" import { inbound } from "mainsdk"; console.log(inbound.policy("/embed/widget")); // headers, csp, cors, csrf exemptions, cookie rules, security.txt ``` ### CSRF A request with an unsafe method (`POST`, `PUT`, `PATCH`, `DELETE`) and a `Cookie` header must be same-origin by Fetch Metadata, come from an origin in `cors.origins` with `credentials: true`, or carry a valid token. Anything else is refused with 403 `MAIN-WEB-0002` before your handler runs. A request with no cookie (an API key or bearer token) is not checked. For a form posted by a browser that sends no Fetch Metadata, send the token: ```ts title="app/form.ts" import { inbound } from "mainsdk"; export function formField() { return ``; // or send it as x-csrf-token } ``` Signed webhooks take cross-site posts by design: list their paths under `csrf.exempt`. Declared inbound webhook endpoints are exempted for you. ### CORS, cookies and security.txt - With no `cors` block, no cross-origin request is answered. Origins are exact or `https://*.domain`; `*` with credentials fails the check. - Every `Set-Cookie` leaves with `Secure` (outside `dev`), `SameSite` (default `Lax`), `Path=/` and `HttpOnly`, unless its name is under `cookies.readable`. The fix is logged once per cookie name (`MAIN-WEB-0008`). - With `security-txt`, `GET /.well-known/security.txt` is served per RFC 9116 and `/security.txt` redirects to it. - To try a stricter policy first, set `inbound.csp.mode: report`. It sends `Content-Security-Policy-Report-Only` and takes violation reports at `POST /mainsdk/csp-report` (204, logged as `MAIN-WEB-0006` without the query string). Move to `enforce` when the reports are quiet. - `headers.coep` and `csp.trusted-types` are opt-in. `Cross-Origin-Embedder-Policy: require-corp` breaks cross-origin images and scripts that send no CORP or CORS headers. `inbound` values change only in a reviewed deploy; they cannot be pushed at run time. ## Verify ```bash npx mainsdk check --env prod ``` A pass has no `MAIN-SANITIZE` or `MAIN-WEB` finding. The `web` gate reads the `inbound` section and your cookie code; the `sanitize` gate reads your source. In a test, call `inbound.handle()` with a `Request` and assert on the headers and status. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-SANITIZE-0001` | Input over `maxBytes` (1 MB by default) or nested deeper than 100 elements. | Send less, or raise `maxBytes`. | | `MAIN-SANITIZE-0002` | An option asks for a never-allowed tag, attribute or scheme. | Remove it from the options. | | `MAIN-SANITIZE-0003` | Raw HTML reaches the page without `sanitize`. | Pass the value through `sanitize.html()` or `sanitize.markdown()`. | | `MAIN-WEB-0001` | `cors.origins` has `*` with `credentials: true` (or `*` at all with `--env prod`). | List the origins that may call you. | | `MAIN-WEB-0002` | A cross-site unsafe request carried a cookie and no valid token (403). | Send it from your own site, add the origin to `cors.origins` with credentials, or list a signed webhook under `csrf.exempt`. | | `MAIN-WEB-0003` | An `inbound` value is invalid: an unknown key, a bad origin or duration. | Fix the key the finding names. | | `MAIN-WEB-0005` | A CORS preflight from an origin, method or header not allowed (403). | Add it to `cors`. | | `MAIN-WEB-0007` | `csp.script` allows `unsafe-inline` or `unsafe-eval`. | Use `nonce` and `strict-dynamic`. | | `MAIN-WEB-0009` | Code sets `SameSite=None` without `Secure`, or `httpOnly: false` for a cookie not under `cookies.readable`. | Add `Secure`, or list the cookie. | | `MAIN-WEB-0010` | `security-txt` has no contact, or `expires` is missing, past or over a year ahead. | Fix the contact or date. | | `MAIN-WEB-0013` | `inbound.nonce()` or `csrfToken()` called outside a request (500). | Call it while a handler runs through `inbound.handle()`. | ## For agents ```text Goal: show untrusted text safely and send protective headers. Read: docs/specs/sanitize.md, docs/specs/inbound-protection.md, this page, main.yaml. Touch only: main.yaml (inbound section), files that render HTML, the server entry that wraps the handler. Do: pass every user or model string that reaches the page as HTML through sanitize.html or sanitize.markdown (source: "ai" for model output); wrap the handler with inbound.handle; set cors.origins to exact origins; list signed webhook paths under csrf.exempt; add security-txt for production. Verify: npx mainsdk check --env prod --json Never allow unsafe-inline or unsafe-eval, add "*" to cors.origins, or silence a MAIN-SANITIZE or MAIN-WEB finding to make the check pass. ``` ## Next - [Restrict which hosts and IPs your app can reach](https://mainsdk.dev/docs/guides/restrict-network): the outbound side. - [Rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits): throttle abusive clients. - [Protect a file so only the right people open it](https://mainsdk.dev/docs/guides/protect-an-app): keep control of a file after you share it. --- Source: https://mainsdk.dev/docs/guides/license-your-app # License your app > Gate paid features with signed licence files checked offline, test every state with the testing kit, and check a customer's file from the command line. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/license-your-app · Updated: 2026-10-28 Your app will check a signed licence file offline, switch the features you sell on and off, and keep serving when the licence expires, when the network is down and when a revocation hasn't arrived. You will pin a root key, name the licensed features, gate them in code, test each state, and check a file with the CLI. It takes about 20 minutes. > **Note:** `licence` is built in 0.4 (spec `licence`) and runs from the repository; it is not published to npm. Issuing licences from MainSDK Cloud is planned for 0.6 (spec `licence-cloud`). Until then you issue with your own CA, or with the testing kit while you build. ## You need - A MainSDK app with a `main.yaml` and a `project` id (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)). - A product name, such as `acme-desktop`, that your licences name. - A root public key from your issuer. While you build, use the well-known test root `ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ`. `mainsdk check` refuses it in production. ## 1. Declare the licence ```yaml title="main.yaml" licence: product: acme-desktop roots: ["ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ"] # the test root; swap for yours before you ship file: ./main.licence features: [sso, audit-export] ``` `features` lists every name that needs a licence. A name you pass to the code and don't list is refused. The default driver is `offline`: it reads the file and makes no network call. ## 2. Gate the feature ```ts title="app/api/sso/route.ts" import { licence } from "mainsdk"; export async function GET() { licence.require("sso"); // throws MAIN-LICENCE-0001 (403) outside valid and grace return Response.json({ ok: true }); } ``` Use `licence.has("audit-export")` to hide a button, and `licence.state()` to show how many days are left. Both read memory only. Gate only what you sell: the app always starts and serves, and nothing else depends on the licence. ## 3. Test every state ```ts title="lib/licence.test.ts" import { it } from "node:test"; import assert from "node:assert/strict"; import { createClock, createTestApp, createTestLicenceCA } from "@mainsdk/testing"; const yaml = `schema: 1 project: prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY licence: product: acme-desktop roots: ["ed25519:CvBKNOIhJSGvXg9chN9qV8-z6byK3-is6-12mXaidWQ"] features: [sso, audit-export] `; it("serves sso in grace and turns it off after", async () => { const clock = createClock(Date.UTC(2026, 10, 1, 12)); const app = createTestApp({ yaml, clock }); const ca = await createTestLicenceCA({ product: "acme-desktop", project: "prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY" }); await app.licence.install(await ca.issue({ features: ["sso"] }), { scope: "app" }); // valid until 2027-11-01, 14d grace assert.equal(app.licence.has("sso"), true); clock.advance("370d"); assert.equal(app.licence.state().status, "grace"); assert.equal(app.licence.has("sso"), true); clock.advance("20d"); assert.equal(app.licence.state().status, "expired"); assert.throws(() => app.licence.require("sso"), { code: "MAIN-LICENCE-0001" }); }); ``` `ca.revoke(...)`, `ca.crl()` and `ca.tamper(token, how)` cover revocation and tampering. See [the licence interface](https://mainsdk.dev/docs/interfaces/licence#testing). ## 4. Check a customer's file ```bash npx mainsdk licence show ./main.licence # claims, chain, newest revocation list and state npx mainsdk licence verify ./main.licence --strict # exit 0 valid, 1 not valid or in grace, 2 usage ``` Run `verify` in CI against a sample licence, or on a customer's file before you send a support reply. A licence bound to a machine needs the machine's id: the customer runs `npx mainsdk licence show --machine` and sends you the `m_…` value. ## 5. Keep installs current For online installs, use the `http` driver so renewals and revocations arrive without a release: ```yaml title="main.yaml" licence: driver: http url: https://licences.acme.dev # your issuer's refresh endpoint; added to network.allow refresh: 6h ``` The app refreshes in the background after it starts listening, never while a request waits. For air-gapped installs, keep `offline` and copy a new licence file and a `licence.trust` bundle in; `licence.update(token)` installs a token at run time and needs the `licence.manage` permission. Give online installs short licences that refresh, because an app that can't reach the endpoint never learns of a revocation. ## 6. Before you ship Replace the test root with your own and check: ```bash npx mainsdk check --env prod ``` A pass exits 0 with no `MAIN-LICENCE` finding. Keep the ownership clear: the CA's private key stays with your issuer, never in the app. ## Check it worked - `npx mainsdk licence verify ./main.licence` exits 0. - The test above passes: `has("sso")` is true in grace and false after. - `npx mainsdk check --env prod` reports no `MAIN-LICENCE-0011` and no `MAIN-LICENCE-0014`. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-LICENCE-0001` | The licence doesn't list the feature, or isn't valid or in grace | Run `mainsdk licence show`; install a licence that lists it | | `MAIN-LICENCE-0004` | The file doesn't verify: wrong root, product or project, an older licence, or an edited byte | Run `mainsdk licence verify --json` and read `reason`; ask the issuer for a new file | | `MAIN-LICENCE-0007` | No file at `licence.file`, or not valid yet | Put the file there, or set `licence.token` | | `MAIN-LICENCE-0011` | The `licence` section is invalid | Fix the key the finding names | | `MAIN-LICENCE-0012` | The code names a feature that isn't under `licence.features` | Add it, or fix the typo | | `MAIN-LICENCE-0014` | The test root is pinned in production | Pin your own root | ## What it can't do It keeps honest customers honest and makes your terms auditable. It can't stop someone who controls the machine from patching the SDK, replacing the pinned root, wiping the runtime store or staying offline so a revocation never arrives. Serve features worth stealing from a server you run. ## For agents ```text Goal: gate the features we sell with signed licences. Read: docs/specs/licence.md, this page, main.yaml. Touch only: main.yaml (licence section), the routes that gate a feature, tests. Do: add the licence section with product, roots and features; call licence.require or licence.has for each licensed feature; test valid, grace, expired and tampered with createTestLicenceCA. Verify: npx mainsdk check --json --agent; npx mainsdk licence verify Never pin the test root for production, never put a CA private key or a licence token in code, and never gate anything that isn't listed under licence.features. ``` ## Next - [App licensing](https://mainsdk.dev/docs/interfaces/licence), the interface reference - [Licensing](https://mainsdk.dev/docs/concepts/licensing): app licences compared with TAPS licences, API keys and entitlements - [Limits, metering and entitlements](https://mainsdk.dev/docs/guides/limits-and-metering) --- Source: https://mainsdk.dev/docs/guides/build-a-durable-workflow # Build a durable workflow > Write a flow that sleeps, waits for an event and asks a person to approve as one async function, and watch it resume after a crash without repeating a step. Status: Preview · Canonical: https://mainsdk.dev/docs/guides/build-a-durable-workflow · Updated: 2026-10-28 You will write an onboarding flow as one async function: send a welcome, wait three days, wait for the user to activate, ask a person to approve credits, then grant them. Each step is journaled, so a crash, a restart or a deploy resumes the run where it was and never repeats a finished step. It takes about 25 minutes. ## You need - A MainSDK app with `main.yaml`. Production needs a `db` section; the journal lives in your database. - Your own functions for the side effects (load a user, send an email, grant credits). The examples below stand in for them. ## 1. Declare the journal ```yaml title="main.yaml" workflows: driver: db # the journal lives in your database retries: { max: 3, backoff: exponential, initial: 1s } # the default for each step ``` ```yaml title="main.env.dev.yaml" workflows: driver: local # in process, for development and tests ``` `mainsdk check --env prod` fails with `MAIN-WORKFLOW-0011` when `workflows` uses the `local` driver, because runs would be lost when an instance is replaced. ## 2. Write the workflow A workflow is a plain async function with a name and a version. Each `step` runs once and its JSON result is journaled. `sleep`, `waitFor` and `approve` park the run and hold no worker while they wait. ```ts title="app/workflows/onboarding.ts" import { approve, sleep, step, waitFor, workflow } from "mainsdk/workflows"; import { s } from "mainsdk/schema"; // Your own code. Steps return JSON, so return ids and small values. async function loadUser(userId: string) { return { id: userId, email: `${userId}@pinecart.dev` }; } async function sendWelcome(email: string) { console.log("welcome sent to", email); return { sent: true }; } async function grantCredits(userId: string, amount: number) { console.log("granted", amount, "to", userId); return { granted: amount }; } export const onboarding = workflow( "onboarding", { version: 1, input: s.object({ userId: s.string() }) }, async ({ userId }) => { const user = await step("load-user", () => loadUser(userId)); await step("welcome", () => sendWelcome(user.email)); await sleep("3d"); // parks the run; holds no worker const activated = await waitFor("user.activated", { match: { userId }, timeout: "7d" }); if (!activated) return { activated: false }; const ok = await approve({ action: "credits.grant", risk: "write", spend: "$5", reason: "Welcome credits", timeout: "2d" }); if (ok.approved) await step("grant", () => grantCredits(userId, 5), { retries: { max: 2 } }); return { activated: true, granted: ok.approved }; }, ); ``` The function runs again from the top on every resume, and each finished step returns its journaled result. So keep everything that reads a clock, picks a random number or calls the network inside a `step`. Outside a step it fails `mainsdk check` with `MAIN-WORKFLOW-0010`. Step results and the run's input must be JSON and at most 256KB (`MAIN-WORKFLOW-0004`). ## 3. Start a run ```ts title="app/api/signup/route.ts" import { workflows, type WorkflowDef } from "mainsdk/workflows"; declare const onboarding: WorkflowDef<{ userId: string }>; // exported from app/workflows/onboarding.ts const { runId } = await workflows.start(onboarding, { userId: "u_1" }, { idempotencyKey: "onboarding:u_1" }); ``` `start` writes the run and returns its `runId`; no step runs first. Starting again with the same `idempotencyKey` returns the same run. Look at where it is: ```ts title="scripts/where-is-it.ts" import { workflows } from "mainsdk/workflows"; declare const runId: string; // from workflows.start() const run = await workflows.get(runId); console.log(run.state, run.currentStep, run.history.map((h) => `${h.step}:${h.kind}`)); ``` A moment after `start` you should see `sleeping sleep [ 'load-user:step', 'welcome:step', 'sleep:sleep' ]`. The history lists names, kinds, attempts and times, never step results or inputs. `state` is `queued`, `running`, `sleeping`, `waiting`, `completed`, `failed`, `cancelled` or `stuck`. ## 4. Wake it with an event and a person The `waitFor` resumes when an event of that type, whose data contains every `match` field, is published: ```ts title="app/users/activate.ts" import { events } from "mainsdk"; await events.publish("user.activated", { userId: "u_1" }); ``` Then `approve` puts a request in the approval queue of [`agents`](https://mainsdk.dev/docs/interfaces/agents) and parks the run. A person approves it; an agent cannot approve its own request. ```ts title="app/admin/approvals.ts" import { agents } from "mainsdk"; declare const session: { user: { id: string } }; // the signed-in approver const [pending] = agents.pendingApprovals(); // what is waiting, with its reason and spend agents.approve(pending.id, { kind: "user", id: session.user.id }); ``` After the approval, `grant` runs and the run completes with `{ activated: true, granted: true }`. If nobody answers in two days, `approve` returns `{ approved: false, reason: "timeout" }` and your code decides what that means. ## 5. Cancel or list runs ```ts title="scripts/runs.ts" import { workflows } from "mainsdk/workflows"; declare const runId: string; // from workflows.start() const waiting = await workflows.list({ name: "onboarding", state: "waiting" }); await workflows.cancel(runId, { reason: "user closed the account" }); ``` `cancel` stops a run that has not finished and records an audit event. On a finished run it fails with `MAIN-WORKFLOW-0008`. ## 6. Change the steps safely A run keeps the version it started on. To change the steps, give the new function the next version and keep the old one registered until its runs finish. New runs start on the highest registered version. ```ts title="app/workflows/onboarding-v2.ts" import { workflow } from "mainsdk/workflows"; export const onboardingV2 = workflow("onboarding", { version: 2 /* , input, and the new steps */ }, async () => { /* the new steps */ }); ``` Edit the steps under a live version and the run stops as `stuck` with `MAIN-WORKFLOW-0002`; unregister a version that still has runs and they stop as `stuck` with `MAIN-WORKFLOW-0003` until you register it again. ## 7. Test it `createTestApp` runs workflows on a fake clock and an in-memory journal, so a three-day sleep takes no time. ```ts title="app/workflows/onboarding.test.ts" import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { createTestApp } from "@mainsdk/testing"; import type { WorkflowDef } from "mainsdk/workflows"; declare const onboarding: WorkflowDef<{ userId: string }>; // exported from app/workflows/onboarding.ts describe("onboarding workflow", () => { it("workflows AC1 runs each step once, sleeps, waits, asks for approval and finishes", async () => { const app = createTestApp(); const { runId } = await app.workflows.start(onboarding, { userId: "u_1" }, { idempotencyKey: "onboarding:u_1" }); await app.clock.tick("1m"); assert.equal((await app.workflows.get(runId)).state, "sleeping"); await app.clock.tick("3d"); // the sleep ends; now it waits for the event assert.equal((await app.workflows.get(runId)).state, "waiting"); await app.events.publish("user.activated", { userId: "u_1" }); await app.clock.tick("1m"); // now it waits for a person const [pending] = app.agents.pendingApprovals(); assert.equal(pending?.call.action, "credits.grant"); app.agents.approve(pending!.id, { kind: "user", id: "usr_1" }); await app.clock.tick("1m"); const run = await app.workflows.get(runId); assert.equal(run.state, "completed"); assert.deepEqual(run.output, { activated: true, granted: true }); assert.deepEqual(run.history.map((h) => h.kind), ["step", "step", "sleep", "waitFor", "approve", "step"]); }); it("workflows AC9 times out the wait and finishes without the grant", async () => { const app = createTestApp(); const { runId } = await app.workflows.start(onboarding, { userId: "u_2" }); await app.clock.tick("11d"); const run = await app.workflows.get(runId); assert.equal(run.state, "completed"); assert.deepEqual(run.output, { activated: false }); }); }); ``` ## Verify ```bash npx mainsdk check --env prod node --test app/workflows/onboarding.test.ts ``` A pass prints `mainsdk check: passed (prod)` and both tests pass. With `workflows.driver: local` in `main.yaml` the prod check fails with `MAIN-WORKFLOW-0011`. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-WORKFLOW-0010` | A clock, random or network call in the workflow function outside a step | Move it into a `step`, or pass the value in as the input | | `MAIN-WORKFLOW-0011` | The `local` driver in production | Set `workflows.driver: db` in `main.yaml` | | `MAIN-WORKFLOW-0001` | `step`, `sleep`, `waitFor` or `approve` called outside a workflow run | Call it inside the function you pass to `workflow()`, started with `workflows.start()` | | `MAIN-WORKFLOW-0002` | The code reached a step the journal doesn't have; the run is `stuck` | Restore the old steps, or start new runs on a new version | | `MAIN-WORKFLOW-0003` | No code is registered for the run's version; the run is `stuck` | Register that version | | `MAIN-WORKFLOW-0004` | A step result or input is not JSON, or is over `max-step-result` | Return a smaller JSON value; store large data and return its id | | `MAIN-WORKFLOW-0005` | A step failed its last retry; the run is `failed` | Fix the cause and start a new run | | `MAIN-WORKFLOW-0009` | A duration is not ``, or is over 365 days | Use a value such as `3d` | | `MAIN-WORKFLOW-0012` | Two definitions share a name and version | Give one a new version | ## For agents ```text Goal: turn into one durable workflow. Touch: main.yaml (workflows), main.env.dev.yaml (workflows.driver: local), one workflow file, the code that calls workflows.start(), one test. Do: put every side effect, clock read, random value and network call inside a step; keep step results to JSON ids and small values; start runs with an idempotencyKey; give changed steps a new version. Do not: edit the steps of a version that has live runs, use Date.now() or fetch outside a step, set the local driver in main.yaml, or loosen a check. Verify: npx mainsdk check --env prod, then node --test on the workflow's test file. Stop and ask if: a step has no safe way to run twice, or an approval needs a person who is not named. ``` ## Next - [Durable workflows](https://mainsdk.dev/docs/interfaces/workflows): every call, key and error. - [Run work in the background](https://mainsdk.dev/docs/guides/run-background-jobs): one side effect, with retries and a dead-letter queue. - [Agents](https://mainsdk.dev/docs/interfaces/agents): the approval queue behind `approve`. --- Source: https://mainsdk.dev/docs/guides/issue-licences-from-the-cloud # Issue licences from MainSDK Cloud > Create a root and an issuing CA in your account's region, issue and renew a licence, revoke it with a second approver, and serve the refresh endpoint your app calls. Status: Planned for 0.6 · Canonical: https://mainsdk.dev/docs/guides/issue-licences-from-the-cloud · Updated: 2026-10-28 You will create a root and an issuing CA, issue a licence for a customer, renew it, and revoke it. Private keys never leave the key service of your account's region. It takes about 15 minutes. ## You need - A MainSDK Cloud account with the `owner` role, a passkey session from the last 5 minutes for every write, and an app that verifies licences (see [License your app](license-your-app.md)). ## 1. Create a root and pin it `POST /v1/licence-roots` returns `{ kid, key: "ed25519:…", status }`. Put the key under `licence.roots` in `main.yaml`. An account holds at most 4 active roots. ## 2. Create an issuing CA `POST /v1/licence-cas` with `{ root, name, products, validity }` (validity up to 5 years) returns the CA certificate envelope. Rotate it with `POST /v1/licence-cas/{kid}/rotate`: the old CA issues nothing new, but its licences stay valid. ## 3. Issue, renew and revoke `POST /v1/licences` with an `Idempotency-Key` header and `{ sub, product, features, exp, grace, … }` returns `{ lid, token, claims }`. Renew with `POST /v1/licences/{lid}/renew` (same `lid`, higher `seq`). Suspend, resume and revoke use the matching routes and take a `reason` and a `grace`. Revoking a CA or retiring a root needs a second person. The first call answers `MAIN-LICENCE-0024` with an `approvalId` (202). Another owner or admin repeats the same call with `approvalId` in the body to carry it out. ## 4. Point your app at the refresh endpoint Set `licence.url` to the Cloud base URL. The app posts its tokens to `POST /v1/licences/refresh` and verifies everything in the answer on its own. Anyone may serve the same contract for their own issuer. ## Where it is stored Every root, CA, licence, audit record and key-use record stays in the account's region (`us`, `eu` or `in`). A call to the wrong region answers 421. The Licences area of the console lists all of it and shows the audit of a licence. The API is defined in `packages/core/contracts/cloud/licences.tsp`. --- Source: https://mainsdk.dev/docs/guides/send-email-and-notifications # Send email and notifications > Send a receipt, a newsletter with one-click unsubscribe and an order update on WhatsApp, SMS or email, with consent and quiet hours, offline. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/send-email-and-notifications · Updated: 2026-10-28 You will add messaging to Pinecart, a Next.js app on MainSDK: a receipt after checkout, a newsletter that carries an unsubscribe link, and an "order shipped" message that goes to WhatsApp, SMS or email, whichever the person allows. You build it offline on local drivers, test it, and finish with a passing `mainsdk check`. It takes about 30 minutes. > **Note:** `email` and `notify` run from the repository in 0.3 (spec `email-notify`). The Resend, Amazon SES, Twilio, MSG91, WhatsApp Cloud API and Bird drivers send requests written from each provider's documentation. They are unverified against the live API; verify with a sandbox key before production. Push is not built, so the push channel is skipped. ## You need - A MainSDK app with a `main.yaml` (see [Thirty-minute path](https://mainsdk.dev/docs/getting-started/thirty-minutes)). - For production later: a sending domain with Resend or Amazon SES, and for WhatsApp or SMS an account with a provider and approved templates. You need none of these to follow the steps. ## 1. Declare the email and a template ```yaml title="main.yaml" email: driver: local # in development; production uses resend or ses from: "Pinecart " unsubscribe-url: https://app.pinecart.dev/unsubscribe templates: receipt: subject: "Receipt {{orderId}}" text: "Thanks for order {{orderId}}." newsletter: subject: "Pinecart news, issue {{issue}}" text: "Issue {{issue}} is out." shipped: subject: "Order {{orderId}} shipped" text: "Order {{orderId}} is on its way." network: allow: [app.pinecart.dev] # mainsdk check reads the URL above as a host ``` `driver: local` keeps every message in a local mailbox, so no key or account is needed. Tracking is off by default: no pixel, no rewritten links. ## 2. Send a receipt ```ts title="app/checkout/receipt.ts" import { email } from "mainsdk"; export async function sendReceipt(to: string, orderId: string) { return email.send({ to, template: "receipt", data: { orderId }, idempotencyKey: `receipt:${orderId}`, }); } ``` The result is `{ id, status: "sent" }`. Call it again with the same key and the driver gets one message. Never build your own mailer: use `email.send` so the suppression list, the key and the limits apply. ## 3. Send a newsletter with an unsubscribe link ```ts title="app/newsletter/send.ts" import { email } from "mainsdk"; export async function sendIssue(to: string, issue: number) { return email.marketing({ to, template: "newsletter", data: { issue }, unsubscribe: await email.unsubscribe.create(to), idempotencyKey: `newsletter:${issue}:${to}`, }); } ``` Without `unsubscribe` this does not compile. The message carries `List-Unsubscribe` and `List-Unsubscribe-Post` headers, so mail apps show an unsubscribe button. Serve the link at the path you put in `unsubscribe-url`: ```ts title="app/unsubscribe/route.ts" import { email } from "mainsdk"; export const GET = (req: Request) => email.unsubscribe.handle(req); export const POST = (req: Request) => email.unsubscribe.handle(req); ``` A POST unsubscribes. A GET shows a confirm button and changes nothing, because mail scanners open links. After an unsubscribe, marketing to that address returns `suppressed`; receipts still go. A bounce or a complaint blocks both. ## 4. Notify a person on the channel they allow Add the channels, a template for each, and local drivers for development: ```yaml title="main.yaml" notify: channels: [whatsapp, sms, email] whatsapp: local sms: local quiet: { from: "22:00", to: "08:00", tz: Asia/Kolkata } templates: order.shipped: whatsapp: { name: order_shipped, language: en, params: [orderId] } sms: { text: "Pinecart: order {{orderId}} shipped", dlt-template-id: "1007000000000001" } email: shipped ``` The `email: shipped` line points the email channel at the `shipped` template from step 1. Send, and record consent when a person agrees to WhatsApp or SMS: ```ts title="app/orders/ship.ts" import { notify } from "mainsdk"; type Customer = { id: string; email: string; phone: string }; export async function agreeToWhatsApp(customer: Customer) { await notify.contact.update(customer.id, { phone: customer.phone, email: customer.email }); return notify.consent.create(customer.id, "whatsapp", { source: "checkout-form" }); } export async function orderShipped(customer: Customer, orderId: string) { return notify(customer, { template: "order.shipped", data: { orderId }, category: "orders", idempotencyKey: `order.shipped:${orderId}`, }); } ``` The result says which channel carried the message and every channel it skipped: - A person with no recorded consent is skipped on WhatsApp with `MAIN-NOTIFY-0004` and on SMS with `MAIN-NOTIFY-0003`; email is the fallback. - An India number needs `dlt-template-id` for SMS (`MAIN-NOTIFY-0005` without it). - A person can opt out of a channel or a category with `notify.preferences(id).set({ channels: { sms: false }, optOut: ["marketing"] })`. - A message that is not urgent, sent in the person's quiet hours, comes back `held` with a `deliverAt` time and goes out when the hours end. Pass `urgent: true` for a security alert. ## 5. Test it offline ```ts title="app/messaging.test.ts" import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { email, notify } from "mainsdk"; import { channels, createTestApp, mailbox } from "@mainsdk/testing"; // The functions from the steps above. declare function sendReceipt(to: string, orderId: string): Promise; declare function sendIssue(to: string, issue: number): Promise<{ status: string }>; declare function orderShipped( customer: { id: string; email: string; phone: string }, orderId: string, ): Promise<{ channel: string; skipped: { code: string }[] }>; const yaml = [ "schema: 1", "project: pinecart", "email:", " from: 'Pinecart '", " unsubscribe-url: https://app.pinecart.dev/unsubscribe", " templates:", " receipt: { subject: 'Receipt {{orderId}}', text: 'Thanks.' }", " newsletter: { subject: 'News {{issue}}', text: 'Issue {{issue}}.' }", " shipped: { subject: 'Shipped {{orderId}}', text: 'On its way.' }", "notify:", " channels: [whatsapp, email]", " whatsapp: local", " templates:", " order.shipped:", " whatsapp: { name: order_shipped, language: en, params: [orderId] }", " email: shipped", ].join("\n"); const jane = { id: "u1", email: "jane@example.com", phone: "+919876543210" }; describe("messaging", () => { it("email-notify AC8 sends a receipt once", async () => { const app = createTestApp({ yaml }); await sendReceipt(jane.email, "A1"); await sendReceipt(jane.email, "A1"); mailbox(app).toHaveSent({ template: "receipt", times: 1 }); }); it("email-notify AC1 sends a newsletter with the one-click headers", async () => { const app = createTestApp({ yaml }); await sendIssue(jane.email, 7); const headers = mailbox(app).last(jane.email)?.headers; assert.equal(headers?.["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click"); }); it("email-notify AC3 does not send marketing to an address that unsubscribed", async () => { const app = createTestApp({ yaml }); await email.suppression.create(jane.email, { reason: "unsubscribe" }); const result = await sendIssue(jane.email, 8); assert.equal(result.status, "suppressed"); mailbox(app).toHaveSent({ template: "newsletter", times: 0 }); }); it("email-notify AC2 uses WhatsApp only after consent", async () => { const app = createTestApp({ yaml }); const first = await orderShipped(jane, "A2"); assert.equal(first.channel, "email"); assert.equal(first.skipped[0]?.code, "MAIN-NOTIFY-0004"); await notify.consent.create(jane, "whatsapp", { source: "test" }); const second = await orderShipped(jane, "A3"); assert.equal(second.channel, "whatsapp"); assert.deepEqual(channels(app).whatsapp?.last()?.params, { orderId: "A3" }); }); }); ``` `mailbox(app)` reads the local mailbox and `channels(app)` reads the local SMS and WhatsApp channels. Neither touches the network. To drill a provider outage, call `mailbox(app).failNext(1)`. ## 6. Go to production Local drivers are refused in production: `email.driver: local` stops the app with `MAIN-EMAIL-0008`, and a local `notify.sms` or `notify.whatsapp` with `MAIN-NOTIFY-0011`. Put the real drivers in the production overlay, and a webhook so bounces and complaints reach the suppression list: ```yaml title="main.env.prod.yaml" email: driver: resend key: secret:RESEND_API_KEY webhook: resend-events notify: whatsapp: whatsapp-cloud sms: msg91 whatsapp-cloud: { phone-number-id: "1234567890", token: secret:WHATSAPP_TOKEN } msg91: { key: secret:MSG91_AUTH_KEY } webhooks: inbound: resend-events: preset: standard secret: secret:RESEND_WEBHOOK_SECRET path: /api/webhooks/resend ``` MSG91 sends SMS from its own template id, so also set `notify.templates.order.shipped.sms.template` and `vars` in `main.yaml` when you use it. Register your templates with each provider before the first live send. See [Resend](https://mainsdk.dev/docs/integrations/resend), [WhatsApp](https://mainsdk.dev/docs/integrations/whatsapp) and [MSG91](https://mainsdk.dev/docs/integrations/msg91), and send one message through each provider's sandbox first. ## Verify ```bash npx mainsdk check npx mainsdk check --env prod npx mainsdk info --json ``` `mainsdk check` passes in development. With the `main.env.prod.yaml` above, `mainsdk check --env prod` reports no `MAIN-CHECK-0026` (it may still warn about a missing `security.txt`, which is not about messaging), and `mainsdk info --json` shows `email.tracking: none`. Run your tests; all four pass. ## If it fails | Code | What it means | Fix | | --- | --- | --- | | `MAIN-EMAIL-0011` | The source calls `email.marketing` with no `unsubscribe`. | Pass `unsubscribe: await email.unsubscribe.create(address)`. | | `MAIN-EMAIL-0006` | The `email` section is invalid, or `unsubscribe-url` is not set where a call needs it. | Fix the key the message names. | | `MAIN-EMAIL-0009` | Email tracks opens or clicks. | Set `tracking: { opens: false, clicks: false }` unless people agreed to be tracked. | | `MAIN-EMAIL-0010` | A real driver in production has no `email.webhook`. | Add the inbound entry and name it. | | `MAIN-NOTIFY-0001` | The `notify` section is invalid or a provider block is missing. | Add the block the message names. | | `MAIN-CHECK-0026` | A local driver in production. | Set a production driver in `main.env.prod.yaml`. | | `MAIN-NET-0007` | `mainsdk check` found a host that `network.allow` does not list, such as the unsubscribe URL's host. | Add the host. | ## For agents ```text Goal: send email and notifications from this app. Read: docs/specs/email-notify.md, this page, main.yaml. Touch only: main.yaml, main.env.prod.yaml, the files that send messages, their tests. Do: use email.send and notify with templates declared in main.yaml; never build a mailer or call a provider directly. Pass an idempotencyKey on every send. Pass unsubscribe: await email.unsubscribe.create(address) to email.marketing. Record notify.consent.create before any SMS or WhatsApp. Leave tracking off. Use local drivers in development only. Verify: npx mainsdk check --json --agent, then the tests. Never hard-code a provider key, and never send marketing without an unsubscribe link. ``` ## Next - [Email](https://mainsdk.dev/docs/interfaces/email): every call, key and code. - [Notifications](https://mainsdk.dev/docs/interfaces/notify): channels, preferences, consent and staged broadcasts. - [Send engagement reminders](https://mainsdk.dev/docs/guides/engagement-messages): reminders that stop when people return. - [Testing](https://mainsdk.dev/docs/guides/testing): fakes, time control and chaos drills. --- Source: https://mainsdk.dev/docs/guides/cloud-login-usage-and-billing # Use MainSDK Cloud, usage and billing > Sign in from the device flow, send events with an ingest key, read your usage, add a card with a cap, and understand grace and the 402 answer. Status: Planned for 0.6 · Canonical: https://mainsdk.dev/docs/guides/cloud-login-usage-and-billing · Updated: 2026-10-28 You will sign in, send events from CI, check what you have used, and set a monthly cap. It takes about 10 minutes. ## You need - A MainSDK app with a project id in `main.yaml` and a GitHub remote. ## 1. Sign in ```bash npx mainsdk login --region eu ``` The command shows a short code and an address; you never type or paste a key. For a copy or a fork, `npx mainsdk init --new-id` gives it a fresh project id first. Nothing is sent before you log in. Underneath, the device flow (RFC 8628) starts at `POST /v1/device/code` with your project and remote. Approve the user code in the console with a passkey, then poll `POST /v1/device/token`. A project id already claimed under another remote answers `MAIN-CLOUD-0009`; choose a new id. ## 2. Send events ```bash npx mainsdk keys create --env prod ``` This creates an ingest key for an environment (`/v1/projects/{id}/keys`). Keys start with `msk_live_` or `msk_test_` and are shown once. CI can send with its OIDC token instead of a key. A project is pinned to one region; another region answers 421 with `MAIN-CLOUD-0001`. Set `telemetry: { sample, budget }` in `main.yaml` to cap what the SDK sends. It samples what it stores, never what it enforces. ## 3. Read your usage ```bash npx mainsdk usage ``` `GET /v1/usage` returns the month's events, the share of the allowance, the days of grace left and your cap. Free proposes 50,000 events a month and 7 days of history. You get one warning at 80%. ## 4. Past the allowance With no card, events continue to 120% or 7 days, whichever comes first, then the Cloud answers 402 (`MAIN-CLOUD-0402`) with a link to the card page. Your app keeps running: nothing inside it is metered, so nothing inside it is blocked. With a card (`npx mainsdk billing card --cap 50` opens the card page; `POST /v1/billing/card` takes the monthly cap), events past the allowance are billed per 100,000 up to the cap, and hosted reviewer runs are priced per run. At the cap the same 402 applies. One-time add-ons go through `POST /v1/addons` and are charged once. Prices and the allowance are proposals until the owner sets them. ## 5. Sign out ```bash npx mainsdk logout ``` Sending stops at once; every check keeps working locally. --- Source: https://mainsdk.dev/docs/guides/enterprise-ready # Make your app enterprise ready > Give your own customers SAML or OIDC SSO, SCIM, phishing-resistant admin sign-in, an audit log, retention rules and legal hold, free in your app. Canonical: https://mainsdk.dev/docs/guides/enterprise-ready · Updated: 2026-10-28 Answer an enterprise buyer's security questionnaire with features your app already has. Everything here runs in the free runtime, inside your app: it is what your app does for your customers, not a paid plan. The example customer is Pinecart's enterprise buyer, with the domain `pinecart.com`. **Before you start:** a MainSDK app with sign-in on `auth` and data on `db`. See [Sign-in and sessions](https://mainsdk.dev/docs/interfaces/auth). ## 1. Turn on SSO per customer organisation SAML 2.0 and OIDC single sign-on ship in phase 1 (milestone 0.3), configured per customer organisation. Where your identity provider driver lacks SAML or SCIM, MainSDK provides it in your app on top of the driver. ```yaml title="main.yaml" auth: driver: github methods: [passkeys, sso] sso: { protocols: [saml, oidc], scim: true } admin: { roles: [admin, support], require: phishing-resistant } ``` ```ts title="app/admin/sso/actions.ts" "use server"; import { auth } from "mainsdk"; export async function enablePinecartSso(clientSecret: string) { await auth.sso.enable("pinecart", { protocol: "oidc", issuer: "https://pinecart.okta.com", clientId: "pinecart-app", clientSecret, domains: ["pinecart.com"], }); } ``` For a customer on SAML, pass `protocol: "saml"` with the IdP's `metadata` and the same `domains`; see [SAML in the auth interface](https://mainsdk.dev/docs/interfaces/auth#saml) for the two routes it needs. The IdP's certificate comes only from that metadata. `jane@pinecart.com` is routed to Pinecart's identity provider by her verified domain. Unsigned assertions, the wrong audience, expired assertions and replayed assertion IDs are refused with `MAIN-AUTH-0008`. ## 2. Provision users with SCIM ```ts title="app/admin/sso/actions.ts" import { auth } from "mainsdk"; const { endpoint, token } = await auth.scim.enable("pinecart", { groupRoles: { "Pinecart Admins": "admin", "Pinecart Staff": "editor" }, }); ``` Give the endpoint and token to Pinecart's IT team. Removing someone in their directory ends that person's sessions in your app. ## 3. Require phishing-resistant admin sign-in With `auth.admin.roles` set as in step 1, `mainsdk check` fails when one of those roles can sign in without passkeys or SSO with phishing-resistant MFA (rule `auth.admin_phishing_resistant`). That covers PCI DSS 4.0.1 req. 8.4.1. ## 4. Set roles and fine-grained access `access` runs RBAC, ABAC and ReBAC on Cedar by default, or on the engine your customer already runs. See [Permissions](https://mainsdk.dev/docs/interfaces/access) and [Policy as code](https://mainsdk.dev/docs/concepts/policy). ## 5. Keep an audit log Every action and every decision is written to an append-only, tamper-evident audit log in OCSF. ```yaml title="main.yaml" logging: audit: true redact: [personal, sensitive, child, phi] retention: 12m ``` Streaming the audit log to each customer organisation's SIEM comes in phase 2. See [Logs, audit and telemetry](https://mainsdk.dev/docs/interfaces/log). ## 6. Set retention, deletion and legal hold ```yaml title="main.yaml" privacy: retention: { personal: 365d, logs: 30d, analytics: 13m } erase: { confirm-within: 30d } ``` ```ts import { privacy } from "mainsdk"; declare const user: string; // the person the hold is for await privacy.hold(user, { until: "2027-06-30", reason: "Pinecart litigation hold" }); const record = await privacy.erase(user, { reason: "customer request" }); // signed erasure record ``` A legal hold pauses erasure and retention for that person and is audited. See [Privacy and data rights](https://mainsdk.dev/docs/interfaces/privacy). ## 7. Keep data in the customer's region Overlays by market keep each customer's data in its region: US, EU or India. See [Data and regions](https://mainsdk.dev/docs/concepts/data-and-regions). ## 8. Restrict outbound network and show what the app does Buyers ask which outside services your app can reach and how they can see it. Allow only named hosts, IPs and ranges, and export the same list as platform rules: see [Restrict which hosts and IPs your app can reach](https://mainsdk.dev/docs/guides/restrict-network). Send the audit log, decisions and destinations to the buyer's tools: see [See every call, decision and change your app makes](https://mainsdk.dev/docs/guides/see-what-your-app-does). ## 9. Verify it worked ```bash npx mainsdk check ``` The check passes only when every admin role signs in with a phishing-resistant method. Then sign in as a test user on a verified domain and confirm you are sent to the customer's identity provider. ## Later: IP allowlists and customer-managed keys Inbound IP allowlists per customer organisation, so only Pinecart's office and VPN ranges can sign in to Pinecart's workspace, run through `limits`. How they are configured is [TBD]: it arrives with the `limits` spec. See [Rate limits and quotas](https://mainsdk.dev/docs/interfaces/limits). Customer-held keys ship in phase 3 (milestone 1.2): ```ts import { crypto } from "mainsdk"; await crypto.keys.customer("pinecart", { kms: "aws-xks", keyArn: "arn:aws:kms:eu-west-1:111122223333:key/1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", }); ``` Pinecart's records are then wrapped by Pinecart's own key, through AWS XKS or Google External Key Manager. If Pinecart revokes its key, reads of its data fail closed; other customers are unaffected. ## What MainSDK doesn't cover Private networking to the customer, on-premise hosting of the app and contractual SLAs belong to whoever runs the app, and `mainsdk check` says so. ## Next - [Restrict which hosts and IPs your app can reach](https://mainsdk.dev/docs/guides/restrict-network) - [Security model](https://mainsdk.dev/docs/concepts/security-model) - [Policy as code](https://mainsdk.dev/docs/concepts/policy) - [Free and Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud) --- Source: https://mainsdk.dev/docs/guides/alerts-and-health # Watch your app's health and resource pressure > Add health probes, a dependency check, SLO targets and resource limits, then read the state from the terminal and from code. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/alerts-and-health · Updated: 2026-10-28 You will make an app report when it is ready, when a dependency is down and when it is short of CPU or memory, and read that state from the terminal and from code. It takes about 15 minutes. Alerts that reach people (spec `alerts`) are planned and not covered here: today a burn or a state change is logged with a code and exported as a metric. ## You need - A MainSDK app with a `main.yaml`, run from the repository (nothing here is published to npm). - One dependency you can ping, such as your database or a partner API. ## 1. Declare the sections ```yaml title="main.yaml" health: critical: [payments] slo: availability: 99.9% resources: pressure: { loop: 100ms, memory: 80% } shed: { loop: 500ms } critical: ["POST /api/checkout/**"] ``` `payments` is the check you add next. `critical` routes keep running when the instance sheds load. ## 2. Register a check ```ts title="app/status.ts" import { status } from "mainsdk"; const pingProvider = async () => ({ ok: true }); // replace with a real ping of your provider status.check("payments", async () => (await pingProvider()).ok, { critical: true }); ``` `pingProvider` stands in for your own function. The check runs every `health.every` (10 seconds) in the background. You should see `payments` in the dependency list once the app starts. ## 3. Read the probes Your server adapter answers `/healthz` and `/readyz` for you. To read the same answer in code: ```ts title="app/ready.ts" import { app } from "mainsdk"; const res = app().health.handle(new Request("http://localhost/readyz")); console.log(res?.status, await res?.text()); ``` You should see `200` and a body with `"status":"ready"`. While `payments` is down it is `503` with `MAIN-HEALTH-0001`. ## 4. React to pressure ```ts title="app/cache.ts" import { resources } from "mainsdk"; const localCache = new Map(); // the app's own in-memory cache resources.onPressure(() => localCache.clear()); ``` `cache` is your own. Under pressure, job attempts and workflow runs wait (except those in `resources.critical`), and `/readyz` says `degraded`. Past the `shed` thresholds new non-critical requests get 503 with `Retry-After`. ## 5. Look from the terminal ```bash npx mainsdk status ``` You should see one line per dependency and a `resources` line with the state. Add `--json` for a script. ## Verify ```bash npx mainsdk check ``` A pass prints no `MAIN-HEALTH` or `MAIN-RESOURCE` findings. A value outside the allowed range is reported before you deploy. ## If it fails | Code | Cause | Fix | | --- | --- | --- | | `MAIN-HEALTH-0005` | `slo.availability` outside 90% to 99.999%, or a latency that is not a duration | Use a value such as `99.9%` and `300ms` | | `MAIN-HEALTH-0006` | `health.critical` names `mainsdk-cloud`, `tuf` or something the app does not configure, or `drain-delay` is too close to `deadline` | Name a driver section or a `status.check`; lower `drain-delay` | | `MAIN-RESOURCE-0003` | A `resources` value is invalid, for example a `shed` threshold not above its `pressure` threshold | Fix the value the finding names | | `MAIN-HEALTH-0004` | A probe threw or timed out | Fix the dependency, or raise `health.timeout` | | `MAIN-RESOURCE-0001` | A request was shed under load | Add its route to `resources.critical`, or reduce load | ## For agents ```text Goal: add health probes, one dependency check and resource limits to this app. Touch: main.yaml (health, slo, resources sections) and one file that calls status.check and resources.onPressure. Do not: list mainsdk-cloud or tuf under health.critical, or loosen a threshold to make a check pass. Verify: npx mainsdk check, then npx mainsdk status. Stop and ask if: a critical route is unknown, or a dependency has no cheap way to ping it. ``` ## Next - [Health and status page](https://mainsdk.dev/docs/interfaces/status-page) - [Fail static](https://mainsdk.dev/docs/concepts/fail-static) - [See every call, decision and change your app makes](https://mainsdk.dev/docs/guides/see-what-your-app-does) --- Source: https://mainsdk.dev/docs/guides/know-when-your-app-breaks # Know when your app breaks before your users tell you > Capture every unhandled error, group it under a stable fingerprint, get alerted when a group is new or spiking, and send events to OpenTelemetry or Sentry. Status: Planned for 0.4 · Canonical: https://mainsdk.dev/docs/guides/know-when-your-app-breaks · Updated: 2026-10-28 You will make an app capture every unhandled error, group it so one bug is one group, alert when a group is new in a release or spikes, and read the groups from the terminal. It takes about 10 minutes and needs no SDK from an error tracker. ## You need - A MainSDK app with a `main.yaml`, behind one of the adapters (`mainsdk/hono`, `mainsdk/next` or `mainsdk/express`). - Optional: an OpenTelemetry collector, or a Sentry project and its DSN. ## 1. Do nothing and read the groups Errors are captured and grouped as soon as the adapter runs. Under `mainsdk dev` they are also written to the local event store: ```bash npx mainsdk errors --since 1h ``` Each row is a fingerprint, a count, the last time it happened and whether it is new in this release. ## 2. Send the events somewhere ```yaml title="main.yaml" network: allow: [o1.ingest.sentry.io] logging: errors: driver: sentry dsn: secret:SENTRY_DSN ``` Use `driver: otlp` to send an OpenTelemetry exception event through `telemetry.otel` instead. Sending never delays a request: events wait in a bounded queue, and when the destination is down they are dropped with one `MAIN-REPORT-0003` record per outage. `mainsdk check` reports `MAIN-REPORT-0005` for a `sentry` driver with no `dsn` or a host missing from `network.allow`. ## 3. Get told about new and spiking errors ```yaml title="main.yaml" logging: errors: alert: new: warning spike: { over: 50, per: 5m, severity: critical } ``` A group that first appears in this release raises `error:` once across all instances when a `kv` section is declared. A group that passes 50 events in 5 minutes raises `error-spike:` and resolves when the window empties. Both go through the routes you set in `alerts`. ## 4. Report browser errors Set `logging.errors.browser: true`, then in the page: ```ts title="app/client.ts" import { reportErrors } from "mainsdk/log/client"; reportErrors(); ``` Reports are limited to 16 KB and 10 a minute per session or address, and the page's query and fragment are removed. ## What is never sent Every event is redacted in process first: secret values, key formats, emails, tokens and phone numbers in the message, and file paths relative to the project. No request body, header, cookie, query value or environment value is read. A signed-in request adds only an opaque hash of the user id. ## Next - [Logs, audit and telemetry](https://mainsdk.dev/docs/interfaces/log): the config keys, calls and codes. - [Debug your app](https://mainsdk.dev/docs/guides/debugging): from a group to a fix. --- Source: https://mainsdk.dev/docs/guides/watch-what-your-app-decides # Watch what your app decides > Subscribe to network, access and action events, assert on them in a test, and print them while you debug a refused call. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/watch-what-your-app-decides · Updated: 2026-10-28 You will watch the decisions your app makes (which hosts it may call, who may do what), check them in a test, and print them while you debug. It takes about 10 minutes. ## You need - A MainSDK app with a `main.yaml`, run from the repository (nothing here is published to npm). - A `network.allow` list with at least one host. ## Steps 1. Subscribe to the network decisions. The callback runs off the request path and can't change a call. ```ts title="lib/watch.ts" import { observe } from "mainsdk"; export const stop = observe("mainsdk:network.decision", (e) => { console.log(e.data.host, e.data.decision); }); ``` 2. Assert on events in a test. `createTestApp()` records them as they happen, so no flush is needed. ```ts title="test/network.test.ts" const app = createTestApp(); await app.access.check({ subject: { id: "u1", roles: [] }, action: "orders.write", resource: { type: "orders" }, }); assert.equal(app.events("mainsdk:access.decision")[0]?.data.decision, "deny"); ``` 3. Print while you debug. In development, set `MAINSDK_DEBUG=mainsdk:access.*` and run the app. Each matching event prints one line with its channel, decision and reasons. ## What you get Every event is redacted before an observer sees it and holds no request body, prompt or secret. Read [the channels reference](https://mainsdk.dev/docs/reference/channels) for each payload, and [`observe`](https://mainsdk.dev/docs/interfaces/observe) for the calls. --- Source: https://mainsdk.dev/docs/guides/check-your-dependencies # Check your dependencies > Run the deps gate, read what it found, set a licence and advisory policy in main.yaml, and keep CI honest when the network is down. Status: Building for 0.5 · Canonical: https://mainsdk.dev/docs/guides/check-your-dependencies · Updated: 2026-10-28 You will run the dependency checks on your app, read a finding, set a policy and ignore one advisory with an expiry. It takes about 10 minutes. ## You need - A MainSDK app with a `main.yaml`, a lockfile (pnpm, npm, yarn or bun) and its packages installed. ## 1. Run the check ```bash npx mainsdk check ``` The first run asks the registry and OSV.dev and keeps the answers for 24 hours. Findings under `deps` name the package, the version, why and the fix. ## 2. Read a finding ```text MAIN-DEPS-0001 high package.json:12 react-query-hooks-pro is not on its registry; a model may have made the name up, and anyone can register it fix: Remove it or replace it with the package you meant; react-query-hooks-pro does not exist on its registry ``` A name that doesn't exist, one first published days ago (`MAIN-DEPS-0002`) and one a letter from a popular package (`MAIN-DEPS-0003`) are the signs of a name an agent made up or got wrong. ## 3. Set your policy ```yaml title="main.yaml" deps: licences: allow: [MIT, Apache-2.0, ISC, BSD-3-Clause] advisories: fail-on: high scripts: allow: [esbuild, sharp] ``` An advisory below `fail-on` is a warning. A package that runs an install script and isn't allowed is a warning too (`MAIN-DEPS-0006`). ## 4. Ignore an advisory for a while ```yaml title="main.yaml" deps: advisories: ignore: - { id: GHSA-xxxx-xxxx-xxxx, until: 2027-01-31, reason: "Not reachable: only used in tests" } ``` Every entry needs an `id`, a future `until` and a `reason`. After `until` the advisory is reported again, with `MAIN-DEPS-0008`. ## 5. Run it in CI CI has the network, so make a missing answer a failure: ```yaml title="main.yaml" deps: offline: fail ``` On a laptop with no network, `mainsdk check` reports `MAIN-DEPS-0007` as info and still checks licences and install scripts. ## Check it worked Add a dependency that doesn't exist to `package.json` and run `npx mainsdk check --json`; it exits 1 with `MAIN-DEPS-0001`. Remove it and the exit is 0 again. See [Dependency checks](https://mainsdk.dev/docs/interfaces/deps). --- Source: https://mainsdk.dev/docs/guides/raise-and-route-alerts # Tell the right person once with alerts > Declare who gets paged and how, raise an alert from your code, fold repeats into one message, acknowledge and resolve it, and send a test. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/raise-and-route-alerts · Updated: 2026-10-28 You will route an alert to a Slack channel and a pager, raise one from code, see fifty repeats become one message, and acknowledge it. It takes about 20 minutes. The SDK's own sources (health, security, resources, jobs, errors) raise alerts through the same router, so one setup covers them. ## You need - A MainSDK app with a `main.yaml`, run from the repository (nothing here is published to npm). - A Slack incoming webhook and, if you page people, a PagerDuty routing key. Neither goes in code. - For acknowledging, a role that holds the `alerts.manage` permission. ## 1. Declare recipients and routes ```yaml title="main.yaml" alerts: from: [health, security, jobs, errors] min: warning recipients: oncall: - { driver: pagerduty, key: secret:PAGERDUTY_ROUTING_KEY } - { driver: slack, webhook: secret:SLACK_ALERTS_WEBHOOK } routes: - { match: { severity: critical }, to: [oncall] } - { to: [oncall] } group: { by: [source], wait: 30s, interval: 5m } access: roles: admin: [alerts.manage] ``` Keys and webhook URLs are `secret:NAME` references, never literals (`MAIN-ALERT-0003`). Production needs a route for `critical` (`MAIN-ALERT-0002`). Alerts below `min` are logged and sent to nobody. ## 2. Raise and resolve from code ```ts title="app/jobs/import.ts" import { alerts } from "mainsdk"; export async function onImportFailed(file: string) { await alerts.raise({ key: "import-failed", severity: "warning", title: "Import failed", runbook: "https://wiki.acme.dev/runbooks/import", attrs: { file }, }); } export async function onImportRecovered() { await alerts.resolve("import-failed"); } ``` A raise with the key of an open alert folds into it: `count` goes up and nothing more is sent. Put no personal data in `title` or `attrs`; user ids become keyed hashes and an attribute outside `telemetry@1` is dropped (`MAIN-ALERT-0013`). ## 3. Acknowledge ```ts title="app/api/alerts/[id]/ack/route.ts" import { alerts } from "mainsdk"; export async function POST(_req: Request, ctx: { params: { id: string } }) { await alerts.ack(ctx.params.id); // needs alerts.manage, else MAIN-ALERT-0014 (403) const open = await alerts.list({ state: "open" }); return Response.json(open); } ``` An acknowledged alert stops repeating and escalating. Who acknowledged is in the audit log. ## 4. Test the recipient ```bash npx mainsdk alerts test oncall ``` It sends one message, marked as a test, through every driver of `oncall` and prints each result. `npx mainsdk info` lists recipients by name and driver (never keys), the routes and the open alerts. ## Verify ```bash npx mainsdk check ``` Exit 0 means the `alerts` section reads, every route names a declared recipient and production has a `critical` route. ## If it fails | Code | What it means | Fix | | --- | --- | --- | | `MAIN-ALERT-0001` | An unknown driver or source, a route to an undeclared recipient, or a bad quiet-hours time | Fix the value the finding names | | `MAIN-ALERT-0002` | Production has `alerts` but no route for `critical` | Add `{ match: { severity: critical }, to: [...] }` | | `MAIN-ALERT-0003` | A webhook host is not in `network.allow`, or a key is a literal | Allow the host; use `secret:NAME` | | `MAIN-ALERT-0011` | A recipient failed after its retries; the others were still sent to | Fix the recipient's endpoint or key | | `MAIN-ALERT-0014` | The caller lacks `alerts.manage` | Give the role the permission | ## For agents ```text Goal: route this app's operational alerts to oncall and raise one from the import job. Touch: main.yaml (alerts, access.roles) and the import job. Do not: call Slack, PagerDuty or email directly for an alert; put a key or URL in code; put personal data in a title or attrs. Verify: npx mainsdk check, then npx mainsdk alerts test oncall. Stop and ask if: there is no secret for the recipient, or nobody holds alerts.manage. ``` ## Next - [Watch your app's health and resource pressure](https://mainsdk.dev/docs/guides/alerts-and-health) - [Know when your app breaks](https://mainsdk.dev/docs/guides/know-when-your-app-breaks) - [Switch things off in an incident](https://mainsdk.dev/docs/guides/switch-things-off-in-an-incident) --- Source: https://mainsdk.dev/docs/guides/i18n # Translate your app > Translate Pinecart with t(), format money and dates per locale with i18n, fall back along a locale chain, and let mainsdk check catch missing strings. Status: Preview · Canonical: https://mainsdk.dev/docs/guides/i18n · Updated: 2026-10-28 Show Pinecart in English, Hindi and Tamil, with money and dates formatted for each locale, from one deployment. > **Note:** Built and run from the repository (spec `i18n`, ADR-0060); not published to npm. **Before you start:** a MainSDK app with a `main.yaml`. The calls are described on [Languages and formats](https://mainsdk.dev/docs/interfaces/i18n). ## 1. Set your locales ```yaml title="main.yaml" i18n: driver: files # files | tolgee | crowdin | lokalise | ai-draft locales: [en-IN, hi-IN, ta-IN] ``` ## 2. Write the messages Messages live in `locales/`, one file per locale: ```yaml title="locales/en-IN.yaml" orders.count: "{count, plural, one {# order} other {# orders}}" orders.total: "Total: {total}" ``` ```yaml title="locales/hi-IN.yaml" orders.count: "{count, plural, one {# ऑर्डर} other {# ऑर्डर}}" orders.total: "कुल: {total}" ``` Messages use Unicode MessageFormat 2; ICU MessageFormat strings, like the plurals above, are converted when they load. Both cover plurals, genders and selections. ## 3. Use them in your UI ```ts title="app/orders/order-summary.ts" import { t, i18n } from "mainsdk"; export function orderSummary(orders: { total: number }[]) { const total = orders.reduce((sum, o) => sum + o.total, 0); return { dir: i18n.dir(), heading: t("orders.count", { count: orders.length }), total: t("orders.total", { total: i18n.format.money(total, "INR") }), }; } ``` Render these values in your component, and set `dir` on its root element. `i18n.format.money(125000, "INR")` gives ₹1,25,000.00 in `en-IN`. Formats come from Unicode CLDR. `i18n.format.date()` and `i18n.format.number()` work the same way, and `i18n.dir()` returns the text direction for right-to-left scripts. ## 4. Know how the locale is chosen The user's choice comes first, then their account, then the browser. The locale is never guessed from the IP address alone. Market and locale resolve per request, so one deployment serves every market. Wrap a handler in `i18n.handle(req, () => …, { user })` to run it in the negotiated locale; `t()` and `i18n.format.*` then use it. A missing message falls back along a chain: `hi-IN`, then `hi`, then `en-IN`, then `en`. Production never shows a key: a message missing everywhere returns an empty string and logs `MAIN-I18N-0009`. Locale-specific email and notification templates (`templates/order.shipped.hi-IN.yaml`) are planned. ## 5. Verify it worked ```bash npx mainsdk check ``` A missing translation fails the check, not the user: a key missing from a locale under `i18n.required` is `MAIN-I18N-0001`, with the file and line of the `t()` call. Run `npx mainsdk i18n pseudo` to get long, accented and right-to-left pseudo-locales that catch layout breaks before a translator sees them. ## Draft new strings with AI With the `ai-draft` driver, `npx mainsdk i18n draft --locale hi-IN` drafts missing strings through `ai`, following the glossary in `i18n.ai-draft.glossary`. Each draft is held as pending in `locales/.drafts/`; a person approves it with `i18n.drafts.approve(id)` before any user sees it. An app agent can't approve (`MAIN-I18N-0007`). ## Keep your translators' tools `npx mainsdk i18n export --format xliff` and `npx mainsdk i18n import ` move strings to and from XLIFF, iOS `.xcstrings`, Android strings and ICU JSON. Generating Android `values-hi/` and iOS `.xcstrings` from the same locale files is planned for phase 3. ## Next - [Languages and formats](https://mainsdk.dev/docs/interfaces/i18n) - [main.yaml and overlays](https://mainsdk.dev/docs/concepts/main-yaml) - [The check](https://mainsdk.dev/docs/concepts/the-check) --- Source: https://mainsdk.dev/docs/guides/upgrade-mainsdk # Upgrade MainSDK > Plan an upgrade, let one command move every MainSDK package and apply the release's codemods, read the pull request it opens, and find out when a security release is out. Status: Building for 0.5 · Canonical: https://mainsdk.dev/docs/guides/upgrade-mainsdk · Updated: 2026-10-28 You will move an app to a new MainSDK release and review the pull request. It takes about 10 minutes. ## You need - A MainSDK app in a git repository with a clean working tree and a lockfile. - `gh` signed in, if you want the command to open the pull request. ## 1. See the plan ```bash npx mainsdk upgrade --to latest --plan ``` The plan lists the versions between yours and the target, each codemod, each manual step, each `main.yaml` move that applies to your file, and each call site of a call the target removes. It writes nothing. ## 2. Apply it ```bash npx mainsdk upgrade --to latest ``` The command creates `chore/upgrade-mainsdk-`, moves every `mainsdk` and `@mainsdk/*` dependency to the one version, installs, and hands over to the newly installed CLI, which applies the notes in version order, runs `sync`, `check` and your `test` script, commits once and opens the pull request. ## 3. Read the pull request The body holds the plan. A failure in `sync`, `check` or your tests gives `MAIN-UPGRADE-0006`: the branch is kept and the pull request is a draft that lists each failure. Steps marked `manual` are listed with file and line (`MAIN-UPGRADE-0003`) for you to do on the branch. ## 4. Watch for security releases ```bash npx mainsdk upgrade --check ``` It exits 1 with `MAIN-UPGRADE-0005` when a release marked `security` is newer than yours. Run it in CI; the answer is cached for 24 hours, and `mainsdk check` shows it too. ## 5. Check that a contract did not break When you vendor a provider's contract, or maintain your own, compare it with its pin before you move on: ```bash npx mainsdk contract diff --json ``` It exits 1 on a breaking change that is not released as a new major, such as a removed field or error code. ## Check it worked `npx mainsdk check` reports no `MAIN-UPGRADE-0002` (every MainSDK package is at one version), and `git log` shows one commit on the upgrade branch. See [Upgrades](https://mainsdk.dev/docs/interfaces/upgrade). --- Source: https://mainsdk.dev/docs/guides/switch-things-off-in-an-incident # Switch things off in an incident without a deploy > Kill an action, route, driver or agent, make the app read-only or sign people out with one command, see what is in force and let it expire. Status: Building for 0.4 · Canonical: https://mainsdk.dev/docs/guides/switch-things-off-in-an-incident · Updated: 2026-10-28 You will kill one route, set the app read-only, read what is in force and clear both. It takes about 15 minutes. Every control is a pushed value that only tightens what `main.yaml` allows, so it takes effect on every instance in seconds and needs no deploy. ## You need - A MainSDK app with a `config` driver that can write: `files` in development, or your store in production. - For an admin screen, `auth` and a role that holds `incident.manage`. ## 1. Kill a route ```bash npx mainsdk incident on kill route:/api/export/* --reason "leak, #812" --expires 4h ``` A target is `action:`, `route:`, `interface:`, `driver:`, `agent:` or `tool:`. A reason is required. The killed route answers 503 `MAIN-INCIDENT-0001` before its handler runs, and the problem names the target, not the reason. ## 2. Go read-only ```bash npx mainsdk incident on read-only --reason "leak, #812" ``` Unsafe requests (`POST`, `PUT`, `PATCH`, `DELETE`), database writes, uploads and event publishes are refused with `MAIN-INCIDENT-0002`; reads keep working. Paths listed under `incident.read-only-allow` in `main.yaml`, such as `/api/webhooks/*`, still take writes. To sign people out instead, use `incident on sign-out [--tenant tnt_…]`; API keys keep working. ## 3. Read what is in force ```bash npx mainsdk incident status ``` Every response carries a `MainSDK-Incident` header naming the controls in force, for example `kill, read-only`. ## 4. Clear it ```bash npx mainsdk incident off kill route:/api/export/* --reason "fixed in 1.4.2" npx mainsdk incident off read-only --reason "fixed in 1.4.2" ``` A control with `--expires` ends on its own. One without it makes `check` and the log warn after 24 hours (`MAIN-INCIDENT-0007`). ## 5. Let an admin do it from a screen ```ts title="app/api/admin/read-only/route.ts" import { incident } from "mainsdk"; export async function POST(req: Request) { await incident.set(req, { readOnly: true }, { reason: "leak, #812" }); return Response.json(await incident.status()); } ``` The caller needs `incident.manage` and a phishing-resistant session less than five minutes old, else `MAIN-INCIDENT-0006` (403) and nothing is written. ## Verify ```bash npx mainsdk check ``` Exit 0 means every `incident` entry in `main.yaml` names a known target and every path in `read-only-allow` is a path. ## If it fails | Code | What it means | Fix | | --- | --- | --- | | `MAIN-INCIDENT-0004` | A kill names an action, agent or interface the app does not have | Fix the target | | `MAIN-INCIDENT-0005` | No reason, or the config driver cannot write | Pass `--reason`; use a driver that writes, or set the printed key by hand | | `MAIN-INCIDENT-0006` | `incident.set` without `incident.manage` or a fresh session | Sign in again with a passkey; grant the permission | | `MAIN-INCIDENT-0007` | A control has been in force over 24 hours with no expiry | Clear it, or set `--expires` | ## For agents ```text Goal: stop the export route for four hours, then clear it. Touch: nothing in code; run the mainsdk incident commands. Do not: write your own kill flag or maintenance mode; loosen main.yaml; omit --reason. Verify: npx mainsdk incident status shows the control; a request to the route gets 503. Stop and ask if: the target is unknown or the person has not said which route to stop. ``` ## Next - [Tell the right person once with alerts](https://mainsdk.dev/docs/guides/raise-and-route-alerts) - [Respond to an attack or CPU pressure](https://mainsdk.dev/docs/guides/respond-to-attacks-and-pressure) - [Change config without a deploy](https://mainsdk.dev/docs/guides/change-config-without-a-deploy) --- Source: https://mainsdk.dev/docs/guides/honour-consent-export-and-erasure # Honour consent, export and erasure requests > Declare retention per data label, record consent, read a consent platform's string, export a person's data and erase it with a signed record. Status: Building for 0.3 · Canonical: https://mainsdk.dev/docs/guides/honour-consent-export-and-erasure · Updated: 2026-10-28 You will declare how long each kind of data is kept, record a consent, export one person's data and erase it. It takes about 25 minutes. ## You need - A MainSDK app with `db` and labelled fields (`personal`, `sensitive`, `child` or `phi`). - For erasing backups, `keys` with `per-user: true`. ## 1. Declare retention for every label in use ```yaml title="main.yaml" privacy: retention: personal: 365d sensitive: 365d logs: 30d analytics: 13m erase: { confirm-within: 30d, retries: 3, retry-delay: 1s } consent: tcf: { analytics: [1], marketing: [1, 2, 7] } ``` A label in use with no rule fails `check` with `MAIN-PRIV-0001`. In `retention`, a bare `m` is months, so `13m` is thirteen months, never minutes. ## 2. Record consent and gate tracking ```ts title="app/lib/consent.ts" import { privacy } from "mainsdk"; export async function onBanner(user: string, analytics: boolean) { await privacy.consent(user).set({ purpose: "analytics", granted: analytics }); } export async function onTcfString(user: string, tcf: string) { await privacy.consent(user).signal({ tcf }); // no second banner } export function track(user: string, send: () => void) { return privacy.guard(user, { purpose: "analytics", send }); // sends only on consent } ``` `consent(user).get(purpose)` is `false` until the person, or a consent platform's signal, has said yes. A string that is not TCF v2 or GPP is refused with `MAIN-PRIV-0005`. ## 3. Export ```ts title="app/api/me/export/route.ts" import { privacy } from "mainsdk"; declare const userId: string; // the signed-in person, from auth.require const bundle = await privacy.export(userId); // one JSON file per driver, plus a manifest ``` The manifest lists each file's labels and region. ## 4. Erase ```ts title="app/api/me/erase/route.ts" import { privacy } from "mainsdk"; declare const userId: string; // the signed-in person, from auth.require const record = await privacy.erase(userId, { reason: "user request" }); privacy.verify(record); // the signature checks against the project key ``` Erase fans out to every driver that holds the person's data and retries the ones that fail. If a driver has not confirmed after its retries, the record is `partial` (`MAIN-PRIV-0006`, logged, never thrown) and `privacy.resume()` retries. A person under a legal hold, `privacy.hold(user, { until, reason })`, is refused with `MAIN-PRIV-0003`; `privacy.release(user)` lifts it. Register anything of your own that holds a person's data: ```ts title="app/lib/privacy.ts" import { privacy } from "mainsdk"; declare const notes: { deleteFor(user: string): Promise; dump(user: string): Promise }; // your own notes store privacy.register("support-notes", (user) => notes.deleteFor(user), { export: (user) => notes.dump(user) }); ``` ## 5. Generate store labels ```bash npx mainsdk privacy labels --format app-store npx mainsdk privacy labels --format play ``` Labels come from your data labels and drivers, never by hand. ## Verify ```bash npx mainsdk check ``` Exit 0 means every label in use has a retention rule and the `privacy` section reads. ## If it fails | Code | What it means | Fix | | --- | --- | --- | | `MAIN-PRIV-0001` | A label in use has no retention rule | Add `privacy.retention.