MainSDK
Search⌘K
Interfaces

Developer experience

Runtime settings

config

Read typed runtime settings from memory, from JSON files or from Redis, and change them without a deploy. A push can only tighten what main.yaml allows.

Building for 0.2ships as a stable APIPhase 1 · Core, public betaSpec config-secretsFree in your app, Apache-2.0

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

Stability The promise an interface ships with.

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

config reads runtime settings from the store you already use, checked against a JSON schema in your repo. A change reaches every running instance in seconds, with no deploy.

config.get() reads memory and never waits on the store, so the app answers at once. A bad value keeps the last good one, and every change the app sees is audited. Reach for it for values operators tune at run time, such as a cart limit or a support banner. App structure and drivers belong in main.yaml; feature switches per user belong in Feature flags.

At a glance

config:
  driver: redis                  # memory | files | redis
  schema: ./config.schema.json
  refresh: 30s                   # poll interval without a change stream
  values: { checkout.currency: INR }
  files: { dir: config/ }        # files driver
  redis: { url: secret:REDIS_URL, prefix: "mainsdk:prj_01JC8Q…" }

Calls

  • config.get(key)
  • config.watch(key, fn)
  • config.watch(prefix, fn)
  • config.refresh()
  • config.ready(opts)
  • config.raw()
  • redisConfig(o)
  • filesConfig(o)
  • new MemoryConfigStore(initial?)
  • loadMain(dir, env?)
  • parseMain(text, overlays?)
  • parseYaml(src)
  • schemaNode(schema, key)
  • config.close()

Guarantees

Whatever the driver.

  • Typed against a schema; a bad value keeps the last good one
  • Reads come from memory, so the app never waits on the store
  • Changes reach every running instance without a deploy
  • A push can only tighten what main.yaml allows
  • Every applied or refused change is audited by key and hash, never by value

Drivers

7 drivers. Select one to see its line in main.yaml.

main.yamlFiles
config:
  driver: files

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-CONFIG-0030Checkmainsdk check, before deployA main.yaml key isn't in the schema; fix names the nearest key, so maxSize suggests max-size and payments suggests pay
MAIN-CONFIG-0031Checkmainsdk check, before deployA store-backed feature that must survive a restart (idempotency, meter, workflow leases) runs on a store whose profile says durable: false
MAIN-CONFIG-0032Checkmainsdk check, before deployA main.yaml value fails the schema: wrong type, not the one allowed constant, or not matching the key's pattern; the finding names the path. Fix: write the value in the form the finding shows
MAIN-CONFIG-0033Checkmainsdk check, before deployThe main.yaml schema isn't built, so keys and values weren't validated (low, a warning). Fix: run pnpm build in packages/sdk, which runs tools/build_main_schema.py
MAIN-CONFIG-0004RuntimeError responses and logsA literal value in a field marked secret
MAIN-CONFIG-0020RuntimeError responses and logsA config value fails its schema; the last good value is kept
MAIN-CONFIG-0021RuntimeError responses and logsA pushed main. value is looser than main.yaml allows, or a limit's period differs; the value is refused and the last good one kept
MAIN-CONFIG-0022RuntimeError responses and logsA pushed key starts with main. but names a path that can't be pushed, such as main.payments.driver; the key is refused
MAIN-CONFIG-0023RuntimeError responses and logsThe config store can't be read (down, or a load timed out); the app serves its last good values, logged once per outage
MAIN-CONFIG-0001RuntimeError responses and logsUnsupported schema value in main.yaml
MAIN-CONFIG-0002RuntimeError responses and logsmain.yaml can't be read: bad indent, a tab, an anchor or tag, or a duplicate key; the error names the line
MAIN-CONFIG-0003RuntimeError responses and logsA value has the wrong format: money ("$0.05"), a rate ($5/day), a size (25MB) or a duration (1h)
MAIN-CONFIG-0005RuntimeError responses and logsNo main.yaml in the project folder
MAIN-CONFIG-0010RuntimeError responses and logsA layer weakens its parent, for example adds a host main.org.yaml doesn't allow

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
Typed get and watchPhase 10.2config-secrets#config-api
File driverPhase 10.2config-secrets#config-drivers
Background refresh by change stream or pollPhase 10.2config-secrets#config-runtime
Last good snapshot kept on disk for a cold startPhase 10.2config-secrets#config-runtime
Tighten-only pushes of main.yaml valuesPhase 10.2config-secrets#config-tighten
Audit of every applied and refused changePhase 10.2config-secrets#config-audit
Memory driver and fs.watch on the files driverPhase 10.2config-secrets#config-memory-files
Redis driver on the built-in RESP clientPhase 10.2config-secrets#config-redis
Deploy files for VercelPhase 10.4deploy#writer
Environment diff for a reviewed promotionPhase 10.4deploy#diff

