LaunchFlag

Docs / SDK

Write the if, ship it dark.

One env key, one import, one line. The flag registers itself the first time your code runs, off in every environment, and you flip it from the dashboard without a deploy.

Install

#

One command, from the root of your project. It installs the package, writes your dev key into .env.local, downloads the agent skill, and configures the MCP server for Claude Code and Cursor.

Terminal

$ npx launchflag init --key lf_dev_… --url https://launchflag.dev

Using pnpm? Run pnpm dlx launchflag init instead of npx.

Leave the flags off and it asks for the dev key and the API token, or pass them as --key, --token and --url. Use --dry-run to see what it would write, and --no-install, --no-mcp or --no-skill to skip a step. Running it twice changes nothing: existing env values are never overwritten.

launchflag is the SDK and the CLI, and it is the only package most projects need. launchflag-mcp is a second package, needed only if you want the MCP server running locally instead of hosted.

Manual setup instead: npm i launchflag, then set LAUNCHFLAG_KEY yourself. Node 18+. No dependencies. React is an optional peer dependency, needed only for the launchflag/react components.

The integration

#

One SDK today, for JavaScript and TypeScript. Every other language calls the HTTP API, which is what the tabs below show.

Your code

import { flag } from "launchflag";

if (await flag("checkout_v2", user.id)) return newCheckout(cart);
return oldCheckout(cart);

The Python and Go tabs fetch /api/config once every five seconds and decide the flag in your process, the way the SDK does, using the same FNV-1a bucket, so a percentage rollout splits traffic identically. One request, an environment key, JSON back.

That is the whole thing. Keep the old path intact and reachable: the flag is what you flip, not the deploy. Where a request has a signed-in user, pass both identifiers: flag("checkout_v2", { id: user.id, email: user.email }). A bare string still works, but the two sides are chosen by different people. Whoever fills in an allowlist types emails, while your code has an id, and u_8213 never matches sam@acme.com. Nothing errors when they disagree: the feature simply never appears. Rollouts bucket on the id, so adding an email later does not reshuffle who is in. Code with no user, like a cron job or a webhook, passes nothing and only sees flags targeted at everyone.

LAUNCHFLAG_KEY is a secret. Call the SDK from Server Components, route handlers, server actions or middleware, never from the browser. With no key set, flag() logs once and returns false.

Coding agents and MCP

#

The agent that writes the risky code can be the one that wraps it in a flag. LaunchFlag ships an agent skill, which changes what your agent writes, and an MCP server, which changes what it can do: create a flag, flip it in dev to test its own work, and promote it to staging. Turning a flag on in production stays yours.

Claude CodeCodexCursor

Works with Claude Code, Codex and Cursor, and with any other client that speaks MCP.

Fastest path, from the root of your project. It installs the SDK, writes the skill to .claude/skills/launchflag/SKILL.md, and points Claude Code and Cursor at the hosted MCP server.

Terminal

$ npx launchflag init --key lf_dev_… --token lfk_… --url https://launchflag.dev

Or connect the hosted endpoint by hand. The three clients take the token differently, so the line is not the same for each. Replace lfk_… with your API token from the dashboard Install page.

Claude Code

$ claude mcp add --transport http launchflag https://launchflag.dev/mcp --header "Authorization: Bearer lfk_…"

Codex

$ export LAUNCHFLAG_TOKEN=lfk_…
$ codex mcp add launchflag --url https://launchflag.dev/mcp --bearer-token-env-var LAUNCHFLAG_TOKEN

Codex has no header flag. It reads the bearer token from the environment every time it starts, so put the export in your shell profile rather than running it once.

Cursor, .cursor/mcp.json

{
  "mcpServers": {
    "launchflag": {
      "type": "http",
      "url": "https://launchflag.dev/mcp",
      "headers": { "Authorization": "Bearer lfk_…" }
    }
  }
}

The hosted endpoint is the default: one URL and a token, nothing to install and nothing to keep running. The token always goes in the Authorization header or an env var, never in the URL. If you would rather the process ran locally, launchflag-mcp is a second package with the same tools over stdio.

