MainSDK
Search⌘K
How-to

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.

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

Every recipe verifies the same way, and adds the test it names:

bash
npx mainsdk check
snapshot="check-pass"
$ 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, db, access, pay, email, jobs, secrets.

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:

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 }
}
lib/payments.ts
import { email, jobs, pay } from "mainsdk";

pay.on("payment.succeeded", async (event) => {
  // the fields of event are [to be decided]; the order id and customer email come from it
  await email.send({ to: "[to be decided]", template: "receipt", data: { orderId: "[to be decided]" } });
  await jobs.run("orders.fulfil", { orderId: "[to be decided]" });   // retries with backoff, dead-letter queue on
});

Where you register the handler for a job name is not decided yet. 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; 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.

Interfaces: ai, auth, limits for request rates, and for RAG search.

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:

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:

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.

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,). 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 not decided yet.

main.yaml
search:
  driver: pgvector
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.

Planned for milestone 0.3 (specs files-share, pay, email-notify). links has no spec yet.

Interfaces: files, share, pay, notify, db, auth.

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:

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; 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. Real-time collaboration and presence comes later, in roadmap phase 5 (spec platforms). Treat this recipe as the planned shape only.

Interfaces: realtime, auth, access.

main.yaml
realtime:
  driver: pusher
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 not decided yet. Presence and conflict handling are not in the catalog.

Verify: npx mainsdk check --env prod. The kit's fake for realtime and its assertions are not decided yet.

e. Mobile app on Expo

Planned for milestone 1.1 (spec expo-adapter, stability beta).

Interfaces: notify with push through Expo, FCM and APNs, auth, updates.

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
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>;
}
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 not decided yet.

Verify: npx mainsdk check --env prod. Device tests are not decided yet.

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 with a wallet method, access with an external attribute, keys, pay, and an agent budget (spec agent-governance, milestone 0.3).

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

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, db, ai, files, secrets.

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:

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:

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

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 not decided yet. See Upload files with S3, Uploadthing or Vercel Blob for the upload flow in full.

Next

Open questions

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet