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.
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:
npx mainsdk check$ npx mainsdk check
mainsdk check · notes · env dev · Main 27
config secrets policy network auth data
Passed · 6 gates · 0 findings · 1.2sAdd --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.
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:
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 }
}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.
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: trueStream 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:
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:
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.
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.
search:
driver: pgvectorimport { 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.
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, share, pay, notify, db, auth.
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:
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.
realtime:
driver: pusherimport { 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.
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 directlyimport { MainProvider } from "mainsdk/expo"; // wraps the app; loads the core
export default function Layout({ children }: { children: React.ReactNode }) {
return <MainProvider>{children}</MainProvider>;
}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).
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 } } }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.
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:
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:
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):
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?