Features  /  SDK integration

Features SDK

The SDK does three things: it reports the feature names your code uses (so they appear in the Registry), it resolves each one to a status for the current user, and it stays in sync — re-rendering the moment you deploy a change. Pick your platform below.

Install & integrate

These are the exact snippets shown on your dashboard's SDK → Features tab — the same source, so they never drift. macOS/Swift (frontend) and the Python, Node.js and PHP (backend) integrations are the complete, fully-tested ones today; the other platforms share the same API and grow as their snippets land.

① Clientfrontend

Initialize

// Xcode → File → Add Package Dependencies:
// https://github.com/onelo-tools/onelo-swift

import OneloSwift

// ─────────────────────────────────────────────────────────────────────
// Onelo Features SDK — Swift (iOS / macOS, SwiftPM or Xcode project)
//
// ⚙️ SETUP — feature ENVIRONMENT (test vs live), one-time per developer.
//    Onelo Features keeps a SEPARATE Test and Live snapshot. In dev you want
//    TEST (your in-progress features show + auto-discover into the registry);
//    production reads LIVE. You pick it with ONELO_FEATURE_ENVIRONMENT — NO
//    special dev/test key. Your normal publishable key is used everywhere;
//    registry growth is bound to this app + device (instance id), not a key.
//    Set the SAME value in your backend (e.g. Python ONELO_FEATURE_ENVIRONMENT)
//    so app + server resolve the same snapshot. Pick ONE method:
//
//    • Method A — Xcode scheme env var (SwiftPM AND classic Xcode):
//        Product → Scheme → Edit Scheme → Run → Arguments → Environment
//        Variables → +   Name: ONELO_FEATURE_ENVIRONMENT   Value: test
//        Keep it a USER scheme (xcuserdata/, gitignored) so it only applies
//        in dev — Archive/Release builds don't see it → resolves LIVE.
//
//    • Method B — xcconfig + Info.plist (classic Xcode project):
//        Config/Onelo.local.xcconfig (add to .gitignore):
//          ONELO_FEATURE_ENVIRONMENT = test
//        Xcode → Project → Info → Configurations: Debug → link that xcconfig;
//        Release → leave empty (→ LIVE). Info.plist:
//          <key>OneloFeatureEnvironment</key>
//          <string>$(ONELO_FEATURE_ENVIRONMENT)</string>
//
//    Anything other than "test"/"live" is ignored; empty in Release → LIVE (safe).
// ─────────────────────────────────────────────────────────────────────

// No featureEnvironment arg needed — the SDK auto-reads ONELO_FEATURE_ENVIRONMENT
// (scheme env var) or the OneloFeatureEnvironment Info.plist key. Pass
// Onelo(..., featureEnvironment: "test") only if you compute it in code.
let onelo = Onelo(
  publishableKey: "onelo_pk_live_YOUR_KEY",
  callbackScheme: "myapp",
  baseURL: URL(string: "https://api.onelo.tools")!
)

// Register EVERY feature name you gate, upfront — not optional. A feature()
// call only registers a name the first time that code path actually runs; a
// feature gated inside a conditionally-shown view can sit gated in your code
// and still never reach the dashboard Registry on a fresh launch. declare()
// registers the whole list immediately, independent of what happens to render.
onelo.features.declare(["advanced-export"])

Usage

