Typed feature flags: keep agent code behind declared flags
To make sure AI-generated code only ships behind typed flags, keep every flag in one registry in your code, check flags only through a helper that accepts the registry's keys, and let the compiler, a lint rule and a test of both flag states catch anything else in CI. With Flagger.gg, the SDK registers that same registry when the app starts, so every flag the code uses exists in the service, and a flag that isn't finished ships off with a not-ready reason.
Updated
Why string flag keys break with agents
Most flag SDKs take the flag key as a plain string. An agent that types "new_chekout" instead of "new_checkout" gets no error: the flag is unknown, so the check is off, and the feature silently never ships. An agent can also call a flag no one created, or leave a check behind after the flag is deleted. At the pace agents merge changes, nobody spots these by reading diffs.
The fix is to make the flag list part of the code, not a convention. There is one registry, one typed way to check a flag, and CI fails when code goes around either of them.
Set it up in five steps
- Declare every flag in one registry. Keep one file, such as
flags.tsorflags.go, that lists each flag with a summary, a description and an owner. A flag that isn't finished gets anotReadyreason. Flagger.gg's SDKs take this registry asdefs(Node) orDefs(Go) and register it with the service when the app starts. - Check flags only through a typed helper. Export one function that takes the registry's key type, so a misspelled or undeclared key is a compile error rather than a quiet "off".
- Ban direct SDK calls elsewhere. A lint rule stops other files from importing the SDK, so the typed helper is the only way in.
- Test both states of every new flag. Run each flagged code path with the flag on and off, using the SDK's test helpers, so an agent's change has to work both ways.
- Tell the agent the rules. Put the registry path and the rules in the file your agent reads (
AGENTS.md,CLAUDE.mdor Cursor rules), and let CI enforce them.
Node and TypeScript
In @flaggergg/node, createFlagger is generic over the registry's keys. flag() suggests those keys in your editor but still accepts any string, which keeps older call sites working. A short wrapper makes the check strict:
// server/flags.ts: the only file that imports the SDK
import { createFlagger, type Def, type FlagContext } from "@flaggergg/node";
export const FLAGS = {
new_checkout: {
summary: "Customers see the rebuilt checkout",
description: "New checkout flow in web/checkout",
owner: "payments",
notReady: "Tax step not done", // ships off until this is removed
},
} satisfies Record<string, Def>;
export type FlagKey = keyof typeof FLAGS;
export const flagger = createFlagger({ sdkKey: process.env.FLAGGER_SDK_KEY!, defs: FLAGS });
// The typed check: an undeclared key fails to compile.
export const isOn = (key: FlagKey, ctx?: FlagContext) => flagger.flag(key, ctx);
isOn("new_checkout", { userId, email, isAdmin }); // ok
isOn("new_chekout", { userId }); // type error: not a FlagKey
Then block direct imports of the SDK everywhere but the registry file, with ESLint's no-restricted-imports rule:
// eslint.config.js
export default [
{ rules: { "no-restricted-imports": ["error", { paths: [{ name: "@flaggergg/node", message: "Use isOn() from server/flags.ts" }] }] } },
{ files: ["server/flags.ts"], rules: { "no-restricted-imports": "off" } },
];
tsc --noEmit and eslint in CI now fail on a key that isn't in the registry, and on any flag check that bypasses it. Public npm installs of @flaggergg/node are In development.
Go
In Go, give flag keys their own type and declare each one as a constant. The registry and the check both use that type, so a call site can't pass a bare string by accident:
package flags
import (
"context"
"os"
flagger "flagger.gg/go"
)
type Key string
const NewCheckout Key = "new_checkout"
var Client = flagger.New(flagger.Options{
SDKKey: os.Getenv("FLAGGER_SDK_KEY"),
Defs: []flagger.Def{
{Key: string(NewCheckout), Owner: "payments", Summary: "Customers see the rebuilt checkout",
Description: "New checkout flow", NotReady: "Tax step not done"},
},
})
// On is the only flag check the rest of the code uses.
func On(ctx context.Context, k Key) bool { return flagger.Enabled(ctx, string(k)) }
Call sites read flags.On(ctx, flags.NewCheckout), and a misspelled constant doesn't compile. Start returns an error for a bad or duplicate key in Defs, so a broken registry fails at boot rather than in production traffic. Public go get installs of flagger.gg/go are In development.
Test both states
A typed key proves the flag exists. It doesn't prove the code works with the flag off. Both SDKs ship helpers that run a test once in each state:
// Node
await flagger.bothFlagStates("new_checkout", async (on) => {
const page = await renderCheckout(cart);
expect(page.includes("Tax")).toBe(on);
});
// Go
flaggertest.BothFlagStates(t, flags.Client, string(flags.NewCheckout), func(t *testing.T, on bool) {
// assert the old path when on is false and the new one when it's true
})
More patterns, including withFlag and setFlagOverride, are in testing both sides of a flag.
What Flagger.gg does with the registry
- Registration at start. The SDK sends the registry to Flagger.gg when the app starts. Missing flags are created, and summaries, descriptions, owners and not-ready reasons are updated. Registration never changes a flag's state or rules, so a deploy never overwrites a state someone set.
- New flags follow the project's policy. In auto-live mode (the default) a new flag starts on unless it carries a not-ready reason, which starts it off. With approvals on, new flags start off. Turning a not-ready flag on needs an explicit confirmation (
--confirm-not-readyin the CLI), and bulk changes refuse it. - "Not in code yet". A flag that no app has registered in an environment in the last 30 days, such as one an agent created through the API or MCP, is marked "Not in code yet" in the Release console. That shows flags that exist in the service but not in any registry.
- Unknown means off. If a key reaches the SDK that the ruleset doesn't have, the check is off, never an error.
For the full flow of agents merging to main behind flags, see trunk-based development with AI agents.
Rules to give the agent
## Feature flags
- Every user-visible change ships behind a flag declared in server/flags.ts (Go: internal/flags/flags.go).
- Check flags only with isOn() / flags.On(). Never import the flag SDK anywhere else.
- A flag for unfinished work gets a notReady reason. Remove the reason in the change that finishes the work.
- Every flagged code path has a bothFlagStates test.
- When a flag has been on for everyone for a while, delete it from the registry and delete the dead code path.
North Bay Ventures, which builds Flagger.gg, sets out the same pattern in keeping AI-written code behind typed feature flags.
To let the agent create, roll out and kill flags as well, give it a scoped agent token and Flagger.gg's MCP server; see Claude Code, Codex and Cursor behind flags.
FAQ
How do I make sure AI-generated code only ships behind typed flags?
Keep every flag in one registry in code, check flags only through a helper typed to the registry's keys, ban direct SDK imports with a lint rule, and require a test of both flag states. CI then fails on any flag check that isn't declared.
What happens if code checks a flag key that doesn't exist?
In Flagger.gg's SDKs an unknown flag is off, so the check never throws. That's safe but silent, which is why the typed helper turns a misspelled key into a compile error.
Does Flagger.gg's Node SDK type flag keys?
Partly. createFlagger knows the registry's keys and your editor suggests them, but flag() also accepts any string. Wrap it in a function that takes keyof typeof FLAGS to make undeclared keys a type error.
Can an agent's deploy reset a flag someone already turned off?
No. Registration creates missing flags and updates their descriptions, but never changes a flag's state or rules.
How does an agent merge a flag for work that isn't finished?
It declares the flag with a notReady reason. The flag starts off, and turning it on needs an explicit confirmation until a later change removes the reason.
Ship your next change behind a flag.
Sign up, add an SDK key to your app and give your agent a token.