Feature Flags skill for Claude Code

Gradual rollouts, instant rollback, no flag graveyard. Claude adds each flag with a type, owner, expiry date and cleanup ticket, branches at a single point with a safe default, and buckets users deterministically so rollouts are sticky. Rollout plans name guardrail metrics and hold times, removal follows a code-first order, and a bundled audit script flags expired, unregistered and orphaned flags in CI.

  • namefeature-flags
  • size170 lines · ≈1,900 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs and operates feature flags by type (release, experiment, ops kill switch, permission), with consistent percentage bucketing, staged rollouts gated on guardrail metrics, safe defaults when the flag service is down, OpenFeature-based vendor-neutral evaluation, testing both code paths, and disciplined cleanup with owners, expiry dates, and a stale-flag audit script. Use when adding a feature flag or kill switch, planning a gradual rollout or rollback, choosing or integrating a flag provider, or cleaning up old flags that clutter the code.

Try asking

  • Put the new checkout behind a feature flag and plan a 1% to 100% rollout
  • Add a kill switch for the recommendations service we can flip during incidents
  • Find all the stale feature flags in this repo and plan their removal

Use it when

  • Adding a feature flag, experiment, or kill switch
  • Planning a gradual rollout with a fast rollback
  • Integrating or switching a flag provider
  • Cleaning up old flags that clutter the code

Skip it when

How to check the result

Both flag paths and the provider-down default are tested, a user sees the same variant on every request, and the flag audit script reports no expired or unregistered flags.

The common mistake it prevents

Deleting a flag in the provider before removing it from deployed code, which silently flips every user back to the code default and old behavior.

~/.claude/skills/feature-flags/SKILL.md

--- name: feature-flags description: Designs and operates feature flags by type (release, experiment, ops kill switch, permission), with consistent percentage bucketing, staged… license: MIT ---

A flag is a temporary branch in production with an owner and an expiry date, except for the few long-lived operational switches that are designed as such. Done well: rollouts are gradual and reversible in seconds, evaluation is consistent per user, the default is safe when the flag service fails, and release flags are deleted within weeks of reaching 100%.

Ground rules

  • Every flag has: a type, an owner, a creation date, and (for release/experiment flags) an expiry date and a cleanup ticket created at the same time as the flag.
  • The code default (used when the provider is unreachable) is the safe, existing behavior.
  • Evaluate a flag once per request or job at the edge of the feature, then pass the decision down. Do not re-check the same flag in ten places.
  • Bucketing is deterministic: the same user gets the same variant on every request and every service.
  • Flags never replace authorization. Entitlements and permissions live in data and are checked by the auth layer.

Flag types

TypePurposeLifetimeDefaultExample
ReleaseShip dark, roll out graduallyDays–weeks; delete after 100%Offrelease.checkout-v2
ExperimentA/B test with measurementUntil decision (weeks)Controlexp.pricing-page-layout
Ops / kill switchDisable expensive or risky functionality under load or incidentLong-lived, documented in runbooksOn (feature enabled), flip off to shedops.recommendations-enabled
Permission / entitlementPlan or beta accessLong-lived; better as dataOffbeta.ai-summaries

Long-lived permission flags tend to become an unmanaged entitlement system; move plan features into the billing/entitlement model when they stabilize.

Workflow

- [ ] 1. Choose type, key, owner, expiry; create the cleanup ticket now
- [ ] 2. Implement one branch point with a safe default
- [ ] 3. Test both paths (and the default path when the provider fails)
- [ ] 4. Define the rollout plan with guardrail metrics and hold times
- [ ] 5. Roll out; watch metrics at each step; roll back by flipping off
- [ ] 6. At 100% and stable: remove the flag from code, then from the provider
- [ ] 7. Audit regularly for stale and unregistered flags

2. Implementation

Use OpenFeature as the evaluation API so the provider (LaunchDarkly, Unleash, Flagsmith, flagd, a homegrown service, and others) can change without touching call sites:

import { OpenFeature } from '@openfeature/server-sdk';

await OpenFeature.setProviderAndWait(provider);   // vendor provider configured at startup
const flags = OpenFeature.getClient('checkout');

export async function checkoutHandler(req, res) {
  const useV2 = await flags.getBooleanValue('release.checkout-v2', false, {
    targetingKey: req.user.id,                    // stable unit for bucketing
    plan: req.user.plan,
    country: req.geo.country,
  });
  return useV2 ? checkoutV2(req, res) : checkoutV1(req, res);
}
from openfeature import api
from openfeature.evaluation_context import EvaluationContext

client = api.get_client()
use_v2 = client.get_boolean_value("release.checkout-v2", False, EvaluationContext(targeting_key=user.id))
  • Prefer SDKs that evaluate locally from cached rules (streaming updates) over a network call per check.
  • Branch at one point: a route, a component boundary, or a strategy selection. Keep old and new implementations as separate functions/modules so removal is a deletion.
  • Do not put PII beyond what targeting needs into the evaluation context; it may be sent to a vendor.

Homegrown percentage bucketing (when not using a vendor):

import { createHash } from 'node:crypto';

export function bucketPercent(flagKey: string, unitId: string): number {
  const digest = createHash('sha256').update(`${flagKey}:${unitId}`).digest();
  return (digest.readUInt32BE(0) / 2 ** 32) * 100;      // stable value in [0, 100)
}
export const isInRollout = (flagKey: string, unitId: string, percent: number) =>
  bucketPercent(flagKey, unitId) < percent;

Including the flag key in the hash prevents the same users from being the early cohort for every flag. Raising the percentage keeps everyone already enabled enabled.

Choose the unit deliberately: user ID for user-facing features, account/tenant ID when everyone in a company must see the same thing, request ID only for stateless backend changes.

3. Testing

  • Unit/integration tests for both branches; parametrize over the flag value.
  • One test with the provider unavailable proves the default path works.
  • E2E: force flag values via the provider’s test mode, environment overrides, or an in-memory OpenFeature provider in tests; never depend on production flag state.

4–5. Rollout plan

Flag: release.checkout-v2 — owner @payments — expires 2026-12-01 — cleanup: PAY-412
Guardrails: checkout error rate ≤ baseline + 0.2 pp; p95 latency ≤ 450 ms; conversion
not below control by more than 1% (experiment dashboard link)
Steps: internal staff → 1% (24 h) → 5% (24 h) → 25% (48 h) → 50% (48 h) → 100%
Rollback: flip to 0% (takes effect < 30 s); on-call may roll back without approval
  • Hold each step long enough to cover traffic cycles (at least one business day for user-facing changes).
  • Compare guardrail metrics between flag-on and flag-off cohorts, not just before/after.
  • Database or contract changes behind a flag must be compatible with both paths (expand/contract; see schema-migrations).

6. Cleanup

  1. At 100% for the agreed soak period, open the removal PR: delete the flag check, the losing branch, its tests, and dead config.
  2. Deploy it everywhere (all services, mobile app versions in use).
  3. Then archive/delete the flag in the provider. Deleting it first makes old code fall back to the default, often the old behavior.

7. Audit

python3 scripts/flag_audit.py --src src --registry flags.json
python3 scripts/flag_audit.py --src apps --src packages --pattern "flags\.get\(\s*['\"]([\w.-]+)['\"]"

The script lists flag keys referenced in code with locations, and with a registry file (key, type, owner, created, expires) reports expired release/experiment flags, flags in code missing from the registry, missing owner/expiry metadata, and registry flags with no remaining references. It exits 1 on problems, so it can run in CI or a weekly job. Export the registry from the provider’s API or keep it as a reviewed file in the repo.

Gotchas

  • Nested or dependent flags (if A && B) multiply untested combinations; avoid, or declare dependencies explicitly in the provider.
  • Client-side flags ship rules and flag names to browsers; unreleased feature names can leak. Evaluate sensitive flags server-side.
  • Mobile apps keep old code paths alive for months; flags there need longer lifetimes and version targeting.
  • Kill switches nobody has flipped in a year may not work. Exercise them in game days.
  • Inconsistent evaluation across services (frontend says on, backend says off) breaks flows; use the same targeting key everywhere or pass the decision along.
  • Flags evaluated inside hot loops or per item in a batch add latency; evaluate once.

Verification

  1. Tests pass for both flag values and for provider-unavailable defaults.
  2. The same user gets the same variant across repeated requests and services.
  3. Rolling back from the provider UI takes effect within the documented time.
  4. scripts/flag_audit.py is clean, or every finding has a ticket.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/flag_audit.py 146 lines · 6.6 KB
#!/usr/bin/env python3
"""Audit feature flag usage in source code against a flag registry.

Usage:
  python3 scripts/flag_audit.py --src src [--src web] [--registry flags.json]
                                [--pattern REGEX] [--today 2026-10-19] [--json]

Finds flag keys referenced in code (default pattern matches common SDK calls such as
isEnabled("key"), getBooleanValue('key', ...), variation("key", ...), useFlag("key"),
is_enabled("key")). Supply --pattern with exactly one capture group for the key to match
a project-specific helper.

Registry file (optional): JSON list of objects like
  {"key": "checkout.new-form", "type": "release", "owner": "team-payments",
   "created": "2026-05-02", "expires": "2026-07-01"}
type is one of release | experiment | ops | permission. ops and permission flags are
long-lived and exempt from expiry checks.

Reports: references per flag, flags used in code but missing from the registry,
registry flags with no code references (candidates to delete in the provider),
release/experiment flags past their expiry date, and missing owner/expiry metadata.

Exit codes: 0 = clean, 1 = problems found, 2 = bad arguments or registry.
"""
import argparse
import datetime as dt
import json
import os
import re
import sys
from collections import defaultdict