// ─── STEP 0 — INVENTORY BEFORE YOU GATE (hard precondition) ──────────────────
// Do NOT write a single
//     onelo.features.feature("advanced-export")
// call until the table in step 4 exists and the developer has SEEN it.
// In the examples below the feature name is already chosen
// for you — choosing it is the actual job, and it is the step integrations skip.
// Gating whatever you happened to read produces a registry that LOOKS complete
// while most of the product stays ungated, unsellable and invisible in the
// dashboard.
//
// 1. ENUMERATE — three passes, in this order. Skip these paths entirely:
//       node_modules/  dist/  build/  out/  .next/  vendor/  __pycache__/
//       .build/  DerivedData/  xcuserdata/  Generated/
//       *.test.*  *.spec.*  *_test.*  test/  tests/
//    Skip anything already wrapped in a feature check a few lines above.
//    a) DESTINATIONS — every screen, window, tab, route, sheet or modal a user can
//       reach. This is the pass most integrations stop at.
//    b) TRIGGERS — everything that navigates to a destination: buttons, menu items,
//       links, deep links, keyboard shortcuts, command-palette entries. A trigger is
//       NOT its own feature — it INHERITS its destination's name. One feature = one
//       destination row + its linked trigger rows, never two names.
//    c) CAPABILITIES — a user-invoked action with end-to-end logic that is NOT a
//       screen: export a file, start a recording, run a sync. Name the ACTION, not
//       the widget that fires it.
//
// 2. QUALIFY — the atom filter. A feature is a unit you could plausibly SELL or
//    GATE, not every widget. DROP atoms: names ending Button, Cell, Row, Item,
//    Tile, Chip, Tag, Badge, Banner, Bar, Header, Footer, Icon, Field, Input,
//    Label, Spinner, Loader, Toast, Tooltip — and anything under /ui/, /atoms/,
//    /primitives/, /components/ui/, /design-system/. KEEP screen-shaped things:
//    names ending Screen, Page, Tab, Window, Sheet, Modal, Wizard, Flow, Activity,
//    Fragment, ViewController — and files under /screens/, /pages/, /routes/,
//    /flows/, /windows/, /onboarding/. Debug / Dev / Internal / Sandbox surfaces:
//    ASK before gating — do not gate them by default.
//
//    ⚠ THE EXCEPTION THAT PAYS — a candidate with NO user-facing call site.
//    The rule is that a capability normally needs at least one call site from the
//    UI, so pure plumbing — init paths, persistence, background sync nobody
//    triggers — must NOT become a feature. BUT:
//    some of the most sellable things in a product have no
//    screen and no button at all.
//    If a candidate changes product BEHAVIOUR — a config boolean, a branch on plan
//    or tier, a per-tenant toggle, a switchable integration (email notifications,
//    webhooks, remove-branding, custom domain, data export) — KEEP it, mark it
//    needs-confirmation, and ASK the developer. Silently filtering one out is the
//    expensive outcome: the developer never learns it was missed. Plumbing with no
//    behavioural effect still gets dropped — that distinction IS the qualify step.
//
// 3. THREAD IT ACROSS THE STACK, THEN SETTLE ONE NAME. A feature rarely lives in
//    one place: a trigger, the destination it opens, and the backend handler where
//    it actually ends are ONE feature. Group them into one thread, e.g.
//        feedback
//          ├─ client   ReportBugButton   (trigger)
//          ├─ client   FeedbackSheet     (destination)
//          └─ backend  routes/feedback   (handler — where it ends)
//    A scan sees an HTTP call, not which endpoint answers it — ASK when the link is
//    not obvious, and show the thread you believe in so it can be corrected.
//
//    ⚠ Onelo keys the registry BY NAME. The same name declared from two platforms
//    becomes ONE entry tagged with both; two DIFFERENT names silently create two
//    features and the tagging never happens. So: one feature = one name, at every
//    point of the thread, on every platform. Never rename between platforms to
//    "make it clearer".
//
//    Then, on every PAID feature, ask: "is there a part of this you want to sell
//    separately?" A sub-feature takes the parent's name plus a hyphen
//    (feedback → feedback-bug). Developers rarely volunteer these, and that is
//    exactly where the upsell lives.
//
// 4. WRITE THE TABLE — before you write any code. One row per feature:
//      feature | file:line | proposed name | destination/trigger/capability | gated / skipped + why
//    Show it to the developer and WAIT for approval. Group by feature, not by file,
//    with sub-features nested under their parent and every needs-confirmation
//    candidate called out.
//
// 5. THEN implement — one THREAD at a time, never one file at a time, and keep the
//    table in the PR description. Gating half a thread is worse than not gating it:
//    the UI hides while the handler stays open, so the feature is off for honest
//    users and on for anyone who knows the URL.
//
// ⚠️ REGISTRATION IS NOT OPTIONAL — call declare() with every name in the table,
//    once, at startup (see "Upfront declaration" below for the exact call). A
//    feature() call only registers a name the FIRST TIME that code path actually
//    RUNS — a feature gated inside a conditionally-rendered component (an
//    early-return, an unselected item, a rarely-hit branch) can sit in your table
//    as "gated" and still never reach the dashboard Registry on a fresh install,
//    because nothing ever called feature() for it. declare() registers the whole
//    table immediately, independent of what happens to render. Do this for EVERY
//    thread you implement in this pass, not just ones you notice are conditional
//    — you cannot always tell from the table alone which destinations are
//    reachable on first load.
//
// COVERAGE RULE: every candidate you enumerated is either gated, or present in the
// table with a stated reason for the skip. "I did not get to it" is not a reason.
// A deliberate skip and an unexamined miss must never look the same in your report.
//
// ─── NAMING CONVENTION ───────────────────────────────────────────────────────
// kebab-case, lowercase, [a-z0-9-] only, max 48 chars. Name the ACTION or the
// DESTINATION — never the widget that happens to open it.
//   Good:  advanced-export · analytics-dashboard · export-recording · settings-window
//   Bad:   export-button · ExportButton · exportBtn · advanced_export · screen2
// Convert PascalCase / camelCase to kebab-case, then strip a trailing suffix:
//   View, Screen, Page, Activity, Fragment, ViewController, ViewModel, Handler,
//   Controller, Service, Widget.
//   AnalyticsDashboardView → analytics-dashboard · ExportHandler → export
// Collision? append -2, -3. Sub-feature? parent name + hyphen: feedback-bug.
// The SETTLED name is what goes in the call: onelo.features.feature("advanced-export")
// — and the SAME string is used at every point of the thread, on every platform.

