UI controls
The complete cookbook for CtrlApp UI controls: every type (button, text, image, banner, card, sheet, modal, drawer_item, menu_item, link, gate, config_value, feature_flag, form_schema, custom), how the SDK resolves and reads them, targeting, variants, localization, kill switch, and events.
A UI control is a typed record for one UI element — a button, banner, modal, drawer item, form, or plain remote-config value. Change copy, swap an image, hide an entry point, or kill a whole surface from the dashboard without shipping a release.
Selection persists across this handbook via localStorage and is reflected in the URL as ?sdk=web so a link is shareable. Dashboard payload JSON stays visible for every SDK.
Platform availability today
| SDK | Availability today |
|---|---|
| Web · TypeScript | Source in this repo (@ctrlapp/sdk). No public npm release yet — vendor via the documented local path. |
| Flutter · Dart | Source in sdk/ctrlapp_flutter. Not on pub.dev — add via a local path dependency. |
| iOS · Swift | Source in sdk/ctrlapp_ios. Local Swift Package only; no SPM/CocoaPods release. Public API is thin (isEnabled/text/image/variant/treatment/track) — richer per-type reads use REST. |
| Android · Kotlin | Source in sdk/ctrlapp_android. Local Gradle module only; not on Maven Central. |
Portal → selected SDK workflow
- In the dashboard: Apps → your app → Controls → New control. Pick a type from
CONTROL_TYPES. - Enter a stable key (e.g.
home.cta_primary). Keys are permanent — never rename after ship. - Fill in the payload/content, targeting, schedule, rollout, and set
is_enabled. - Save. The per-app bundle cache is invalidated on write, so the next successful
/controlsrequest re-resolves. - The selected client picks up the change on its next successful load or poll (Web:
ctrl.load()/startPollingat 60s default; Flutter/Kotlin: automatic background poll started byCtrlApp.init; Swift: automatic afterinitialize). While a device is offline, only the last successfully synced map is available. - Render safely (fail closed for optional surfaces; local defaults for essential UI; allowlist any remote URL/action).
- Send
impression/click/dismissvia the SDK'strack().
Each SDK sends its own platform value on /register and /controls: web for the JS SDK, ios / android for Swift/Kotlin, and ios or android for Flutter depending on the host device. Use that value in your platform targeting rules.
When to use a UI control vs a feature flag
- Use a UI control when the change is content-shaped: text, image, CTA target, a card that appears somewhere, a form the client should render. The server ships a shaped
CtrlValuethe SDK reads by key. - Use a dedicated Feature Flag when the change is a boolean/multivariate treatment evaluated by the flag ruleset — real treatments, impressions, and experiment metrics live there, not on
feature_flagcontrols. See the note under feature_flag.
@ctrlapp/sdk source lives in this repo, but a public registry release is still pending. For today's integrations use REST — see REST quickstart — or vendor the SDK by the supported source path documented in SDK & package availability. Do not rely on a public npm install @ctrlapp/sdk yet.Quick start (5 steps)
- 1Pick a stable key and a typeKeys look like
home.cta_primary— letters, digits,. _ - :. Keys are permanent identifiers; do not rename them once they ship. Types come fromCONTROL_TYPES— see the table below. - 2Configure content and, where relevant, a local fallbackFor essential local UI (copy, headings, static screens) hard-code a default and only overlay remote fields when they arrive. For optional remote surfaces (promo, banner, modal) skip the fallback and simply hide when the key is absent. Which policy is right depends on the surface — see Local fallbacks.
- 3Initialize, register, loadts· app/bootstrap.ts
import { Ctrl } from "@ctrlapp/sdk"; const ctrl = Ctrl.init({ apiKey: PUBLISHABLE_KEY, deviceId: getOrCreateDeviceId(), platform: "web", locale: navigator.language, }); await ctrl.register(); // ensures the device exists server-side await ctrl.load(); // resolved controls map ctrl.startPolling(); // background refresh (default 60s) // Optional and independent of UI controls: app-level /session block / // force-update policy. Only call if you enforce ctrl.blocked() somewhere. // await ctrl.refreshSession(); - 4Read the resolved valuets· web · ts
if (ctrl.isVisible("home.cta_primary")) { button.textContent = ctrl.text("home.cta_primary", "Buy now"); button.disabled = !ctrl.isEnabled("home.cta_primary"); ctrl.track("home.cta_primary", "impression"); } - 5Enable gradually, verify, trackShip the control with
is_enabled: falseorrollout_percent: 10, verify with a real device in the target segment, then widen. Every interaction should callctrl.track(key, event).
How the server resolves a control
The server pre-resolves every control per device on /controls. Clients never see payloads they are not eligible for. Actual evaluation order in src/lib/controls.server.ts:
- Control-level gates —
is_killedandis_enabledmust both allow serving. (This is per-control, distinct from the app-level/sessionblock/force-update decision.) - Schedule window —
starts_at/ends_atcompared as absolute UTC timestamps againstDate.now(). - Targeting — normalized
targetingrules + segments +rollout_percent, then legacy per-column fields (platform, min/max version, country, tags, device ids, legacy rollout). - Sticky A/B variant pick — a variant's
payloadmerges over the base payload; the assignment is written toui_control_assignmentsso the same device keeps the same variant. - Locale overrides — exact-locale first (e.g.
ckb-IQ), then base language (ckb), then the base payload. - Shape into
CtrlValue— known/reserved fields lifted to typed slots; non-reserved fields land onextra.
The CtrlValue shape
export type CtrlValue = {
type: string; // one of CONTROL_TYPES
visible: boolean; // payload.visible (default true)
enabled: boolean; // payload.enabled (default true)
locked: boolean; // payload.locked (default false)
text: string | null;
subtitle: string | null;
cta: string | null;
icon: string | null;
image_url: string | null;
badge: string | null;
variant: string | null; // payload.variant — free-form presentation hint
variant_key: string | null; // assigned A/B variant key (from ui_control_variants)
color_token: string | null;
size: string | null;
tooltip: string | null;
sort_order: number;
action: { kind: string; value: string | null };
extra: Record<string, unknown>; // any non-reserved payload fields
};variant vs variant_key. variant is a free-form label you wrote on the payload (a design hint like "aggressive").variant_key is the A/B assignment the server picked from ui_control_variants. They are independent — do not use one where you mean the other.
SDK accessor map
| Call | Returns | Behavior for unknown key |
|---|---|---|
| ctrl.get(key) | CtrlValue | null | null (fail closed) |
| ctrl.isVisible(key) | boolean | false (fail closed) |
| ctrl.isEnabled(key) | boolean | false (fail closed) |
| ctrl.text(key, fallback?) | string | returns the fallback |
| ctrl.image(key) | string | null | null |
| ctrl.icon(key) | string | null | null |
| ctrl.link(key) | string | null | null — do NOT navigate on null |
| ctrl.variant(key) | string | null (variant_key) | null |
| ctrl.payload<T>(key, field) | T | null | null |
| ctrl.flag(key) | { isOn, isVisible, isEnabled, variant: variant_key } | isOn=false (fail closed) |
| ctrl.track(key, event) | void (batched, flushed ~2s) | still queued locally, may be discarded server-side |
Every accessor is fail-closed: an unknown key never turns something on. The SDK also caches the last successfully resolved controls map in localStorage and hydrates it before the first network call, so a cold offline start uses the most recent map it managed to fetch.
visibility vs enabled vs locked vs kill switch
- Outer
is_enabled(dashboard) — off means the server does not ship the control at all. SDK sees the key as unknown. - Control kill (
is_killed) — removes this one control from the next resolved/controlsmap. Distinct from the app-level/sessionblock/force-update decision. payload.visible— the key IS shipped, but the SDK returnsisVisible = false. Use to hide the surface while keeping analytics/rollout wiring.payload.enabled— visible but not interactable (grey out a button, disable a form).payload.locked— advisory flag surfaced onCtrlValue.locked; the SDK does not enforce it. Use in your UI to render a lock badge or gate behind an upgrade.
The app-level /session policy (whether the whole app is blocked / force-updated) is cached separately by refreshSession()and lives on ctrl.blocked() / blockDecision(), not on any per-control kill.
Local fallbacks and unknown keys
A key resolves to a fail-closed default when it is unknown to the device (never shipped, disabled, killed, targeted out, or the control was newly killed and this device has since successfully re-fetched the map without it). While offline the SDK can only serve the last map it successfully synced: it cannot learn a new remote kill until the next successful ctrl.load() / poll.
Pick a fallback policy per surface:
- Optional remote surface (promo strip, marketing banner, modal): check
ctrl.isVisible(key)orctrl.get(key)and render nothing when null/hidden. This is the killable surface — a kill hides it as soon as the client re-syncs. - Essential local UI (labels, headings, static copy, permanent CTA): render your local default unconditionally and only overlay remote fields when present. This surface intentionally stays available if the control is absent — do not describe it as killable.
// Safe read of a UI control. Returns { value: null } when the key is not
// shipped to this device (targeted-out, killed, or unknown). What to do
// with null is a deliberate per-surface choice — it is NOT always "fall
// back":
// * optional remote surface (promo, banner, modal): render nothing.
// * essential local UI (labels, headings, permanent CTAs): keep your
// local default rendered and only overlay remote fields when present.
export function readControl<T = unknown>(
ctrl: Ctrl,
key: string,
): { visible: boolean; enabled: boolean; value: CtrlValue | null; payload: (f: string) => T | null } {
const v = ctrl.get(key);
return {
visible: ctrl.isVisible(key),
enabled: ctrl.isEnabled(key),
value: v,
payload: (f) => ctrl.payload<T>(key, f),
};
}Choose a type
| type | Best for | Important payload fields | Read pattern |
|---|---|---|---|
| button | Any CTA/tap target | text, cta, action_kind, action_value, enabled | ctrl.text(k), ctrl.link(k), ctrl.isEnabled(k) |
| text | Copy blocks, labels | text, subtitle | ctrl.text(k, "fallback") |
| image | Hero/thumbnails | image_url, text (alt) | ctrl.image(k) ?? defaultUrl |
| banner | Top-of-page promo | text, subtitle, cta, image_url, action_* | ctrl.get(k) + track() |
| drawer_item | Side-drawer entry | text, icon, action_*, sort_order | ctrl.text(k), ctrl.icon(k), ctrl.link(k) |
| menu_item | Menu/list entry | text, icon, action_* | ctrl.text(k), ctrl.link(k) |
| link | Plain hyperlink | text, action_value (URL) | ctrl.text(k), ctrl.link(k) |
| card | Content card block | text, subtitle, image_url, cta, action_* | ctrl.get(k) |
| sheet | Bottom sheet | text, subtitle, cta, action_* | ctrl.get(k) + track() |
| modal | Full modal dialog | text, subtitle, cta, action_* | ctrl.get(k) + track() |
| gate | Feature availability | visible, enabled | ctrl.isVisible(k) / ctrl.isEnabled(k) |
| config_value | Remote-config value | value_type, value, options, min, max | ctrl.payload(k, "value") |
| feature_flag | Optional UI availability toggle | visible, enabled, flag_kind/default_treatment/treatments (authoring metadata), payload.variant (presentation hint) | ctrl.flag(k).isOn; ctrl.get(k)?.variant (payload hint); ctrl.variant(k) or ctrl.flag(k).variant (A/B variant_key) |
| form_schema | Remote form | title, fields[], submit_label | ctrl.get(k) then validate |
| custom | Arbitrary JSON | anything you author | ctrl.payload<T>(k, "field") |
Per-type reference
button
A tap/click target with copy, an optional icon, and an action to route on click.
{
"text": "Upgrade to Pro",
"cta": "upgrade",
"action_kind": "in_app_route",
"action_value": "/billing",
"enabled": true
}if (ctrl.isVisible("home.upgrade")) {
btn.textContent = ctrl.text("home.upgrade", "Upgrade");
btn.disabled = !ctrl.isEnabled("home.upgrade");
btn.onclick = () => {
ctrl.track("home.upgrade", "click");
routeSafely(ctrl.link("home.upgrade"));
};
ctrl.track("home.upgrade", "impression");
}text
Copy blocks, labels, headlines. No action, no interactivity.
{ "text": "Welcome back", "subtitle": "Here is what changed." }h1.textContent = ctrl.text("home.title", "Welcome");
// subtitle is a lifted CtrlValue field — read via ctrl.get().
p.textContent = ctrl.get("home.title")?.subtitle ?? "Here is what changed.";image
Hero, thumbnail, logo. Server enforces HTTP(S) URLs; the SDK returns null when missing.
{ "image_url": "https://cdn.example.com/hero.jpg", "text": "Two hikers" }const ALLOWED_IMAGE_HOSTS = new Set(["cdn.example.com", "images.example.com"]);
function safeImageUrl(raw: string | null | undefined): string | null {
if (!raw) return null;
let u: URL;
try { u = new URL(raw); } catch { return null; }
if (u.protocol !== "https:") return null;
return ALLOWED_IMAGE_HOSTS.has(u.hostname) ? u.toString() : null;
}
const safe = safeImageUrl(ctrl.image("home.hero"));
img.src = safe ?? bundledHero;
img.alt = ctrl.text("home.hero", "");banner
Top-of-page promotional strip with copy, optional image, CTA, and action.
{
"text": "Summer sale — 30% off",
"subtitle": "Ends Sunday",
"cta": "Shop now",
"image_url": "https://cdn.example.com/summer.jpg",
"action_kind": "in_app_route",
"action_value": "/shop"
}const v = ctrl.get("home.banner");
if (v?.visible) {
render(<Banner title={v.text} subtitle={v.subtitle} cta={v.cta}
onClick={() => { ctrl.track("home.banner", "click"); routeSafely(v.action.value); }}
onDismiss={() => ctrl.track("home.banner", "dismiss")} />);
ctrl.track("home.banner", "impression");
}drawer_item
One entry in an app side-drawer / navigation drawer.
{
"text": "What's new",
"icon": "sparkles",
"sort_order": 10,
"badge": "NEW",
"action_kind": "in_app_route",
"action_value": "/whats-new"
}const item = ctrl.get("drawer.whats_new");
if (item?.visible) drawer.append({
label: item.text, icon: item.icon, badge: item.badge,
onClick: () => { ctrl.track("drawer.whats_new", "click"); routeSafely(item.action.value); },
});menu_item
Same shape as drawer_item — use for menus, action sheets, or any list of tappable rows.
{ "text": "Report a problem", "icon": "flag", "action_kind": "in_app_route", "action_value": "/support" }if (ctrl.isVisible("menu.report")) list.push({
label: ctrl.text("menu.report", "Report"),
onSelect: () => { ctrl.track("menu.report", "click"); routeSafely(ctrl.link("menu.report")); },
});link
Plain hyperlink. action_value must be a valid URL — the server validates this.
{ "text": "Read the changelog", "action_kind": "external_url", "action_value": "https://ctrlapp.krdcode.com/docs/changelog" }// NEVER assign a raw remote value to a.href. Validate first.
if (ctrl.isVisible("footer.changelog")) {
const safeHref = allowedExternalHref(ctrl.link("footer.changelog"));
a.textContent = ctrl.text("footer.changelog", "Changelog");
if (safeHref) {
a.href = safeHref;
a.rel = "noopener noreferrer";
a.target = "_blank";
a.onclick = () => ctrl.track("footer.changelog", "click");
} else {
a.remove();
}
}card
A card block on a home/feed screen, richer than a banner.
{
"text": "Try the new dashboard",
"subtitle": "Faster search, live kill-switch.",
"image_url": "https://cdn.example.com/dash.png",
"cta": "Open",
"action_kind": "in_app_route",
"action_value": "/dashboard"
}const CARD_KEY = "home.card.dashboard";
const v = ctrl.get(CARD_KEY);
return v?.visible ? (
<Card {...v} onClick={() => {
ctrl.track(CARD_KEY, "click"); // track the KEY, not v.type ("card")
routeSafely(v.action.value);
}} />
) : null;sheet
Bottom sheet dialog — same fields as banner/modal.
{ "text": "Enable notifications?", "subtitle": "Get shipping updates.", "cta": "Enable", "action_kind": "in_app_route", "action_value": "/settings/notifications" }if (ctrl.isVisible("push.opt_in")) {
const v = ctrl.get("push.opt_in")!;
showBottomSheet({ ...v, onCta: () => { ctrl.track("push.opt_in", "click"); routeSafely(v.action.value); } });
ctrl.track("push.opt_in", "impression");
}modal
Full-screen modal — force-update warnings, migration prompts, gated announcements.
{ "text": "New Terms of Service", "subtitle": "Please review before continuing.", "cta": "Review", "action_kind": "external_url", "action_value": "https://ctrlapp.krdcode.com/legal/tos" }const v = ctrl.get("legal.tos_modal");
if (v?.visible) showModal({
title: v.text, body: v.subtitle, cta: v.cta,
onCta: () => { ctrl.track("legal.tos_modal", "click"); routeSafely(v.action.value); },
onDismiss: () => ctrl.track("legal.tos_modal", "dismiss"),
});gate
A UI-surface availability toggle: show/hide (or enable/disable) an optional part of the interface. It is NOT a behavioral experiment API — for treatment evaluation, impressions, and experiments use dedicated Feature Flags.
{ "visible": true, "enabled": true }if (ctrl.isVisible("settings.beta_section")) {
render(<BetaSection disabled={!ctrl.isEnabled("settings.beta_section")} />);
}gate to switch between application code paths, run A/B treatments, or record experiment exposures — those live in the dedicated Feature Flags system.config_value
Remote-config style typed value. value_type is one of string, number, bool, json, color, url, enum.
{
"value_type": "number",
"value": 15,
"min": 1,
"max": 60,
"description": "Autosave interval, seconds"
}// value_type / min / max / options are authoring metadata. Always
// narrow (typeof/Zod) and clamp/allowlist on the client.
const n = ctrl.payload<unknown>("editor.autosave_seconds", "value");
const seconds = typeof n === "number" && Number.isFinite(n)
? Math.min(60, Math.max(1, n)) : 30;feature_flag (control type)
A UI-facing on/off (or labeled variant) surface stored as a control. Distinct from CtrlApp's dedicated Feature Flags system.
{
"visible": true,
"enabled": true,
"variant": "aggressive",
"flag_kind": "multivariate",
"default_treatment": "off",
"treatments": [
{ "key": "on", "value": true, "weight": 50 },
{ "key": "off", "value": false, "weight": 50 }
]
}// Gate an OPTIONAL UI surface. For behavioral/experiment evaluation
// use dedicated Feature Flags (/api/public/sdk/v1/flags/ruleset).
const f = ctrl.flag("home.new_layout");
if (f.isOn) renderNewLayoutSurface();
// A/B assignment (variant_key) picked from ui_control_variants:
const variantKey = ctrl.variant("home.new_layout"); // === f.variant
if (variantKey === "aggressive_copy") applyAggressiveCopy();
// payload.variant is a free-form presentation hint on CtrlValue.variant.
const presentationHint = ctrl.get("home.new_layout")?.variant;flag_kind, default_treatment, and treatments[] are accepted by the payload schema in src/lib/control-schemas.ts but are not evaluated by the /controls delivery path in src/lib/controls.server.ts. The delivery pipeline only ships the shaped CtrlValue, and ctrl.flag(key) returns { isOn, isVisible, isEnabled, variant: variant_key }. For real treatment evaluation, impressions, and experiments use the dedicated Feature Flags system (/api/public/sdk/v1/flags/ruleset).form_schema
A remotely-defined form: title, fields[], and a submit action. Real field types: text, email, number, bool, select, textarea, date, file, page (step separator).
{
"title": "Contact us",
"submit_label": "Send",
"submit_action": "https://api.example.com/contact",
"fields": [
{ "key": "email", "label": "Email", "type": "email", "required": true },
{ "key": "topic", "label": "Topic", "type": "select",
"options": ["billing", "bug", "other"], "required": true },
{ "key": "details", "label": "Details", "type": "textarea",
"visible_if": { "field": "topic", "op": "eq", "value": "bug" } },
{ "key": "page", "type": "page" },
{ "key": "attachment", "label": "Screenshot", "type": "file",
"accept": "image/*", "max_size_mb": 5 }
]
}// Consumers don't have @/lib/control-schemas. Narrow with your own guard.
type FormField = { key: string; type: string; label?: string; required?: boolean };
type FormSchema = { title?: string; submit_label?: string; submit_action?: string; fields: FormField[] };
const FIELD_TYPES = new Set(["text","email","number","bool","select","textarea","date","file","page"]);
function isFormSchema(x: unknown): x is FormSchema {
if (!x || typeof x !== "object") return false;
const o = x as Record<string, unknown>;
if (!Array.isArray(o.fields)) return false;
return o.fields.every((f) =>
!!f && typeof f === "object"
&& typeof (f as { key: unknown }).key === "string"
&& FIELD_TYPES.has((f as { type: unknown }).type as string),
);
}
const v = ctrl.get("support.contact");
const raw = { ...(v?.extra ?? {}), subtitle: v?.subtitle ?? undefined };
if (!isFormSchema(raw)) return renderStaticContactForm();
renderForm(raw);@/lib/control-schemas from consumer code — they are private to this repo. Use a self-contained narrowing helper as above. type: "page" is a step separator: every field before the next page belongs to the previous step. visible_if supports operators eq, neq, in, contains, truthy, falsy. The submit endpoint must still be allowlisted client-side and re-validated server-side.custom
Arbitrary JSON you author and interpret on the client. Reserved common fields (text, subtitle, cta, image_url, action_*, etc.) still get lifted to CtrlValue; non-reserved fields land on CtrlValue.extra.
{ "layout": "grid", "columns": 3, "highlight": ["a", "b"] }const layout = ctrl.payload<string>("home.custom_layout", "layout") ?? "list";
const cols = ctrl.payload<number>("home.custom_layout", "columns") ?? 1;Operating controls
Common state fields
visible,enabled,locked— see the section above.sort_order— integer used by list surfaces (drawer, menu).variant,color_token,size— free-form UI hints your app maps to a static design-token table. Never inject them raw.tooltip,badge— text overlays.
Actions
Every actionable control has action_kind andaction_value. Kinds: none, deep_link, external_url, in_app_route. The SDK exposes the resolved value on ctrl.get(key).action and via ctrl.link(key). The consuming app is responsible for routing safely — never execute a remote string as a URL, deep link, or router path without exact allowlisting.
// Exact allowlists. URL parsing avoids the classic "prefix match" bug
// where /^https:\/\/ctrlapp\.krdcode\.com/ also lets
// "https://ctrlapp.krdcode.com.evil.example" through.
const ALLOWED_ROUTES = new Set([
"/home", "/billing", "/settings", "/shop",
"/whats-new", "/support", "/dashboard", "/settings/notifications",
]);
const ALLOWED_HTTPS_ORIGINS = new Set(["https://ctrlapp.krdcode.com"]);
const ALLOWED_DEEP_LINK_SCHEME = "myapp:"; // e.g. myapp://checkout
const ALLOWED_DEEP_LINK_HOSTS = new Set(["checkout", "settings"]);
// Returns the target string only if it is on the exact HTTPS origin allowlist.
// Callers use this to decide whether to render/enable an anchor at all.
export function allowedExternalHref(target: string | null | undefined): string | null {
if (!target) return null;
let url: URL;
try { url = new URL(target); } catch { return null; }
if (url.protocol !== "https:") return null;
return ALLOWED_HTTPS_ORIGINS.has(url.origin) ? url.toString() : null;
}
// Pass an app-provided navigate callback (e.g. TanStack Router's navigate,
// React Router's useNavigate(), or a router-agnostic wrapper). Falls back
// to same-origin window.location.assign for the exact allowed paths.
export type Navigate = (to: string) => void;
export function routeSafely(
target: string | null | undefined,
navigate?: Navigate,
): void {
if (!target) return;
// In-app route: exact match against an allowlist.
if (target.startsWith("/")) {
if (!ALLOWED_ROUTES.has(target)) return;
if (navigate) navigate(target);
else window.location.assign(target);
return;
}
// External HTTPS: exact origin comparison, new tab with noopener,noreferrer.
const safe = allowedExternalHref(target);
if (safe) {
window.open(safe, "_blank", "noopener,noreferrer");
return;
}
// Deep link: exact scheme + host allowlist.
let url: URL;
try { url = new URL(target); } catch { return; }
if (url.protocol === ALLOWED_DEEP_LINK_SCHEME && ALLOWED_DEEP_LINK_HOSTS.has(url.hostname)) {
window.location.href = url.toString();
return;
}
// Anything else is rejected. Never follow untrusted URLs.
}Targeting
Normalized targeting (src/lib/control-schemas.ts) accepts:
- Attributes:
platform,country,app_version,device_tag,user_id,locale, pluscustom.<key>. - Operators:
eq, neq, in, nin, gte, lte, semver_gte, semver_lt, regex, exists. - match:
all(AND) orany(OR) across rules. - segments: reusable audiences by id.
- rollout_percent: 0–100. See the rollout section for the actual bucket behavior — it is not a single exact percentage today.
- Legacy columns (
target_platform,target_min_version,target_max_version,target_country,target_tags,target_device_ids, legacyrollout_percent) are evaluated in addition to normalized targeting — a control passes only if BOTH pass.
Production controls delivery in matches() (src/lib/controls.server.ts) currently builds the evaluator context from only: deviceUid, platform, country, appVersion, tags, and segments.
- Reliable on delivery today:
platform,country,app_version,device_tag, andsegments. - Accepted by the schema/preview but NOT wired into production delivery today:
user_id,locale, andcustom.*. These attributes evaluate against missing / unavailable context on real devices — depending on the operator a rule may either fail unexpectedly (eq,in) or pass unexpectedly (neq,nin, negativeexists). Treat them as unsafe to rely on until runtime wiring exists — use locale overrides for locale, and tags/segments for user/custom audiences.
Percentage rollout and sticky A/B variants
The current source runs two distinct deterministic rollout gates and a control passes only when both pass:
- Normalized
targeting.rollout_percent— bucketed by SHA-1 of`${deviceUid}:rollout`inevaluateTargeting(). Evaluated only whenrollout_percent < 100. - Legacy
ui_controls.rollout_percent— bucketed by FNV-1a of`${device_uid}:${control_id}`inlegacyMatches(), and always evaluated.
The dashboard's Targeting tab currently writes the same slider value into both fields. Because the two gates use distinct hash inputs (device-only vs device+control) and distinct hash functions (SHA-1 vs FNV-1a), the effective exposure at a slider value below 100 is the intersection of two distinct deterministic buckets — not a single exact percentage, and their statistical independence is not proven. For precise experiment allocation use a dedicated Feature Flag and verify reach against a real registered device before you widen. (Do not attempt to change this runtime here.)
A/B variant assignment is separate: the server picks a variant deterministically per (device_uid, control_id), writes the assignment to ui_control_assignments, merges the variant's payload over the base payload, and returns the assigned key as variant_key (readable via ctrl.variant(key)).
Localization
Every control can carry per-locale payload overrides inui_control_locales. Resolution order: exact locale (ckb-IQ), base language (ckb), base payload. Kurdish (Sorani, RTL) is a first-class supported locale — see Localization.
Schedule windows
In the dashboard you pick starts_at/ends_at as wall-clock times in a selected timezone; the dashboard converts them to absolute UTC ISO timestamps for storage. At delivery time withinSchedule() simply compares those stored absolute timestamps to Date.now() — it does not re-evaluate the timezone dynamically at request time.
Kill switch and how to release it
Killing a control flips is_killed = true and removes it from the next resolved /controls map (the per-app bundle cache is invalidated on write, so the next request re-resolves without the key). To release the kill, use the dashboard's "Release kill switch" action on the same control. The mutation is recorded in the audit log for both events.
Control kill is per-control. It does not touch the app-level /session block/force-update decision.
Preview, analytics, tracking, history
- Live preview (
previewControl) simulates the evaluator against a supplied context and returns a decision trace. It is a strong debugging tool but is not identical to delivery: forced variants, sticky assignments already written toui_control_assignments, and the exact context fields available to production delivery can differ. Always verify a real registered device before rollout. - Impressions / clicks / dismisses come from
ctrl.track(key, event). The SDK batches events in memory and flushes them ~2 seconds after the first queued event, or on the next explicitctrl.flushEvents()call.ctrl.dispose()drops the queue rather than flushing it — onpagehidecallctrl.flushEvents()for a best-effort keepalive send (do NOT dispose in that handler); on application teardown awaitctrl.flushEvents()before callingctrl.dispose(). - Per-target breakdown is available in the dashboard (backed by
controlBreakdown). - Audit log records create/update/toggle/kill/delete mutations. History rolls a still-existing control back to a saved snapshot. After a control is deleted the History rollback path is unavailable; recovery requires restoring from a backup/export or re-creating the control, subject to the operations you actually have available.
End-to-end: remotely-controlled promo banner
Optional remote surface: the banner is hidden when the key is absent, killed, disabled, or targeted-out. Impression fires when the visible state becomes true or the effective variant_key changes. Navigation goes through routeSafely(). The banner image is validated against a self-contained CDN allowlist. pagehidetriggers a best-effort ctrl.flushEvents() (keepalive); application teardown awaits the flush before calling ctrl.dispose().
import { useEffect, useMemo, useState } from "react";
import { Ctrl, type CtrlValue } from "@ctrlapp/sdk";
import { routeSafely } from "@/lib/route-safely";
const ALLOWED_IMAGE_HOSTS = new Set(["cdn.example.com", "images.example.com"]);
function safeImageUrl(raw: string | null | undefined): string | null {
if (!raw) return null;
let u: URL;
try { u = new URL(raw); } catch { return null; }
if (u.protocol !== "https:") return null;
return ALLOWED_IMAGE_HOSTS.has(u.hostname) ? u.toString() : null;
}
const BUNDLED_PROMO_IMG = "/assets/promo-fallback.png";
export function PromoBanner({ ctrl }: { ctrl: Ctrl }) {
const [v, setV] = useState<CtrlValue | null>(() => ctrl.get("home.promo"));
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const off = ctrl.subscribe(() => setV(ctrl.get("home.promo")));
ctrl.load().catch(() => undefined);
return off;
}, [ctrl]);
const shouldShow = !!v && v.visible && !dismissed;
useEffect(() => {
if (shouldShow) ctrl.track("home.promo", "impression");
}, [ctrl, shouldShow, v?.variant_key]);
const safeImg = useMemo(() => safeImageUrl(v?.image_url), [v?.image_url]);
if (!shouldShow) return null;
return (
<aside role="region" aria-label={v!.text ?? "Promotion"}>
<img src={safeImg ?? BUNDLED_PROMO_IMG} alt={v!.text ?? ""} />
<h3>{v!.text ?? "Save today"}</h3>
{v!.subtitle ? <p>{v!.subtitle}</p> : null}
<button disabled={!v!.enabled} onClick={() => {
ctrl.track("home.promo", "click");
routeSafely(v!.action.value);
}}>{v!.cta ?? "Open"}</button>
<button onClick={() => { ctrl.track("home.promo", "dismiss"); setDismissed(true); }}>Dismiss</button>
</aside>
);
}- Toggling is_enabled off in the dashboard hides the banner on the client's next successful ctrl.load() / poll (default 60s).
- Killing the control hides it on the next successful load/poll (up to the configured interval, 60s by default); the optional promo hides because the key is now absent from the resolved map.
- A new device outside the target segment never receives the payload.
- ctrl.track('home.promo', 'impression') and 'click' show up in the control's analytics panel (batched ~2s or on flushEvents()).
Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
| Key returns null | Control disabled/killed, outside schedule window, targeting excludes device, or key mistyped. Check /controls response for the key. |
| Control hidden even though shipped | payload.visible is false, or targeted-out for this device. |
| Button reads text but click does nothing | payload.enabled is false, or action.value is null / rejected by your allowlist. |
| Stale content | Poll interval is 60s by default. Call ctrl.load() on resume, or lower pollIntervalMs. There is no ~2s guarantee for control updates. |
| Wrong locale | The device sent no locale, or no locale row exists for that language. Add an exact or base-language override. |
| Wrong audience | Legacy target_* columns are ANDed with normalized targeting. Both must pass. user_id/locale/custom.* rules evaluate against missing context on real devices — negative operators (neq/nin, negative exists) can pass unexpectedly, positive operators (eq/in) can fail unexpectedly. Treat them as unsafe; see the Targeting callout. |
| Unexpected variant | Variants are sticky per (device_uid, control_id). Delete the assignment or use dashboard preview to force a variant. |
| Invalid payload rejected on save | Per-type schema in control-schemas.ts caught bad shape — the error names the failing field. |
| Analytics show no clicks | You forgot ctrl.track(key, 'click'). Batched flush is ~2s. pagehide triggers a best-effort keepalive flush; on application teardown await ctrl.flushEvents() before ctrl.dispose() — dispose drops the queue. |
| Users tap-navigate to junk URLs | You did not allowlist action.value. Route via routeSafely() with exact host/route sets — not regex prefix matches. |
Production checklist
- Every actionable key routes through
routeSafely()with exact allowlists. - Essential local UI has a hard-coded default; optional remote surfaces hide when the key is absent.
- Roll new controls out at
rollout_percent: 10before widening — remember the two-gate reality above and verify reach against a real device. - Kill switch tested against a real device: while online, the next successful
ctrl.load()/ poll drops the key. While already offline the SDK can only serve the last successfully synced map. - Analytics wired:
impressionfires when the visible state becomes true or the effectivevariant_keychanges,clickon interaction,dismisswhen closable. pagehidetriggers a best-effortctrl.flushEvents()(keepalive; do not dispose in that handler). Application teardown awaits the flush beforectrl.dispose().- Preview every change in the dashboard, then verify on a real registered device before enabling — preview is not identical to delivery.
- If you conflate control kill with the app-level
/sessionblock — they are separate systems — control kill removes one key from the next resolved/controlsmap; the block/force-update decision lives onctrl.blocked(). - If you expected a kill to reach an already-offline device — the SDK can only use the last successfully synced map while offline. A kill takes effect on that device's next successful
ctrl.load()/ poll. - If you routed a raw
ctrl.link(...)value — always callrouteSafely()with exact host/route allowlists — prefix-match regexes are exploitable. - If you cast
extrato a type without checking — narrow with Zod or typeof — the wire isunknownJSON. - If you read
ctrl.flag(key).variantexpectingpayload.variant— they are different —ctrl.flag().variantis the assignedvariant_key;payload.variantis a free-form label onCtrlValue.variant.