@yaebal/feature-flags
ctx.flags.isEnabled(key) / ctx.flags.getVariant(key) — boolean and
multivariate (A/B/n) feature flags with persisted overrides via @yaebal/sklad, telegram-native targeting
(percentage, user/chat id, chat type, language, premium, date window), and adapters for external
providers (LaunchDarkly, GrowthBook, plain env vars). evaluation order per check is override → global override → provider → local rules → default. every flag key is
typed against the catalog you pass in — a typo'd key is a compile error, not a runtime surprise.
install
pnpm add @yaebal/feature-flagsusage
install featureFlags() with bot.install(). it adds ctx.flags to every handler's context. flags is the catalog: a plain boolean for a
static flag, a FlagDefinition for rollout rules, or a VariantDefinition for a multivariate flag (see below). the bucket identity is per-user by default
(ctx.from.id, falling back to ctx.chat.id) — override with bucketKey; the eval context (telegram targeting fields, clock) comes from getContext.
import { createBot } from "yaebal";
import { featureFlags } from "@yaebal/feature-flags";
const bot = createBot(process.env.BOT_TOKEN!)
.install(
featureFlags({
flags: { "new-ui": { default: false, rules: [{ percentage: 0 }] } }, // off for everyone, for now
}),
);
bot.command("feature", async (ctx) => {
const on = await ctx.flags.isEnabled("new-ui");
await ctx.reply(`new-ui: ${on ? "on" : "off"}`);
});
bot.command("enable", async (ctx) => {
await ctx.flags.setOverride("new-ui", true); // wins over the 0% rollout, just for this user
await ctx.reply("new-ui enabled for you");
});
bot.start();import { featureFlags } from "@yaebal/feature-flags";
bot.install(
featureFlags({
flags: {
"new-ui": { default: false, rules: [{ percentage: 25 }] },
maintenance: false,
},
}),
);
bot.command("start", async (ctx) => {
const welcome = (await ctx.flags.isEnabled("new-ui")) ? "welcome to the new ui!" : "welcome!";
await ctx.reply(welcome);
});rollout rules
a flag with rules is enabled if any rule matches, checked in order —
the first match's value wins (defaults to true; set it to false to carve out a kill-switch slice even when default is true). within one rule, every condition you set must hold (AND). percentage rollout
hashes `${key}:${bucketKey}` with fnv-1a (exported as bucketOf for
testing) — stable across restarts and processes, unlike Math.random().
featureFlags({
flags: {
"new-ui": {
default: false,
// rules are checked in order — the first match wins. within one rule, every
// condition you set must hold (AND).
rules: [
{ percentage: 10 }, // 10% of buckets, deterministic per user
{ userIds: [12345, "67890"] }, // always on for these users
{ chatTypes: ["group", "supergroup"] }, // only in group chats
{ languageCodes: ["ru", "uk"] }, // only for these telegram client languages
{ premiumOnly: true }, // only for telegram premium users
{ from: new Date("2026-03-01"), to: new Date("2026-04-01") }, // date window
],
},
"legacy-mode": {
default: true,
// value: false carves out a kill-switch slice, even though the default is true
rules: [{ userIds: [666], value: false }],
},
},
});multivariate flags
give a flag variants instead of a plain default: boolean and it becomes
an A/B/n test: ctx.flags.getVariant(key) returns one of the declared values, typed as
their literal union. a bucket's assignment is picked once, deterministically, from the same hash
behind percentage rollout — so the same user always sees the same variant. rules on a
variant flag force a specific value outright (no on/off, just which one wins) rather than gating.
import { createBot } from "yaebal";
import { featureFlags } from "@yaebal/feature-flags";
const bot = createBot(process.env.BOT_TOKEN!).install(
featureFlags({
flags: {
checkout: {
default: "control",
// a bucket's variant is picked once, deterministically — the same user always sees the same one
variants: [
{ value: "control", weight: 50 },
{ value: "v2", weight: 50 },
],
},
},
}),
);
bot.command("checkout", async (ctx) => {
const variant = await ctx.flags.getVariant("checkout");
await ctx.reply(`checkout: ${variant}`);
});
bot.command("promote", async (ctx) => {
await ctx.flags.setGlobalOverride("checkout", "v2"); // force the winner for every bucket, no redeploy
await ctx.reply("checkout: v2 promoted for everyone");
});
bot.start();featureFlags({
flags: {
checkout: {
default: "control",
// a bucket's variant is picked once, deterministically — same hash as percentage rollout
variants: [
{ value: "control", weight: 50 },
{ value: "v2", weight: 50 },
],
// rules force a specific variant outright — no on/off, just which value wins
rules: [{ userIds: [42], value: "v2" }],
},
},
});
bot.command("checkout", async (ctx) => {
const variant = await ctx.flags.getVariant("checkout"); // "control" | "v2", typed
await ctx.reply(`checkout: ${variant}`);
});overrides
force a flag for one bucket — an admin command, a support workaround — persisted via storage (defaults to in-memory, lost on restart). a per-bucket override always wins,
even over a configured provider. setGlobalOverride forces every bucket at once — a
kill switch that needs no redeploy — and both accept an optional ttl so the override
expires on its own. overrides live under their own flags:-prefixed keys, so sharing
one storage adapter with @yaebal/session or another yaebal plugin never
collides.
import { redisStorage } from "@yaebal/sklad";
bot.install(featureFlags({ flags: { "new-ui": false }, storage: redisStorage(client) }));
bot.command("beta", async (ctx) => {
await ctx.flags.setOverride("new-ui", true); // wins over provider and local rules, for this bucket
await ctx.reply("you're in!");
});
bot.command("kill", async (ctx) => {
// forces every bucket, independent of any per-bucket override — an emergency kill switch
await ctx.flags.setGlobalOverride("new-ui", false, { ttl: 60 * 60 * 1000 }); // auto-expires in 1h
await ctx.reply("new-ui disabled for everyone, for the next hour");
});
// later: await ctx.flags.clearOverride("new-ui"); / await ctx.flags.clearGlobalOverride("new-ui");guard & whenFlag
flagGuard/variantGuard plug straight into bot.guard() — but
like any guard, they gate everything registered after them in that composer, so where you
call it matters. whenFlag instead builds an isolated branch (the same primitive behind Composer.filter) — installing it never gates a handler registered elsewhere on the
same composer, regardless of order.
import { flagGuard, whenFlag } from "@yaebal/feature-flags";
// gates everything registered after it in *this* composer — order matters
bot.guard(flagGuard("new-ui")).command("beta-only", (ctx) => ctx.reply("new ui exclusive"));
// an isolated branch instead — doesn't matter where you install it, and it never
// gates a sibling handler registered elsewhere on the same composer
bot.install(
whenFlag("new-ui", (branch) => branch.command("beta-only", (ctx) => ctx.reply("new ui exclusive"))),
);admin commands
flagsAdmin installs a telegram-native ops surface for the flags featureFlags() added — list every flag, force a global override, or clear one, straight from a chat, gated by an isAdmin check you provide. no separate dashboard, and it works on every runtime yaebal
supports (including edge/serverless).
import { flagsAdmin } from "@yaebal/feature-flags";
bot.install(flagsAdmin({ isAdmin: (ctx) => ctx.from?.id === OWNER_ID }));
// /flags — every flag's value for your own bucket
// /flags set new-ui true — global override (parses true/false, numbers, or a string)
// /flags clear new-ui — remove the global overrideexternal providers
provider is consulted before the local catalog, for boolean flags only — a defined true/false wins, undefined falls through to local rules.
a provider that throws is caught and treated as undefined (fail-open onto the local
catalog) rather than taking the update down with it. all three adapters type their client
structurally, so this package depends on no SDK.
import { envProvider, growthBookAdapter, launchDarklyAdapter } from "@yaebal/feature-flags";
// LaunchDarkly — any client satisfying { variationDetail(key, context, defaultValue) }
bot.install(featureFlags({ flags: { "new-ui": false }, provider: launchDarklyAdapter(ldClient) }));
// GrowthBook — a factory builds a fresh client per evaluation, so concurrent updates for
// different users never interleave one another's targeting attributes
bot.install(
featureFlags({
flags: { "new-ui": false },
provider: growthBookAdapter((evalContext) => new GrowthBook({ attributes: { id: evalContext.userId } })),
}),
);
// process.env — FLAG_NEW_UI=true, no SaaS required
bot.install(featureFlags({ flags: { "new-ui": false }, provider: envProvider() }));standalone use
createFlags(options) builds a client independent of any bot or ctx — same
shape as ctx.flags, plus an explicit evalContext per call.
import { createFlags } from "@yaebal/feature-flags";
const flags = createFlags({ flags: { "new-ui": { default: false, rules: [{ percentage: 25 }] } } });
await flags.isEnabled("new-ui", { userId: 42 });api
| export | signature | description |
|---|---|---|
featureFlags | (options: FeatureFlagsOptions<F>) => Plugin<Context, { flags: FlagsControl<F> }> | installs ctx.flags, typed against the catalog F |
createFlags | (options: FeatureFlagsOptions<F>) => Flags<F> | standalone client, independent of any bot or ctx |
flagGuard / variantGuard | (key, value?) => (ctx) => Promise<boolean> | predicate for bot.guard() |
whenFlag | (key, build) => Plugin | an isolated branch scoped to a boolean flag |
flagsAdmin | (options: FlagsAdminOptions<F>) => Plugin | /flags ops commands, gated by isAdmin |
bucketOf | (input: string) => number | deterministic [0, 10000) hash behind percentage/variant rollout |
launchDarklyAdapter | (client: LaunchDarklyClientLike) => FlagProvider | consult a LaunchDarkly server-side client |
growthBookAdapter | (client, options?) => FlagProvider | consult a GrowthBook client or per-evaluation client factory |
envProvider | (options?: EnvProviderOptions) => FlagProvider | read FLAG_<KEY> from process.env |
FlagsControl interface (ctx.flags)
| method | returns | description |
|---|---|---|
isEnabled(key) | Promise<boolean> | override → global → provider → rules → default |
getVariant(key) | Promise<T> | override → global → rules → weighted pick, for a multivariate flag |
setOverride(key, value, options?) | Promise<void> | force key for the current bucket, optional ttl |
clearOverride(key) | Promise<void> | remove the bucket override |
setGlobalOverride(key, value, options?) | Promise<void> | force key for every bucket |
clearGlobalOverride(key) | Promise<void> | remove the global override |
snapshot() | Promise<Record<string, unknown>> | evaluate the whole catalog at once, for the current bucket |
FeatureFlagsOptions
| field | type | default | description |
|---|---|---|---|
flags | F extends FlagsCatalog | — | required. boolean, FlagDefinition, or VariantDefinition per key; validated up front (malformed rules/variants throw at construction, not on first use) |
storage | StorageAdapter<unknown> | MemoryStorage | where per-bucket and global overrides live — any @yaebal/sklad adapter |
provider | FlagProvider | — | external provider consulted before local rules, boolean flags only; errors fail open |
getContext | (ctx: Context) => FlagEvalContext | telegram's from/chat fields | derive the eval context (targeting fields, clock) for an update |
bucketKey | (evalContext) => string | per-user, falling back to per-chat | derive the bucket identity — feeds percentage/variant hashing and override storage, so they always agree |
onEvaluate | (event: EvaluationEvent<F>) => void | — | observe every evaluation's result and source — exposure logging, debugging |
onProviderError | (error, key) => void | — | observe a provider failure (evaluation still falls through to local rules) |
testing
drive ctx.flags with @yaebal/test as
usual — assert on the reply, or on createFlags(...).isEnabled(key, evalContext) directly for unit-level rollout checks.
import { createTestEnv } from "@yaebal/test";
import { featureFlags } from "@yaebal/feature-flags";
const bot = new Composer<Context>()
.install(featureFlags({ flags: { "new-ui": { default: false, rules: [{ userIds: [1] }] } } }))
.command("check", async (ctx) => ctx.reply(String(await ctx.flags.isEnabled("new-ui"))));
const env = createTestEnv(bot);
await env.createUser({ id: 1 }).sendCommand("check"); // "true" — id 1 is targeted
await env.createUser({ id: 2 }).sendCommand("check"); // "false"MemoryStorage for redisStorage/sqliteStorage so
overrides survive restarts and are shared across processes — the same options work no matter
which adapter is behind it.@yaebal/panel is a
chat-inbox ui without a plugin/widget extension point, so flag management ships as flagsAdmin's bot commands instead of a panel page — it works with or without the panel
installed.