Feature Flags
Olly uses GrowthBook as the single feature-flag and runtime-config layer for the platform. Triage is the canonical adopter today; balance follows the same pattern; new services should reach for it before adding a one-off env var or if ENABLED_X: constant.
The system is intentionally:
- Centralised - one server, one UI, one SDK call, no per-service rolling-your-own.
- Hot-reloading - UI change → live in 30 s, no restart.
- Fail-open - SDK unavailable, wrong type, missing key → call site returns the
default=it asked for. A wrong value in GrowthBook must never 500 a request. - Typed at the call site - the
default=you pass tells the wrapper how to coerce the value (bool, int, float, str, list, dict).
Where it runs
| Piece | URL | Notes |
|---|---|---|
| GrowthBook server (UI + admin API) | https://growthbook.dev.hiolly.com | Source of truth. Define features, set values, audit changes here. |
| GrowthBook public API | https://growthbook-api.dev.hiolly.com | REST API for write-through (used by the triage /v5/levers editor). |
| SDK endpoint (VPC) | http://10.0.1.2:3501 | What services hit; not exposed publicly. |
| Triage-specific enriched UI | https://prompts.dev.hiolly.com/levers | Custom Olly editor for the 22 narrower levers (rich metadata: cluster, branch, code_ref, rationale, risk). Optional sugar on top of GrowthBook. |
Server runs on dev-2 (olly_olly compose project), Mongo-backed, attached to the platform network so SDK clients reach it over the VPC.
How a flag is read in code
Python / TypeScript (triage, balance - the canonical pattern)
Same shape across both SDKs. Pick a language and the rest of the page (and every other docs page with code samples) remembers your choice:
from triage.v5_config import get_lever
# Number, bool, str, list, dict - type is inferred from `default`
threshold = get_lever("narrow.commit.confidence_min_commit", default=0.85)
strict = get_lever("narrow.prefilter_strict_sex", default=False)
model = get_lever("rf.observer.llm_model", default="gemini-3.5-flash")import { getLever } from "@olly/triage-config";
// Number, bool, str - type is inferred from the supplied `default`
const threshold = await getLever("narrow.commit.confidence_min_commit", { default: 0.85 });
const strict = await getLever("narrow.prefilter_strict_sex", { default: false });
const model = await getLever("rf.observer.llm_model", { default: "gemini-3.5-flash" });Same shape in balance:
from balance.flags import get_flag
if get_flag("balance.chat.suggestion.enabled", default=False):
...import { getFlag } from "@olly/balance-flags";
if (await getFlag("balance.chat.suggestion.enabled", { default: false })) {
// …
}The default= kwarg is load-bearing
It is the value the call site MUST receive if GrowthBook is unreachable, the feature doesn't exist, or the stored value is the wrong type. Always set it to the historically-correct value the code used before the flag entered the picture - never None, never a random sentinel.
Wiring contract (FastAPI services)
The SDK loads features once at startup and refreshes every 30 s in the background. Every service that reads flags must:
- Export
GROWTHBOOK_API_HOST=http://10.0.1.2:3501and aGROWTHBOOK_SDK_KEY=…in its compose entry. - Start the refresh loop in its FastAPI
lifespanand cancel it on shutdown - seeservices/triage/src/triage/v5_config.py:start_refresh_loop.
Skip the refresh loop and your flags freeze
Without start_refresh_loop running, values are pinned at the first request and changes in the GrowthBook UI never propagate to the service.
# main.py lifespan
from triage.v5_config import start_refresh_loop, stop_refresh_loop
@asynccontextmanager
async def _lifespan(app):
task = await start_refresh_loop()
yield
await stop_refresh_loop()If you're standing up a new Python service, lift v5_config.py verbatim - it's the reference implementation. balance currently lacks the refresh loop (it only loads once); that's on the consolidation list below.
Go / TypeScript
GrowthBook ships official SDKs for both. Olly Go services don't currently consume flags - when the first one does, mirror the Python pattern: one wrapper module per service exposing GetFlag(key, default) that fails open to default on any error, and a background goroutine that calls features.Load() every 30 s.
How to add a new flag
- Define in GrowthBook
- Sign in to https://growthbook.dev.hiolly.com.
- Features → Add Feature.
- Key = the literal string your code will pass to
get_lever()/get_flag(). Use dotted namespaces:<service>.<area>.<name>(e.g.claims.fraud.batch_threshold,balance.gate.confidence.auto). - Value type = boolean / number / string / JSON. Must match what the call-site default looks like.
- Default value = the value to serve when no rule matches. Pick the value you want the flag to take initially (often the same as the code-side default; sometimes the new behaviour you're rolling out).
- Environment = Dev (only environment running right now). Toggle "Enabled in environment" on.
- Read it from code -
get_lever("…", default=<historical value>)at the point you'd otherwise have a magic constant. - (Optional) Enrich for triage-style UI - only if you want the
/levers-style rich editor for your area. Add an entry toLEVERSinservices/triage/src/triage/v5_levers_api.py(or a service-local mirror) withcluster,desc,rationale,code_ref,risk. The base GrowthBook UI is sufficient for most cases. - Deploy the code change. New code redeploys; subsequent value tweaks don't.
Treat feature keys like a package path
Once a key is used in production code, renaming it is a refactor with deploys - every caller that reads the old key gets default until they redeploy with the new key. Pick the namespace deliberately the first time.
Deploy once, tweak forever
The code change ships the call site (get_lever("…", default=…)). After that, value changes happen entirely in the GrowthBook UI - no redeploy required.
How to toggle a flag
| Action | Where | Effect |
|---|---|---|
| Flip a boolean / change a value | GrowthBook UI → feature → edit default | Live in ≤ 30 s on every service running the refresh loop. |
| Per-environment override | UI → feature → environment toggle | Only meaningful once UAT / prod environments exist in GrowthBook. |
| Audience targeting (per user, per tenant) | UI → feature → "Add rule" → targeting condition | Requires services to pass attributes into the SDK call (not yet wired). |
| Programmatic update | POST https://growthbook-api.dev.hiolly.com/api/v1/features/{id} | What the triage /v5/levers editor uses. Requires an admin API key. |
| Roll back | UI → feature → revision history → restore | Same hot-reload window. |
Restart-required changes are not normal
If you find yourself restarting to pick up a value, the refresh loop isn't running for that service - fix that, don't restart.
Failure semantics
Every wrapper is deliberately fail-open. The order things degrade:
- SDK env vars unset → wrapper short-circuits to
default. (Useful for local tests without GrowthBook.) - Initial SDK fetch fails → wrapper returns
defaultand the refresh loop retries silently. get_feature_valueraises → wrapper logs a warning and returnsdefault.- Stored value doesn't coerce to the type of
default(e.g. you stored"true"for an int) → wrapper logs a warning and returnsdefault.
The default at the call site MUST be the historically-correct production value
Not None, not 0. If GrowthBook goes down at 3 a.m., the platform keeps behaving the way it did before flags existed.
What's flag-controlled today
Triage (22 levers, all read by v5)
| Cluster | Examples |
|---|---|
| Commit thresholds | narrow.commit.confidence_fast_commit (0.90), narrow.commit.confidence_min_commit (0.85), narrow.commit.completeness_gate (0.80), narrow.commit.frustration_escalation_limit (3) |
| Catalogue / shortlisting | narrow.catalogue.shortlist_min_score (3), narrow.catalogue.shortlist_score_gap_from_top (8), narrow.catalogue.shortlist_max_size (15) |
| Fact-finding | narrow.subfacts_per_turn (3) |
| Safety / RF sidecar | rf.position_trigger (0.3), rf.rescan_every_n_turns (3), rf.keyword_prior_delta (0.6), rf.soft_signals (dict) |
| Stubbed (defined, not yet wired) | narrow.ranking_temperature, narrow.priority, narrow.tone_rules, narrow.prefilter_strict_region, narrow.prefilter_strict_sex |
Authoritative catalogue with rationale and risk: services/triage/src/triage/v5_levers_api.py.
Balance
| Surface | Examples |
|---|---|
| Chat features | balance.chat.suggestion.enabled, balance.chat.contradiction.enabled |
| Scheduler | balance.engine.daily.cron |
| Confidence gates | balance.gate.confidence.auto (0.75), balance.gate.confidence.confirm (0.5) |
| Shape weights | balance.shapes.<shape>.measure, balance.shapes.<shape>.weight, balance.floors.<shape>.<detector> |
Other services
None yet. Claims, eligibility, enrollment, billing, provider, etc. don't read GrowthBook today. As they pick up flag-worthy behaviour (kill-switches, ramp-ups, AB tests), they should reach for the pattern above.
Consolidation roadmap
We have leftover ad-hoc toggles from before GrowthBook existed. These are the migration targets - none are urgent, but new code shouldn't add to the pile:
| Pattern | Location | Migrate to |
|---|---|---|
os.environ.get("TRIAGE_PROMPT_VARIANT") | services/triage/src/triage/agent.py, chat_completions.py | triage.prompt.variant (string) |
os.environ.get("TRIAGE_DISPATCHER") | services/triage/src/triage/chat_completions.py | triage.dispatcher.mode (string) |
os.environ.get("RF_OBS_LLM_MODEL") | services/triage/src/triage/v5_rf_sidecar.py | rf.observer.llm_model (string) |
Hardcoded V5_MODEL, V3_MODEL, etc. | v5_narrow.py, v3_narrow.py | <endpoint>.model (string) per endpoint |
_maybe_init() with no background refresh | services/balance/src/balance/flags.py | Adopt triage's start_refresh_loop so UI changes propagate without restart |
| Go services with no flag layer | claims, billing, eligibility, enrollment, broker-api, … | Add a thin flags.Get(key, default) wrapper around the GrowthBook Go SDK when the first kill-switch / ramp use-case lands |
Rule of thumb when reaching for a new env var
If the value might change without a code change, it's a flag, not config. Config = endpoints, credentials, ports (rarely change, restart is fine). Flags = thresholds, behaviour toggles, model choices, experiment arms (change at runtime, no restart).
Operational notes
- Auth on the UI - GrowthBook self-hosted runs behind nginx with Olly admin SSO. Same login as catalog / langfuse.
- Backups - Mongo volume is part of the dev-2 nightly snapshot. Feature definitions are recoverable, but treat the GrowthBook UI as code: prefer narrow, named feature keys over reusing one bag of JSON.
- Observability of the refresh loop - the triage refresh loop logs each successful load at INFO. If you suspect a flag isn't updating, check the service's logs for
growthbook: loaded N featuresand the timestamp.
No UAT / prod environments yet
Every value lives under the "Dev" environment. When prod lands, add a separate environment and per-env override path; do NOT use a single environment with if hostname == "prod" in the rules.
