MainSDK
Search⌘K
Docs

main.yaml keys

Every main.yaml key the specs define, with its type, default where one is set, what it does and an example, grouped by interface.

Planned for 0.1

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

main.yaml declares what your app is, needs and may do. This page lists every key a spec or the interface catalog shows, with an example value taken from it. For how overlays, layers and exceptions work, read main.yaml.

Point your editor at the published JSON Schema to get autocomplete and inline errors:

main.yaml
# yaml-language-server: $schema=https://mainsdk.dev/schema/main.yaml/1.json
schema: 1
project: prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY
baseline: main27
users: { audience: business, regions: [us, eu] }
targets: { web: { framework: nextjs } }
auth: { driver: github, methods: [passkeys, sso] }
ai: { driver: bedrock, data: { training: never, retention: zero } }
data: { store: postgres, residency: { default: us, by-market: { eu: eu } } }
db:
  driver: postgres
  regions: { us: secret:DATABASE_URL_US, eu: secret:DATABASE_URL_EU }
  fields:
    user.email: { label: personal }
policy: { engine: cedar, placement: server }
network: { allow: ["*.pinecart.dev"], default: deny }
overlays: { order: [market, segment, env] }
exceptions: []

Rules for every key

  • Types. duration is a number and a unit: 30s, 10m, 24h, 7d, 13m (months, in retention), 7y. A secret ref is a string matching secret:[A-Z0-9_]+. A literal value in a field marked secret fails with MAIN-CONFIG-0004.
  • Merging. Overlays merge deep by key. Lists replace; they don't append.
  • Layers. main.org.yaml → main.workspace.yaml → main.yaml → overlays. A layer may tighten its parent. Weakening it without an exception fails with MAIN-CONFIG-0010.
  • Resolving. resolveConfig(dir, { market, segment, env, target, … }, { today }) (from mainsdk/config-resolve) returns { config, sources, exceptions, layers }: maps merge by key, lists replace, overlays apply in overlays.order (later wins), and sources gives the file and line of every leaf by JSON pointer. main.org.yaml and main.workspace.yaml are found in the app folder or the nearest folder above it. An exception counts when it has a rule (the path, such as network.allow), a reason, an owner and an expires date that has not passed.
  • Ownership. Each interface spec owns its block. Its page under interfaces shows the block in context.
  • Unknown keys. The JSON Schema flags them in your editor.
  • Defaults. A blank Default cell means the spec sets no default yet. "Catalog default" names the driver the interface catalog marks as default.

Project and baseline

KeyTypeDefaultDescriptionExample
schemaintegerrequiredConfig contract version. Anything but 1 fails with MAIN-CONFIG-00011
projectstringwritten by initProject ID, a UUIDv7 shown as prj_…. An identifier, never a credentialprj_01JC8Q4W7ZK3M5N8P2R6T9V0XY
baselinestringBaseline generation the app meetsmain27
namestringappThe app's name, used as the service name in telemetrycheckout
usersobjectaudience and regions together{ audience: business, regions: [us, eu] }
users.audiencestringWho uses the appbusiness
users.regionslist of us, eu, inWhere your users are[us, eu]
residencyus, eu, in, or { default, by-market }usThe region the app's own data lives in when a tenant names none; security events follow it. Residency by data kind is a later phase (see below){ default: eu }
standardslistStandards to map evidence to[soc2, gdpr]
featureslistCapabilities mainsdk sync sets up[payments, email]
industrystring or listIndustry pack overlays (phase 3, 1.2)[payments, healthcare]
marketslistMarkets the app serves; packs lock personal data to their regions and vault checks its countries cover them[in, eu, us-ca]
compliance.pageslistPayment pages the PCI DSS script inventory and page integrity controls read (spec industry-packs)[checkout-page.ts]
compliance.scriptslistApproved scripts on those pages, each with host, reason and owner[{ host: js.stripe.com, reason: Hosted fields, owner: payments }]
compliance.providerslistThird-party providers in the register the packs check, by name[stripe, sendgrid]
compliance.change-controlbooleanfalseEvery change is reviewed before release; the packs that need it check thistrue
versionstring0.0.0The app's own release version, reported by mainsdk info and in telemetry resources1.4.2
ownersmap of glob to teamafter CODEOWNERSOwning team per path, used by assure (phase 2, 0.7){ "app/checkout/**": web-team }

The Cloud spec writes project as an object, { id, region }, while the main.yaml spec writes it as a string. Which shape ships is not decided.

targets

KeyTypeDefaultDescriptionExample
targets.web.frameworkstringWeb frameworknextjs
targets.api.runtimestringAPI runtimenode
targets.<name>.platformstringWhere the target is deployed, such as vercel or cloudflare. Checks read it to see whether the target is serverless or edge, for example to refuse more than one cluster processvercel
targets.mobileobjectExpo target: framework, platforms, bundleId (phase 3, 1.1){ framework: expo, platforms: [ios, android] }

overlays and exceptions

KeyTypeDefaultDescriptionExample
overlays.orderlist[market, segment, env], written by initOrder overlays apply in; never guessed at run time[market, segment, env]
exceptions[].rulestringrequiredThe rule or code the exception coversMAIN-NET-0004
exceptions[].reasonstringrequiredWhyPartner gateway upgrade booked for November
exceptions[].ownerstringrequiredWho owns it"@pinecart/platform"
exceptions[].approverstringWho approved it, used by assureravi@pinecart.dev
exceptions[].expiresdaterequiredAfter this date the exception fails like none2026-11-30

exceptions is a list of the entries above. A layer that weakens its parent needs one, and an expired entry fails like none.

Overlay files: main.<market>.yaml, main.segment.<name>.yaml, main.env.<name>.yaml, main.target.<name>.yaml, locales/<locale>.yaml. Only the market overlay is read at run time today: config.get answers from main.<market>.yaml for the market of the request (spec adapters).

Main 27 asks exceptions for an owner; assure asks for an approver. Whether they are one field or both are required is still open.

providers and fallback

KeyTypeDefaultDescriptionExample
providers.<interface>stringDriver per interface, in short form{ email: resend }
fallback.<interface>listDrivers to fail over to, in order{ email: [resend, ses] }

Identity and access

KeyTypeDefaultDescriptionExample
auth.driverstringcatalog default githubgithub, clerk, auth0, better-auth, workos, supabase, localgithub
auth.methodslistpasskeysSign-in methods; passwords are off once passkeys are on[passkeys, whatsapp, sso]
auth.whatsapp.driverstringWhatsApp code sender: whatsapp-cloud, bird, msg91whatsapp-cloud
auth.ssoobjectprotocols, scim{ protocols: [saml, oidc], scim: true }
auth.sessionobjectttl, refresh, bind{ ttl: 15m, refresh: 7d, bind: dbsc }
auth.routes.basepath/_mainsdk/authWhere every adapter mounts the browser sign-in routes; the client's base must match/_mainsdk/auth
auth.recovery.waitduration24hWaiting period for recovery without a second factor24h
auth.adminobjectAdmin roles and their sign-in rule{ roles: [admin, support], require: phishing-resistant }
auth.qr, auth.push, auth.recoverobjectApp-to-desktop QR, push approval, device recovery (phase 3, 1.1){ numberMatch: true, fallback: whatsapp }
access.driverstringcedarcedar, opa, openfga, spicedb, cerbos, permit, oso, avpopenfga
access.modelslistrbac, abac, rebac (ReBAC needs a driver that declares it)[rbac, abac]
access.whenobjectAn action to the named rules that must also hold; a rule that is not registered denies{ orders.refund: [limit] }
access.relationsobjecteditor, viewerA relation name to the actions it grants on the resource{ editor: [read, write, update] }
access.modestringenforceenforce, warn or observe: what a deny doesenforce
access.fresh-after-writebooleanRead your own writes in the same requesttrue
access.unreachableobjectFail closed, with a short cache{ deny: true, cache: 30s }
policy.enginestringcedarcedar or opacedar
policy.placementstringserverserver, edge or browserserver
policy.browser.localbooleanfalseLazy-load the TypeScript Cedar engine in the browsertrue
policy.dirpathpolicies/Where policy files livepolicies/
policy.modestringnew policies start in observeobserve, warn or enforceenforce
policy.opa.bundlepathCompiled Rego bundle, evaluated in process./policies/rego

The auth spec's example still uses clerk as its default driver; the catalog default is github. The phase 3 recovery block is spelled recover in its spec and recovery in the auth spec.

Data, keys and privacy