// If you use your own auth system, call await onelo.identify(userId) after login
// so per-user/per-plan targeting can apply. (Skip if using Onelo Auth — automatic.)

// ── IDENTIFY ──────────────────────────────────────────────
await onelo.identify(currentUser.id)

// ⚡ FIRST READ: for a RETURNING user, identify() restores THAT user's cached
// snapshot SYNCHRONOUSLY — feature() below is already correct with ZERO wait.
// Do NOT reflexively `await onelo.features.ready()` before every read "to be
// safe" — that blocks every launch on the network, cache or not, which is
// worse than the problem it solves. The flicker only exists for a genuinely
// NEW identity/device with no cached snapshot yet; if that matters for one
// specific view, track a separate `@State private var ready = false` scoped
// to it and render a skeleton — never fold "not yet known" into .hidden, and
// never block app launch on it.

// Check features synchronously — no init() needed.
// Updates push in real-time the moment an admin clicks Deploy in the Onelo dashboard.
// ⚠️ THE SDK NEVER READS THE DRAFT — a status set in the dashboard Registry does
// nothing until Deploy is clicked. "I changed it and nothing happened" is almost
// always a missing Deploy click, not a code bug.
// The SDK also auto-refreshes when your app comes to foreground or the system
// wakes from sleep (macOS App Nap aware), and when the signed-in user's plan
// changes (purchase / upgrade / downgrade) — so newly-unlocked features appear
// immediately, no app restart. Background apps stay in sync.
//
// ⚠️ Render by STATUS — do NOT gate UI with `if feature.isEnabled { show }`.
// isEnabled is true only for enabled/new/beta, so that pattern HIDES greyed
// (locked), coming_soon and upsell features instead of showing their padlock /
// badge. Use menuItem() on macOS, or the helpers below, so locked features stay
// visible.

// macOS menu (NSMenu): menuItem() maps every status for you —
//   greyed → 🔒 padlock · coming_soon → "Coming Soon" badge · upsell → "Available in <plan>".
//   For greyed / upsell / coming_soon, when the dev enabled tap-to-upgrade (upgradeCta +
//   requiredPlan) the item is CLICKABLE and opens the upgrade flow; otherwise disabled.
//   new/beta → badge · enabled → runs your action · hidden → omitted (returns nil).
#if canImport(AppKit)
let menu = NSMenu()
if let item = onelo.features.feature("advanced-export")
        .menuItem(title: "Advanced Export…", action: #selector(openAdvancedExport)) {
    menu.addItem(item)   // greyed features appear here — disabled, with a padlock
}
#endif

// SwiftUI / UIKit: show unless hidden/disabled, then style from the status.
//
// SELF-CHECK before calling this done — verify EVERY status against stubbed
// data, don't eyeball it: .enabled → plain · .new/.beta → SAME plus
// badgeLabel ('New'/'Beta', already handled below — no extra branch needed) ·
// .greyed → 🔒 tap-to-upgrade · .upsell → 'Available in <plan>' · .comingSoon
// → 'Coming Soon' · .hidden/.disabled → nothing. No badge on new/beta in your
// build? Print `f` right before this block before assuming a rendering bug —
// an undeployed dashboard status looks identical to one from here (see "THE
// SDK NEVER READS THE DRAFT" above).
let f = onelo.features.feature("advanced-export")
if f.isVisible && !f.isDisabled {      // .isVisible is false ONLY for .hidden
    // A blocked tile is tappable-to-UPGRADE only when the backend says so: f.upgradeCta
    // (dashboard "Tapping the feature opens the upgrade flow") + f.requiredPlan — covers
    // greyed, upsell AND coming_soon. f.badgeLabel is the ready-made label (🔒 / "Available
    // in Pro" / …) — render it, NOT the raw requiredPlan slug. e.g.:
    //   let canUpgrade = f.upgradeCta && f.requiredPlan != nil
    //   Button(f.badgeLabel.map { "Export \($0)" } ?? "Export") {
    //       if f.isEnabled { runExport() }
    //       else if canUpgrade { Task { await onelo.openUpgrade(forPlan: f.requiredPlan!) } }
    //   }
    //   .disabled(!f.isEnabled && !canUpgrade)   // blocked + no upgrade CTA → inert
}

// Run gated code only when the feature is actually usable (enabled/new/beta):
if onelo.features.feature("advanced-export").isEnabled {
    // …
}

// Escape hatch — force a snapshot reconcile via REST. Debounced internally
// to one network call per second; safe to call from anywhere.
// You should rarely need this; deploys reach the SDK automatically.
await onelo.features.refresh()

// Helpers + HOW EACH STATUS DRIVES YOUR UI — gate INTERACTIVITY on isEnabled; a blocked
// tile is tappable-to-UPGRADE only when f.upgradeCta && f.requiredPlan != nil (the backend's
// own signal — covers greyed, upsell AND coming_soon). NEVER gate on isUpsell/isGreyed alone.
//   if !f.isVisible || f.isDisabled { EmptyView() }   // .hidden / .disabled → render nothing
//   let canUpgrade = f.upgradeCta && f.requiredPlan != nil
//   Button(f.badgeLabel.map { "Export \($0)" } ?? "Export") {
//       if f.isEnabled { runExport() }
//       else if canUpgrade { Task { await onelo.openUpgrade(forPlan: f.requiredPlan!) } }
//   }
//   .disabled(!f.isEnabled && !canUpgrade)      // blocked + no upgrade CTA → inert
// .isEnabled    → .enabled/.new/.beta — USABLE; the ONE interactivity gate.
// .isVisible    → false ONLY for .hidden → when false, render nothing. Also skip .disabled.
// .isDisabled   → .disabled — a killed feature; render nothing.
// .isNew / .isBeta   → usable; cosmetic badge only.
// .isGreyed     → .greyed      — VISIBLE with 🔒; blocked. Tappable → upgrade when upgradeCta.
// .isComingSoon → .coming_soon — VISIBLE; blocked. Tappable → upgrade when upgradeCta.
// .isUpsell     → .upsell      — VISIBLE with "Available in <plan>"; blocked. Tappable → upgrade when upgradeCta.
// .upgradeCta   → dashboard "tap opens upgrade" toggle; PAIR WITH .requiredPlan → the tappable gate.
// .badgeLabel   → ready-made: 🔒 (greyed) · "Available in Pro" (upsell) · New/Beta/Coming Soon · nil.
// .requiredPlan / .requiredPlanLabel → plan that unlocks it (machine slug / human label — render the LABEL).
// .status       → .enabled | .new | .beta | .coming_soon | .greyed | .hidden | .upsell | .disabled
② Serverbackend

Install

pip install git+https://github.com/onelo-tools/onelo-python.git

Initialize

# ─────────────────────────────────────────────────────────────────
# Onelo Features SDK — Python (server-side / backend)
#
# 🔑 KEY — your live secret key (onelo_sk_live_*). Acts like a classic
#    server API key: SSE stream, identify(), monitor, auth verification.
#    KEEP IT THE SAME in dev/staging/prod. Get it from: dashboard →
#    API Keys → Secret keys.
#
# 🌐 FEATURE ENVIRONMENT — "test" or "live". THIS selects which feature
#    snapshot the server reads AND whether feature DISCOVERY is active —
#    NOT the key (no separate test/discovery key needed anymore). In
#    dev/staging set "test": the SDK reads the Test snapshot, registers
#    the slugs your code checks into the dashboard registry, and shows
#    online under the Test tab. In prod set "live" (or omit → live).
#    Set the SAME value in your client app (Swift OneloFeatureEnvironment)
#    so app + backend resolve the same snapshot.
#    Registry growth is bound to this app + INSTANCE — a stable per-process
#    id (set ONELO_INSTANCE_ID for containers; otherwise auto-generated and
#    persisted). The dashboard authorizes/unpins that instance under
#    Features → Registry → Deploy access. No key type is involved.
#    SETUP — just an env var per deployment target:
#      .env.staging    →  ONELO_FEATURE_ENVIRONMENT=test
#      .env.production →  ONELO_FEATURE_ENVIRONMENT=live   (or omit)
#
# 🔒 Keys: keep OUT of git (.env in .gitignore). Load prod secrets via
#    Vault / AWS Secrets Manager / K8s Secrets / etc.
# ─────────────────────────────────────────────────────────────────

# ── Module-level setup (runs once per process) ──────────────────
import os
from onelo import Onelo

onelo = Onelo(
    secret_key=os.environ["ONELO_SECRET_KEY"],  # onelo_sk_live_*
    feature_environment=os.environ.get("ONELO_FEATURE_ENVIRONMENT"),  # "test" in staging, "live"/unset in prod
    api_url="https://api.onelo.tools",
    # Optional: ONELO_INSTANCE_ID for a stable instance identity in
    # containers (else a per-process id is generated + persisted).
    # Optional knobs (sensible defaults — uncomment to override):
    # app_version="1.0.0",     # surfaced in SDK telemetry
    # strategy="auto",         # "auto" | "sse" | "polling" (SDK-side SSE fallback)
)

# Register EVERY feature name you gate, upfront — not optional. A feature()
# call only registers a name the first time that code path actually runs; a
# route that's rarely hit (an admin panel, a rare branch) can sit gated in
# your code and still never reach the dashboard Registry on a fresh deploy.
# declare() registers the whole list immediately, independent of traffic.
onelo.features.declare([
    # add every feature name you gate, e.g. "chat-stream", "voice-stream"
])

# Recommended: block until first refresh so the very first request
# already sees real feature state (avoids 'hidden' fail-closed defaults).
# Both forms are equivalent — pick whichever reads better in your code.
onelo.features.ready(timeout=2.0)   # parity with Swift's onelo.features.ready()
# onelo.ready(timeout=2.0)          # alias on the client

Usage

# ─── STEP 0 — INVENTORY BEFORE YOU GATE (hard precondition) ──────────────────
# Do NOT write a single
#     onelo.features.feature("advanced-export")
# call until the table in step 4 exists and the developer has SEEN it.
# In the examples below the feature name is already chosen
# for you — choosing it is the actual job, and it is the step integrations skip.
# Gating whatever you happened to read produces a registry that LOOKS complete
# while most of the product stays ungated, unsellable and invisible in the
# dashboard.
#
# 1. ENUMERATE — three passes, in this order. Skip these paths entirely:
#       node_modules/  dist/  build/  out/  .next/  vendor/  __pycache__/
#       .build/  DerivedData/  xcuserdata/  Generated/
#       *.test.*  *.spec.*  *_test.*  test/  tests/
#    Skip anything already wrapped in a feature check a few lines above.
#    a) ENTRY POINTS — every route / endpoint / RPC / GraphQL resolver a client can
#       reach, plus every CLI command, scheduled job and queue consumer. These are
#       inherently the right unit; the atom filter does not apply to them.
#    b) CALLERS — everything that invokes an entry point: a frontend fetch, a
#       webhook sender, another service, a cron entry. A caller is NOT its own
#       feature — it INHERITS the entry point's name. One feature = one entry-point
#       row + its linked caller rows, never two names.
#    c) CAPABILITIES — a unit of work with end-to-end logic that is not a route:
#       generate a report, run an export, sync a third party, send a digest.
#       Name the ACTION, not the function that happens to hold it.
#
# 2. QUALIFY. A feature is a unit you could plausibly SELL or GATE — not every
#    function. Route handlers, jobs and consumers: KEEP. Drop the plumbing:
#    middleware, serializers, DB models, migrations, config loaders, health checks,
#    client factories. Internal / debug / admin-only endpoints: ASK before gating —
#    do not gate them by default.
#
#    ⚠ THE EXCEPTION THAT PAYS — a candidate with NO user-facing call site.
#    The rule is that a capability normally needs at least one call site from the
#    UI, so pure plumbing — init paths, persistence, background sync nobody
#    triggers — must NOT become a feature. BUT:
#    on a backend most sellable behaviour has no UI at all,
#    so this is the NORM here, not the edge case.
#    If a candidate changes product BEHAVIOUR — a config boolean, a branch on plan
#    or tier, a per-tenant toggle, a switchable integration (email notifications,
#    webhooks, remove-branding, custom domain, data export) — KEEP it, mark it
#    needs-confirmation, and ASK the developer. Silently filtering one out is the
#    expensive outcome: the developer never learns it was missed. Plumbing with no
#    behavioural effect still gets dropped — that distinction IS the qualify step.
#
# 3. THREAD IT ACROSS THE STACK, THEN SETTLE ONE NAME. A feature rarely lives in
#    one place: a trigger, the destination it opens, and the backend handler where
#    it actually ends are ONE feature. Group them into one thread, e.g.
#        feedback
#          ├─ client   ReportBugButton   (trigger)
#          ├─ client   FeedbackSheet     (destination)
#          └─ backend  routes/feedback   (handler — where it ends)
#    A scan sees an HTTP call, not which endpoint answers it — ASK when the link is
#    not obvious, and show the thread you believe in so it can be corrected.
#
#    ⚠ Onelo keys the registry BY NAME. The same name declared from two platforms
#    becomes ONE entry tagged with both; two DIFFERENT names silently create two
#    features and the tagging never happens. So: one feature = one name, at every
#    point of the thread, on every platform. Never rename between platforms to
#    "make it clearer".
#
#    Then, on every PAID feature, ask: "is there a part of this you want to sell
#    separately?" A sub-feature takes the parent's name plus a hyphen
#    (feedback → feedback-bug). Developers rarely volunteer these, and that is
#    exactly where the upsell lives.
#
# 4. WRITE THE TABLE — before you write any code. One row per feature:
#      feature | file:line | proposed name | destination/trigger/capability | gated / skipped + why
#    Show it to the developer and WAIT for approval. Group by feature, not by file,
#    with sub-features nested under their parent and every needs-confirmation
#    candidate called out.
#
# 5. THEN implement — one THREAD at a time, never one file at a time, and keep the
#    table in the PR description. Gating half a thread is worse than not gating it:
#    the UI hides while the handler stays open, so the feature is off for honest
#    users and on for anyone who knows the URL.
#
# ⚠️ REGISTRATION IS NOT OPTIONAL — call declare() with every name in the table,
#    once, at startup (see "Upfront declaration" below for the exact call). A
#    feature() call only registers a name the FIRST TIME that code path actually
#    RUNS — a feature gated inside a conditionally-rendered component (an
#    early-return, an unselected item, a rarely-hit branch) can sit in your table
#    as "gated" and still never reach the dashboard Registry on a fresh install,
#    because nothing ever called feature() for it. declare() registers the whole
#    table immediately, independent of what happens to render. Do this for EVERY
#    thread you implement in this pass, not just ones you notice are conditional
#    — you cannot always tell from the table alone which destinations are
#    reachable on first load.
#
# COVERAGE RULE: every candidate you enumerated is either gated, or present in the
# table with a stated reason for the skip. "I did not get to it" is not a reason.
# A deliberate skip and an unexamined miss must never look the same in your report.
#
# ─── NAMING CONVENTION ───────────────────────────────────────────────────────
# kebab-case, lowercase, [a-z0-9-] only, max 48 chars. Name the ACTION or the
# DESTINATION — never the widget that happens to open it.
#   Good:  advanced-export · analytics-dashboard · export-recording · settings-window
#   Bad:   export-button · ExportButton · exportBtn · advanced_export · screen2
# Convert PascalCase / camelCase to kebab-case, then strip a trailing suffix:
#   View, Screen, Page, Activity, Fragment, ViewController, ViewModel, Handler,
#   Controller, Service, Widget.
#   AnalyticsDashboardView → analytics-dashboard · ExportHandler → export
# Collision? append -2, -3. Sub-feature? parent name + hyphen: feedback-bug.
# The SETTLED name is what goes in the call: onelo.features.feature("advanced-export")
# — and the SAME string is used at every point of the thread, on every platform.

# ── Usage (inside your route handler) ───────────────────────────
# ⚠️ identify() sets ONE process-global identity and the SDK swaps its
# whole cache to that user's targeted snapshot (full SSE reconnect on
# each switch). It is NOT per-request state. Use it only when the
# entire process acts as a single user — CLI tools, worker jobs,
# single-tenant services. In a multi-user backend, calling
# identify(user.id) per request would race: concurrent requests share
# the one identity, so user A can be evaluated with user B's targeting.
#
# Multi-user backend rule of thumb:
#   • Global flags (no per-user / per-plan targeting): just call
#     feature(name) — no identify() at all. This is the common case.
#   • Per-user / per-plan targeting server-side: use for_user(user_id)
#     (below). It is STATELESS and multi-user-safe — resolves THIS
#     user's plan-gated features without touching the global identity,
#     so concurrent requests can't race.
#
# Heads-up: if a plan-gated feature (status upsell/greyed) is read through
# the global feature() path, the SDK logs a one-time warning pointing you to
# for_user() — it's evaluating the gate against the shared identity, not the
# request's user. Silence with ONELO_SUPPRESS_GATING_WARNING=1 if intentional
# (e.g. a single-user CLI showing a teaser).

# Global flag (no targeting) — the common case:
@app.get("/export")
async def export(user = Depends(current_user)):
    if not onelo.features.feature("advanced-export").is_enabled:
        raise HTTPException(404)
    # ... run the feature

# Per-user / per-plan gating — resolve for THIS request's user. Cached
# per-user for ~30s so a busy backend (HTTP handlers, WebSocket servers)
# doesn't re-hit the network every request. Fail-closed: a network error
# resolves every feature to hidden, never raises.
@app.websocket("/ws/face")
async def face(ws, user = Depends(current_user)):
    uf = await onelo.features.for_user(user.id)
    if not uf.feature("face-stream").is_enabled:
        await ws.close()
        return
    # ... stream

# Server-rendered upsell — tell the user WHICH plan unlocks a locked feature.
# The backend resolves the plan; you just render its label. Works on both
# feature() and for_user(). upgrade_hint is None when there's nothing to upsell.
#
# SELF-CHECK before calling this done — verify EVERY status against stubbed
# data, don't eyeball it: enabled/new/beta → is_enabled True, render_reports()
# · greyed/upsell/coming_soon → is_enabled False, upgrade_hint set → locked
# view · hidden → is_enabled False, upgrade_hint None → render_hidden(). No
# upgrade_hint where you expect one? THE SDK NEVER READS THE DRAFT — a status
# set in the dashboard Registry does nothing until Deploy is clicked. Print
# `feat` before assuming a bug; an undeployed status looks identical to one.
@app.get("/reports")
async def reports(user = Depends(current_user)):
    feat = (await onelo.features.for_user(user.id)).feature("advanced-reports")
    if feat.is_enabled:
        return render_reports()
    if feat.upgrade_hint:                       # e.g. "Pro"
        return render_locked(f"Available in {feat.upgrade_hint}", cta=feat.upgrade_cta)
    return render_hidden()

# After YOUR backend processes a plan change (e.g. your own Stripe webhook,
# or a grant/revoke), drop the cached snapshot so the NEXT for_user() is fresh
# immediately instead of waiting out the ~30s TTL:
# onelo.features.invalidate_user(user_id)   # pass nothing to clear every user

# Single-user processes (worker / CLI) may pin an identity once:
# onelo.identify(job_user_id)   # ...and onelo.identify(None) to clear

# Other property checks on the Feature returned by feature(name):
# .is_visible          → True for any visible status; False for "hidden" AND for
#                        any status this SDK build doesn't recognise (fail-closed)
# .is_greyed           → True when status is "greyed"
# .is_new              → True when status is "new"
# .is_beta             → True when status is "beta"
# .is_coming_soon      → True when status is "coming_soon"
# .is_upsell           → True when status is "upsell"
# .is_known            → False if the backend sent a status newer than this SDK
#                        build — a hint to bump the SDK (still fail-closed as hidden)
# .status              → wire status ("enabled" | "new" | "beta" | "coming_soon" | "greyed" | "upsell" | "hidden")
#
# Upsell metadata (attached to plan-gated features — for server-rendered CTAs):
# .upgrade_hint        → human plan label to render, e.g. "Pro"; None when nothing to upsell
# .required_plan_label → same human label as a raw field (.required_plan = machine key)
# .upgrade_cta         → True if you enabled a tap-to-upgrade action for this feature
# .reason              → why this status resolved ("plan" | "user_override" | "paywall_off" | ...)

The snippet carries its own step-by-step setup in comments (especially the feature-environment setup — ONELO_FEATURE_ENVIRONMENT / Swift OneloFeatureEnvironment). Copy it from your dashboard SDK tab to get the placeholders below filled in automatically.

Placeholders in the snippet

The snippet above is a template. On your dashboard's SDK → Features tab these placeholders are filled in for you; if you copy from here, replace them by hand:

PlaceholderWhat it isWhere to get it
{{pkLive}}Your live publishable key (onelo_pk_live_*) — the client key.Dashboard → API Keys
{{publishableKey}}The publishable key for platforms that take a single key.Dashboard → API Keys
{{apiUrl}}Your Onelo backend base URL — the SDK calls this.Pre-filled by the dashboard for your environment
{{swiftRef}} / {{pythonRef}} / …The install ref (branch / version) for that platform.Pre-filled by the dashboard (staging vs production)
There is no separate dev, test, or discovery key. The snippet ships your live publishable key and picks Test vs Live at runtime with ONELO_FEATURE_ENVIRONMENT (Swift: OneloFeatureEnvironment) — see below. Discovery is authorized per device, not per key.

Your keys, and what each does

KeyLives inWhat it's for
onelo_pk_live_*Client apps (compiled into the binary)Reads the Live feature snapshot. Safe to ship.
onelo_sk_live_*Your backend / server onlyServer-trust operations — identify, monitor, auth verification. Never ship to a client. (The features stream itself works with the publishable key.)
What grows your registry is running in the Test environment (ONELO_FEATURE_ENVIRONMENT=test) from a device that's been authorized under Deploy accessnot a special key. Live keys reading the Live snapshot are read-only. There is no test tenant, no fake data, and nothing to hardcode.

Feature environment — Test vs Live for the SDK

Which snapshot the SDK reads — Test or Live — is set by ONELO_FEATURE_ENVIRONMENT. That's the determinant — not which key you hold. Your publishable key on staging deliberately resolves the Test snapshot when you set the variable; leave it unset and the environment falls back to the key prefix — a live key resolves Live, a pk_test key resolves Test.

ONELO_FEATURE_ENVIRONMENTResult
testReads the Test snapshot (and, on the backend, registers Test presence + reacts to Discover Features).
liveReads the Live snapshot.
unsetFalls back to the key prefix: a live key resolves Live (a release build with no value is safe), a pk_test key resolves Test.
Set the same value in your client app and your backend (e.g. ONELO_FEATURE_ENVIRONMENT=test in staging, live or unset in production). That's what stops the classic "app sees Test, backend sees Live" split.

Identify users for targeting

Plain on/off features need no identity. As soon as a feature is plan-gated or user-targeted, Onelo needs to know who is asking. How you tell it differs by where the code runs:

WhereHowNotes
Client (Swift, JS…)await onelo.identify(userId) after loginAutomatic when you use Onelo Auth. Sets the identity for the whole client.
Backend, single-useronelo.identify(userId)Process-global. Fine for CLIs, workers, single-tenant services.
Backend, multi-userawait onelo.features.for_user(userId) per requestStateless and concurrency-safe — never use process-global identify() per request, or users race each other.
Python backends: identify() swaps one process-global identity and reconnects the whole cache — calling it per request lets concurrent requests resolve the wrong user. Use for_user(user_id) for per-request gating; it caches per user (~30s) and fails closed on network errors. The snippet shows both paths.

Client identity is protected by your app's identity binding (App Attest / Allowed Domains — see Security), and the real enforcement boundary for paid access is your backend with the secret key.

Client platforms — avoid the first-paint flicker. identify() (or Onelo Auth's own session resolve) settles per-user targeting over the network. The cache is per-user, so on a cold load or right after login the SDK doesn't yet know which user's cached snapshot to restore until that resolve lands. A feature() read before it settles can briefly show the wrong status — and if your UI treats "not yet known" the same as hidden, a real feature will flash, disappear, then reappear. That reads as a caching bug in Onelo; it isn't — the UI asked before the SDK had an answer. Every client SDK exposes await onelo.features.ready(timeoutMs) for exactly this: await it before your first read/first paint, or track a separate loading flag and render a skeleton — never fold "unknown yet" into the hidden branch. The snippet above shows the exact call for your platform.

Reading statuses

Read every feature by its status, not a boolean — gating UI with if feature.isEnabled hides locked / coming-soon / upsell features instead of showing their padlock or badge. Each SDK exposes the same helpers so you don't compare enums by hand:

HelperTrue when the status is
isEnabledenabled, new, or beta (on AND usable)
isVisibleanything except hidden
isGreyedgreyed (locked padlock)
isUpsellupsell — locked, shows an “Available in <plan>” tease. Tap-to-upgrade itself is driven by upgradeCta + requiredPlan (which greyed / coming_soon can carry too), not by this flag
isNew / isBeta / isComingSoonthe matching status
statusthe raw value: enabled | new | beta | coming_soon | greyed | upsell | hidden (plus a fail-closed disabled for unknown/error states)

On macOS the snippet's menuItem() helper maps every status for you: hidden is omitted, new/beta get a badge, and any locked status (greyed / coming_soon / upsell) that carries upgradeCta + requiredPlan becomes a clickable item that opens the upgrade flow (subscribers → Change-plan, everyone else → the store) — otherwise it stays a disabled padlock. Updates arrive in real time over the SDK's live connection — on deploy, on foreground, on plan change — so newly-unlocked features appear without a restart. Call refresh() only when you need to force a reconcile; it's rarely necessary.

Building your own UI instead of menuItem()? The gate for "this is a tap-to-upgrade slot" is the same on every SDK: upgradeCta && requiredPlan != null — true for any locked status the dashboard's CTA toggle covers (default on), not just upsell. When it holds, show requiredPlanLabel / badgeLabel (never the raw requiredPlan slug) and route the tap to the SDK's openUpgrade with that plan (Swift: openUpgrade(forPlan:)).

Rendering the upsell yourself — a backend, or any server-rendered UI? A locked feature carries the plan that unlocks it. In Python, upgrade_hint is the plan label (e.g. "Pro") — render it as f"Available in {feat.upgrade_hint}"; it's None when there's nothing to upsell, so if feat.upgrade_hint: is all you need. In Swift/JS, upgradeHint is a small object (requiredPlan + currentStatus) — the ready-made human label there is badgeLabel / requiredPlanLabel, with upgrade_cta deciding whether to show a tap-to-upgrade action.

Next

Features SDK — Onelo Docs