Quick start

Point config at a store and a schema, then read keys by name. With the files driver, each file in config/ holds one prefix: config/cart.json with { "maxItems": 20 } sets cart.maxItems.

main.yaml
config:
  driver: files
  schema: ./config.schema.json
config.schema.json
{
  "type": "object",
  "properties": {
    "cart": {
      "type": "object",
      "properties": { "maxItems": { "type": "integer" } }
    }
  }
}

app/lib/cart.ts

import { config } from "mainsdk";

export function canAdd(count: number) {
  return count < (config.get("cart.maxItems") as number);   // synchronous; reads memory
}

config.get() returns unknown today, so cast the value as above. Generating TypeScript types from the schema, so that config.get("cart.maxItem") fails type checking, is planned. A pushed value that fails the schema is refused at run time with MAIN-CONFIG-0020.

Configuration

main.yaml
config:
  driver: redis                  # memory | files | redis (postgres planned)
  schema: ./config.schema.json   # JSON schema every value is checked against
  refresh: 30s                   # poll interval; the store can also send change hints
  values: { cart.maxItems: 20 }  # deployed defaults; overlays may change them
  files: { dir: config/ }        # files driver: config/*.json, merged in file-name order
  redis: { url: secret:REDIS_URL, prefix: "mainsdk:prj_01JC8Q…" }   # prefix defaults to mainsdk:<project>
KeyTypeWhat it does
driverstringmemory for tests, files for JSON in your repo, redis for a store shared by several instances
schemapathJSON schema for the settings. Values that fail it are refused, and its defaults are the lowest layer. Generated TypeScript types are planned
refreshdurationPoll interval, 30s by default. With a change stream, the store is still polled every ten times this, to catch a missed hint
valuesmapValues deployed with the app. The store's value wins over these
files.dirpathFolder the files driver reads, config/ by default
redis.urlsecret: referenceConnection URL; rediss:// for TLS. A literal URL fails the check
redis.prefixstringKeys and channel the store uses, <prefix>:config. Defaults to mainsdk:<project>

Settings are not secrets. Keep credentials in Secrets and refer to them with secret:NAME.

Where a value comes from

From lowest to highest: the schema default, then config.values in main.yaml after its overlays, then the latest value the driver applied. A key that is in none of them returns undefined.

Overlays by environment

Use files in development and a shared store in production:

main.env.dev.yaml
config:
  driver: files
main.env.prod.yaml
config:
  driver: redis

What you get

CallWhat it does
config.get(key)The current value of a key, from memory; undefined when no layer sets it
config.watch(key, fn)Calls you once for each change to the key, after it is applied; a key ending in .* watches a prefix. Returns a function that stops it
config.refresh()Loads from the store now and applies it, instead of waiting for the poll; it never rejects
config.ready(opts)Waits for the first load or its timeout; it never rejects, so a slow store cannot stop start-up
config.raw()The provider's own client, for a feature only that provider has; every use is logged
config.close()Stops polling and the change stream

Always:

  • Reads never wait on the store, and a store outage serves the last good values.
  • A value that fails the schema is refused and the last good one is kept.
  • A push can only tighten what main.yaml allows.
  • Every applied or refused change is audited by key and hash, never by value.