KeyTypeDefaultDescriptionExample
data.storestringStore kindpostgres
data.residencyus, eu, in, or objectRegion per market: { default, by-market }{ default: eu, by-market: { in: in } }
db.driverstringcatalog default postgrespostgres, neon, supabase, sqlite (local only)neon
db.regionsmap of region to secret refOne store per region{ us: secret:DB_US, eu: secret:DB_EU }
db.fields.<model.field>.labelstringpersonal, sensitive, child, phi, pan (refused). An unlabelled personal field fails MAIN-DATA-0021personal
db.fields.<model.field>.encryptbooleanEnvelope encryption through keystrue
db.fields.<model.field>.vaultbooleanKeep the value in a residency vault (phase 3, 1.2)true
db.pathstring.mainsdk/db.sqliteThe SQLite file for the local driver.mainsdk/db.sqlite
db.urlsecret refsecret:DATABASE_URLOne store for every regionsecret:DATABASE_URL
db.regions.<region>secret ref, or { url, pooled }pooled: true tells mainsdk check the URL is already a pooled endpoint{ url: secret:DB_EU, pooled: true }
db.models.<model>.trashdurationKeep deleted records this long so restore works; capped by the label's retention30d
db.models.<model>.versionsbooleanRow versions: updates and deletes must say which version they sawtrue
db.models.<model>.uniquelistUnique indexes that skip trashed rows[number]
db.timeout.request, db.timeout.jobduration5s, 60sStatement timeout in a request (30s at most) and in a job3s
db.pool.max, db.pool.acquire, db.pool.idlenumber, duration10 (1 serverless), 2s, 30sConnections per store; max over 100 fails MAIN-DATA-0056{ max: 4 }
db.slow, db.repeatduration, number200ms, 10Log a slower query, or one query shape run more often in one request100ms
db.migrations.toolstringfound from the projectprisma, drizzle or sqlprisma
db.migrations.path, .basestringtool default, origin/developMigrations folder, and the ref that decides which migrations are newprisma/migrations
db.migrations.lock-timeout, .retriesduration, number5s, 5How long a statement waits for a lock, and attempts after a timeout1s
db.migrations.allowlistExceptions to a migration rule, each { file, rule, reason, expires }
kv.driverstringlocallocal or redis (upstash, vercel-runtime-cache, cloudflare-kv are planned)redis
kv.filepathlocal only: keep values across restarts.mainsdk/kv.json
kv.redis.url, kv.redis.prefixsecret ref, stringsecret:REDIS_URL, mainsdk:<project>The Redis URL and a prefix added to every key{ url: secret:REDIS_URL, prefix: app }
cache.driverstringkv when kv is set, else memorykv (the kv store with an in-process tier in front) or memory (in-process only; each instance has its own entries)kv
cache.timeoutduration50msA store call slower than this is skipped and your function runs instead50ms
cache.max-valuesize256KBThe largest value in bytes of its JSON; a larger one is not stored (MAIN-CACHE-0003)256KB
cache.memory.max-entries, cache.memory.max-ttlnumber, duration1000, 30sThe size of the in-process tier and the longest an entry lives in it{ max-entries: 1000, max-ttl: 30s }
keys.driverstringcatalog default aws-kmsaws-kms, gcp-kms, azure-keyvault, vault-transit, local; pkcs11, kmip, aws-cloudhsm, azure-hsm, gcp-hsm in phase 3aws-kms
keys.keystring or map of regionWrapping key per region{ eu: arn:aws:kms:eu-west-1:… }
keys.per-userbooleanOne key per person, so deleting it erases their datatrue
keys.rotatedurationRotation period365d
keys.modulepathpkcs11 driver: the vendor PKCS#11 library/opt/hsm/libpkcs11.so
keys.slotinteger0pkcs11 driver: the HSM slot0
keys.pinsecret refpkcs11 driver: the slot PIN; it must be a secret: referencesecret:HSM_PIN
keys.fipsobjectThe FIPS 140 certificate your module holds (standard, level, certificate); MainSDK states a FIPS claim only from this{ standard: "140-3", level: 3, certificate: "4567" }
crypto.fipsbooleanfalseFIPS mode: unapproved algorithms fail (MAIN-CRYPTO-0011); needs crypto.moduletrue
crypto.moduleobjectThe validated module the app runs on: name, version and certificate (3 to 5 digits){ name: Acme Crypto Module, version: "3.0.9", certificate: "4567" }
digital-idsobjectWallets the app accepts (wallets) and each named request (requests.<name>: purpose, claims){ wallets: [digilocker], requests: { age: { purpose: Sell age-restricted items, claims: [age_over_18] } } }
privacy.retention.<label>durationRetention per label (personal, sensitive, child, phi), plus logs and analytics. A bare m is months, so 13m is thirteen months. A label in use with no rule fails MAIN-PRIV-0001; a value that does not read fails MAIN-PRIV-0004{ personal: 365d, logs: 30d, analytics: 13m }
privacy.erase.confirm-withinduration30dHow long drivers have to confirm erasure before privacy.pending() marks it overdue30d
privacy.erase.retriesnumber3How many times each driver's erase hook is called, 1 to 103
privacy.erase.retry-delayduration1sThe wait before the first retry; it doubles each time. Here m is minutes1s
privacy.consent.tcf.<purpose>list of numbersanalytics: [1], personalisation: [1, 3, 4], marketing: [1, 2, 7]The IAB TCF purposes (1 to 24) a consent purpose needs; a TCF or GPP signal sets the purposes listed here{ analytics: [1] }
vaultobjectResidency vault driver and route (phase 3, 1.2){ driver: incountry, route: { by: user.country } }
vault.countrieslistCountries the vault driver can store in; check compares them with markets[in, ae, sa]
vault.locationsmapThe vault location of each country{ ae: loc-uae }
backupobjectrpo, rto, keep, drill (phase 3, 1.2){ rpo: 15m, rto: 1h, keep: 35d }
backup.driverstringModule whose default export is the app's BackupDriver, which mainsdk drill restore calls./backup.ts
backup.providerobjectWhat the provider says it keeps: pitr-window, pitr-granularity, snapshot-every, snapshot-keep, offsite, object-lock; check compares it with rpo and keep{ pitr-window: 7d, offsite: true }

Secrets, config and network

