---
title: "Runtime settings"
description: "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."
section: "interfaces"
status: "Building for 0.2 · ships as a stable API"
spec: "config-secrets"
canonical: "https://mainsdk.dev/docs/interfaces/config"
updated: "2026-10-28"
---

# Runtime settings

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

Status: Building for 0.2 · ships as a stable API · Canonical: https://mainsdk.dev/docs/interfaces/config · 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](https://mainsdk.dev/docs/concepts/main-yaml); feature switches per user belong in [Feature flags](https://mainsdk.dev/docs/interfaces/flags).

## At a glance

- Status: Building for 0.2 · ships as a stable API
- Phase: 1 (Core, public beta)
- Milestone: 0.2 Core loop
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/config-secrets.md
- Category: Developer experience

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

In main.yaml:

```yaml
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…" }
```

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

Pick one in main.yaml with `config.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Files | `files` |  |  |
| Local (in process) | `local` |  |  |
| Redis | `redis` |  |  |
| Postgres | `postgres` |  |  |
| Vercel Global Config | `vercel-global-config` |  |  |
| AWS AppConfig | `aws-appconfig` |  |  |
| Cloudflare KV | `cloudflare-kv` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-CONFIG-0030` | check | 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` | check | 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` | check | 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` | check | 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` | runtime | A literal value in a field marked secret |
| `MAIN-CONFIG-0020` | runtime | A config value fails its schema; the last good value is kept |
| `MAIN-CONFIG-0021` | runtime | 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` | runtime | 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` | runtime | 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` | runtime | Unsupported `schema` value in `main.yaml` |
| `MAIN-CONFIG-0002` | runtime | `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` | runtime | A value has the wrong format: money (`"$0.05"`), a rate (`$5/day`), a size (`25MB`) or a duration (`1h`) |
| `MAIN-CONFIG-0005` | runtime | No `main.yaml` in the project folder |
| `MAIN-CONFIG-0010` | runtime | A layer weakens its parent, for example adds a host `main.org.yaml` doesn't allow |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Typed get and watch | 1 | 0.2 | `config-secrets#config-api` |
| File driver | 1 | 0.2 | `config-secrets#config-drivers` |
| Background refresh by change stream or poll | 1 | 0.2 | `config-secrets#config-runtime` |
| Last good snapshot kept on disk for a cold start | 1 | 0.2 | `config-secrets#config-runtime` |
| Tighten-only pushes of main.yaml values | 1 | 0.2 | `config-secrets#config-tighten` |
| Audit of every applied and refused change | 1 | 0.2 | `config-secrets#config-audit` |
| Memory driver and fs.watch on the files driver | 1 | 0.2 | `config-secrets#config-memory-files` |
| Redis driver on the built-in RESP client | 1 | 0.2 | `config-secrets#config-redis` |
| Deploy files for Vercel | 1 | 0.4 | `deploy#writer` |
| Environment diff for a reviewed promotion | 1 | 0.4 | `deploy#diff` |

## MainSDK Cloud

Everything on this page is free in your app.


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

```yaml title="main.yaml"
config:
  driver: files
  schema: ./config.schema.json
```

```json title="config.schema.json"
{
  "type": "object",
  "properties": {
    "cart": {
      "type": "object",
      "properties": { "maxItems": { "type": "integer" } }
    }
  }
}
```

TypeScript:

```ts
import { config } from "mainsdk";

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

Rust:

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

```yaml title="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>
```

| 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 `default`s 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](https://mainsdk.dev/docs/interfaces/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:

```yaml title="main.env.dev.yaml"
config:
  driver: files
```

```yaml title="main.env.prod.yaml"
config:
  driver: redis
```

## What 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.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

```ts title="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.

```ts title="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.

```ts title="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.

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

```ts title="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

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

```yaml title="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.

```ts title="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](https://mainsdk.dev/docs/interfaces/testing) 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](https://mainsdk.dev/docs/interfaces/secrets) and [Data and records](https://mainsdk.dev/docs/interfaces/db).

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

```ts title="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

- [Feature flags](https://mainsdk.dev/docs/interfaces/flags): turn features on per user or market.
- [Secrets](https://mainsdk.dev/docs/interfaces/secrets): credentials, server only.
- [Change config without a deploy](https://mainsdk.dev/docs/guides/change-config-without-a-deploy): a walkthrough with rollouts and watchers.
- [Logs, audit and telemetry](https://mainsdk.dev/docs/interfaces/log): where config changes are audited.