Examples

Read a setting in a Next.js route handler

app/api/cart/route.ts
import { auth, config } from "mainsdk";

export async function POST(req: Request) {
  await auth.require(req);
  const { items } = (await req.json()) as { items: unknown[] };
  const max = config.get("cart.maxItems") as number;
  if (items.length > max) {
    return Response.json({ error: `Keep the cart to ${max} items` }, { status: 422 });
  }
  return Response.json({ ok: true });
}

React to a change without a deploy

config.watch() calls you once for each change to the key, after the change is applied, and returns a function that stops it.

server/worker.ts
import { config, log } from "mainsdk";

let batchSize = config.get("worker.batchSize") as number;

const stop = config.watch("worker.batchSize", (value) => {
  batchSize = value as number;
  log.info("batch size changed", { batchSize });
});

process.on("SIGTERM", () => stop());

Watch a group of keys

A key ending in .* watches every key under that prefix. The callback gets the change's key.

server/checkout.ts
import { config, log } from "mainsdk";

config.watch("checkout.*", (_value, change) => {
  log.info("checkout setting changed", { key: change.key });
});

A support banner from a fetch handler

Any server that speaks Request and Response, Hono included, can read a setting the same way.

server/banner.ts
import { config } from "mainsdk";

export default {
  fetch(req: Request) {
    if (new URL(req.url).pathname !== "/banner") return new Response("Not found", { status: 404 });
    return Response.json({ text: config.get("support.banner") });
  },
};

Pushing main.yaml values

The store can change a few main. settings at run time, such as a spend limit or an allowed host. A push can only tighten them. Each key is checked on its own, so a refused key keeps its last good value and the other keys in the same push still apply.

Path after main.A push is accepted when
network.allow, ai.modelsEvery entry is one main.yaml already has, or a host, IP or model name that one matches
permissions.disk.read, permissions.disk.write, permissions.system, permissions.processesEvery entry is one main.yaml already has, or, for disk, a folder inside a declared folder
logging.redact, ai.redactEvery label in main.yaml is still in the list
network.modeNot weaker: observe < warn < enforce
network.defaultNot weaker: allow < deny
permissions.modeNot weaker: observe < enforce
logging.auditNot weaker: false < true
limits.<name>, ai.limits.<name>, agents.<name>.budget.spend, agents.<name>.budget.callsThe same period as main.yaml and an amount no larger. A limit missing from main.yaml accepts any value
agents.<name>.enabledfalse always; true only when main.yaml doesn't set false
agents.<name>.approve.riskApproval is asked at least as often as in main.yaml
agents.<name>.approve.spend-overAn amount no larger than main.yaml
feeds.page.end, feeds.page.pause_after, feeds.trending.min-count, feeds.max-followingpage.end is never turned off; pause_after and max-following are no larger and min-count no smaller than main.yaml (or its default)
notify.engagement.max-per-week, notify.engagement.stop-after-ignored, notify.engagement.quiet-hoursNo larger count; quiet hours that still cover the main.yaml window

Exceptions in main.yaml do not apply to a push. Removing a pushed key brings back the main.yaml value. A pushed secret value is refused: put secrets in Secrets.

What happens when something goes wrong

If someone sets cart.maxItems to "twenty" in the store, the change is rejected with MAIN-CONFIG-0020, and config.get() keeps returning the last good value.

If the store goes down, the app keeps serving the last good values from memory and logs MAIN-CONFIG-0023 once for the outage. It does not wait and does not restart. The last applied snapshot is kept in .mainsdk/cache/config.json, so a restart during an outage serves it too.

Reference

CallWhat it does
config.close()Stops polling and the change stream. Call it on shutdown or at the end of a test
loadMain(dir, env?)Reads main.yaml from a folder and applies main.env.<env>.yaml on top when it exists. env defaults to MAINSDK_ENV, then dev
parseMain(text, overlays?)Parses main.yaml text and optional overlay texts, in order, into one validated config
parseYaml(src)Parses main.yaml text into plain objects, arrays and scalars
schemaNode(schema, key)The schema node for a flat dotted key, such as to read its x-tighten direction
filesConfig(opts)The files driver: config/*.json merged in file-name order, watched with fs.watch and polled
redisConfig(opts)The redis driver: GET <prefix>:config for the snapshot and SUBSCRIBE <prefix>:config for change hints

Typed keys

config.get and config.watch are typed from config.schema. Write the declaration file once, and again when the schema changes; a typo in a key then fails type checking, and an integer key reads as number. A key with no default also reads as undefined.

scripts/config-types.ts
import { readFileSync, writeFileSync } from "node:fs";
import { configTypes } from "mainsdk";

writeFileSync("mainsdk-config.d.ts", configTypes(JSON.parse(readFileSync("config.schema.json", "utf8"))));

Until a file is generated, keys are plain strings and values are unknown.

Errors and checks

CodeWhenFix
MAIN-CONFIG-0004A literal value sits under a secret field (key, secret, token, password, apiKey, clientSecret), or config.redis.url is a literalUse a secret: reference, and keep the secret in secrets
MAIN-CONFIG-0020A changed value fails the schema, or a file isn't valid JSON; the last good value is keptCorrect the value in the store, or update config.schema.json
MAIN-CONFIG-0021A pushed main. value is looser than main.yaml allowsPush a tighter value, or change main.yaml in a reviewed deploy
MAIN-CONFIG-0022A pushed main. path can only change in a reviewed deploy, such as main.payments.driverChange it in main.yaml and deploy
MAIN-CONFIG-0023The store can't be read; the app serves its last good values, logged once per outageRestore the store; no restart is needed

Type errors for unknown keys show up in your editor and build, before mainsdk check runs. Run mainsdk explain MAIN-CONFIG-0020 for the cause and fix offline.

Swapping drivers

Change one line. Your schema, config.get() and config.watch() calls stay the same; copy the values into the new store.

main.yaml
config:
  driver: redis   # was files

When you need a provider-only feature, config.raw() returns the provider's client. Every use is logged and listed by mainsdk check.

Testing

The test app runs config on an in-memory MemoryConfigStore you can share and script. Change a value, call app.live.refresh(), and read it back.

app/lib/cart.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { MemoryConfigStore } from "mainsdk";

describe("cart settings", () => {
  it("applies a pushed value", async () => {
    const store = new MemoryConfigStore();
    const app = createTestApp({ yaml: "schema: 1\nproject: prj_test\nconfig:\n  driver: memory", configDriver: store });
    store.set("cart.maxItems", 20);
    await app.live.refresh();
    assert.equal(app.live.get("cart.maxItems"), 20);
  });
});

See the Testing kit for fakes and the clock.

Security and privacy

  • Every applied and refused change is written to the audit log with the key, hashes of the old and new values, and the source.
  • No setting value appears in an audit record, a log line or an error. Only its key and hash do.
  • Values are validated against your schema, so a bad value in the store cannot reach your code.
  • A push can't loosen a rule in main.yaml, so a compromised store can only make the app stricter.
  • Do not keep credentials or personal data in settings; use Secrets and Data and records.

FAQ

How is this different from main.yaml? main.yaml declares what your app is and which drivers it uses, and changes go through a pull request. config holds values that change at run time without a deploy.

How is this different from feature flags? Flags turn features on per user or market. Their definitions are kept in the same store, at flags.<name>, and pass the same checks. See Feature flags.

What if the store is down at startup? The app starts at once and does not wait for the store. It serves the last applied snapshot from .mainsdk/cache/config.json if there is one. With no cache, each key returns its config.values entry or schema default until the first load succeeds.

Refresh and ready

config.refresh() loads from the store now and applies it. It never rejects. config.ready() waits for the first load, or for its timeout. It never rejects either, so a slow store cannot stop start-up.

server/start.ts
import { config } from "mainsdk";

await config.ready({ timeout: 2000 });   // first load applied, or 2 s passed
await config.refresh();                  // pull the latest values now

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet