---
title: "main.yaml keys"
description: "Every main.yaml key the specs define, with its type, default where one is set, what it does and an example, grouped by interface."
section: "reference"
status: "Planned for 0.1"
spec: "main-yaml"
canonical: "https://mainsdk.dev/docs/reference/main-yaml"
updated: "2026-10-28"
---

# 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: Planned for 0.1 · Canonical: https://mainsdk.dev/docs/reference/main-yaml · 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](https://mainsdk.dev/docs/concepts/main-yaml).

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

```yaml title="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](https://mainsdk.dev/docs/interfaces/auth) 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](https://mainsdk.dev/docs/interfaces/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:

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

| 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](https://mainsdk.dev/docs/interfaces/tenant).

## Shared context

Owned by spec [context](https://mainsdk.dev/docs/interfaces/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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/playbooks) | `{ use: [attack, pressure] }` |
| `posture` | object | | Defence levels `guarded`, `high` and `lockdown`, each a list of steps added to the one below. Spec [playbooks](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/interfaces/geo)). 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](https://mainsdk.dev/docs/interfaces/protect).

`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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/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](https://mainsdk.dev/docs/specs/i18n) | `locale` |
| `i18n.dir` | string | `locales` | The folder of locale files and the glossary | `locales` |

## licence

App licensing (spec `licence`, built); see [Licensing](https://mainsdk.dev/docs/concepts/licensing) and [the licence interface](https://mainsdk.dev/docs/interfaces/licence).

| 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](https://mainsdk.dev/docs/interfaces/compute). 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](https://mainsdk.dev/docs/interfaces/licence) example.

## deps

Dependency checks (spec [dependency-check](https://mainsdk.dev/docs/specs/dependency-check)); see [Dependency checks](https://mainsdk.dev/docs/interfaces/deps). 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](https://mainsdk.dev/docs/specs/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.

```yaml title="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](https://mainsdk.dev/docs/interfaces/deploy)). 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

- [main.yaml](https://mainsdk.dev/docs/concepts/main-yaml)
- [Check codes](https://mainsdk.dev/docs/reference/check-codes)
- [CLI](https://mainsdk.dev/docs/reference/cli)