DEFAULT_PATTERN = (
    r"""(?:isEnabled|isFeatureEnabled|getBooleanValue|getStringValue|getNumberValue|getObjectValue|"""
    r"""getBooleanDetails|boolVariation|stringVariation|variation|useFlag|useFeatureFlag|useBooleanFlagValue|"""
    r"""is_enabled|get_boolean_value|get_string_value|feature_enabled)\s*\(\s*['"]([A-Za-z0-9_.:\-/]+)['"]"""
)
EXTS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rb", ".go", ".java", ".kt",
        ".cs", ".php", ".swift", ".vue", ".svelte"}
SKIP_DIRS = {"node_modules", ".git", "dist", "build", "vendor", ".next", "coverage", "__pycache__", ".venv"}
LONG_LIVED = {"ops", "permission"}


def scan(paths, pattern):
    refs = defaultdict(list)
    rx = re.compile(pattern)
    for base in paths:
        for root, dirs, files in os.walk(base):
            dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
            for f in files:
                if os.path.splitext(f)[1] not in EXTS:
                    continue
                path = os.path.join(root, f)
                try:
                    with open(path, encoding="utf-8", errors="ignore") as fh:
                        for lineno, line in enumerate(fh, 1):
                            for m in rx.finditer(line):
                                refs[m.group(1)].append(f"{path}:{lineno}")
                except OSError:
                    continue
    return refs


def load_registry(path):
    try:
        with open(path, encoding="utf-8") as fh:
            data = json.load(fh)
    except (OSError, json.JSONDecodeError) as exc:
        print(f"error: cannot read registry {path}: {exc}", file=sys.stderr)
        sys.exit(2)
    if isinstance(data, dict) and "flags" in data:
        data = data["flags"]
    if not isinstance(data, list):
        print("error: registry must be a JSON list of flag objects (or {\"flags\": [...]})", file=sys.stderr)
        sys.exit(2)
    return {item["key"]: item for item in data if isinstance(item, dict) and "key" in item}


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--src", action="append", required=True, help="source directory (repeatable)")
    ap.add_argument("--registry")
    ap.add_argument("--pattern", default=DEFAULT_PATTERN)
    ap.add_argument("--today", default=dt.date.today().isoformat())
    ap.add_argument("--json", action="store_true")
    args = ap.parse_args()

    try:
        if re.compile(args.pattern).groups != 1:
            raise ValueError("pattern must have exactly one capture group")
        today = dt.date.fromisoformat(args.today)
    except (re.error, ValueError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        sys.exit(2)

    refs = scan(args.src, args.pattern)
    report = {"references": {k: v for k, v in sorted(refs.items())},
              "unregistered": [], "unreferenced": [], "expired": [], "missing_metadata": []}

    if args.registry:
        reg = load_registry(args.registry)
        report["unregistered"] = sorted(k for k in refs if k not in reg)
        report["unreferenced"] = sorted(k for k in reg if k not in refs)
        for key, meta in sorted(reg.items()):
            ftype = meta.get("type", "release")
            missing = [f for f in ("owner", "type") if not meta.get(f)]
            if ftype not in LONG_LIVED and not meta.get("expires"):
                missing.append("expires")
            if missing:
                report["missing_metadata"].append({"key": key, "missing": missing})
            exp = meta.get("expires")
            if ftype not in LONG_LIVED and exp:
                try:
                    if dt.date.fromisoformat(exp) < today:
                        report["expired"].append({"key": key, "expires": exp, "owner": meta.get("owner"),
                                                  "still_referenced": key in refs})
                except ValueError:
                    report["missing_metadata"].append({"key": key, "missing": [f"valid expires (got {exp!r})"]})

    problems = (len(report["unregistered"]) + len(report["expired"]) + len(report["missing_metadata"]))

    if args.json:
        print(json.dumps(report, indent=2))
    else:
        print(f"Flags referenced in code: {len(refs)}")
        for key, locs in sorted(refs.items(), key=lambda kv: -len(kv[1])):
            print(f"  {len(locs):4}  {key}   e.g. {locs[0]}")
        if args.registry:
            def section(title, items, fmt):
                print(f"\n{title} ({len(items)})")
                for it in items:
                    print("  " + fmt(it))
            section("EXPIRED release/experiment flags", report["expired"],
                    lambda e: f"{e['key']} expired {e['expires']} owner={e['owner']} "
                              f"{'(still in code: remove)' if e['still_referenced'] else '(code gone: delete in provider)'}")
            section("UNREGISTERED flags used in code", report["unregistered"], str)
            section("MISSING metadata", report["missing_metadata"],
                    lambda m: f"{m['key']}: missing {', '.join(m['missing'])}")
            section("Registry flags with no code references (delete in provider after deploy)",
                    report["unreferenced"], str)
        print(f"\nRESULT: {'PROBLEMS FOUND' if problems else 'clean'}")
    sys.exit(1 if problems else 0)


if __name__ == "__main__":
    main()

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc