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

#

Terminal

$ npm i @launchflag/sdk

Node 18+. No dependencies. React is an optional peer dependency, needed only for the @launchflag/sdk/react components.

One command setup

#

The SDK ships a small init command. It writes your key into .env.local, downloads the agent skill, and configures the MCP server for Claude Code and Cursor.

Terminal

$ npx launchflag init

It prompts for the dev key and the API token, or takes them as flags: --key, --token, --url. Use --dry-run to see what it would write, --no-mcp and --no-skill to skip those steps. Running it twice changes nothing: existing env values are never overwritten.

The integration

#

Your code

import { flag } from "@launchflag/sdk";

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

That is the whole thing. Keep the old path intact and reachable: the flag is what you flip, not the deploy. Pass the same identifier you target people by, usually a user id or an email. 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.

React components

#

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

The same two checks, called directly

import { flag, variant } from "@launchflag/sdk";

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

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/sdk";

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. LaunchFlag serves the arm and records nothing else: measure it wherever you already measure things.

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.

failMode is the declared intent that travels with the flag, and it is what the dashboard and your team read. The lever inside the SDK is defaults: a cold process that cannot reach the API serves those values, so a cosmetic flag you want on by default belongs there.

Explicit config

import { createLaunchFlag } from "@launchflag/sdk";

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

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.

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.

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; };

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"}},
 "emergencyOff":false,"env":"dev"}

This is what the SDK calls. keys is the list of flags your code has asked for, registered if they do not exist yet. 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.

Plan limits are separate: 1,000,000 evaluations a month on Pro, 5,000,000 on Launch. The SDK cache means a page view that checks the same flag five times counts once.

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.