Smpl Flags overview
A feature flag in smplkit is a typed, named decision your code asks the platform about at runtime: should the new checkout flow be on for this user?, what's the retry timeout for this account?, which theme should this device render? Flags are environment-aware (different states in staging vs production), rules-driven (target by user, account, device, anything you can pass as a context), and typed (boolean, string, number, or JSON).
A concrete example
from smplkit import SmplClient, Op, Rule
with SmplClient(environment="production", service="web") as client:
flag = client.flags.boolean_flag(
"checkout-v2",
default=False,
description="Rolls out the new checkout experience.",
)
if flag.get():
show_new_checkout()
else:
show_old_checkout()Your code declares the flag once at startup. Every flag.get() evaluates locally — no network call. Behind the scenes, the SDK auto-registers the flag with the platform (via POST /api/v1/flags/bulk), keeps it in sync over WebSocket, and evaluates rules against your code's current context provider.
Flag types
A flag has exactly one type:
BOOLEAN— always two values; the kill switch / on-off pattern.STRING— categorical choices (variant names, region codes, color labels).NUMERIC— numeric tunables (timeouts, retry counts, thresholds).JSON— structured config blobs (theme dictionaries, complex policy objects).
Constrained vs unconstrained
A flag can be constrained to a closed set of allowed values, or unconstrained:
- Constrained (
valuesarray set). All defaults and rule outcomes must reference one of the listed values. The console renders dropdowns; renames cascade across the flag. Boolean flags are always constrained (with exactly two entries). - Unconstrained (
values: null). Any value matching the type is accepted. Useful for tunables that legitimately take any number, or strings drawn from a large keyspace.
You can convert in either direction; conversions validate existing values to make sure nothing breaks.
Per-environment configuration
Every flag has a default at the top level (the absolute fallback) plus per-environment configuration:
{
"id": "checkout-v2",
"type": "BOOLEAN",
"default": false,
"environments": {
"staging": {
"enabled": true,
"default": true,
"rules": [...]
},
"production": {
"enabled": true,
"default": false,
"rules": [...]
}
}
}For a given environment:
enabled— the kill switch. Whenfalse, all rules are skipped and the environment-default (or top-level default) is served. Whentrue, rules evaluate.default— the per-environment default if no rule matches. Falls back to the flag's top-level default if unset.rules— an ordered list. First match wins.
Environments not present in the flag's environments object inherit the top-level default with rules effectively disabled.
Targeting rules
A rule is a JSON Logic expression plus a value to serve when the expression is true:
{
"description": "Enterprise users in US",
"logic": {
"and": [
{ "==": [{"var": "user.plan"}, "enterprise"] },
{ "==": [{"var": "account.region"}, "us"] }
]
},
"value": true
}Rules live in environments[env].rules and are evaluated in order — the first whose logic evaluates true gets its value returned. If no rule matches, the environment-default is served.
The SDK's fluent Rule builder generates the JSON Logic for you:
from smplkit import Op, Rule
flag.add_rule(
Rule("Enterprise users in US", environment="production")
.when("user.plan", Op.EQ, "enterprise")
.when("account.region", Op.EQ, "us")
.serve(True)
)Multiple .when() calls are AND-ed.
Evaluation order
For each flag.get():
- Look up the flag in the SDK's local cache.
- Read the current evaluation context from your registered provider.
- Find the configuration for the current environment.
- If
enabled = false, return the environment-default (or top-level default). - Otherwise iterate
rulesin order; first match returns its value. - If no rule matches, return the environment-default (or top-level default).
All of this happens in-process. The SDK never makes a network call during evaluation.
Discovered vs managed
Flags auto-discovered via the SDK start as discovered (managed = false) — visible in the console but with no environment configuration, no rules, no per-environment defaults. The SDK's first declaration determines the flag's type and code-level default.
Once an admin edits a discovered flag in the console (any save), it becomes managed (managed = true) and gains the full configuration capability. There's no dedicated "promote" endpoint — it's an implicit transition through the first PUT.
Flag count is not sensitive to managed vs discovered: the flags.items governance check on POST /api/v1/flags counts every flag (managed or not). The check ships -1 (unlimited) on every plan today, so flag count is effectively uncapped, but the gate is in place. Auto-discovery (POST /api/v1/flags/bulk) is exempt from governance entirely. See Discovered vs managed.
Promote to managed
Promote an auto-discovered flag so you can configure environment-specific defaults, kill switches, and targeting rules. Prerequisites: at least MEMBER role.
Flag promotion is implicit
Unlike loggers, there's no dedicated promote endpoint for flags. Any PUT that sets managed: true (typically the first save from the console) makes the flag managed. The SDK's client.flags.boolean_flag(...) declaration always creates as discovered (via POST /api/v1/flags/bulk) — promotion happens later, through the console or an explicit API call.
In the console
- Go to Flags in the sidebar.
- Find the discovered flag in the list.
- Click the flag to open the editor.
- Make any change — set a per-environment default, add a rule, even just edit the description.
- Save.
The first save promotes the flag to managed. After that, all the environment configuration controls (kill switch, environment-default, rules, value editing) are available.
Via the API
curl -X PUT https://app.smplkit.com/api/v1/flags/checkout-v2 \
-H "Authorization: Bearer $SMPLKIT_API_KEY" \
-H "Content-Type: application/vnd.api+json" \
-d '{
"data": {
"type": "flag",
"id": "checkout-v2",
"attributes": {
"managed": true,
"type": "BOOLEAN",
"default": false,
"environments": {
"production": {
"enabled": true,
"default": false,
"rules": []
}
}
}
}
}'Once promoted, future PUTs preserve the managed = true state unless you explicitly set it to false (which the platform doesn't currently support — see below).
Via the SDK
The flag management SDK creates flags as managed when you call new_*_flag(...) and save() on a fresh flag — these go through POST /api/v1/flags, which defaults managed to true. To promote an existing discovered flag, fetch it first, mutate, and save:
from smplkit import AsyncSmplManagementClient, Op, Rule
async with AsyncSmplManagementClient() as manage:
checkout = await manage.flags.get("checkout-v2")
checkout.add_rule(
Rule("Enterprise users", environment="production")
.when("user.plan", Op.EQ, "enterprise")
.serve(True)
)
await checkout.save() # promotes to managedDemoting a flag — not yet supported
The platform does not currently support demoting a managed flag back to discovered. Once a flag is managed, it stays managed for the life of the row. To clear a flag entirely, delete it:
curl -X DELETE https://app.smplkit.com/api/v1/flags/checkout-v2 \
-H "Authorization: Bearer $SMPLKIT_API_KEY"The next time the SDK reports it via auto-discovery (POST /api/v1/flags/bulk), it'll come back fresh as a discovered flag.
Verify
curl https://app.smplkit.com/api/v1/flags/checkout-v2 \
-H "Authorization: Bearer $SMPLKIT_API_KEY"data.attributes.managed should be true. Per-environment configuration, rules, and kill switches are now editable through the full flag schema.
Flag sources
Each (flag, service, environment) combination is recorded in flag_source. This shows you exactly where a flag is observed — which services declared it, which environments those services run in, what type and default each declaration used.
Drift across services is visible: if payments declared checkout-v2 as a BOOLEAN with default false, and web declared it as a STRING, you'll see two source rows with conflicting metadata. The first declaration wins on the flag itself; subsequent declarations only update source rows.
Limits
Plan-driven (entitlement keys):
flags.items— total flags per account, checked onPOST /api/v1/flags. Currently-1(unlimited) on every plan; auto-discovery (POST /api/v1/flags/bulk) is exempt regardless.flags.rules— rules per flag, validated per write. Free: 5; Standard: 25; Pro: unlimited.
Hard caps (platform-wide):
- 10,000 flag rows per account.
- 100,000 flag source rows per account.
API surface
GET /api/v1/flags— list, filter bytype,managed,references_context,references_context_typeGET /api/v1/flags/{key}— fetch onePOST /api/v1/flags— explicit create (creates asmanaged = trueby default)POST /api/v1/flags/bulk— auto-discovery (creates asmanaged = false)PUT /api/v1/flags/{key}— full replaceDELETE /api/v1/flags/{key}— soft-deleteGET /api/v1/flags/{key}/sources— observed-by listingGET /api/v1/flag_sources— cross-flag source listing
Related
- Smpl Flags Management — SDK and API patterns
- Smpl Flags Runtime — declaring and evaluating flags
- Discovered vs managed
- Contexts and context types
- API Reference — Flags

