Skip to content
Updated Jun 9, 2026

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

PieceURLNotes
GrowthBook server (UI + admin API)https://growthbook.dev.hiolly.comSource of truth. Define features, set values, audit changes here.
GrowthBook public APIhttps://growthbook-api.dev.hiolly.comREST API for write-through (used by the triage /v5/levers editor).
SDK endpoint (VPC)http://10.0.1.2:3501What services hit; not exposed publicly.
Triage-specific enriched UIhttps://prompts.dev.hiolly.com/leversCustom 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:

python
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")
typescript
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:

python
from balance.flags import get_flag

if get_flag("balance.chat.suggestion.enabled", default=False):
    ...
typescript
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:

  1. Export GROWTHBOOK_API_HOST=http://10.0.1.2:3501 and a GROWTHBOOK_SDK_KEY=… in its compose entry.
  2. Start the refresh loop in its FastAPI lifespan and cancel it on shutdown - see services/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.

python
# 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

  1. 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.
  2. Read it from code - get_lever("…", default=<historical value>) at the point you'd otherwise have a magic constant.
  3. (Optional) Enrich for triage-style UI - only if you want the /levers-style rich editor for your area. Add an entry to LEVERS in services/triage/src/triage/v5_levers_api.py (or a service-local mirror) with cluster, desc, rationale, code_ref, risk. The base GrowthBook UI is sufficient for most cases.
  4. 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

ActionWhereEffect
Flip a boolean / change a valueGrowthBook UI → feature → edit defaultLive in ≤ 30 s on every service running the refresh loop.
Per-environment overrideUI → feature → environment toggleOnly meaningful once UAT / prod environments exist in GrowthBook.
Audience targeting (per user, per tenant)UI → feature → "Add rule" → targeting conditionRequires services to pass attributes into the SDK call (not yet wired).
Programmatic updatePOST https://growthbook-api.dev.hiolly.com/api/v1/features/{id}What the triage /v5/levers editor uses. Requires an admin API key.
Roll backUI → feature → revision history → restoreSame 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:

  1. SDK env vars unset → wrapper short-circuits to default. (Useful for local tests without GrowthBook.)
  2. Initial SDK fetch fails → wrapper returns default and the refresh loop retries silently.
  3. get_feature_value raises → wrapper logs a warning and returns default.
  4. Stored value doesn't coerce to the type of default (e.g. you stored "true" for an int) → wrapper logs a warning and returns default.

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)

ClusterExamples
Commit thresholdsnarrow.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 / shortlistingnarrow.catalogue.shortlist_min_score (3), narrow.catalogue.shortlist_score_gap_from_top (8), narrow.catalogue.shortlist_max_size (15)
Fact-findingnarrow.subfacts_per_turn (3)
Safety / RF sidecarrf.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

SurfaceExamples
Chat featuresbalance.chat.suggestion.enabled, balance.chat.contradiction.enabled
Schedulerbalance.engine.daily.cron
Confidence gatesbalance.gate.confidence.auto (0.75), balance.gate.confidence.confirm (0.5)
Shape weightsbalance.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:

PatternLocationMigrate to
os.environ.get("TRIAGE_PROMPT_VARIANT")services/triage/src/triage/agent.py, chat_completions.pytriage.prompt.variant (string)
os.environ.get("TRIAGE_DISPATCHER")services/triage/src/triage/chat_completions.pytriage.dispatcher.mode (string)
os.environ.get("RF_OBS_LLM_MODEL")services/triage/src/triage/v5_rf_sidecar.pyrf.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 refreshservices/balance/src/balance/flags.pyAdopt triage's start_refresh_loop so UI changes propagate without restart
Go services with no flag layerclaims, 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 features and 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.

Olly Health Insurance Platform