---
title: "Feature flags"
description: "Turn a feature on for some users, a market or a share of users, and change it while the app runs. Every instance gives the same answer for the same user."
section: "interfaces"
status: "Preview · ships as a beta API"
spec: "flags"
canonical: "https://mainsdk.dev/docs/interfaces/flags"
updated: "2026-10-28"
---

# Feature flags

> Turn a feature on for some users, a market or a share of users, and change it while the app runs. Every instance gives the same answer for the same user.

Status: Preview · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/flags · Updated: 2026-10-28

`flags` turns a feature on for some users, a market or a percentage of everyone, and you change it while the app runs. Reach for it for gradual rollouts, kill switches and per-market features.

> **Note:** `flags` is built (spec `flags`, milestone 0.3) and runs from the repository; it is not published to npm. The catalog still lists it as planned until the milestone closes.

## At a glance

- Status: Preview · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.3 Every interface
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/flags.md
- Category: Developer experience

Calls:

- `flags.enabled(name, ctx)`
- `flags.value(name, fallback, ctx)`

In main.yaml:

```yaml
flags:
  driver: config
```

Guarantees, whatever the driver:

- The same answer for the same user on every instance
- Reads memory only; no network call or disk read per check
- A change applies with no restart and is audited like any config change


## Drivers

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

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Config store | `config` | yes |  |
| Any OpenFeature provider | `openfeature` |  |  |
| PostHog | `posthog` |  |  |
| Vercel Flags | `vercel-flags` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-FLAGS-0001` | runtime | A flag was evaluated with no definition in config; the fallback was served, logged once per name |
| `MAIN-FLAGS-0002` | runtime | A pushed flag definition is invalid; it was refused and the last good definition kept |
| `MAIN-FLAGS-0003` | runtime | A rule served a value of a different type from the fallback, or `enabled` got a non-boolean; the fallback was served, logged once per name |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Enabled and value from the config store | 1 | 0.3 | `flags#flags-eval` |
| Stable percentage rollout by user | 1 | 0.3 | `flags#flags-rollout` |
| Live updates and refusal of invalid definitions | 1 | 0.3 | `flags#flags-live` |
| Target by plan | 1 | 0.3 | `entitlements#limits-flags` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

Define the flag in your config store, under `flags.<name>`. With the `files` driver, `config/flags.json` holds it:

```json title="config/flags.json"
{ "new-checkout": { "enabled": true, "rollout": 25 } }
```

```yaml title="main.yaml"
flags:
  driver: config
```

```ts title="app/api/checkout/route.ts"
import { auth, flags } from "mainsdk";

export async function GET(req: Request) {
  const session = await auth.require(req);
  const useNew = flags.enabled("new-checkout", { user: session.user });   // synchronous: no await
  return Response.json({ checkout: useNew ? "new" : "classic" });
}
```

## Configuration

```yaml title="main.yaml"
flags:
  driver: config   # config (default) | openfeature (planned)
```

`config` is the only driver today. A flag's definition is the config value at `flags.<name>`:

```jsonc title="flags.<name> in the config store"
{
  "enabled": true,         // required; false turns the flag off for everyone, before any rule
  "rollout": 25,           // percent of users, 0 to 100, up to two decimals; default 100
  "salt": "new-checkout",  // optional; default is the flag name. Change it to reshuffle who is in
  "rules": [               // optional; checked in order, first match wins, before rollout
    { "user": ["u_123"], "serve": true },
    { "attr": "market", "in": ["in"], "serve": true },
    { "attr": "plan", "notIn": ["enterprise"], "serve": false }
  ],
  "value": "blue"          // optional; what flags.value() returns when the flag is on
}
```

## What you get

A flag is a thin layer on [`config`](https://mainsdk.dev/docs/interfaces/config): its definition lives in the same store, arrives through the same change stream, passes the same checks and is audited the same way. `flags.enabled()` and `flags.value()` are synchronous and read memory, so a check makes no network call and waits on nothing. Every instance gives the same answer for the same user, so a user never flips between old and new as requests land on different instances.

### How a check decides

1. No definition: the flag is off, and `flags.value()` returns the fallback. The name is logged once.
2. `enabled: false`: off, whatever the rules say.
3. Rules, in order. The first rule that matches serves its `serve` value. A rule whose attribute is missing from the context does not match.
4. Rollout. The user's bucket is the first four bytes of the SHA-256 of `<salt>:<user>`, modulo 10,000. The flag is on when the bucket is under `rollout` × 100. With no user, the flag is on only at 100.
5. On: `enabled` returns `true`, and `value` returns the definition's `value` or the fallback. Off: `false` and the fallback.

The bucket depends only on the salt and the user id. Raising `rollout` keeps everyone who was already on. Changing the `salt` moves everyone at once.

## Examples

### Turn a feature on for one market first

```json title="flags.<name> in the config store"
{ "enabled": true, "rollout": 5, "rules": [{ "attr": "market", "in": ["in"], "serve": true }] }
```

```ts title="app/api/checkout/route.ts"
import { flags } from "mainsdk";

export function checkoutFor(userId: string, market: string) {
  return flags.enabled("new-checkout", { user: userId, attrs: { market } }) ? "new" : "classic";
}
```

India gets the new checkout first, then 5% of everyone else. Raise `rollout` to 100 when it is ready; nobody who was on drops off.

### Kill switch in an API route

```ts title="app/api/imports/route.ts"
import { auth, flags } from "mainsdk";

export async function POST(req: Request) {
  const session = await auth.require(req);
  if (!flags.enabled("imports", { user: session.user })) {
    return Response.json({ error: "Imports are paused" }, { status: 503 });
  }
  return new Response(null, { status: 202 });
}
```

Set `flags.imports` to `{ "enabled": false }` in your config store and every instance stops accepting imports, with no deploy.

### Read a typed value

A flag can carry a value. `flags.value()` returns it when the flag is on, and the fallback otherwise. The fallback sets the type.

```ts title="app/lib/limits.ts"
import { flags } from "mainsdk";

export function exportRowLimit(userId: string) {
  return flags.value("export.row-limit", 500, { user: userId });
}
```

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-FLAGS-0001` | A flag was checked with no definition; the fallback was served. Logged once per name | Define `flags.<name>` in the config store, or check the right name |
| `MAIN-FLAGS-0002` | A pushed definition is invalid. It is refused, the last good definition stays, and the refusal is audited | Fix the definition: `enabled` a boolean, `rollout` from 0 to 100 with at most two decimals, and each rule with exactly one of `user`, `in` or `notIn` |
| `MAIN-FLAGS-0003` | A rule serves a value of another type from the fallback, or `enabled` gets a non-boolean. The fallback is served. Logged once per name | Serve a value of the same type as the fallback |

Evaluation writes no audit record per check and logs no user id or attribute. A flag log line carries only the flag name. A flag that is refused keeps its last good definition; see [Config](https://mainsdk.dev/docs/interfaces/config).

## Swapping drivers

Flags use the config driver you already run, so changing `config.driver` moves the flag definitions with it. The `openfeature` driver is planned: it would keep definitions in LaunchDarkly, Flagsmith, Unleash, PostHog or Vercel Flags. Your `flags.enabled()` and `flags.value()` calls stay the same.

## Testing

Share a `MemoryConfigStore` with the test app, set definitions at `flags.<name>`, and assert each answer with `app.flags.enabled()` or `app.flags.value()`. Evaluation is synchronous, so only a change needs `await app.live.refresh()`.

```ts title="app/flags.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";
import { MemoryConfigStore } from "mainsdk";

describe("new-checkout", () => {
  it("is on for India and off elsewhere", () => {
    const store = new MemoryConfigStore();
    store.set("flags.new-checkout", { enabled: true, rollout: 0, rules: [{ attr: "market", in: ["in"], serve: true }] });
    const app = createTestApp({ yaml: "schema: 1\nproject: prj_test\nconfig:\n  driver: memory", configDriver: store });
    assert.equal(app.flags.enabled("new-checkout", { user: "u1", attrs: { market: "in" } }), true);
    assert.equal(app.flags.enabled("new-checkout", { user: "u1", attrs: { market: "us" } }), false);
  });
});
```

See the [Testing kit](https://mainsdk.dev/docs/interfaces/testing).

## Security and privacy

- Evaluation logs only the flag name. User ids and attributes are never written to a log, an audit record or a provider.
- Rollout buckets come from a hash of the salt and the user id, so no list of users is stored.
- Definitions pass the same checks and audit as any config change.

## FAQ

**Can a flag call a remote service?** No. Evaluation reads memory only: no network call, disk read or audit record per check.

**Why not `await flags.enabled()`?** It is synchronous. Awaiting it does nothing but add a tick.

**How is this different from config?** Config holds typed values for the whole app. Flags are on or off, or a value, per user, with rollouts and rules.

## Target by plan

Inside a tenant, the context also carries the tenant's `tenant` id, `tenant_kind`, `region` and `plan` as attributes (attributes you pass win). A rule such as `{ "attr": "plan", "in": ["pro"], "serve": true }` turns a feature on for `pro` and leaves it off for `free` without a code change. The plan comes from [entitlements](https://mainsdk.dev/docs/interfaces/entitlements). For a feature that is part of a plan, prefer `entitlements.require()`: it refuses with an upgrade hint, where a flag only hides.

## Next

- [Runtime settings](https://mainsdk.dev/docs/interfaces/config): the store flags are kept in.
- [Change config without a deploy](https://mainsdk.dev/docs/guides/change-config-without-a-deploy): rollouts and watchers, step by step.
- [Testing kit](https://mainsdk.dev/docs/interfaces/testing): the memory driver and assertions.