Local stdio, the alternative

$ claude mcp add launchflag -e LAUNCHFLAG_TOKEN=lfk_… -e LAUNCHFLAG_URL=https://launchflag.dev -- npx -y launchflag-mcp@latest

The tools are list_flags, cleanup_flags, create_flag, set_flag, promote_flag, delete_flag, emergency_off and list_projects. The token is per user and manages every project you own, so treat it like a password and keep the config file that holds it out of git. Full guide, including the skill workflow and troubleshooting: Agents and MCP.

React components

#

Flag and Variant are async server components. React 19+.

The same two checks, called directly

import { flag, variant } from "launchflag";

const on = await flag("checkout_v2", user.id);
const arm = await variant("pricing_test", user.id);

Who sees a flag

#

Five ways to answer “who sees this”. Every one is decided inside your process with no lookup, except first N. Everyone is on for every caller. Logged in is on for anyone you pass an identifier for.

Percentage is the one people ask about most, so here is the whole mechanism. There is no counting, no minimum, and nothing to wait for:

The entire decision

bucket  = fnv1a(flagKey + ":" + userId) % 100   // 0-99, always the same for that pair
enabled = bucket < percentage

Each person is hashed once with the flag key into a number from 0 to 99. Under 10 means they are in the 10%. So the first user ever to reach the flag is in or out on their own identifier alone, and we never need to know how many users you have.

It is sticky, so nobody watches a feature flicker between page loads. Raising the percentage only ever adds people, because a bucket under 10 is also under 20, so no one loses a feature mid-rollout. And the flag key is part of the hash, so the same unlucky user is not the guinea pig for everything you ship.

Small numbers are lumpy. At 10% with twenty users you would expect two, but nobody at all is an ordinary outcome, not a broken flag. The user check on the flag page tells you which side of the line anyone falls on. Callers with no identifier are out of a percentage rollout, because there is nothing stable to bucket them by.

First N is a real count, so it is the one mode that needs the server: the SDK calls /api/eval and caches the answer for 60 seconds per user. Use it when you mean exactly a hundred people rather than about a tenth of them. Allowlist takes exact ids, exact emails, or a whole domain written *@acme.com, trimming and folding case on both sides.

A/B tests and variants

#

A multivariate flag has 2 to 5 arms with integer weights summing to 100. Each user keeps the same arm forever, bucketed from the user id, so the split is stable across servers and deploys.

Your code

import { variant } from "launchflag";

const arm = await variant("pricing_test", user.id);        // "a" | "b" | null
const arm2 = await variant("pricing_test", user.id, "a");  // "a" when off or unknown

null means the flag is off for this user, unknown, or a plain boolean flag. Variants share the same snapshot and cache as flag(), so they cost no extra request.

Running an A/B test

#

We do the assignment half and none of the measurement half. LaunchFlag splits traffic and keeps each person on the same arm. Which arm won is a question for whatever you already use to measure things: we store no per-arm numbers, so there is no dashboard here that will declare a winner.

The whole loop is three steps. Ask for the arm, render it, and send the arm with the event you care about.

Your code

const arm = await variant("pricing_test", { id: user.id });
track("signup", { plan: "pro", arm });     // your analytics, not ours

That is how this site runs its own headline test: the arm is chosen server side, stamped onto the rendered element, and sent with the signup goal so the funnel can be split by arm afterwards.

Three things will skew a test if you do not know them. Callers with no identifier all get the first arm, because there is nothing to bucket them by, which on a public page quietly puts every logged-out visitor in the control group. Pass a stable per-visitor id, a first-party cookie is enough, or the test only runs on signed-in users.

Do not change the weights mid-test. Arms are picked by walking the cumulative weights, so moving 50/50 to 60/40 moves the people bucketed at 50 to 59 across. They are stable while the weights are, not across an edit. Adding or removing an arm reshuffles everyone for the same reason, so decide the arms before you start.