KeyTypeDefaultDescriptionExample
secrets.driverstringcatalog default vaultlocal, env, aws-secrets-manager, gcp-secret-manager, azure-key-vault, vault, doppler, infisicalvault
secrets.vaultobjectaddress, auth (prefer workload-identity){ address: https://vault.pinecart.internal, auth: workload-identity }
config.driverstringmemory, files, redis; postgres is plannedredis
config.schemapathJSON Schema the settings are typed against./config.schema.json
config.refreshduration30sPoll interval; with a change stream, a reconciling poll every ten times this30s
config.valuesmapDeployed defaults; the store's value wins{ checkout.currency: INR }
config.values.<key>anyOne deployed default, by its dotted key; the value is any JSON type the key's schema allows20
config.filesobject{ dir: config/ }dir: the folder the files driver reads{ dir: config/ }
config.redisobjecturl (a secret: reference) and prefix{ url: secret:REDIS_URL }
network.allowlistIPs, CIDR ranges, exact domains, *.domain for subdomains (HTTP, HTTPS and WebSocket); tcp://target:ports, udp://target:ports and unix:///path (or unix:///dir/*) entries for raw sockets, see net["*.pinecart.dev", 10.20.0.0/16, "tcp://db.acme.dev:5432"]
network.defaultstringdenyWhat happens to undeclared hostsdeny
network.modestringobserve, warn or enforceenforce
network.httpobjecttimeout: 10s, connect: 3s, retries: { attempts: 3, backoff: 200ms, max: 5s, deadline: 30s }, breaker: { failures: 50%, window: 30s, min: 10, cooldown: 15s }Timeouts, retries and the per-host breaker for every outbound call; hosts sets an exact host or *.domain{ timeout: 10s, hosts: { api.partner.com: { timeout: 3s } } }
network.listenlistnoneWhat the app may listen on, with net.listen, net.listenUnix and a UDP bind: tcp:7000, "tcp:9000-9010@0.0.0.0", unix:///run/app/ctl.sock, or { protocol, port, bind?, from? }. Loopback unless the entry names a bind; no default, even with network.default: allow[tcp:7000, unix:///run/app/ctl.sock, { protocol: tcp, port: 7100, bind: 0.0.0.0, from: [10.0.0.0/8] }]
network.udpobjectoffbroadcast (ports the app may broadcast to) and multicast (ip:port groups it may join and send to){ broadcast: [9], multicast: ["239.255.0.1:5000"] }
network.tcpobjecttimeout: 3s, idle: 5m, keepalive: 30s, retries: { attempts: 3, backoff: 200ms, max: 5s }, breaker: { failures: 50%, window: 30s, min: 10, cooldown: 15s }, max-connections: 1000, tls: { min-version: TLSv1.2 }Defaults for every TCP connection and each net.listen server; retries and breaker take off{ idle: 1m, tls: { min-version: TLSv1.3 } }

Drivers add their own hosts, so network.allow lists only your own destinations.

Messaging, payments, files and jobs

KeyTypeDefaultDescriptionExample
email.driverstringresend when email.key is set, else localresend, ses or local. Postmark, Bird and SMTP are not built. local in production stops the app with MAIN-EMAIL-0008resend
email.keysecret refThe provider key. For ses it is the secret access key, used with email.access-key-id; without both, SES uses the runtime rolesecret:RESEND_API_KEY
email.fromstringMainSDK Dev <dev@mainsdk.invalid>The sender address, Name <address>"Shop <shop@example.com>"
email.reply-tostringThe reply-to addresssupport@example.com
email.trackingobjectoffOpen and click tracking; must be turned on explicitly{ opens: false, clicks: false }
email.tracking.opensbooleanfalseKeeps open-tracking pixels in the HTML when true; stripped otherwisefalse
email.tracking.clicksbooleanfalseAllows link tracking when truefalse
email.unsubscribe-urlstringThe public HTTPS URL of your unsubscribe route, which email.unsubscribe.handle serves. email.unsubscribe.create needs ithttps://app.example.com/unsubscribe
email.webhookstringThe name of the webhooks.inbound entry that receives the provider's bounce and complaint eventsresend-events
email.limitsobjectnoneSend caps per tenant: per-minute, per-day, tenants{ per-minute: 60, per-day: 5000 }
email.limits.per-minuteintegernoneSends allowed per minute; more fails with MAIN-EMAIL-000560
email.limits.per-dayintegernoneSends allowed per day5000
email.limits.tenants.<tenant>.per-minuteintegerPer-minute cap for one tenant, in place of the default10
email.limits.tenants.<tenant>.per-dayintegerPer-day cap for one tenant500
email.senders.<tenant>.fromstringThe sender address for one tenant"Acme <hello@acme.example>"
email.senders.<tenant>.reply-tostringThe reply-to address for one tenanthelp@acme.example
email.fallbacklistnoneDrivers to try, in order, when the first fails in a way a retry could fix. fallback.email is read when this is empty[ses]
email.regionstringAWS_REGIONses only: the region of your verified identity. Required for ses when AWS_REGION is not seteu-west-1
email.access-key-idstringses only: the access key id; email.key holds its secret. Leave both out on AWS compute to use the roleAKIAEXAMPLE
email.configuration-setstringses only: the configuration set that publishes eventsmainsdk-events
email.suppression-keysecret refbuilt from the project nameThe secret that keys the hash of each address in the suppression list. Set it so the list survives a project renamesecret:EMAIL_SUPPRESSION_KEY
email.templates.<name>.subjectstringThe subject, with {{name}} placeholders"Receipt {{orderId}}"
email.templates.<name>.textstringThe plain-text body. A template needs text or html"Thanks for order {{orderId}}."
email.templates.<name>.htmlstringThe HTML body; values are escaped"<p>Thanks for order {{orderId}}.</p>"
notify.channelslist[email]Order channels are tried in, filtered by preferences: push, whatsapp, sms, email[push, whatsapp, sms, email]
notify.smsstringnoneThe SMS driver: twilio, msg91 or local. local in production stops the app with MAIN-NOTIFY-0011msg91
notify.whatsappstringnoneThe WhatsApp driver: whatsapp-cloud, bird, msg91 or local. local in production stops the app with MAIN-NOTIFY-0011whatsapp-cloud
notify.pushobjectPush drivers (phase 3, 1.1){ drivers: [fcm, apns], via: expo }
notify.quietobjectnoneDefault quiet hours: from, to, tz{ from: "22:00", to: "08:00", tz: Asia/Kolkata }
notify.quiet.fromstringStart of quiet hours, HH:MM on a 24-hour clock"22:00"
notify.quiet.tostringEnd of quiet hours, HH:MM"08:00"
notify.quiet.tzstringUTCIANA time zone for people with no zone of their ownAsia/Kolkata
notify.rolloutobjectstages 1%, 10%, 100%Staged sends that pause on spikes: stages, pause-if, settle{ stages: ["1%", "10%", "100%"] }
notify.rollout.stageslist["1%", "10%", "100%"]The share of the audience reached by the end of each stage; must grow and end at "100%"["1%", "10%", "100%"]
notify.rollout.pause-ifobjectbounces 2%, complaints 0.1%, unsubscribes 1%Thresholds that hold the next stage: bounces, complaints, unsubscribes, errors{ bounces: "2%" }
notify.rollout.pause-if.bouncesstring"2%"Pause when this share of the stage's emails bounced"2%"
notify.rollout.pause-if.complaintsstring"0.1%"Pause on this share of complaints"0.1%"
notify.rollout.pause-if.unsubscribesstring"1%"Pause on this share of unsubscribes"1%"
notify.rollout.pause-if.errorsstringnonePause on this share of failed sends"5%"
notify.rollout.settleduration0How long to wait after a stage for bounces to arrive before judging it10m
notify.marketinglist[marketing]Categories whose email goes as marketing, with an unsubscribe link[marketing, news]
notify.templates.<name>.sms.textstringThe SMS text, with {{name}} placeholders. Twilio sends it; MSG91 needs template instead"Order {{order}} shipped"
notify.templates.<name>.sms.dlt-template-idstringThe DLT-registered template id. SMS to a +91 number is skipped without it (MAIN-NOTIFY-0005)"1007000000000001"
notify.templates.<name>.sms.templatestringThe provider's own template id, such as an MSG91 flow id64f0c0ffee
notify.templates.<name>.sms.varslistThe data keys, in order, passed as the provider template's variables[order]
notify.templates.<name>.whatsapp.namestringThe provider-approved WhatsApp template nameorder_shipped
notify.templates.<name>.whatsapp.languagestringenThe template language codeen
notify.templates.<name>.whatsapp.paramslistThe data keys, in order, passed as the template's body variables[order]
notify.templates.<name>.emailstringthe notification's own nameThe email template to use on the email channelorder-shipped
notify.limits.sms-per-dayintegernoneSMS sends allowed per day, to bound cost; more is skipped with MAIN-NOTIFY-00103
notify.limits.whatsapp-per-dayintegernoneWhatsApp sends allowed per day5
notify.twilio.accountstringThe Twilio account SIDACxxxxxxxx
notify.twilio.userstringAn API key SID; with it key is that key's secret, without it key is the auth tokenSKxxxxxxxx
notify.twilio.keysecret refsecret:TWILIO_AUTH_TOKENThe Twilio secretsecret:TWILIO_AUTH_TOKEN
notify.twilio.fromstringThe Twilio number to send from. Set this or messaging-service"+14155550100"
notify.twilio.messaging-servicestringA Messaging Service SID, in place of fromMGxxxxxxxx
notify.msg91.keysecret refsecret:MSG91_AUTH_KEYThe MSG91 auth key, for SMS and WhatsAppsecret:MSG91_AUTH_KEY
notify.msg91.whatsapp.numberstringThe integrated WhatsApp number"919876500000"
notify.msg91.whatsapp.namespacestringThe WhatsApp template namespaceabc_namespace
notify.whatsapp-cloud.phone-number-idstringThe WhatsApp Business phone number id"1234567890"
notify.whatsapp-cloud.tokensecret refsecret:WHATSAPP_TOKENThe access token of a system usersecret:WHATSAPP_TOKEN
notify.whatsapp-cloud.versionstringv21.0The Graph API versionv21.0
notify.bird.workspacestringThe Bird workspace idws_123
notify.bird.channelstringThe Bird WhatsApp channel idch_123
notify.bird.keysecret refsecret:BIRD_ACCESS_KEYThe Bird access keysecret:BIRD_ACCESS_KEY
notify.bird.projectstringThe template project idproj_123
notify.bird.versionstringThe template versionlatest
notify.engagementobjectEngagement messages: max-per-week (required, 1 to 21), quiet-hours, stop-after-ignored, audience (children: strict), guardian (controls), categories, active, ignored-after, send-time, copy, proof, links (a {{link.<name>}} placeholder to its target URL), measure. A push may only lower max-per-week and stop-after-ignored and widen quiet-hours{ max-per-week: 4, quiet-hours: "21:00-08:00" }
payments.driverstringcatalog default stripePayment driver; a market overlay changes itstripe
payments.methodslistPayment methods[upi, cards]
pay.<driver>objectkey and webhook secret refs, methods{ key: secret:STRIPE_SECRET_KEY, webhook: secret:STRIPE_WEBHOOK_SECRET }
filesobjectdriver, key, scan, max-size, types, signing-key, base-url, bucket, region, endpoint, prefix, access-key-id{ driver: getqr, key: secret:GETQR_API_KEY, scan: true, max-size: 25MB }
files.access-key-idstrings3 only: the access key identifier (not a secret). Set it with files.key; with neither, the runtime's IAM role is usedAKIA…
files.endpointstrings3 only: the endpoint of an S3-compatible store; {bucket} is replacedhttps://acme.r2.cloudflarestorage.com
files.prefixstrings3 only: a key prefixuploads/
files.signing-keystringper processSigns upload tokens and read URLs; a secret: referencesecret:FILES_SIGNING_KEY
files.base-urlstringgetqr.idgetqr only: the getqr.id API origin, for a sandboxhttps://sandbox.getqr.id
shareobjectdefault-expiry, max-expiry{ default-expiry: 7d, max-expiry: 90d }
webhooksobjectdriver, secret, retries{ driver: builtin, retries: { max: 8, window: 24h } }
jobsobjectdriver, dead-letter{ driver: inngest, dead-letter: true }
events.broadcastobjectdriver (memory or redis), redis, max-payload (default 64KB){ driver: redis, redis: { url: secret:REDIS_URL }, max-payload: 64KB }
events.schemasobjectThe event schema registry: per event type version (from 1), compat (backward, the default, or none) and fields (name to string, number, boolean, object or array, with ? for optional). mainsdk sync pins them in main.events.lock; a breaking change under the same version is MAIN-EVENT-0004.{ order.shipped: { version: 1, fields: { orderId: string, carrier: "string?" } } }
idempotencyobjectWhere idempotency records live and for how long{ store: db, ttl: 24h }

The pay spec routes by market with a map, payments: { in: razorpay, default: stripe }. The main.yaml spec and the catalog use payments.driver and put the India driver in main.in.yaml. This page follows the catalog.

AI and agents

KeyTypeDefaultDescriptionExample
ai.driverstringcatalog default bedrockbedrock, anthropic, openai, gateway, localbedrock
ai.keystringsecret:ANTHROPIC_API_KEY or secret:OPENAI_API_KEY by driverThe provider API key, always a secret: reference. bedrock takes none: it uses the rolesecret:ANTHROPIC_API_KEY
ai.regionstringThe AWS region with your model access, bedrock onlyus-east-1
ai.modelslistModel allowlist[anthropic/claude-*]
ai.dataobjecttraining, retention{ training: never, retention: zero }
ai.redactlistLabels removed before model calls[personal, sensitive, child, phi]
ai.limitsobjectSpend limits per-user, per-feature, total{ per-user: $1/day, total: $500/month }
ai.labelbooleanAI disclosure and content labelstrue
ai.video.driverstringopenai, google or gateway; with none, ai.video is not available (spec ai-video)openai
ai.video.keystringsecret:OPENAI_API_KEY, secret:GOOGLE_API_KEY or secret:AI_GATEWAY_API_KEY by driverThe video provider key, always a secret: referencesecret:OPENAI_API_KEY
ai.video.max-secondsnumberThe longest clip a call may ask for8
ai.voice.driverstringSpeech-to-speech driver: openai-realtime, gemini-live or fake (spec ai-voice)openai-realtime
ai.voice.keystringby driverThe voice provider key, a secret: referencesecret:OPENAI_API_KEY
ai.voice.telephonyobjectdriver (twilio, telnyx, sip), its credentials as secret: references, webhook-url, numbers mapping a phone number to a session name, and the signed media stream route stream-url (a wss:// URL), stream-secret (a secret: reference) and stream-ttl (default 10m){ driver: twilio, account-sid: secret:TWILIO_SID, auth-token: secret:TWILIO_TOKEN, numbers: { "+15550100": support } }
ai.voice.sessionsobjectNamed sessions a number routes to: voice, instructions, feature, agent, disclosure, and the other session options{ support: { voice: alloy, instructions: "Help with orders", feature: voice-support, agent: support } }
ai.voice.roomsobjectdriver (livekit, daily), url, api-key, api-secret, and bridge, the wss:// media bridge URL where {room} is the room name{ driver: livekit, url: wss://x.livekit.cloud, api-key: secret:LK_KEY, api-secret: secret:LK_SECRET }
ai.voice.disclosurestringThe line spoken first on every session; an empty one on a routed number is MAIN-VOICE-0010This call uses an AI assistant.
ai.voice.closingstringThe line spoken when a session reaches its limitGoodbye.
ai.voice.recordstringoffoff or consent; a recording needs the caller's call-recording consentconsent
ai.voice.max-durationstringThe longest a session may run15m
advisor.onlistSignal names or prefix.* patterns that wake the advisor at medium or above (spec advisor)[attack.suspected, anomaly.detected]
advisor.maylistThe declared playbook rules it may propose; required, and each must exist[attack.contain, pressure.slim]
advisor.runstringapproveapprove holds every proposal for a person; auto applies only notice and instance stepsapprove
advisor.windowduration15mHow far back it reads signals15m
advisor.max-per-hournumber4Wakes per hour at most4
advisor.featurestringadvisorThe ai feature its calls are made underadvisor
advisor.modelstringfirst of ai.modelsThe model it asksanthropic/claude-sonnet-4-5
mcp.exposelistActions exposed as MCP tools[orders.refund, orders.list]
mcp.confirmobjectwrite asks; destructive always asksWhen agents need a person's yes{ write: true, destructive: true }
mcp.authobjectissuer, scopes, token-ttl{ scopes: [orders:read], token-ttl: 10m }
memory.driverstringdbdb, local; mem0, zep, letta, pinecone, mcp in 0.5 (spec memory)db
memory.embedobjectmodel, feature for embeddings, made in a background job through ai{ model: openai/text-embedding-3-small, feature: memory-embed }
memory.extractobjectmodel, feature that learn and consolidate use{ model: anthropic/claude-haiku-4-5, feature: memory-learn }
memory.ttlobjectLifetime per kind; the label's retention caps it{ event: 90d, note: 365d }
memory.from-toolsstringquarantinequarantine, allow or refuse for writes from tools, MCP, the web and importsquarantine
memory.childrenstringoffoff or guardianoff
agents.<name>.memoryobjectnone (no access)read (own, all, none), write, forget (approve, deny), recalls and writes per day{ read: own, write: true, forget: approve, writes: 100/day }

The residency vaults spec (phase 3) writes ai.redact as an object, { driver: agentcloak }.

The advisor and the voice sessions are written like this:

yaml
advisor:
  on: [attack.suspected, anomaly.detected]
  may: [attack.contain, pressure.slim]
  run: approve
  window: 15m
  max-per-hour: 4
ai:
  voice:
    sessions:
      support: { voice: alloy, instructions: "Help callers.", feature: voice-support, agent: support }

Logs and telemetry

KeyTypeDefaultDescriptionExample
logging.auditbooleanAppend-only, tamper-evident audit logtrue
logging.redactlistLabels redacted in process[personal, sensitive, child, phi]
logging.exportlistExport targets[siem]
logging.retentiondurationAudit and log retention12m
logging.errors.driverstringotlpWhere error events go: otlp (through telemetry.otel), sentry or local; with no logging.errors section errors are grouped and kept locallysentry
logging.errors.dsnsecretThe Sentry DSN, sentry only; its host must be in network.allow (MAIN-REPORT-0005)secret:SENTRY_DSN
logging.errors.releasestringautoThe release on every event; auto is the seal's build hash, else MAINSDK_RELEASE, else the package versionauto
logging.errors.samplenumber1.0Share of events exported, from 0 to 1; counts and groups always see every event1.0
logging.errors.processbooleantrueCapture uncaughtException and unhandledRejection as fatal, flush for at most 2 s, exit 1true
logging.errors.browserbooleanfalseServe POST /_mainsdk/errors for reportErrors()false
logging.errors.ignorelist[]Error names or MAIN codes that are never grouped, counted or exported[AbortError]
logging.errors.alert.newstringwarningSeverity of the alert for a group new in this release; off disables itwarning
logging.errors.alert.spikemap{ over: 50, per: 5m, severity: critical }Raise error-spike:<fingerprint> when a group passes over events in per (over is at least 1){ over: 50, per: 5m, severity: critical }
telemetry.tolist[terminal]Any mix of terminal, otel, mainsdk[terminal, otel]
telemetry.otelobjectOTLP endpoint and headers{ endpoint: https://otel.pinecart.dev:4318 }
telemetry.samplenumber 0–1Trace sampling; errors, decisions and audit are never sampled0.2
telemetry.budgeteventsEvents a month sent to MainSDK Cloud2M
feedbackobjectTracker and prompts for user reports (phase 2, 0.6){ tracker: linear, prompt: { onCrash: true } }
feedback.driverstringbuiltinThe support provider: builtin, intercom, zendesk or plainzendesk

Nothing reaches MainSDK Cloud before mainsdk login, even with mainsdk in telemetry.to.

Tenants, keys, plans and agents

KeyTypeDefaultDescriptionExample
tenancy.isolationstringsharedshared, schema or databaseshared
tenancy.resolvelistHow a request's tenant is found, in order: apikey, session, subdomain, header[apikey, session, subdomain]
tenancy.host, tenancy.personal, tenancy.directorymixedSubdomain base, a personal tenant per person, and the directory's region{ host: acme.app, personal: true, directory: eu }
tenancy.roleslistMember roles[owner, admin, member, viewer]
tenancy.systemobjectallow: the files that may call tenant.system(){ allow: [src/admin/**] }
tenancy.suspended, tenancy.eraseobjectCalls allowed while suspended; the erase waiting period{ allow: [billing.*] }, { wait: 7d }
auth.orgsobjectinvites.expires and domains.auto-join{ invites: { expires: 7d } }
apikeys.driverstringdbdb or memory (tests only)db
apikeys.prefixstringthe app name2 to 12 lowercase letters and digitsacme
apikeys.headerstringHeader read after Authorization: Bearerx-api-key
apikeys.scopeslistThe access permissions a key may carry[orders.read, orders.write]
apikeys.max-ttlduration365dThe longest key lifetime365d
apikeys.limits.defaultrateRate limit for each key600/min
plans.<name>objectfeatures, limits and prices for one plan{ features: [basic, export], limits: { seats: 50 } }
entitlements.driverstringdbdb or memorydb
entitlements.defaultstringThe plan of a tenant with no other plan; must be under plansfree
entitlements.grace, entitlements.upgrade, entitlements.usagemixedPast-due grace, upgrade link, usage sources{ grace: 7d, upgrade: /settings/billing }
agents.<name>objectbudget (spend, calls), approve (risk, spend-over) and enabled{ budget: { spend: $5/day }, approve: { risk: destructive } }
permissions.modestringenforceobserve or enforceenforce
permissions.diskobjectread and write folders{ read: [./content], write: [./.cache] }
permissions.system, permissions.processeslistServer facts the app may read, and programs it may run[hostname, platform], [git]

Keys inside these blocks that the rows above name only in dotted form:

BlockKeyTypeDefaultMeaning
tenancyisolationstringsharedshared, schema or database; anything else is refused at load
tenancyresolvelist[apikey, session]Resolvers tried in order
tenancyhoststringBase domain for the subdomain resolver
tenancygloballistModels shared by all tenants (no personal fields)
tenancydatabasesmapTenant ID to a secret reference; used only with isolation: database
apikeysrolestringmemberThe role a key's principal carries
entitlementsupgradestringURL named in the fix of a plan refusal
entitlementsusagemapmeter:<name>Usage source per limit name
health.shutdowndeadlinedurationTime allowed to drain before exit; drain-delay plus 2s must stay below it (MAIN-HEALTH-0005)
health.shutdownsignalslist[SIGTERM, SIGINT]Signals that start the drain

Each spec owns its block: tenancy, apikeys, plans, entitlements, agents and permissions. See their pages under interfaces.

Shared context

Owned by spec context. Declared in every app of a context group.

KeyTypeDefaultDescriptionExample
context.groupstringRequired. The group peer apps of one owner share: lower-case letters, digits and dashesacme-learning
context.identitystringauthauth (the signed-in subject), first-party (a resettable random id) or bothauth
context.purposes.<name>.descriptionstringRequired. What the person reads when asked for consent"Use the same language in every Acme app"
context.purposes.<name>.attributes, .eventslist[]The built-in or declared attribute keys and event types the purpose may carry[locale, theme]
context.purposes.<name>.retentionduration30dHow long received context is kept; at most 90d30d
context.purposes.<name>.share-withlist[]Project ids of the mutual peer apps that receive this purpose[prj_01JD8X]
context.purposes.<name>.childrenstringguardian-consent on every purpose of a children's appguardian-consent
context.fields.attributes.<name>objectUp to 16 extra attributes (with events): type and, for a string, an enum or max-length of at most 64{ type: string, enum: [maths, science] }
context.fields.eventslist[]Extra event types, kebab-case or dotted[lesson.completed]
context.sinkslist[]Onward copies inside the app: db or events; anything else is refused[events]

End-to-end encryption

KeyTypeDefaultDescriptionExample
e2e.drivermls, localmlsThe driver behind e2e. mls is the @mainsdk/e2e-mls package (RFC 9420; not production until an external audit signs off); local is a fake for development that check refuses in production. Spec e2emls
e2e.suiteclassic, pq-hybridclassicThe ciphersuite family. pq-hybrid is refused with MAIN-E2E-0007 until spec post-quantum is in placeclassic
e2e.recoverypasskey, nonepasskeyHow a person gets their keys back on a new device: the passkey PRF extensionpasskey
e2e.fieldsobjectModel fields the app encrypts end to end, as model: [field]. check names the trade-off (MAIN-E2E-0008) when search, moderation or analytics would read one{ notes: [title, body] }
e2e.acceptlistsearch, moderation or analytics: trade-offs you accept, which silences MAIN-E2E-0008 for that feature[search]

Realtime, analytics and health

KeyTypeDefaultDescriptionExample
realtimeobjectdriver, path, channels, heartbeat, max-age, replay, buffer, max-event{ driver: broadcast, channels: [order] }
analyticsobjectto, without-consent, min-group, events, metrics, posthog. Also replaces stats{ to: [builtin], events: { … } }
apiobjectbase, openapi (serve, include, title), snapshot, idempotency.require{ base: /api, snapshot: api/openapi.json }
feedsobjectdriver (db or local), items, fanout (threshold, batch), max-following, timeline (order, max, keep), page (limit, end, pause_after), ranked, trending (window, k, min-count), realtime. A push may only keep page.end on, lower page.pause_after and max-following, and raise trending.min-count{ driver: db, items: posts, page: { pause_after: 200 } }
checkobjectai: true lets mainsdk check run its model pass (for engagement, ai.classify on opt-out copy no rule decided) through the app's ai interface; without it the pass is skipped with a note{ ai: true }
importsobjectinline-max, batch, max-rows, max-columns, json-max, suggest, sources{ batch: 500, sources: { crm: { driver: rest, every: 5m } } }
connectionsobjectstore, callback, providers (preset, client-id, scopes, hosts, step-up), and per-agent agents.<name>.connections{ providers: { github: { preset: github, scopes: [read:user] } } }
seedobjectnumber, tenants, regions, per-tenant, models, ai{ number: 42, tenants: 3 }
previewobjectdriver (neon, supabase, local), from, seed, ttl{ driver: local, ttl: 7d }
healthobjectlive, ready, critical, every, timeout, shutdown{ critical: [db], shutdown: { deadline: 25s } }
sloobjectavailability (90% to 99.999%), latency_p95, window (default 30d), exclude (route patterns) and alert.to (log, telemetry; both by default). An invalid value is MAIN-HEALTH-0005{ availability: 99.9%, latency_p95: 300ms, alert: { to: [log] } }
resourcesobjectHost pressure guard: every (default 1s, 0 turns it off), hold, pressure and shed (cpu, loop, memory thresholds), critical (never paused or shed), retry-after, max-pause, nice (0 to 19). Absent means built-in defaults. Spec resources{ every: 1s, shed: { loop: 500ms }, critical: ["POST /api/checkout/**"] }
normsobjectLearn what normal looks like: every (1m to 15m), season (hour-of-week, hour-of-day, none), tz, learn (0 to 28d), min-samples (10 or more), half-life, metrics, sensitivity (z, quantile, margin, hold), persist. Absent means off. Spec norms{ season: hour-of-week, tz: Asia/Kolkata, learn: 7d }
signalsobjectTyped signals between the parts of the app: keep (100 to 10,000), max-per-minute, key (secret:NAME, signs app-scope signals), ttl (default, max up to 24h). Absent turns signals on only when playbooks, posture, guard or norms is set. Spec signals{ keep: 1000, key: secret:MAINSDK_SIGNAL_KEY }
playbooksobjectWhat the app does when a signal arrives: mode (on, dry-run, off), use (attack, pressure, key-rotation), presets, rules (when, run, do, for, until, cooldown, rollback-if). Spec playbooks{ use: [attack, pressure] }
postureobjectDefence levels guarded, high and lockdown, each a list of steps added to the one below. Spec playbooks{ levels: { high: [...] } }
guardobjectCeilings for every reaction: max-active, max-per-hour, quarantine, max-hops, block, limit-floor, tenant-quorum. Spec reaction-safety{ max-active: 3, quarantine: 1h }
neighboursobjectHear and send host-scope signals with other apps on the machine: host (on), listen, share, priority (1 to 9), max-per-minute, ports, asks (accept, max-posture, max-for, max-per-hour). Spec neighbours{ host: on, listen: [attack.*], share: [attack.suspected] }
appsobjectThe per-machine app registry and the app link between declared peers: registry (on, off), dir, link (on, off), identity (ed25519: public key), peers (project ids, both sides must list each other), expose (action names a peer may call), ports (loopback fallback range). Spec app-registry{ link: on, identity: "ed25519:MCow…", peers: [prj_01JD8X], expose: [orders.status] }
opsobjectThe ops server an agent reaches through mainsdk ops mcp: mcp.socket (on by default in dev only), mcp.http, mcp.port, mcp.write (approve or off), mcp.socket-ports (loopback fallback range), agent (default ops), card. Invalid values are MAIN-OPS-0001. Spec ops-mcp{ mcp: { write: approve }, agent: ops }
workersobjectCPU work on worker threads: size (auto or 1 to 64), queue (default 1000), timeout (30s), memory (256MB per thread), idle (30s), env (variable names copied in, never a secret's), modules (globs of the worker modules the app may run), builtin (on or off) and pools.<name> (the same keys plus isolation: process). Invalid values are MAIN-WORKER-0009. Spec parallelism{ size: auto, modules: ["src/workers/*.worker.ts"], pools: { pdf: { size: 2, isolation: process } } }
clusterobjectOne instance per core: processes (auto or 1 to 64; 1 means no cluster), start-timeout (30s) and respawn (backoff, max, crashes, window). Spec parallelism{ processes: auto, respawn: { backoff: 1s, crashes: 5 } }
paginationobjectkey: the name of the secret that signs list cursors. Absent means cursors are signed with a per-instance key. Spec pagination{ key: PAGINATION_KEY }
appobjecturl: the public origin of the app. Used for security.txt Canonical and as the default share.origin of the local share driver (http://localhost:3000 when absent){ url: https://acme.dev }
inboundobjectBrowser-facing protection: headers, csp, csrf, cors, cookies, routes, security-txt. Signed webhook paths are exempted from CSRF automatically. Spec inbound-protection{ csp: { mode: enforce }, csrf: { exempt: [/api/webhooks/*] } }

Security events, incident controls and alerts

KeyTypeDefaultDescriptionExample
security.events.tolist[log]Where security events go: log (always on), otel (the OTLP exporter), events (events.publish("security.<type>"))[log, otel]
security.events.minstringlowLowest severity sent to otel and events: info, low, medium, high, critical. The log keeps everythinglow
security.events.regions.<region>objectWhere one region's events go: endpoint and headers (values as secret:NAME). Defaults to telemetry.otel{ endpoint: https://otel.eu.acme.dev:4318 }
security.detect.brute-forceobject{ failures: 10, window: 5m }Failed sign-ins on one account. Threshold of 2 or more{ failures: 10, window: 5m }
security.detect.stuffingobject{ accounts: 20, window: 5m }Different accounts failed from one source network (/24 for IPv4, /48 for IPv6){ accounts: 20, window: 5m }
security.detect.probingobject{ denials: 20, window: 1m }Different resources denied to one actor{ denials: 20, window: 1m }
incident.killlist[]Targets switched off: action:<name>, route:<glob>, interface:<id>, driver:<id>, agent:<name>, tool:<name>. Deployed values are usually empty; controls arrive as main.incident.* pushes[action:orders.refund]
incident.read-onlybooleanfalseRefuse writes in db, files and jobs, and unsafe requeststrue
incident.read-only-allowlist[]Deployed only: paths that may still take unsafe requests while read-only[/api/auth/*, /api/webhooks/*]
incident.sessions-afternull or RFC 3339 stringRFC 3339 instant; sessions and refresh tokens issued before it end2026-10-27T02:00:00Z
incident.tenants.<tenant>.sessions-afterstringThe same cut-off for one tenant2026-10-27T02:00:00Z
incident.expires.<entry>stringRFC 3339 instant after which one control stops applying2026-10-27T06:00:00Z
alerts.fromlistall sourcesSDK sources: health, security, resources, jobs, backup, errors; [] for the app's own alerts only[health, security]
alerts.minstringwarningLowest severity that notifies anyone: info, warning, criticalwarning
alerts.recipients.<name>listNamed group of destinations: { driver: email|slack|pagerduty|opsgenie|webhook, ... }; keys and URLs as secret:NAME[{ driver: slack, webhook: secret:SLACK_ALERTS_WEBHOOK }]
alerts.routeslistFirst match wins: match (source, severity), to (recipient names), escalate (after, to)[{ to: [oncall] }]
alerts.groupobjectby, wait, interval: one message per group, updated at most every interval{ by: [source], wait: 30s, interval: 5m }
alerts.repeatdurationRe-send an open, unacknowledged alert4h
alerts.quietobjectfrom, to, tz: warnings wait as a digest; critical alerts always go at once{ from: "22:00", to: "08:00", tz: Asia/Kolkata }
alerts.max-per-hournumberPer recipient; the rest of the hour arrives as one summary30

Bots, canaries, exfiltration, search and moderation

KeyTypeDefaultDescriptionExample
botsobjectmode (observe or enforce), doors.<name> (challenge and deny scores, path, form, disposable-email), challenge.driver (pow, turnstile, hcaptcha), pumping (per-prefix, countries), crawlers (names to verify by DNS) and block (addresses that only tighten). Spec bot-defence{ mode: enforce, doors: { sign-up: { challenge: 30, deny: 80 } } }
canariesobjectFake data that raises an alert when read: records (a count per model), keys (plant fake keys) and on-touch (alert). Spec canary{ records: { users: 2 }, keys: true, on-touch: alert }
exfiltrationobjectmode (observe or enforce), outbound (secrets, receivers by host and data label) and responses (pan, bulk, actor-window). Spec exfiltration-guard{ mode: enforce, outbound: { secrets: block } }
search.timeoutduration2sA query that takes longer fails with MAIN-SEARCH-00042s
search.max-depthnumber1000The most hits one cursor chain returns. An engine driver of typesense or meilisearch takes at most 10001000
search.indexes.<name>objectmodel, access and fields (search, weight, facet, filter, sort) for one index{ model: posts, access: posts.read }
search.typesense, search.meilisearchobjectkey (a secret: reference) and regions, a URL per region, for that engine driver{ key: secret:TYPESENSE_KEY, regions: { eu: https://eu.search.example.com } }
moderate.reasonslistspam, harassment, hate and moreThe reasons a report may give; any other reason is refused[spam, hate]
moderate.termsstringtermsThe document a statement of reasons cites as its groundterms
moderate.prescreen.classify, moderate.prescreen.hashobjectA label model with labels, hold-on and remove-on; a hash list driver with a threshold{ classify: { labels: [ok, spam], remove-on: [spam] } }
moderate.reports, moderate.appealsobjectreports.per-user and reports.per-target limits; appeals.window, how long a person may appeal{ per-user: 20/hour }
analytics.buffernumber10000Events held in memory before the oldest are dropped10000
analytics.posthogobjecthost, key and regions of a PostHog project. A region you do not serve fails the check{ host: eu.i.posthog.com, regions: [eu] }

geo

A coarse location hint from the declared platform's headers, and geocoding through network (Location). A hint is never for access.

KeyTypeDefaultDescriptionExample
geoobjectdriver platform-headersThe driver, the platform to read, the provider key, the cache and the timeout{ driver: mapbox, key: secret:MAPBOX_TOKEN }
geo.driverstringplatform-headersplatform-headers (a hint, no geocoder), mapbox, google-maps or local (the testing kit's table). An unknown value fails with MAIN-GEO-0005mapbox
geo.edgestringdeploy.target when it is vercel, cloudflare or flyThe platform whose headers geo.of reads: vercel, cloudflare, fly or cloudfront. With none declared the hint is emptycloudfront
geo.keysecret refThe provider key for mapbox and google-maps. Required for them, and only as secret:NAMEsecret:MAPBOX_TOKEN
geo.cache-ttlduration30dHow long a found place is cached per tenant; a missing address at most one hour. 0 turns the cache off7d
geo.timeoutduration2sThe longest one geocode call may take, after which the answer is null2s
verify.driverstringlocalDelivers the phone code MainSDK made: twilio-verify, bird or localtwilio-verify
verify.keysecret refone per processKeys the digest of codes, numbers and addresses; set it so every instance can check a code another sentsecret:VERIFY_KEY
verify.codeobjectttl (default and most 10m) and tries (default and most 5); a setting may only lower them, a higher one fails MAIN-VERIFY-0006{ ttl: 10m, tries: 5 }
verify.limitsobjectper-number (default 3), per-ip (default 10) and window (default 10m){ per-number: 3, per-ip: 10, window: 10m }
verify.emailobjectbase (the app origin an email link lands on; defaults to links.base) and path (default /verify/email){ base: https://app.example.com }
verify.identity.<market>stringThe identity driver for a market, or default: digilocker, onfido, persona or local{ in: digilocker, eu: onfido }
verify.require.<market>stringThe level a market requires: basic, substantial or high. A level its driver cannot reach fails the check with MAIN-VERIFY-0004{ in: substantial, eu: high }
verify.policystringprivacy-policyThe consent policy stored with identity claims when a call names noneprivacy-2026-10
verify.drivers.<id>objectThe settings of one driver; credentials must be secret: references{ onfido: { token: secret:ONFIDO_TOKEN, workflow: wf_123, region: eu } }
updates.driverstringlocalThe store the app's client talks to: expo-updates, google-play, app-store, tauri (1.1) or local; the server needs noneexpo-updates
updates.min-versionversionThe lowest client version the server serves (426 MAIN-UPDATE-0001 below it). A push of main.updates.min-version may only raise it. Above the stable release fails MAIN-UPDATE-00042.4.0
updates.buildstringThe build id of this release, published at /_mainsdk/updates/build and over the build stream2026.10.27-1
updates.streamduration2mHow long one build stream stays open before the browser reconnects2m
updates.strictbooleanfalseAlso refuse a request that names no app versionfalse
updates.exemptlist[]Paths a blocked client may still reach, next to /_mainsdk/auth/signout[/api/me/export]
updates.channelsobjectstable and beta, each { version, rollout, markets }: the release offered, the percent of users (default 100) and the percent per market. An unknown channel or a percent outside 0 to 100 fails MAIN-UPDATE-0003{ stable: { version: 2.6.0, rollout: 50, markets: { IN: 100 } } }
updates.api.deprecatedlist[]API path prefixes on their way out: path, sunset (a date), optional since and successor. They answer Deprecation, Sunset and Link, then 410 MAIN-API-0006[{ path: /api/v1, sunset: 2027-06-30, successor: /api/v2 }]

Interfaces without a spec

These blocks come from the catalog. The interfaces have no spec yet, so the keys may change when it lands.

KeyDrivers in the catalogExample
search.drivertypesense, meilisearch, algolia, pgvectortypesense
meter.driverstripe-meters, lagostripe-meters
links.drivergetqrgetqr
media.drivergetqr, cloudinary, muxgetqr
pdf.driverbuiltin, docraptor, documensobuiltin
crypto.drivercloud-kms, vault-transit, libsodiumlibsodium
limits.driverbuiltin, upstash, arcjetbuiltin
status.driverbuiltin, provider (the status page; probes are under health)builtin
i18n.driver, i18n.localesfiles, tolgee, crowdin, lokalise, ai-draft{ driver: files, locales: [en-IN, hi-IN, ta-IN] }
updates.driverexpo-updates, app-store, google-play, tauriexpo-updates
flags.driverconfig, openfeature (planned)config

protect.driver is local (default), taps-node, taps-hosted or sovedocs (milestone 0.4). The other protect keys are url, share-domain, key, mode, suite, renew, threshold, mcp and environment; see Protected files.

documents and sandbox are in the table below, with the interfaces that read them.

KeyTypeDefaultDescriptionExample
documentsobjectdriver builtindriver, ocr (driver, region-endpoints), render (driver, fonts) and limits (max-size, max-pages, inline-pages, max-zip-ratio). Spec documents{ driver: builtin, limits: { max-pages: 500 } }
sandboxobjectdriver localdriver (vercel-sandbox, e2b, local), limits, network (hosts, inside network.allow), secrets (the only secrets code may get) and local (programs). Spec sandbox{ driver: vercel-sandbox, network: [pypi.org] }
sandbox.pricestringdriver estimatePrice per second of a box, to override a driver's estimate. Spec sandbox$0.0001
sandbox.vercel.tokenstringsecret:VERCEL_TOKENThe Vercel Sandbox API token, as a secret referencesecret:VERCEL_TOKEN
sandbox.vercel.projectstringThe Vercel project that owns the sandboxesprj_123
sandbox.vercel.teamstringThe Vercel team, when the project belongs to oneteam_123
sandbox.vercel.runtimestringThe Vercel Sandbox runtime imagenode22
sandbox.e2b.keystringsecret:E2B_API_KEYThe E2B API key, as a secret referencesecret:E2B_API_KEY
sandbox.e2b.templatestringThe E2B template the sandboxes start frombase
i18n.cookiestringlocaleThe cookie that holds the reader's explicit locale choice. Spec i18nlocale
i18n.dirstringlocalesThe folder of locale files and the glossarylocales

licence

App licensing (spec licence, built); see Licensing and the licence interface.

KeyTypeDefaultDescriptionExample
licence.driverstringofflineoffline (files and tokens only), http (offline verify, background refresh) or local (mainsdk dev and tests)http
licence.productstringrequiredMust equal the licence's productacme-desktop
licence.rootslistrequiredPinned root public keys, 1 to 4, ed25519: and base64url of 32 bytes["ed25519:CvBK…"]
licence.filepath./main.licenceThe app licence read at start, as a JSON container or the compact mlic1. form; not with token./main.licence
licence.tokensecret refThe app licence as a compact token; not with filesecret:MAINSDK_LICENCE
licence.trustpathCA certificates and revocation lists as a trust bundle./main.licence-trust
licence.featureslist[]Feature names that need a licence[sso, audit-export]
licence.urlURLhttp driver only, and required there: the refresh endpoint base, https (or localhost); its host is added to the network allowlisthttps://api.mainsdk.dev
licence.refreshduration6hhttp driver: refresh interval, 5m to 7d, jittered by up to 10%6h
licence.skewduration5mClock skew tolerated, 0 to 1h5m
licence.overstringenforceenforce or report when a licensed limit is passedreport
licence.machinestringosMachine id source: os (/etc/machine-id, Linux) or env (MAINSDK_MACHINE_ID)env

compute

Remote GPU and long jobs on the provider you chose (spec compute, built); see the compute interface. A target with no price or timeout stops the app from starting (MAIN-COMPUTE-0013).

KeyTypeDefaultDescriptionExample
compute.heartbeatduration60sNo sign of life for three of these and the job is lost; at least 1s60s
compute.pollduration15sHow often the job workflow asks the provider; at least 1s15s
compute.results.labelstringpersonalThe data label every result file carriespersonal
compute.results.max-sizesize2GBThe largest result file kept2GB
compute.results.typeslistaudio/*, image/*, video/*, application/jsonContent types a result may have["image/*"]
compute.limits.per-tenantmoney per monthSpend one tenant may reach in a month$50/month
compute.limits.totalmoney per monthSpend all tenants may reach in a month$500/month
compute.targets.<name>.driverstringrequiredmodal, replicate, runpod or localreplicate
compute.targets.<name>.regionstringus, eu or in; required unless the driver is localus
compute.targets.<name>.timeoutdurationrequiredLongest a job may run; 1s to 24h30m
compute.targets.<name>.pricepricerequiredWorst-case price per second, such as $0.0014/s$0.0014/s
compute.targets.<name>.max-costmoneyThe most one job may cost; checked before submit$2
compute.targets.<name>.retriesnumber0Retries after a provider fault; 0 to 50
compute.targets.<name>.heartbeatdurationcompute.heartbeatHeartbeat for this target120s
compute.targets.<name>.model, app, function, gpu, endpointstringWhat the provider runs, by drivernightmareai/real-esrgan:<64 hex>
compute.targets.<name>.program, argsstring, listlocal driver: the program (also under permissions.processes) and its argumentsupscale

sync

Spec sync. The server half reads this section; a device passes the same table versions to sync.open.

KeyTypeDefaultDescriptionExample
sync.driverstringlocalWhere the server keeps rows: local (in memory)local
sync.tablesobjectnoneEach synced table with its schema version (whole number, 1 or more) and its conflicts rule: last-writer-wins, server-wins or action:<name>{ notes: { version: 1, conflicts: last-writer-wins } }
sync.quotaobject{ rows: 100000, bytes: 50MB, batch: 500 }What one tenant may store (rows, bytes) and the most changes in one push (batch){ rows: 1000, bytes: 10MB }
sync.max-clock-driftduration5mHow far ahead of the server clock a stamp may be before the row is refused2m
sync.pagenumber500Rows in one pull500

Names in the source

main.yaml keys are written in kebab-case. The runtime reads each section into a config object whose fields are camelCase and carry the unit in milliseconds where a duration is meant. This table maps the field names to the keys you write.

Field read by the runtimeKey you writeBlock
maxActive, maxPerHour, maxHops, tenantQuorum, limitFloormax-active, max-per-hour, max-hops, tenant-quorum, limit-floorguard
quarantineMsquarantineguard
approveTimeoutMsapprove-timeoutplaybooks
alertToalert.toslo
latencyMslatency_p95slo
drainDelayshutdown.drain-delayhealth
retryAfter, maxPauseretry-after, max-pauseresources
minSamplesmin-samplesnorms
windowMswindowadvisor
maxPerMinutemax-per-minutesignals, neighbours
ttlDefault, ttlMaxttl.default, ttl.maxsignals
graceMsgraceentitlements
maxTtlMsmax-ttlapikeys
limitlimits.defaultapikeys
eraseWaitMserase.waittenancy
suspendedAllow, systemAllowsuspended.allow, system.allowtenancy
refreshMs, skewMsrefresh, skewlicence
fromToolsfrom-toolsmemory
embedembedmemory
imageimagemedia
timeoutMs, maxDepth, indexestimeout, max-depth, indexessearch (today you write timeout, not the millisecond field)
typesense, meilisearchtypesense, meilisearchsearch
classify, hashprescreen.classify, prescreen.hashmoderate
reports, appeals, reasons, termsreports, appeals, reasons, termsmoderate
buffer, posthogbuffer, posthoganalytics

Keys the licence block reads by one plain name: roots (pinned root keys), trust (trust bundle path) and machine (machine id source). Their rows are under licence. above and in the licence interface example.

deps

Dependency checks (spec dependency-check); see Dependency checks. Every key has a default, so the section is optional.

KeyTypeDefaultDescriptionExample
deps.min-ageduration7dA direct dependency first published more recently is MAIN-DEPS-00027d
deps.licences.denylist[AGPL-1.0-or-later, AGPL-3.0-only, AGPL-3.0-or-later, SSPL-1.0]SPDX ids that fail with MAIN-DEPS-0005; a trailing * matches a family[AGPL-*]
deps.licences.allowlist[]When not empty, any licence outside it is reported[MIT, Apache-2.0, ISC]
deps.licences.unknownstringwarnwarn or fail for a missing or non-SPDX licencefail
deps.advisories.fail-onstringhighcritical, high, moderate, low or none; a lower severity is a warningcritical
deps.advisories.ignorelist[]Entries with id, a future until and a reason; an expired or incomplete one is MAIN-DEPS-0008[{ id: GHSA-xxxx-xxxx-xxxx, until: 2027-01-31, reason: "Only used in tests" }]
deps.scripts.allowlist[]Packages allowed to run install scripts[esbuild, sharp]
deps.offlinestringinfoinfo or fail when advisories and ages couldn't be checked (MAIN-DEPS-0007)fail

seal

Build seal and boot check (spec seal). Every key has a default, so the section is optional.

KeyTypeDefaultDescriptionExample
seal.modestringwarnoff, warn or refuse: what the boot check does when the build does not match its sealrefuse
seal.includelistRoots hashed into the build hash, relative to the app folder; default ., which leaves out node_modules/. main.yaml and its overlays are always hashed[.next/standalone, public]
seal.excludelist[]Globs left out of the build hash["**/*.map"]
seal.signerslist[]Who may sign this app: an entry with key (ed25519: and a base64 public key) or sigstore (issuer and identity)[{ key: "ed25519:MCowBQYDK2VwAyEA" }]
seal.publish.sbombooleanfalseAlso serve /.well-known/mainsdk.sbom.cdx.jsontrue

Later phases

KeyPhaseDescriptionExample
connectors3 (1.2)GRC, privacy, vulnerability and ticket tools{ tickets: { driver: github } }
markets3Markets the app must comply in[in, eu, us-ca]
residency (by data kind)3Residency by data kind, beyond the default above{ personal: in, default: eu }
enforcement3Enforcement mode for market rules{ mode: warn }
sovereignty5In-country data, keys, operators and access{ in: { keys: in-country } }

markets, residency, enforcement and sovereignty come from the product requirements; their specs follow. The requirements' license key is now the licence section above (spec licence, milestone 0.4).

Workspace and org files

main.workspace.yaml (monorepo support, phase 2, 0.7) adds packages: auto and policies, and can set providers and overlays for every app. main.org.yaml holds platform-owned defaults with the same keys. Where main.org.yaml comes from for a repo outside the org's monorepo is still open.

main.workspace.yaml
schema: 1
packages: auto
policies: [policies/]
providers: { email: resend }
overlays: { order: [market, segment, env] }

deploy and jobs.schedules

Deploy files and serverless schedules (Deploy config). Set deploy.target per environment in main.env.<env>.yaml.

KeyTypeDefaultDescriptionExample
deployobjectThe target, regions, port and tick settings mainsdk deploy config reads{ target: vercel }
deploy.targetstringvercel, cloudflare, fly, docker or node. Selects the files deploy config writes and whether schedules run through the tick routevercel
deploy.regionslistderived from the data regionsPlatform regions. Each must sit inside the data regions of db.regions, files.regions, or MAIN-DEPLOY-0002[fra1]
deploy.portinteger3000The port of the app on fly, docker and node8080
deploy.tick.secretsecret refsecret:CRON_SECRETThe bearer the platform's cron sends to POST /_mainsdk/jobs/tick; serverless targets onlysecret:CRON_SECRET
deploy.tick.lateduration10mA tick later than this after its slot still runs and logs MAIN-DEPLOY-000810m
deploy.vercel.planstringprohobby, pro or enterprise. Sets the cron and region limits checked with MAIN-DEPLOY-0004hobby
deploy.docker.basestringnode:24-slimThe base image of the Dockerfile's runtime stagenode:24-slim
jobs.schedulesobjectStatic schedules as name to five-field cron expression (UTC). Literal jobs.schedule calls found by mainsdk discover are added{ nightly-report: "0 3 * * *" }
jobs.schedules.<name>stringThe cron expression of one schedule; the name is also the job name the tick queues"0 3 * * *"

Connected devices

KeyTypeDefaultDescriptionExample
deviceobjectDeclares connected devices; with no device section nothing is mounted, stored or timed, even when mainsdk/device is imported{ enrol: { proofs: [token] } }
device.driverlocal or tapslocalIdentity and attestation driver; taps comes from @mainsdk/device-tapslocal
device.pathstring/mainsdk/deviceWhere the adapters mount the device routes/mainsdk/device
device.audiencestringthe project idThe aud every device-signed payload must carryacme-fleet
device.authoritylistOne or two secret:NAME references to PKCS#8 Ed25519 authority keys; a literal key is MAIN-DEVICE-0003[secret:MAINSDK_DEVICE_KEY]
device.maxnumber10000Devices per tenant that are not revoked; 1 to 1,000,00010000
device.concurrencynumber64Device requests in flight at once, so devices never starve the app's users; 1 to 100,00064
device.basesobjectthe request's own addressDevice address of each region (us, eu, in) the credential names{ eu: "https://eu.acme.dev/mainsdk/device" }
device.enrol.proofslist[token, code]Enrolment proofs allowed: token, code, oidc, attested[token, oidc]
device.enrol.token-ttlduration24hDefault life of an enrolment token; 5m to 30d24h
device.enrol.oidclistWorkload issuers: each entry needs issuer (https), audience and tenant, and may name a subject glob[{ issuer: "https://token.actions.githubusercontent.com", audience: acme, subject: "repo:acme/*", tenant: tnt_acme }]
device.credential.ttlduration24hLife of a device credential; 1h to 30d24h
device.credential.renew-graceduration30dHow long after expiry a device may still renew with its key; 0 or 1ms to 90d30d
device.attest.requirenone, software, hardware, environmentnoneThe least attestation level an enrolment or renewal needs; hardware and environment need driver: tapssoftware
device.attest.everyduration24hHow often a device presents fresh evidence; 1h to 30d24h
device.attest.graceduration72hHow long past every a level holds before it drops to none; 0 or 1ms to 30d72h
device.attest.formatslistEvidence formats accepted (taps only)[apple-app-attest]
device.attest.min-security-levelL1, L2, L3The weakest security level accepted (taps only)L2
device.attest.rootsobjectRoot certificate files per format (taps only){ android-key-attestation: [roots/android.pem] }
device.attest.buildsstringA file of accepted builds (taps only)builds.json
device.attest.allow-self-signed-bootbooleanfalseAccept a self-signed boot chain (taps only)false
device.attest.apple-app-idslistApple app ids accepted (taps only)[TEAMID.com.acme.agent]
device.heartbeat.everyduration60sThe heartbeat interval devices are asked to keep; 15s to 1h; doubled under pressure60s
device.heartbeat.late-afternumber2Missed intervals before a device is late; 2 to 102
device.heartbeat.silent-afternumber5Missed intervals before a device is silent; 3 to 100 and above late-after5
device.telemetry.streamsobjectDeclared streams by dotted lower-case name; each takes max-record, personal and redact{ usage.sample: { redact: [user.email] } }
device.telemetry.max-recordsize64KBLargest record; 1KB to 256KB64KB
device.telemetry.max-batchsize1MBLargest batch; 64KB to 4MB1MB
device.telemetry.ratestring60/minBatches per device per s, min or h60/min
device.telemetry.dailysizeAccepted bytes per tenant per UTC day; further batches get 4295GB

Device commands and device policy

KeyTypeDefaultDescriptionExample
device.commands.ttlduration15mDefault expiry of a command; from 1m to max-ttl15m
device.commands.max-ttlduration7dThe longest expiry a create may ask for7d
device.commands.max-targetsnumber100Devices one create may target without confirm: <count>; 1 to 100,000100
device.commands.reaction-maxnumber50Devices one playbook step may target; the rest are refused with MAIN-GUARD-000350
device.policy.rolloutobject5%, 25%, 100% an hour apart, back out at 5% failedstages, hold (5m to 7d) and rollback-if (failed 1% to 50%) for every publish{ stages: ["5%", "25%", "100%"], hold: 1h, rollback-if: { failed: 5% } }
device.policy.approvenumber0People who must approve a publish: 0, 1 or 21
device.policy.tightenobjectPaths an overlay may change and which way is tighter: lower, higher, true, false, subset, superset{ report.interval: higher }

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet