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.
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 | .disabledInstall
pip install git+https://github.com/onelo-tools/onelo-python.gitInitialize
# ─────────────────────────────────────────────────────────────────
# 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 clientUsage
# ─── 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:
| Placeholder | What it is | Where 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) |
ONELO_FEATURE_ENVIRONMENT (Swift: OneloFeatureEnvironment) — see below. Discovery is authorized per device, not per key.Your keys, and what each does
| Key | Lives in | What 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 only | Server-trust operations — identify, monitor, auth verification. Never ship to a client. (The features stream itself works with the publishable key.) |
ONELO_FEATURE_ENVIRONMENT=test) from a device that's been authorized under Deploy access — not 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_ENVIRONMENT | Result |
|---|---|
test | Reads the Test snapshot (and, on the backend, registers Test presence + reacts to Discover Features). |
live | Reads the Live snapshot. |
| unset | Falls back to the key prefix: a live key resolves Live (a release build with no value is safe), a pk_test key resolves Test. |
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:
| Where | How | Notes |
|---|---|---|
| Client (Swift, JS…) | await onelo.identify(userId) after login | Automatic when you use Onelo Auth. Sets the identity for the whole client. |
| Backend, single-user | onelo.identify(userId) | Process-global. Fine for CLIs, workers, single-tenant services. |
| Backend, multi-user | await onelo.features.for_user(userId) per request | Stateless and concurrency-safe — never use process-global identify() per request, or users race each other. |
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.
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:
| Helper | True when the status is |
|---|---|
isEnabled | enabled, new, or beta (on AND usable) |
isVisible | anything except hidden |
isGreyed | greyed (locked padlock) |
isUpsell | upsell — 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 / isComingSoon | the matching status |
status | the 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.