Flag dependencies

#

A flag can require another one. While the flag it requires is off for a user, the dependent flag is off for that user too, whatever its own rollout says. Set it on the flag's page under Depends on, or hand it to an agent.

Over MCP

depend_flag { key: "checkout_v2", requires: "new_pricing" }

The chain resolves in your process, in the same pass as the flag itself: no extra request, and the answer is identical whether the SDK, the API or this dashboard is doing the asking. why() reports prerequisite_off, so a flag that looks like it should be serving tells you which one is holding it down.

A flag cannot depend on itself, and a dependency that would close a loop is refused when you set it. Chains stop at ten deep.

One limit: a flag published to browsers cannot depend on another flag. The public document holds only the flags you exposed and never an allowlist, so the prerequisite could not be evaluated there and would quietly resolve to off. Setting both is refused rather than served wrong.

OpenFeature

#

LaunchFlag ships an OpenFeature provider, so your code can talk to the standard API instead of to us. Adopting LaunchFlag stays reversible: switching to another provider is one line, and that is the point of it.

Your code

npm install @openfeature/server-sdk

import { OpenFeature } from "@openfeature/server-sdk";
import { launchFlagProvider } from "launchflag/openfeature";

await OpenFeature.setProviderAndWait(launchFlagProvider({ key: process.env.LAUNCHFLAG_KEY! }));

const client = OpenFeature.getClient();
if (await client.getBooleanValue("checkout_v2", false, { targetingKey: user.id })) {
  return newCheckout(cart);
}

Evaluation still happens in your process. The provider wraps the same client, so the config document, the cache, the fail modes and self-registration all behave exactly as they do without it, and no evaluation makes a network call.

targetingKey is the user. Pass email in the context too if you use allowlists. Booleans map to flags and strings map to multivariate arms. LaunchFlag has no numeric or object flags, so those return the default you passed rather than pretending: a mixed codebase keeps working while it moves.

Local evaluation

#

The SDK fetches one document, /api/config, and decides every flag inside your process. That is one request per ttlMs for the whole server, not per user: 200 users across 40 flags cost the same single fetch. The document holds no user data, so it caches at the edge and your app never waits on us.

GET /api/config · the rules for one environment

curl -i "https://launchflag.dev/api/config?key=$LAUNCHFLAG_KEY"

cache-control: public, max-age=0, s-maxage=5, stale-while-revalidate=60
etag: "v42-prod"

{"v":42,"env":"prod","emergencyOff":false,"archived":false,
 "flags":{"checkout_v2":{"type":"boolean","failMode":"closed","essential":false,"enabled":true,
   "targeting":"percentage","percentage":25,"limit":100,"allowlist":[],"variants":[]}}}

Every refresh sends If-None-Match. An unchanged config comes back 304 and costs nothing. Every change you make bumps v, which is the ETag, so the next refresh picks it up within ttlMs.

First N targeting is the one exception: admission is a row in our database, not arithmetic, so those flags call /api/eval and the answer is cached for 60 seconds per user. Percentage buckets, allowlists, logged-in checks, variant picks and emergency off all resolve locally.

What you evaluate is buffered per key and posted to /api/counts every 10 seconds, or on flush() before a short-lived process exits. A failed post keeps its counts for the next attempt and never surfaces on the flag path. Pass mode: "remote" to createLaunchFlag to go back to server-side evaluation, one snapshot per user.

Ops note for self-hosting behind a CDN: set CLOUDFLARE_ZONE_ID and CLOUDFLARE_API_TOKEN and every flag change purges the cached config for all three keys, so a flip is live in a second instead of waiting out s-maxage. With either one unset nothing is purged and the edge copy just expires.

When LaunchFlag is unreachable

#
  • One call, cached 5 seconds. The rule set is fetched once per ttlMs, 5000 by default, and shared by every user in the process. A render that checks eight flags costs no request at all. Concurrent calls share the one fetch in flight.
  • A slow API never blocks a render. Requests abort at timeoutMs, 800 ms by default.
  • A down API never flips a flag. A failed fetch keeps serving the last known config and retries after the next TTL window. With nothing cached yet, flag() returns defaults[key] if you passed one, then its third argument, else false. flag(), variant() and all() never throw.
  • Fail closed is the default. A flag has a failMode of closed or open, and self-registered flags are closed. Closed is the right call for money, auth, deletion and anything with an external side effect. Open is for cosmetics.

We cannot enforce failMode for you, and no flag service can: the SDK would have to read it out of the config document it just failed to fetch. It is the flag's recorded intent, and the lever that implements it lives in your code. That lever is defaults in createLaunchFlag, or the third argument to flag(key, user, fallback) for a single call. Pick the safety default on the flag page and it shows you the exact defaults entry to paste, so the intent and the code say the same thing.

Explicit config

import { createLaunchFlag } from "launchflag";

const lf = createLaunchFlag({
  key: process.env.LAUNCHFLAG_KEY!,
  baseUrl: "https://launchflag.dev",
  mode: "local",      // "remote" to evaluate server side instead
  ttlMs: 5000,        // config cache window
  timeoutMs: 800,     // abort a slow request
  defaults: { new_nav: true, checkout_v2: false },
});

await lf.flag("checkout_v2", user.id);         // boolean
await lf.flag("new_nav", user.id, true);       // third argument: only when nothing is known
await lf.variant("pricing_test", user.id);     // the arm
await lf.all(user.id);                         // every flag as { key: boolean }
await lf.why("checkout_v2", user.id);          // { enabled, reason, variant }, uncached, throws on error
await lf.flush();                              // report buffered counts now
lf.clear();                                    // drop the cached config

Environments and keys

#

Every project has up to three environments and one key each. The key decides which environment is evaluated, so the same code returns different answers on your laptop and in production. Free runs 2 of them, dev and prod; Pro runs 3, adding staging between the two.

KeyEnvironmentWhere it belongs
lf_dev_…devYour machine. .env.local, gitignored.
lf_staging_…stagingThe staging host only.
lf_prod_…prodThe production host only. Never in the repo.
lfk_…managementYour API token for the MCP server and /api/v1. Per user, not per project.

Name them what you like on the dashboard, and turn off the ones you do not use. The API always uses dev, staging and prod. With staging off, dev promotes straight to prod.

Flip a flag in dev, test it, then copy that environment forward with promote: dev to staging, staging to prod. A human does the last step. Keys are on the dashboard Install page and can be regenerated from Settings.

Notifications

#

LaunchFlag can post a line in Slack or Discord when something changes. Both at once, up to five channels, set up on the dashboard under Settings, Notifications. Each destination chooses what it hears: Critical only, the default, is production flips, emergency off and lift, key regeneration, plan changes and member changes.Everything is every audited change, which is a firehose most teams mute within a week.

🚩 checkout-api: Adarsh M. turned ON `checkout_v2` (prod): everyone

Slack, if you have never made a webhook:

  • 1. Open api.slack.com/apps and click Create New App.
  • 2. Choose From scratch.
  • 3. Name it, pick the workspace, and click Create App.
  • 4. In the left menu under Features, click Incoming Webhooks.
  • 5. Turn the Activate Incoming Webhooks toggle on.
  • 6. Scroll down and click Add New Webhook to Workspace.
  • 7. Pick the channel, then click Allow.
  • 8. Copy the URL under Webhook URL and paste it into Settings, Notifications.

Discord:

  • 1. Open the server and channel you want the alerts in.
  • 2. Hover the channel name and click the cog, Edit Channel.
  • 3. Click Integrations in the left menu.
  • 4. Click Webhooks, then New Webhook.
  • 5. Name it and check the channel it posts to.
  • 6. Click Copy Webhook URL and paste it into Settings, Notifications.

Anyone who has that URL can post to the channel, so treat it like a password and keep it out of shared documents. Only hooks.slack.com, discord.com and discordapp.com are accepted; nothing else can be saved. Each destination has a Send test button, and a destination that answers 404 or 410 three times in a row turns itself off rather than retrying a deleted channel forever.

No dependency, 15 lines

#

If you would rather not add a package, this is the whole client. Same self registration, same 5 second cache, and it reports its own evaluation counts back so the dashboard stays honest.

flag.ts

const KEY = process.env.LAUNCHFLAG_KEY!, API = "https://launchflag.dev/api/flags";
const counts = new Map<string, [number, number]>();
let cache: { at: number; user?: string; flags: Record<string, { enabled: boolean; variant: string | null }> } | null = null;

export async function flag(name: string, user?: string, fallback = false): Promise<boolean> {
  if (!cache || cache.user !== user || !(name in cache.flags) || Date.now() - cache.at > 5000) {
    const c = [...counts].map(([k, v]) => `${k}:${v[0]}:${v[1]}`).join(",");
    const q = `?user=${encodeURIComponent(user ?? "")}&keys=${encodeURIComponent(name)}&c=${c}`;
    try { const res = await fetch(API + q, { headers: { authorization: `Bearer ${KEY}` }, signal: AbortSignal.timeout(800) });
      if (res.ok) (cache = { at: Date.now(), user, flags: (await res.json()).flags }), counts.clear(); } catch { /* keep the last snapshot */ }
  }
  const on = cache?.flags[name]?.enabled ?? fallback, v = counts.get(name) ?? [0, 0];
  counts.set(name, [v[0] + 1, v[1] + (on ? 1 : 0)]);
  return on;
}
export const variant = async (n: string, u?: string) => { await flag(n, u); return cache?.flags[n]?.variant ?? null; };

In TypeScript, install @types/node if you do not already have it, so process.env type-checks.

Raw API

#

Five endpoints, all authenticated by an environment key sent as Authorization: Bearer lf_dev_…. They also accept the key as ?key=, which is what /api/config uses: a CDN will not cache a request that carries an Authorization header.

GET /api/eval · one flag, with the reason

#
curl "https://launchflag.dev/api/eval?flag=checkout_v2&user=u_123" \
  -H "Authorization: Bearer $LAUNCHFLAG_KEY"

{"enabled":false,"reason":"disabled","variant":null}

reason says why you got that answer: disabled, everyone, logged_in, anonymous, allowlist, not_in_allowlist, in_rollout, out_of_rollout, first_n, first_n_full, emergency_off, unknown_flag or registered.

GET /api/flags · every flag, one call

#
curl "https://launchflag.dev/api/flags?user=u_123&keys=checkout_v2&c=checkout_v2:12:5" \
  -H "Authorization: Bearer $LAUNCHFLAG_KEY"

{"flags":{"checkout_v2":{"enabled":false,"failMode":"closed","variant":null,"type":"boolean"},
          "new_nav":{"enabled":true,"failMode":"open","variant":null,"type":"boolean"},
          "pricing_test":{"enabled":true,"failMode":"closed","variant":"b","type":"multivariate"}},
 "emergencyOff":false,"env":"dev"}

This is what the SDK calls, and it always answers with the whole config for that environment: every flag in the project, evaluated for this user. keys filters nothing. It is the list of flags your code has asked for, so LaunchFlag can register any it has never seen, off. c reports what you evaluated locally since the last refresh, as key:evals:on pairs, so cached evaluations still show up in your counts. The snapshot fetch itself is not counted as an eval.

GET /api/config · the rule set, no user, cacheable

#
curl -i "https://launchflag.dev/api/config?key=$LAUNCHFLAG_KEY"

cache-control: public, max-age=0, s-maxage=5, stale-while-revalidate=60
etag: "v42-prod"

{"v":42,"env":"prod","emergencyOff":false,"archived":false,
 "flags":{"checkout_v2":{"type":"boolean","failMode":"closed","essential":false,"enabled":true,
   "targeting":"percentage","percentage":25,"limit":100,"allowlist":[],"variants":[]}}}

