Runtime settings
configRead 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.
config-secretsFree in your app, Apache-2.0Status 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.
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…" }import { config, log } from "mainsdk";
const maxItems = config.get("cart.maxItems"); // typed from the schema; synchronous, from memory
const stop = config.watch("cart.maxItems", (v) => log.info("changed", { v }));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);
});
});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.
config:
driver: filesChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-CONFIG-0030 | Checkmainsdk check, before deploy | A main.yaml key isn't in the schema; fix names the nearest key, so maxSize suggests max-size and payments suggests pay |
MAIN-CONFIG-0031 | Checkmainsdk check, before deploy | A store-backed feature that must survive a restart (idempotency, meter, workflow leases) runs on a store whose profile says durable: false |
MAIN-CONFIG-0032 | Checkmainsdk check, before deploy | A 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-0033 | Checkmainsdk check, before deploy | The 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-0004 | RuntimeError responses and logs | A literal value in a field marked secret |
MAIN-CONFIG-0020 | RuntimeError responses and logs | A config value fails its schema; the last good value is kept |
MAIN-CONFIG-0021 | RuntimeError responses and logs | A 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-0022 | RuntimeError responses and logs | A 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-0023 | RuntimeError responses and logs | The 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-0001 | RuntimeError responses and logs | Unsupported schema value in main.yaml |
MAIN-CONFIG-0002 | RuntimeError responses and logs | main.yaml can't be read: bad indent, a tab, an anchor or tag, or a duplicate key; the error names the line |
MAIN-CONFIG-0003 | RuntimeError responses and logs | A value has the wrong format: money ("$0.05"), a rate ($5/day), a size (25MB) or a duration (1h) |
MAIN-CONFIG-0005 | RuntimeError responses and logs | No main.yaml in the project folder |
MAIN-CONFIG-0010 | RuntimeError responses and logs | A 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.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| Typed get and watch | Phase 1 | 0.2 | config-secrets#config-api |
| File driver | Phase 1 | 0.2 | config-secrets#config-drivers |
| Background refresh by change stream or poll | Phase 1 | 0.2 | config-secrets#config-runtime |
| Last good snapshot kept on disk for a cold start | Phase 1 | 0.2 | config-secrets#config-runtime |
| Tighten-only pushes of main.yaml values | Phase 1 | 0.2 | config-secrets#config-tighten |
| Audit of every applied and refused change | Phase 1 | 0.2 | config-secrets#config-audit |
| Memory driver and fs.watch on the files driver | Phase 1 | 0.2 | config-secrets#config-memory-files |
| Redis driver on the built-in RESP client | Phase 1 | 0.2 | config-secrets#config-redis |
| Deploy files for Vercel | Phase 1 | 0.4 | deploy#writer |
| Environment diff for a reviewed promotion | Phase 1 | 0.4 | deploy#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.
config:
driver: files
schema: ./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
}use mainsdk::prelude::*;
let app = App::test();
let project = app.config().get("project").await?;
assert_eq!(project, Some("prj_test".into()));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
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>| Key | Type | What it does |
|---|---|---|
driver | string | memory for tests, files for JSON in your repo, redis for a store shared by several instances |
schema | path | JSON schema for the settings. Values that fail it are refused, and its defaults are the lowest layer. Generated TypeScript types are planned |
refresh | duration | Poll interval, 30s by default. With a change stream, the store is still polled every ten times this, to catch a missed hint |
values | map | Values deployed with the app. The store's value wins over these |
files.dir | path | Folder the files driver reads, config/ by default |
redis.url | secret: reference | Connection URL; rediss:// for TLS. A literal URL fails the check |
redis.prefix | string | Keys 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:
config:
driver: filesconfig:
driver: redisWhat you get
| Call | What 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.yamlallows. - Every applied or refused change is audited by key and hash, never by value.
Examples
Read a setting in a Next.js route handler
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.
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.
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.
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.models | Every 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.processes | Every entry is one main.yaml already has, or, for disk, a folder inside a declared folder |
logging.redact, ai.redact | Every label in main.yaml is still in the list |
network.mode | Not weaker: observe < warn < enforce |
network.default | Not weaker: allow < deny |
permissions.mode | Not weaker: observe < enforce |
logging.audit | Not weaker: false < true |
limits.<name>, ai.limits.<name>, agents.<name>.budget.spend, agents.<name>.budget.calls | The same period as main.yaml and an amount no larger. A limit missing from main.yaml accepts any value |
agents.<name>.enabled | false always; true only when main.yaml doesn't set false |
agents.<name>.approve.risk | Approval is asked at least as often as in main.yaml |
agents.<name>.approve.spend-over | An amount no larger than main.yaml |
feeds.page.end, feeds.page.pause_after, feeds.trending.min-count, feeds.max-following | page.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-hours | No 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
| Call | What 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.
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
| Code | When | Fix |
|---|---|---|
MAIN-CONFIG-0004 | A literal value sits under a secret field (key, secret, token, password, apiKey, clientSecret), or config.redis.url is a literal | Use a secret: reference, and keep the secret in secrets |
MAIN-CONFIG-0020 | A changed value fails the schema, or a file isn't valid JSON; the last good value is kept | Correct the value in the store, or update config.schema.json |
MAIN-CONFIG-0021 | A pushed main. value is looser than main.yaml allows | Push a tighter value, or change main.yaml in a reviewed deploy |
MAIN-CONFIG-0022 | A pushed main. path can only change in a reviewed deploy, such as main.payments.driver | Change it in main.yaml and deploy |
MAIN-CONFIG-0023 | The store can't be read; the app serves its last good values, logged once per outage | Restore 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.
config:
driver: redis # was filesWhen 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.
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.
import { config } from "mainsdk";
await config.ready({ timeout: 2000 }); // first load applied, or 2 s passed
await config.refresh(); // pull the latest values nowNext
- Feature flags: turn features on per user or market.
- Secrets: credentials, server only.
- Change config without a deploy: a walkthrough with rollouts and watchers.
- Logs, audit and telemetry: where config changes are audited.
Was this page helpful?