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.
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.
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:
# 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.
durationis a number and a unit:30s,10m,24h,7d,13m(months, in retention),7y. Asecret refis a string matchingsecret:[A-Z0-9_]+. A literal value in a field marked secret fails withMAIN-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 withMAIN-CONFIG-0010. - Resolving.
resolveConfig(dir, { market, segment, env, target, … }, { today })(frommainsdk/config-resolve) returns{ config, sources, exceptions, layers }: maps merge by key, lists replace, overlays apply inoverlays.order(later wins), andsourcesgives the file and line of every leaf by JSON pointer.main.org.yamlandmain.workspace.yamlare found in the app folder or the nearest folder above it. An exception counts when it has arule(the path, such asnetwork.allow), areason, anownerand anexpiresdate 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
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
schema | integer | required | Config contract version. Anything but 1 fails with MAIN-CONFIG-0001 | 1 |
project | string | written by init | Project ID, a UUIDv7 shown as prj_…. An identifier, never a credential | prj_01JC8Q4W7ZK3M5N8P2R6T9V0XY |
baseline | string | Baseline generation the app meets | main27 | |
name | string | app | The app's name, used as the service name in telemetry | checkout |
users | object | audience and regions together | { audience: business, regions: [us, eu] } | |
users.audience | string | Who uses the app | business | |
users.regions | list of us, eu, in | Where your users are | [us, eu] | |
residency | us, eu, in, or { default, by-market } | us | The 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 } |
standards | list | Standards to map evidence to | [soc2, gdpr] | |
features | list | Capabilities mainsdk sync sets up | [payments, email] | |
industry | string or list | Industry pack overlays (phase 3, 1.2) | [payments, healthcare] | |
markets | list | Markets the app serves; packs lock personal data to their regions and vault checks its countries cover them | [in, eu, us-ca] | |
compliance.pages | list | Payment pages the PCI DSS script inventory and page integrity controls read (spec industry-packs) | [checkout-page.ts] | |
compliance.scripts | list | Approved scripts on those pages, each with host, reason and owner | [{ host: js.stripe.com, reason: Hosted fields, owner: payments }] | |
compliance.providers | list | Third-party providers in the register the packs check, by name | [stripe, sendgrid] | |
compliance.change-control | boolean | false | Every change is reviewed before release; the packs that need it check this | true |
version | string | 0.0.0 | The app's own release version, reported by mainsdk info and in telemetry resources | 1.4.2 |
owners | map of glob to team | after CODEOWNERS | Owning 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
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
targets.web.framework | string | Web framework | nextjs | |
targets.api.runtime | string | API runtime | node | |
targets.<name>.platform | string | Where 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 process | vercel | |
targets.mobile | object | Expo target: framework, platforms, bundleId (phase 3, 1.1) | { framework: expo, platforms: [ios, android] } |
overlays and exceptions
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
overlays.order | list | [market, segment, env], written by init | Order overlays apply in; never guessed at run time | [market, segment, env] |
exceptions[].rule | string | required | The rule or code the exception covers | MAIN-NET-0004 |
exceptions[].reason | string | required | Why | Partner gateway upgrade booked for November |
exceptions[].owner | string | required | Who owns it | "@pinecart/platform" |
exceptions[].approver | string | Who approved it, used by assure | ravi@pinecart.dev | |
exceptions[].expires | date | required | After this date the exception fails like none | 2026-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
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
providers.<interface> | string | Driver per interface, in short form | { email: resend } | |
fallback.<interface> | list | Drivers to fail over to, in order | { email: [resend, ses] } |
Identity and access
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
auth.driver | string | catalog default github | github, clerk, auth0, better-auth, workos, supabase, local | github |
auth.methods | list | passkeys | Sign-in methods; passwords are off once passkeys are on | [passkeys, whatsapp, sso] |
auth.whatsapp.driver | string | WhatsApp code sender: whatsapp-cloud, bird, msg91 | whatsapp-cloud | |
auth.sso | object | protocols, scim | { protocols: [saml, oidc], scim: true } | |
auth.session | object | ttl, refresh, bind | { ttl: 15m, refresh: 7d, bind: dbsc } | |
auth.routes.base | path | /_mainsdk/auth | Where every adapter mounts the browser sign-in routes; the client's base must match | /_mainsdk/auth |
auth.recovery.wait | duration | 24h | Waiting period for recovery without a second factor | 24h |
auth.admin | object | Admin roles and their sign-in rule | { roles: [admin, support], require: phishing-resistant } | |
auth.qr, auth.push, auth.recover | object | App-to-desktop QR, push approval, device recovery (phase 3, 1.1) | { numberMatch: true, fallback: whatsapp } | |
access.driver | string | cedar | cedar, opa, openfga, spicedb, cerbos, permit, oso, avp | openfga |
access.models | list | rbac, abac, rebac (ReBAC needs a driver that declares it) | [rbac, abac] | |
access.when | object | An action to the named rules that must also hold; a rule that is not registered denies | { orders.refund: [limit] } | |
access.relations | object | editor, viewer | A relation name to the actions it grants on the resource | { editor: [read, write, update] } |
access.mode | string | enforce | enforce, warn or observe: what a deny does | enforce |
access.fresh-after-write | boolean | Read your own writes in the same request | true | |
access.unreachable | object | Fail closed, with a short cache | { deny: true, cache: 30s } | |
policy.engine | string | cedar | cedar or opa | cedar |
policy.placement | string | server | server, edge or browser | server |
policy.browser.local | boolean | false | Lazy-load the TypeScript Cedar engine in the browser | true |
policy.dir | path | policies/ | Where policy files live | policies/ |
policy.mode | string | new policies start in observe | observe, warn or enforce | enforce |
policy.opa.bundle | path | Compiled 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
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
data.store | string | Store kind | postgres | |
data.residency | us, eu, in, or object | Region per market: { default, by-market } | { default: eu, by-market: { in: in } } | |
db.driver | string | catalog default postgres | postgres, neon, supabase, sqlite (local only) | neon |
db.regions | map of region to secret ref | One store per region | { us: secret:DB_US, eu: secret:DB_EU } | |
db.fields.<model.field>.label | string | personal, sensitive, child, phi, pan (refused). An unlabelled personal field fails MAIN-DATA-0021 | personal | |
db.fields.<model.field>.encrypt | boolean | Envelope encryption through keys | true | |
db.fields.<model.field>.vault | boolean | Keep the value in a residency vault (phase 3, 1.2) | true | |
db.path | string | .mainsdk/db.sqlite | The SQLite file for the local driver | .mainsdk/db.sqlite |
db.url | secret ref | secret:DATABASE_URL | One store for every region | secret: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>.trash | duration | Keep deleted records this long so restore works; capped by the label's retention | 30d | |
db.models.<model>.versions | boolean | Row versions: updates and deletes must say which version they saw | true | |
db.models.<model>.unique | list | Unique indexes that skip trashed rows | [number] | |
db.timeout.request, db.timeout.job | duration | 5s, 60s | Statement timeout in a request (30s at most) and in a job | 3s |
db.pool.max, db.pool.acquire, db.pool.idle | number, duration | 10 (1 serverless), 2s, 30s | Connections per store; max over 100 fails MAIN-DATA-0056 | { max: 4 } |
db.slow, db.repeat | duration, number | 200ms, 10 | Log a slower query, or one query shape run more often in one request | 100ms |
db.migrations.tool | string | found from the project | prisma, drizzle or sql | prisma |
db.migrations.path, .base | string | tool default, origin/develop | Migrations folder, and the ref that decides which migrations are new | prisma/migrations |
db.migrations.lock-timeout, .retries | duration, number | 5s, 5 | How long a statement waits for a lock, and attempts after a timeout | 1s |
db.migrations.allow | list | Exceptions to a migration rule, each { file, rule, reason, expires } | ||
kv.driver | string | local | local or redis (upstash, vercel-runtime-cache, cloudflare-kv are planned) | redis |
kv.file | path | local only: keep values across restarts | .mainsdk/kv.json | |
kv.redis.url, kv.redis.prefix | secret ref, string | secret:REDIS_URL, mainsdk:<project> | The Redis URL and a prefix added to every key | { url: secret:REDIS_URL, prefix: app } |
cache.driver | string | kv when kv is set, else memory | kv (the kv store with an in-process tier in front) or memory (in-process only; each instance has its own entries) | kv |
cache.timeout | duration | 50ms | A store call slower than this is skipped and your function runs instead | 50ms |
cache.max-value | size | 256KB | The largest value in bytes of its JSON; a larger one is not stored (MAIN-CACHE-0003) | 256KB |
cache.memory.max-entries, cache.memory.max-ttl | number, duration | 1000, 30s | The size of the in-process tier and the longest an entry lives in it | { max-entries: 1000, max-ttl: 30s } |
keys.driver | string | catalog default aws-kms | aws-kms, gcp-kms, azure-keyvault, vault-transit, local; pkcs11, kmip, aws-cloudhsm, azure-hsm, gcp-hsm in phase 3 | aws-kms |
keys.key | string or map of region | Wrapping key per region | { eu: arn:aws:kms:eu-west-1:… } | |
keys.per-user | boolean | One key per person, so deleting it erases their data | true | |
keys.rotate | duration | Rotation period | 365d | |
keys.module | path | pkcs11 driver: the vendor PKCS#11 library | /opt/hsm/libpkcs11.so | |
keys.slot | integer | 0 | pkcs11 driver: the HSM slot | 0 |
keys.pin | secret ref | pkcs11 driver: the slot PIN; it must be a secret: reference | secret:HSM_PIN | |
keys.fips | object | The 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.fips | boolean | false | FIPS mode: unapproved algorithms fail (MAIN-CRYPTO-0011); needs crypto.module | true |
crypto.module | object | The 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-ids | object | Wallets 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> | duration | Retention 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-within | duration | 30d | How long drivers have to confirm erasure before privacy.pending() marks it overdue | 30d |
privacy.erase.retries | number | 3 | How many times each driver's erase hook is called, 1 to 10 | 3 |
privacy.erase.retry-delay | duration | 1s | The wait before the first retry; it doubles each time. Here m is minutes | 1s |
privacy.consent.tcf.<purpose> | list of numbers | analytics: [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] } |
vault | object | Residency vault driver and route (phase 3, 1.2) | { driver: incountry, route: { by: user.country } } | |
vault.countries | list | Countries the vault driver can store in; check compares them with markets | [in, ae, sa] | |
vault.locations | map | The vault location of each country | { ae: loc-uae } | |
backup | object | rpo, rto, keep, drill (phase 3, 1.2) | { rpo: 15m, rto: 1h, keep: 35d } | |
backup.driver | string | Module whose default export is the app's BackupDriver, which mainsdk drill restore calls | ./backup.ts | |
backup.provider | object | What 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
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
secrets.driver | string | catalog default vault | local, env, aws-secrets-manager, gcp-secret-manager, azure-key-vault, vault, doppler, infisical | vault |
secrets.vault | object | address, auth (prefer workload-identity) | { address: https://vault.pinecart.internal, auth: workload-identity } | |
config.driver | string | memory, files, redis; postgres is planned | redis | |
config.schema | path | JSON Schema the settings are typed against | ./config.schema.json | |
config.refresh | duration | 30s | Poll interval; with a change stream, a reconciling poll every ten times this | 30s |
config.values | map | Deployed defaults; the store's value wins | { checkout.currency: INR } | |
config.values.<key> | any | One deployed default, by its dotted key; the value is any JSON type the key's schema allows | 20 | |
config.files | object | { dir: config/ } | dir: the folder the files driver reads | { dir: config/ } |
config.redis | object | url (a secret: reference) and prefix | { url: secret:REDIS_URL } | |
network.allow | list | IPs, 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.default | string | deny | What happens to undeclared hosts | deny |
network.mode | string | observe, warn or enforce | enforce | |
network.http | object | timeout: 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.listen | list | none | What 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.udp | object | off | broadcast (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.tcp | object | timeout: 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
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
email.driver | string | resend when email.key is set, else local | resend, ses or local. Postmark, Bird and SMTP are not built. local in production stops the app with MAIN-EMAIL-0008 | resend |
email.key | secret ref | The provider key. For ses it is the secret access key, used with email.access-key-id; without both, SES uses the runtime role | secret:RESEND_API_KEY | |
email.from | string | MainSDK Dev <dev@mainsdk.invalid> | The sender address, Name <address> | "Shop <shop@example.com>" |
email.reply-to | string | The reply-to address | support@example.com | |
email.tracking | object | off | Open and click tracking; must be turned on explicitly | { opens: false, clicks: false } |
email.tracking.opens | boolean | false | Keeps open-tracking pixels in the HTML when true; stripped otherwise | false |
email.tracking.clicks | boolean | false | Allows link tracking when true | false |
email.unsubscribe-url | string | The public HTTPS URL of your unsubscribe route, which email.unsubscribe.handle serves. email.unsubscribe.create needs it | https://app.example.com/unsubscribe | |
email.webhook | string | The name of the webhooks.inbound entry that receives the provider's bounce and complaint events | resend-events | |
email.limits | object | none | Send caps per tenant: per-minute, per-day, tenants | { per-minute: 60, per-day: 5000 } |
email.limits.per-minute | integer | none | Sends allowed per minute; more fails with MAIN-EMAIL-0005 | 60 |
email.limits.per-day | integer | none | Sends allowed per day | 5000 |
email.limits.tenants.<tenant>.per-minute | integer | Per-minute cap for one tenant, in place of the default | 10 | |
email.limits.tenants.<tenant>.per-day | integer | Per-day cap for one tenant | 500 | |
email.senders.<tenant>.from | string | The sender address for one tenant | "Acme <hello@acme.example>" | |
email.senders.<tenant>.reply-to | string | The reply-to address for one tenant | help@acme.example | |
email.fallback | list | none | Drivers 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.region | string | AWS_REGION | ses only: the region of your verified identity. Required for ses when AWS_REGION is not set | eu-west-1 |
email.access-key-id | string | ses only: the access key id; email.key holds its secret. Leave both out on AWS compute to use the role | AKIAEXAMPLE | |
email.configuration-set | string | ses only: the configuration set that publishes events | mainsdk-events | |
email.suppression-key | secret ref | built from the project name | The secret that keys the hash of each address in the suppression list. Set it so the list survives a project rename | secret:EMAIL_SUPPRESSION_KEY |
email.templates.<name>.subject | string | The subject, with {{name}} placeholders | "Receipt {{orderId}}" | |
email.templates.<name>.text | string | The plain-text body. A template needs text or html | "Thanks for order {{orderId}}." | |
email.templates.<name>.html | string | The HTML body; values are escaped | "<p>Thanks for order {{orderId}}.</p>" | |
notify.channels | list | [email] | Order channels are tried in, filtered by preferences: push, whatsapp, sms, email | [push, whatsapp, sms, email] |
notify.sms | string | none | The SMS driver: twilio, msg91 or local. local in production stops the app with MAIN-NOTIFY-0011 | msg91 |
notify.whatsapp | string | none | The WhatsApp driver: whatsapp-cloud, bird, msg91 or local. local in production stops the app with MAIN-NOTIFY-0011 | whatsapp-cloud |
notify.push | object | Push drivers (phase 3, 1.1) | { drivers: [fcm, apns], via: expo } | |
notify.quiet | object | none | Default quiet hours: from, to, tz | { from: "22:00", to: "08:00", tz: Asia/Kolkata } |
notify.quiet.from | string | Start of quiet hours, HH:MM on a 24-hour clock | "22:00" | |
notify.quiet.to | string | End of quiet hours, HH:MM | "08:00" | |
notify.quiet.tz | string | UTC | IANA time zone for people with no zone of their own | Asia/Kolkata |
notify.rollout | object | stages 1%, 10%, 100% | Staged sends that pause on spikes: stages, pause-if, settle | { stages: ["1%", "10%", "100%"] } |
notify.rollout.stages | list | ["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-if | object | bounces 2%, complaints 0.1%, unsubscribes 1% | Thresholds that hold the next stage: bounces, complaints, unsubscribes, errors | { bounces: "2%" } |
notify.rollout.pause-if.bounces | string | "2%" | Pause when this share of the stage's emails bounced | "2%" |
notify.rollout.pause-if.complaints | string | "0.1%" | Pause on this share of complaints | "0.1%" |
notify.rollout.pause-if.unsubscribes | string | "1%" | Pause on this share of unsubscribes | "1%" |
notify.rollout.pause-if.errors | string | none | Pause on this share of failed sends | "5%" |
notify.rollout.settle | duration | 0 | How long to wait after a stage for bounces to arrive before judging it | 10m |
notify.marketing | list | [marketing] | Categories whose email goes as marketing, with an unsubscribe link | [marketing, news] |
notify.templates.<name>.sms.text | string | The SMS text, with {{name}} placeholders. Twilio sends it; MSG91 needs template instead | "Order {{order}} shipped" | |
notify.templates.<name>.sms.dlt-template-id | string | The DLT-registered template id. SMS to a +91 number is skipped without it (MAIN-NOTIFY-0005) | "1007000000000001" | |
notify.templates.<name>.sms.template | string | The provider's own template id, such as an MSG91 flow id | 64f0c0ffee | |
notify.templates.<name>.sms.vars | list | The data keys, in order, passed as the provider template's variables | [order] | |
notify.templates.<name>.whatsapp.name | string | The provider-approved WhatsApp template name | order_shipped | |
notify.templates.<name>.whatsapp.language | string | en | The template language code | en |
notify.templates.<name>.whatsapp.params | list | The data keys, in order, passed as the template's body variables | [order] | |
notify.templates.<name>.email | string | the notification's own name | The email template to use on the email channel | order-shipped |
notify.limits.sms-per-day | integer | none | SMS sends allowed per day, to bound cost; more is skipped with MAIN-NOTIFY-0010 | 3 |
notify.limits.whatsapp-per-day | integer | none | WhatsApp sends allowed per day | 5 |
notify.twilio.account | string | The Twilio account SID | ACxxxxxxxx | |
notify.twilio.user | string | An API key SID; with it key is that key's secret, without it key is the auth token | SKxxxxxxxx | |
notify.twilio.key | secret ref | secret:TWILIO_AUTH_TOKEN | The Twilio secret | secret:TWILIO_AUTH_TOKEN |
notify.twilio.from | string | The Twilio number to send from. Set this or messaging-service | "+14155550100" | |
notify.twilio.messaging-service | string | A Messaging Service SID, in place of from | MGxxxxxxxx | |
notify.msg91.key | secret ref | secret:MSG91_AUTH_KEY | The MSG91 auth key, for SMS and WhatsApp | secret:MSG91_AUTH_KEY |
notify.msg91.whatsapp.number | string | The integrated WhatsApp number | "919876500000" | |
notify.msg91.whatsapp.namespace | string | The WhatsApp template namespace | abc_namespace | |
notify.whatsapp-cloud.phone-number-id | string | The WhatsApp Business phone number id | "1234567890" | |
notify.whatsapp-cloud.token | secret ref | secret:WHATSAPP_TOKEN | The access token of a system user | secret:WHATSAPP_TOKEN |
notify.whatsapp-cloud.version | string | v21.0 | The Graph API version | v21.0 |
notify.bird.workspace | string | The Bird workspace id | ws_123 | |
notify.bird.channel | string | The Bird WhatsApp channel id | ch_123 | |
notify.bird.key | secret ref | secret:BIRD_ACCESS_KEY | The Bird access key | secret:BIRD_ACCESS_KEY |
notify.bird.project | string | The template project id | proj_123 | |
notify.bird.version | string | The template version | latest | |
notify.engagement | object | Engagement 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.driver | string | catalog default stripe | Payment driver; a market overlay changes it | stripe |
payments.methods | list | Payment methods | [upi, cards] | |
pay.<driver> | object | key and webhook secret refs, methods | { key: secret:STRIPE_SECRET_KEY, webhook: secret:STRIPE_WEBHOOK_SECRET } | |
files | object | driver, 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-id | string | s3 only: the access key identifier (not a secret). Set it with files.key; with neither, the runtime's IAM role is used | AKIA… | |
files.endpoint | string | s3 only: the endpoint of an S3-compatible store; {bucket} is replaced | https://acme.r2.cloudflarestorage.com | |
files.prefix | string | s3 only: a key prefix | uploads/ | |
files.signing-key | string | per process | Signs upload tokens and read URLs; a secret: reference | secret:FILES_SIGNING_KEY |
files.base-url | string | getqr.id | getqr only: the getqr.id API origin, for a sandbox | https://sandbox.getqr.id |
share | object | default-expiry, max-expiry | { default-expiry: 7d, max-expiry: 90d } | |
webhooks | object | driver, secret, retries | { driver: builtin, retries: { max: 8, window: 24h } } | |
jobs | object | driver, dead-letter | { driver: inngest, dead-letter: true } | |
events.broadcast | object | driver (memory or redis), redis, max-payload (default 64KB) | { driver: redis, redis: { url: secret:REDIS_URL }, max-payload: 64KB } | |
events.schemas | object | The 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?" } } } | |
idempotency | object | Where 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
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
ai.driver | string | catalog default bedrock | bedrock, anthropic, openai, gateway, local | bedrock |
ai.key | string | secret:ANTHROPIC_API_KEY or secret:OPENAI_API_KEY by driver | The provider API key, always a secret: reference. bedrock takes none: it uses the role | secret:ANTHROPIC_API_KEY |
ai.region | string | The AWS region with your model access, bedrock only | us-east-1 | |
ai.models | list | Model allowlist | [anthropic/claude-*] | |
ai.data | object | training, retention | { training: never, retention: zero } | |
ai.redact | list | Labels removed before model calls | [personal, sensitive, child, phi] | |
ai.limits | object | Spend limits per-user, per-feature, total | { per-user: $1/day, total: $500/month } | |
ai.label | boolean | AI disclosure and content labels | true | |
ai.video.driver | string | openai, google or gateway; with none, ai.video is not available (spec ai-video) | openai | |
ai.video.key | string | secret:OPENAI_API_KEY, secret:GOOGLE_API_KEY or secret:AI_GATEWAY_API_KEY by driver | The video provider key, always a secret: reference | secret:OPENAI_API_KEY |
ai.video.max-seconds | number | The longest clip a call may ask for | 8 | |
ai.voice.driver | string | Speech-to-speech driver: openai-realtime, gemini-live or fake (spec ai-voice) | openai-realtime | |
ai.voice.key | string | by driver | The voice provider key, a secret: reference | secret:OPENAI_API_KEY |
ai.voice.telephony | object | driver (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.sessions | object | Named 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.rooms | object | driver (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.disclosure | string | The line spoken first on every session; an empty one on a routed number is MAIN-VOICE-0010 | This call uses an AI assistant. | |
ai.voice.closing | string | The line spoken when a session reaches its limit | Goodbye. | |
ai.voice.record | string | off | off or consent; a recording needs the caller's call-recording consent | consent |
ai.voice.max-duration | string | The longest a session may run | 15m | |
advisor.on | list | Signal names or prefix.* patterns that wake the advisor at medium or above (spec advisor) | [attack.suspected, anomaly.detected] | |
advisor.may | list | The declared playbook rules it may propose; required, and each must exist | [attack.contain, pressure.slim] | |
advisor.run | string | approve | approve holds every proposal for a person; auto applies only notice and instance steps | approve |
advisor.window | duration | 15m | How far back it reads signals | 15m |
advisor.max-per-hour | number | 4 | Wakes per hour at most | 4 |
advisor.feature | string | advisor | The ai feature its calls are made under | advisor |
advisor.model | string | first of ai.models | The model it asks | anthropic/claude-sonnet-4-5 |
mcp.expose | list | Actions exposed as MCP tools | [orders.refund, orders.list] | |
mcp.confirm | object | write asks; destructive always asks | When agents need a person's yes | { write: true, destructive: true } |
mcp.auth | object | issuer, scopes, token-ttl | { scopes: [orders:read], token-ttl: 10m } | |
memory.driver | string | db | db, local; mem0, zep, letta, pinecone, mcp in 0.5 (spec memory) | db |
memory.embed | object | model, feature for embeddings, made in a background job through ai | { model: openai/text-embedding-3-small, feature: memory-embed } | |
memory.extract | object | model, feature that learn and consolidate use | { model: anthropic/claude-haiku-4-5, feature: memory-learn } | |
memory.ttl | object | Lifetime per kind; the label's retention caps it | { event: 90d, note: 365d } | |
memory.from-tools | string | quarantine | quarantine, allow or refuse for writes from tools, MCP, the web and imports | quarantine |
memory.children | string | off | off or guardian | off |
agents.<name>.memory | object | none (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:
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
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
logging.audit | boolean | Append-only, tamper-evident audit log | true | |
logging.redact | list | Labels redacted in process | [personal, sensitive, child, phi] | |
logging.export | list | Export targets | [siem] | |
logging.retention | duration | Audit and log retention | 12m | |
logging.errors.driver | string | otlp | Where error events go: otlp (through telemetry.otel), sentry or local; with no logging.errors section errors are grouped and kept locally | sentry |
logging.errors.dsn | secret | The Sentry DSN, sentry only; its host must be in network.allow (MAIN-REPORT-0005) | secret:SENTRY_DSN | |
logging.errors.release | string | auto | The release on every event; auto is the seal's build hash, else MAINSDK_RELEASE, else the package version | auto |
logging.errors.sample | number | 1.0 | Share of events exported, from 0 to 1; counts and groups always see every event | 1.0 |
logging.errors.process | boolean | true | Capture uncaughtException and unhandledRejection as fatal, flush for at most 2 s, exit 1 | true |
logging.errors.browser | boolean | false | Serve POST /_mainsdk/errors for reportErrors() | false |
logging.errors.ignore | list | [] | Error names or MAIN codes that are never grouped, counted or exported | [AbortError] |
logging.errors.alert.new | string | warning | Severity of the alert for a group new in this release; off disables it | warning |
logging.errors.alert.spike | map | { 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.to | list | [terminal] | Any mix of terminal, otel, mainsdk | [terminal, otel] |
telemetry.otel | object | OTLP endpoint and headers | { endpoint: https://otel.pinecart.dev:4318 } | |
telemetry.sample | number 0–1 | Trace sampling; errors, decisions and audit are never sampled | 0.2 | |
telemetry.budget | events | Events a month sent to MainSDK Cloud | 2M | |
feedback | object | Tracker and prompts for user reports (phase 2, 0.6) | { tracker: linear, prompt: { onCrash: true } } | |
feedback.driver | string | builtin | The support provider: builtin, intercom, zendesk or plain | zendesk |
Nothing reaches MainSDK Cloud before mainsdk login, even with mainsdk in telemetry.to.
Tenants, keys, plans and agents
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
tenancy.isolation | string | shared | shared, schema or database | shared |
tenancy.resolve | list | How a request's tenant is found, in order: apikey, session, subdomain, header | [apikey, session, subdomain] | |
tenancy.host, tenancy.personal, tenancy.directory | mixed | Subdomain base, a personal tenant per person, and the directory's region | { host: acme.app, personal: true, directory: eu } | |
tenancy.roles | list | Member roles | [owner, admin, member, viewer] | |
tenancy.system | object | allow: the files that may call tenant.system() | { allow: [src/admin/**] } | |
tenancy.suspended, tenancy.erase | object | Calls allowed while suspended; the erase waiting period | { allow: [billing.*] }, { wait: 7d } | |
auth.orgs | object | invites.expires and domains.auto-join | { invites: { expires: 7d } } | |
apikeys.driver | string | db | db or memory (tests only) | db |
apikeys.prefix | string | the app name | 2 to 12 lowercase letters and digits | acme |
apikeys.header | string | Header read after Authorization: Bearer | x-api-key | |
apikeys.scopes | list | The access permissions a key may carry | [orders.read, orders.write] | |
apikeys.max-ttl | duration | 365d | The longest key lifetime | 365d |
apikeys.limits.default | rate | Rate limit for each key | 600/min | |
plans.<name> | object | features, limits and prices for one plan | { features: [basic, export], limits: { seats: 50 } } | |
entitlements.driver | string | db | db or memory | db |
entitlements.default | string | The plan of a tenant with no other plan; must be under plans | free | |
entitlements.grace, entitlements.upgrade, entitlements.usage | mixed | Past-due grace, upgrade link, usage sources | { grace: 7d, upgrade: /settings/billing } | |
agents.<name> | object | budget (spend, calls), approve (risk, spend-over) and enabled | { budget: { spend: $5/day }, approve: { risk: destructive } } | |
permissions.mode | string | enforce | observe or enforce | enforce |
permissions.disk | object | read and write folders | { read: [./content], write: [./.cache] } | |
permissions.system, permissions.processes | list | Server 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:
| Block | Key | Type | Default | Meaning |
|---|---|---|---|---|
tenancy | isolation | string | shared | shared, schema or database; anything else is refused at load |
tenancy | resolve | list | [apikey, session] | Resolvers tried in order |
tenancy | host | string | Base domain for the subdomain resolver | |
tenancy | global | list | Models shared by all tenants (no personal fields) | |
tenancy | databases | map | Tenant ID to a secret reference; used only with isolation: database | |
apikeys | role | string | member | The role a key's principal carries |
entitlements | upgrade | string | URL named in the fix of a plan refusal | |
entitlements | usage | map | meter:<name> | Usage source per limit name |
health.shutdown | deadline | duration | Time allowed to drain before exit; drain-delay plus 2s must stay below it (MAIN-HEALTH-0005) | |
health.shutdown | signals | list | [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.
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
context.group | string | Required. The group peer apps of one owner share: lower-case letters, digits and dashes | acme-learning | |
context.identity | string | auth | auth (the signed-in subject), first-party (a resettable random id) or both | auth |
context.purposes.<name>.description | string | Required. What the person reads when asked for consent | "Use the same language in every Acme app" | |
context.purposes.<name>.attributes, .events | list | [] | The built-in or declared attribute keys and event types the purpose may carry | [locale, theme] |
context.purposes.<name>.retention | duration | 30d | How long received context is kept; at most 90d | 30d |
context.purposes.<name>.share-with | list | [] | Project ids of the mutual peer apps that receive this purpose | [prj_01JD8X] |
context.purposes.<name>.children | string | guardian-consent on every purpose of a children's app | guardian-consent | |
context.fields.attributes.<name> | object | Up 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.events | list | [] | Extra event types, kebab-case or dotted | [lesson.completed] |
context.sinks | list | [] | Onward copies inside the app: db or events; anything else is refused | [events] |
End-to-end encryption
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
e2e.driver | mls, local | mls | The 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 e2e | mls |
e2e.suite | classic, pq-hybrid | classic | The ciphersuite family. pq-hybrid is refused with MAIN-E2E-0007 until spec post-quantum is in place | classic |
e2e.recovery | passkey, none | passkey | How a person gets their keys back on a new device: the passkey PRF extension | passkey |
e2e.fields | object | Model 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.accept | list | search, moderation or analytics: trade-offs you accept, which silences MAIN-E2E-0008 for that feature | [search] |
Realtime, analytics and health
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
realtime | object | driver, path, channels, heartbeat, max-age, replay, buffer, max-event | { driver: broadcast, channels: [order] } | |
analytics | object | to, without-consent, min-group, events, metrics, posthog. Also replaces stats | { to: [builtin], events: { … } } | |
api | object | base, openapi (serve, include, title), snapshot, idempotency.require | { base: /api, snapshot: api/openapi.json } | |
feeds | object | driver (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 } } | |
check | object | ai: 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 } | |
imports | object | inline-max, batch, max-rows, max-columns, json-max, suggest, sources | { batch: 500, sources: { crm: { driver: rest, every: 5m } } } | |
connections | object | store, callback, providers (preset, client-id, scopes, hosts, step-up), and per-agent agents.<name>.connections | { providers: { github: { preset: github, scopes: [read:user] } } } | |
seed | object | number, tenants, regions, per-tenant, models, ai | { number: 42, tenants: 3 } | |
preview | object | driver (neon, supabase, local), from, seed, ttl | { driver: local, ttl: 7d } | |
health | object | live, ready, critical, every, timeout, shutdown | { critical: [db], shutdown: { deadline: 25s } } | |
slo | object | availability (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] } } | |
resources | object | Host 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/**"] } | |
norms | object | Learn 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 } | |
signals | object | Typed 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 } | |
playbooks | object | What 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] } | |
posture | object | Defence levels guarded, high and lockdown, each a list of steps added to the one below. Spec playbooks | { levels: { high: [...] } } | |
guard | object | Ceilings for every reaction: max-active, max-per-hour, quarantine, max-hops, block, limit-floor, tenant-quorum. Spec reaction-safety | { max-active: 3, quarantine: 1h } | |
neighbours | object | Hear 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] } | |
apps | object | The 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] } | |
ops | object | The 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 } | |
workers | object | CPU 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 } } } | |
cluster | object | One 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 } } | |
pagination | object | key: the name of the secret that signs list cursors. Absent means cursors are signed with a per-instance key. Spec pagination | { key: PAGINATION_KEY } | |
app | object | url: 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 } | |
inbound | object | Browser-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
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
security.events.to | list | [log] | Where security events go: log (always on), otel (the OTLP exporter), events (events.publish("security.<type>")) | [log, otel] |
security.events.min | string | low | Lowest severity sent to otel and events: info, low, medium, high, critical. The log keeps everything | low |
security.events.regions.<region> | object | Where 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-force | object | { failures: 10, window: 5m } | Failed sign-ins on one account. Threshold of 2 or more | { failures: 10, window: 5m } |
security.detect.stuffing | object | { accounts: 20, window: 5m } | Different accounts failed from one source network (/24 for IPv4, /48 for IPv6) | { accounts: 20, window: 5m } |
security.detect.probing | object | { denials: 20, window: 1m } | Different resources denied to one actor | { denials: 20, window: 1m } |
incident.kill | list | [] | 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-only | boolean | false | Refuse writes in db, files and jobs, and unsafe requests | true |
incident.read-only-allow | list | [] | Deployed only: paths that may still take unsafe requests while read-only | [/api/auth/*, /api/webhooks/*] |
incident.sessions-after | null or RFC 3339 string | RFC 3339 instant; sessions and refresh tokens issued before it end | 2026-10-27T02:00:00Z | |
incident.tenants.<tenant>.sessions-after | string | The same cut-off for one tenant | 2026-10-27T02:00:00Z | |
incident.expires.<entry> | string | RFC 3339 instant after which one control stops applying | 2026-10-27T06:00:00Z | |
alerts.from | list | all sources | SDK sources: health, security, resources, jobs, backup, errors; [] for the app's own alerts only | [health, security] |
alerts.min | string | warning | Lowest severity that notifies anyone: info, warning, critical | warning |
alerts.recipients.<name> | list | Named group of destinations: { driver: email|slack|pagerduty|opsgenie|webhook, ... }; keys and URLs as secret:NAME | [{ driver: slack, webhook: secret:SLACK_ALERTS_WEBHOOK }] | |
alerts.routes | list | First match wins: match (source, severity), to (recipient names), escalate (after, to) | [{ to: [oncall] }] | |
alerts.group | object | by, wait, interval: one message per group, updated at most every interval | { by: [source], wait: 30s, interval: 5m } | |
alerts.repeat | duration | Re-send an open, unacknowledged alert | 4h | |
alerts.quiet | object | from, 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-hour | number | Per recipient; the rest of the hour arrives as one summary | 30 |
Bots, canaries, exfiltration, search and moderation
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
bots | object | mode (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 } } } | |
canaries | object | Fake 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 } | |
exfiltration | object | mode (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.timeout | duration | 2s | A query that takes longer fails with MAIN-SEARCH-0004 | 2s |
search.max-depth | number | 1000 | The most hits one cursor chain returns. An engine driver of typesense or meilisearch takes at most 1000 | 1000 |
search.indexes.<name> | object | model, access and fields (search, weight, facet, filter, sort) for one index | { model: posts, access: posts.read } | |
search.typesense, search.meilisearch | object | key (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.reasons | list | spam, harassment, hate and more | The reasons a report may give; any other reason is refused | [spam, hate] |
moderate.terms | string | terms | The document a statement of reasons cites as its ground | terms |
moderate.prescreen.classify, moderate.prescreen.hash | object | A 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.appeals | object | reports.per-user and reports.per-target limits; appeals.window, how long a person may appeal | { per-user: 20/hour } | |
analytics.buffer | number | 10000 | Events held in memory before the oldest are dropped | 10000 |
analytics.posthog | object | host, 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.
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
geo | object | driver platform-headers | The driver, the platform to read, the provider key, the cache and the timeout | { driver: mapbox, key: secret:MAPBOX_TOKEN } |
geo.driver | string | platform-headers | platform-headers (a hint, no geocoder), mapbox, google-maps or local (the testing kit's table). An unknown value fails with MAIN-GEO-0005 | mapbox |
geo.edge | string | deploy.target when it is vercel, cloudflare or fly | The platform whose headers geo.of reads: vercel, cloudflare, fly or cloudfront. With none declared the hint is empty | cloudfront |
geo.key | secret ref | The provider key for mapbox and google-maps. Required for them, and only as secret:NAME | secret:MAPBOX_TOKEN | |
geo.cache-ttl | duration | 30d | How long a found place is cached per tenant; a missing address at most one hour. 0 turns the cache off | 7d |
geo.timeout | duration | 2s | The longest one geocode call may take, after which the answer is null | 2s |
verify.driver | string | local | Delivers the phone code MainSDK made: twilio-verify, bird or local | twilio-verify |
verify.key | secret ref | one per process | Keys the digest of codes, numbers and addresses; set it so every instance can check a code another sent | secret:VERIFY_KEY |
verify.code | object | ttl (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.limits | object | per-number (default 3), per-ip (default 10) and window (default 10m) | { per-number: 3, per-ip: 10, window: 10m } | |
verify.email | object | base (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> | string | The identity driver for a market, or default: digilocker, onfido, persona or local | { in: digilocker, eu: onfido } | |
verify.require.<market> | string | The 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.policy | string | privacy-policy | The consent policy stored with identity claims when a call names none | privacy-2026-10 |
verify.drivers.<id> | object | The settings of one driver; credentials must be secret: references | { onfido: { token: secret:ONFIDO_TOKEN, workflow: wf_123, region: eu } } | |
updates.driver | string | local | The store the app's client talks to: expo-updates, google-play, app-store, tauri (1.1) or local; the server needs none | expo-updates |
updates.min-version | version | The 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-0004 | 2.4.0 | |
updates.build | string | The build id of this release, published at /_mainsdk/updates/build and over the build stream | 2026.10.27-1 | |
updates.stream | duration | 2m | How long one build stream stays open before the browser reconnects | 2m |
updates.strict | boolean | false | Also refuse a request that names no app version | false |
updates.exempt | list | [] | Paths a blocked client may still reach, next to /_mainsdk/auth/signout | [/api/me/export] |
updates.channels | object | stable 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.deprecated | list | [] | 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.
| Key | Drivers in the catalog | Example |
|---|---|---|
search.driver | typesense, meilisearch, algolia, pgvector | typesense |
meter.driver | stripe-meters, lago | stripe-meters |
links.driver | getqr | getqr |
media.driver | getqr, cloudinary, mux | getqr |
pdf.driver | builtin, docraptor, documenso | builtin |
crypto.driver | cloud-kms, vault-transit, libsodium | libsodium |
limits.driver | builtin, upstash, arcjet | builtin |
status.driver | builtin, provider (the status page; probes are under health) | builtin |
i18n.driver, i18n.locales | files, tolgee, crowdin, lokalise, ai-draft | { driver: files, locales: [en-IN, hi-IN, ta-IN] } |
updates.driver | expo-updates, app-store, google-play, tauri | expo-updates |
flags.driver | config, 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.
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
documents | object | driver builtin | driver, 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 } } |
sandbox | object | driver local | driver (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.price | string | driver estimate | Price per second of a box, to override a driver's estimate. Spec sandbox | $0.0001 |
sandbox.vercel.token | string | secret:VERCEL_TOKEN | The Vercel Sandbox API token, as a secret reference | secret:VERCEL_TOKEN |
sandbox.vercel.project | string | The Vercel project that owns the sandboxes | prj_123 | |
sandbox.vercel.team | string | The Vercel team, when the project belongs to one | team_123 | |
sandbox.vercel.runtime | string | The Vercel Sandbox runtime image | node22 | |
sandbox.e2b.key | string | secret:E2B_API_KEY | The E2B API key, as a secret reference | secret:E2B_API_KEY |
sandbox.e2b.template | string | The E2B template the sandboxes start from | base | |
i18n.cookie | string | locale | The cookie that holds the reader's explicit locale choice. Spec i18n | locale |
i18n.dir | string | locales | The folder of locale files and the glossary | locales |
licence
App licensing (spec licence, built); see Licensing and the licence interface.
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
licence.driver | string | offline | offline (files and tokens only), http (offline verify, background refresh) or local (mainsdk dev and tests) | http |
licence.product | string | required | Must equal the licence's product | acme-desktop |
licence.roots | list | required | Pinned root public keys, 1 to 4, ed25519: and base64url of 32 bytes | ["ed25519:CvBK…"] |
licence.file | path | ./main.licence | The app licence read at start, as a JSON container or the compact mlic1. form; not with token | ./main.licence |
licence.token | secret ref | The app licence as a compact token; not with file | secret:MAINSDK_LICENCE | |
licence.trust | path | CA certificates and revocation lists as a trust bundle | ./main.licence-trust | |
licence.features | list | [] | Feature names that need a licence | [sso, audit-export] |
licence.url | URL | http driver only, and required there: the refresh endpoint base, https (or localhost); its host is added to the network allowlist | https://api.mainsdk.dev | |
licence.refresh | duration | 6h | http driver: refresh interval, 5m to 7d, jittered by up to 10% | 6h |
licence.skew | duration | 5m | Clock skew tolerated, 0 to 1h | 5m |
licence.over | string | enforce | enforce or report when a licensed limit is passed | report |
licence.machine | string | os | Machine 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).
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
compute.heartbeat | duration | 60s | No sign of life for three of these and the job is lost; at least 1s | 60s |
compute.poll | duration | 15s | How often the job workflow asks the provider; at least 1s | 15s |
compute.results.label | string | personal | The data label every result file carries | personal |
compute.results.max-size | size | 2GB | The largest result file kept | 2GB |
compute.results.types | list | audio/*, image/*, video/*, application/json | Content types a result may have | ["image/*"] |
compute.limits.per-tenant | money per month | Spend one tenant may reach in a month | $50/month | |
compute.limits.total | money per month | Spend all tenants may reach in a month | $500/month | |
compute.targets.<name>.driver | string | required | modal, replicate, runpod or local | replicate |
compute.targets.<name>.region | string | us, eu or in; required unless the driver is local | us | |
compute.targets.<name>.timeout | duration | required | Longest a job may run; 1s to 24h | 30m |
compute.targets.<name>.price | price | required | Worst-case price per second, such as $0.0014/s | $0.0014/s |
compute.targets.<name>.max-cost | money | The most one job may cost; checked before submit | $2 | |
compute.targets.<name>.retries | number | 0 | Retries after a provider fault; 0 to 5 | 0 |
compute.targets.<name>.heartbeat | duration | compute.heartbeat | Heartbeat for this target | 120s |
compute.targets.<name>.model, app, function, gpu, endpoint | string | What the provider runs, by driver | nightmareai/real-esrgan:<64 hex> | |
compute.targets.<name>.program, args | string, list | local driver: the program (also under permissions.processes) and its arguments | upscale |
sync
Spec sync. The server half reads this section; a device passes the same table versions to sync.open.
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
sync.driver | string | local | Where the server keeps rows: local (in memory) | local |
sync.tables | object | none | Each 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.quota | object | { 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-drift | duration | 5m | How far ahead of the server clock a stamp may be before the row is refused | 2m |
sync.page | number | 500 | Rows in one pull | 500 |
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 runtime | Key you write | Block |
|---|---|---|
maxActive, maxPerHour, maxHops, tenantQuorum, limitFloor | max-active, max-per-hour, max-hops, tenant-quorum, limit-floor | guard |
quarantineMs | quarantine | guard |
approveTimeoutMs | approve-timeout | playbooks |
alertTo | alert.to | slo |
latencyMs | latency_p95 | slo |
drainDelay | shutdown.drain-delay | health |
retryAfter, maxPause | retry-after, max-pause | resources |
minSamples | min-samples | norms |
windowMs | window | advisor |
maxPerMinute | max-per-minute | signals, neighbours |
ttlDefault, ttlMax | ttl.default, ttl.max | signals |
graceMs | grace | entitlements |
maxTtlMs | max-ttl | apikeys |
limit | limits.default | apikeys |
eraseWaitMs | erase.wait | tenancy |
suspendedAllow, systemAllow | suspended.allow, system.allow | tenancy |
refreshMs, skewMs | refresh, skew | licence |
fromTools | from-tools | memory |
embed | embed | memory |
image | image | media |
timeoutMs, maxDepth, indexes | timeout, max-depth, indexes | search (today you write timeout, not the millisecond field) |
typesense, meilisearch | typesense, meilisearch | search |
classify, hash | prescreen.classify, prescreen.hash | moderate |
reports, appeals, reasons, terms | reports, appeals, reasons, terms | moderate |
buffer, posthog | buffer, posthog | analytics |
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.
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
deps.min-age | duration | 7d | A direct dependency first published more recently is MAIN-DEPS-0002 | 7d |
deps.licences.deny | list | [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.allow | list | [] | When not empty, any licence outside it is reported | [MIT, Apache-2.0, ISC] |
deps.licences.unknown | string | warn | warn or fail for a missing or non-SPDX licence | fail |
deps.advisories.fail-on | string | high | critical, high, moderate, low or none; a lower severity is a warning | critical |
deps.advisories.ignore | list | [] | 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.allow | list | [] | Packages allowed to run install scripts | [esbuild, sharp] |
deps.offline | string | info | info 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.
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
seal.mode | string | warn | off, warn or refuse: what the boot check does when the build does not match its seal | refuse |
seal.include | list | Roots 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.exclude | list | [] | Globs left out of the build hash | ["**/*.map"] |
seal.signers | list | [] | 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.sbom | boolean | false | Also serve /.well-known/mainsdk.sbom.cdx.json | true |
Later phases
| Key | Phase | Description | Example |
|---|---|---|---|
connectors | 3 (1.2) | GRC, privacy, vulnerability and ticket tools | { tickets: { driver: github } } |
markets | 3 | Markets the app must comply in | [in, eu, us-ca] |
residency (by data kind) | 3 | Residency by data kind, beyond the default above | { personal: in, default: eu } |
enforcement | 3 | Enforcement mode for market rules | { mode: warn } |
sovereignty | 5 | In-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.
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.
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
deploy | object | The target, regions, port and tick settings mainsdk deploy config reads | { target: vercel } | |
deploy.target | string | vercel, cloudflare, fly, docker or node. Selects the files deploy config writes and whether schedules run through the tick route | vercel | |
deploy.regions | list | derived from the data regions | Platform regions. Each must sit inside the data regions of db.regions, files.regions, or MAIN-DEPLOY-0002 | [fra1] |
deploy.port | integer | 3000 | The port of the app on fly, docker and node | 8080 |
deploy.tick.secret | secret ref | secret:CRON_SECRET | The bearer the platform's cron sends to POST /_mainsdk/jobs/tick; serverless targets only | secret:CRON_SECRET |
deploy.tick.late | duration | 10m | A tick later than this after its slot still runs and logs MAIN-DEPLOY-0008 | 10m |
deploy.vercel.plan | string | pro | hobby, pro or enterprise. Sets the cron and region limits checked with MAIN-DEPLOY-0004 | hobby |
deploy.docker.base | string | node:24-slim | The base image of the Dockerfile's runtime stage | node:24-slim |
jobs.schedules | object | Static 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> | string | The cron expression of one schedule; the name is also the job name the tick queues | "0 3 * * *" |
Connected devices
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
device | object | Declares connected devices; with no device section nothing is mounted, stored or timed, even when mainsdk/device is imported | { enrol: { proofs: [token] } } | |
device.driver | local or taps | local | Identity and attestation driver; taps comes from @mainsdk/device-taps | local |
device.path | string | /mainsdk/device | Where the adapters mount the device routes | /mainsdk/device |
device.audience | string | the project id | The aud every device-signed payload must carry | acme-fleet |
device.authority | list | One or two secret:NAME references to PKCS#8 Ed25519 authority keys; a literal key is MAIN-DEVICE-0003 | [secret:MAINSDK_DEVICE_KEY] | |
device.max | number | 10000 | Devices per tenant that are not revoked; 1 to 1,000,000 | 10000 |
device.concurrency | number | 64 | Device requests in flight at once, so devices never starve the app's users; 1 to 100,000 | 64 |
device.bases | object | the request's own address | Device address of each region (us, eu, in) the credential names | { eu: "https://eu.acme.dev/mainsdk/device" } |
device.enrol.proofs | list | [token, code] | Enrolment proofs allowed: token, code, oidc, attested | [token, oidc] |
device.enrol.token-ttl | duration | 24h | Default life of an enrolment token; 5m to 30d | 24h |
device.enrol.oidc | list | Workload 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.ttl | duration | 24h | Life of a device credential; 1h to 30d | 24h |
device.credential.renew-grace | duration | 30d | How long after expiry a device may still renew with its key; 0 or 1ms to 90d | 30d |
device.attest.require | none, software, hardware, environment | none | The least attestation level an enrolment or renewal needs; hardware and environment need driver: taps | software |
device.attest.every | duration | 24h | How often a device presents fresh evidence; 1h to 30d | 24h |
device.attest.grace | duration | 72h | How long past every a level holds before it drops to none; 0 or 1ms to 30d | 72h |
device.attest.formats | list | Evidence formats accepted (taps only) | [apple-app-attest] | |
device.attest.min-security-level | L1, L2, L3 | The weakest security level accepted (taps only) | L2 | |
device.attest.roots | object | Root certificate files per format (taps only) | { android-key-attestation: [roots/android.pem] } | |
device.attest.builds | string | A file of accepted builds (taps only) | builds.json | |
device.attest.allow-self-signed-boot | boolean | false | Accept a self-signed boot chain (taps only) | false |
device.attest.apple-app-ids | list | Apple app ids accepted (taps only) | [TEAMID.com.acme.agent] | |
device.heartbeat.every | duration | 60s | The heartbeat interval devices are asked to keep; 15s to 1h; doubled under pressure | 60s |
device.heartbeat.late-after | number | 2 | Missed intervals before a device is late; 2 to 10 | 2 |
device.heartbeat.silent-after | number | 5 | Missed intervals before a device is silent; 3 to 100 and above late-after | 5 |
device.telemetry.streams | object | Declared streams by dotted lower-case name; each takes max-record, personal and redact | { usage.sample: { redact: [user.email] } } | |
device.telemetry.max-record | size | 64KB | Largest record; 1KB to 256KB | 64KB |
device.telemetry.max-batch | size | 1MB | Largest batch; 64KB to 4MB | 1MB |
device.telemetry.rate | string | 60/min | Batches per device per s, min or h | 60/min |
device.telemetry.daily | size | Accepted bytes per tenant per UTC day; further batches get 429 | 5GB |
Device commands and device policy
| Key | Type | Default | Description | Example |
|---|---|---|---|---|
device.commands.ttl | duration | 15m | Default expiry of a command; from 1m to max-ttl | 15m |
device.commands.max-ttl | duration | 7d | The longest expiry a create may ask for | 7d |
device.commands.max-targets | number | 100 | Devices one create may target without confirm: <count>; 1 to 100,000 | 100 |
device.commands.reaction-max | number | 50 | Devices one playbook step may target; the rest are refused with MAIN-GUARD-0003 | 50 |
device.policy.rollout | object | 5%, 25%, 100% an hour apart, back out at 5% failed | stages, 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.approve | number | 0 | People who must approve a publish: 0, 1 or 2 | 1 |
device.policy.tighten | object | Paths an overlay may change and which way is tighter: lower, higher, true, false, subset, superset | { report.interval: higher } |
Next
Was this page helpful?