The document local mode evaluates against. One per project and environment, versioned by v, which is also the ETag. Counts are not accepted here, they go to /api/counts.

POST /api/counts · what you evaluated locally

#
curl -X POST "https://launchflag.dev/api/counts" \
  -H "Authorization: Bearer $LAUNCHFLAG_KEY" -H "content-type: application/json" \
  -d '{"c":{"checkout_v2":{"evals":12,"on":5}}}'

204 No Content

At most 50 keys per call, each count an integer up to 1,000,000. Keys the project does not know are dropped rather than rejected. Answers 204 and nothing else.

POST /api/register · create flags without evaluating them

#
curl -X POST "https://launchflag.dev/api/register" \
  -H "Authorization: Bearer $LAUNCHFLAG_KEY" -H "content-type: application/json" \
  -d '{"keys":["checkout_v2","onboarding_ai_summary"]}'

{"created":["checkout_v2"],"skipped":["onboarding_ai_summary"]}

The management API at /api/v1/flags creates, patches, promotes and deletes flags. It takes an lfk_… token instead, and it is what the MCP server talks to.

Rate limits

#

Sliding one minute windows, counted per key rather than per IP, so one busy server keeps its whole allowance. Over the limit you get a 429 with a retry-after header in seconds.

BucketLimitApplies to
Environment key600 / minute/api/config, /api/counts, /api/eval, /api/flags, /api/register
API token120 / minute/api/v1/*, /mcp
IP, failed auth only120 / minuteWrong or missing key. A valid key never burns this budget.

There is no monthly evaluation cap on any plan: flags are evaluated inside your own app, so we do not charge for traffic. These per-minute limits are about protecting the API, nothing else.

What registers a flag

#

You never have to create a flag before using it. A key is registered the first time LaunchFlag sees it, off in dev, staging and prod, fail closed, described as registered by the SDK.

  • flag() or variant() asking for a key the project does not have. The SDK sends it in ?keys= on its next snapshot fetch.
  • GET /api/eval with an unknown flag.
  • POST /api/register with a list of keys, when you want them on the dashboard before the code path ever runs.
  • create_flag over MCP, or POST /api/v1/flags, when you want a real description, a failMode of open, or variants from the start.

Keys must match [a-z][a-z0-9_]{0,63}: lowercase, snake_case, starting with a letter. Anything else is ignored rather than registered, and the evaluation still returns false. Registration never breaks an evaluation: if it fails, the key stays unknown and your old path runs.

Cancelling, and a card that fails

#

Both plans bill monthly or annually. Annual is 20% below twelve monthly payments: Pro is $12 a month or $115 a year, Launch is $29 a month or $278 a year. The plan and the cycle are separate, so switching cycle never changes your limits.

Switch either way from Settings, Billing, using the monthly and annual control on the plan card. It is a change to the subscription you already have, not a new one: the difference is charged or credited right then, and your renewal date moves to that day plus the new term. The Billing tab shows the new date once the provider confirms it, usually within seconds.

Cancel from Settings, Billing, Cancel plan. One confirmation and you are done. There is no email to write and no notice period.

You keep the plan until the end of the period you already paid for, and the date is on the Billing tab. There is no refund and no proration: cancelling stops the next charge, it does not claw back the current one. Changed your mind before that date? Resume plan puts it back, same subscription, same price.

A card that fails is treated differently, because you did not ask for anything. The plan gets three days past the end of the paid period before the account lands on Free, which is enough to survive a decline and one retry. Update the card in the billing portal inside that window and nothing changes.

Nothing in your app changes, either way. Every flag keeps serving exactly the state it was in, at the same speed, on the same keys, for as long as you leave it there. /api/eval, /api/flags and /api/config keep answering. What stops is the dashboard: it locks, so you can read your flags but not change them.

Coming back is one click. Sign in, upgrade, and the dashboard unlocks with every project, flag and key exactly where you left it. Nothing is deleted when a plan ends.