---
title: "Recipes by app type"
description: "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."
section: "guides"
canonical: "https://mainsdk.dev/docs/guides/stack-recipes"
updated: "2026-10-28"
---

# 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 <MainProvider>{children}</MainProvider>;
}
```

```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)
