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.

Choose your SDK

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

SDKAvailability today
Web · TypeScriptSource in this repo (@ctrlapp/sdk). No public npm release yet — vendor via the documented local path.
Flutter · DartSource in sdk/ctrlapp_flutter. Not on pub.dev — add via a local path dependency.
iOS · SwiftSource 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 · KotlinSource in sdk/ctrlapp_android. Local Gradle module only; not on Maven Central.

Portal → selected SDK workflow

  1. In the dashboard: Apps → your app → Controls → New control. Pick a type from CONTROL_TYPES.
  2. Enter a stable key (e.g. home.cta_primary). Keys are permanent — never rename after ship.
  3. Fill in the payload/content, targeting, schedule, rollout, and set is_enabled.
  4. Save. The per-app bundle cache is invalidated on write, so the next successful /controls request re-resolves.
  5. The selected client picks up the change on its next successful load or poll (Web: ctrl.load() / startPolling at 60s default; Flutter/Kotlin: automatic background poll started by CtrlApp.init; Swift: automatic after initialize). While a device is offline, only the last successfully synced map is available.
  6. Render safely (fail closed for optional surfaces; local defaults for essential UI; allowlist any remote URL/action).
  7. Send impression / click / dismiss via the SDK's track().

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 CtrlValue the 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 onfeature_flag controls. See the note under feature_flag.
DashboardDashboard → Apps → your app → Controls
SDK availability
@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)

  1. 1
    Pick a stable key and a type
    Keys 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.
  2. 2
    Configure content and, where relevant, a local fallback
    For 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.
  3. 3
    Initialize, register, load
    ts· 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();
  4. 4
    Read the resolved value
    ts· 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");
    }
  5. 5
    Enable gradually, verify, track
    Ship the control with is_enabled: false orrollout_percent: 10, verify with a real device in the target segment, then widen. Every interaction should call ctrl.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:

  1. Control-level gatesis_killed and is_enabled must both allow serving. (This is per-control, distinct from the app-level /session block/force-update decision.)
  2. Schedule windowstarts_at/ends_at compared as absolute UTC timestamps against Date.now().
  3. Targeting — normalized targeting rules + segments + rollout_percent, then legacy per-column fields (platform, min/max version, country, tags, device ids, legacy rollout).
  4. Sticky A/B variant pick — a variant's payload merges over the base payload; the assignment is written to ui_control_assignments so the same device keeps the same variant.
  5. Locale overrides — exact-locale first (e.g. ckb-IQ), then base language (ckb), then the base payload.
  6. Shape into CtrlValue — known/reserved fields lifted to typed slots; non-reserved fields land on extra.

The CtrlValue shape

ts· @ctrlapp/sdk
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

CallReturnsBehavior for unknown key
ctrl.get(key)CtrlValue | nullnull (fail closed)
ctrl.isVisible(key)booleanfalse (fail closed)
ctrl.isEnabled(key)booleanfalse (fail closed)
ctrl.text(key, fallback?)stringreturns the fallback
ctrl.image(key)string | nullnull
ctrl.icon(key)string | nullnull
ctrl.link(key)string | nullnull — do NOT navigate on null
ctrl.variant(key)string | null (variant_key)null
ctrl.payload<T>(key, field)T | nullnull
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 /controls map. Distinct from the app-level /session block/force-update decision.
  • payload.visible — the key IS shipped, but the SDK returns isVisible = 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 on CtrlValue.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) or ctrl.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.
ts· src/lib/read-control.ts
// 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

typeBest forImportant payload fieldsRead pattern
buttonAny CTA/tap targettext, cta, action_kind, action_value, enabledctrl.text(k), ctrl.link(k), ctrl.isEnabled(k)
textCopy blocks, labelstext, subtitlectrl.text(k, "fallback")
imageHero/thumbnailsimage_url, text (alt)ctrl.image(k) ?? defaultUrl
bannerTop-of-page promotext, subtitle, cta, image_url, action_*ctrl.get(k) + track()
drawer_itemSide-drawer entrytext, icon, action_*, sort_orderctrl.text(k), ctrl.icon(k), ctrl.link(k)
menu_itemMenu/list entrytext, icon, action_*ctrl.text(k), ctrl.link(k)
linkPlain hyperlinktext, action_value (URL)ctrl.text(k), ctrl.link(k)
cardContent card blocktext, subtitle, image_url, cta, action_*ctrl.get(k)
sheetBottom sheettext, subtitle, cta, action_*ctrl.get(k) + track()
modalFull modal dialogtext, subtitle, cta, action_*ctrl.get(k) + track()
gateFeature availabilityvisible, enabledctrl.isVisible(k) / ctrl.isEnabled(k)
config_valueRemote-config valuevalue_type, value, options, min, maxctrl.payload(k, "value")
feature_flagOptional UI availability togglevisible, 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_schemaRemote formtitle, fields[], submit_labelctrl.get(k) then validate
customArbitrary JSONanything you authorctrl.payload<T>(k, "field")

Per-type reference

button

A tap/click target with copy, an optional icon, and an action to route on click.

Key payload fields: text, cta, icon, subtitle, tooltip, badge, action_kind, action_value, enabled
In the portaltype = button·key = home.upgrade
Configure: text, cta, action_kind, action_value, enabled
json· dashboard payload (platform-neutral)
{
  "text": "Upgrade to Pro",
  "cta": "upgrade",
  "action_kind": "in_app_route",
  "action_value": "/billing",
  "enabled": true
}
ts· web · ts
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");
}
Note
Never navigate on a raw remote string. Always go through routeSafely — see Actions below.

text

Copy blocks, labels, headlines. No action, no interactivity.

Key payload fields: text, subtitle
In the portaltype = text·key = home.title
Configure: text, subtitle
json· dashboard payload (platform-neutral)
{ "text": "Welcome back", "subtitle": "Here is what changed." }
ts· web · ts
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.

Key payload fields: image_url, text (alt)
In the portaltype = image·key = home.hero
Configure: image_url, text (alt)
json· dashboard payload (platform-neutral)
{ "image_url": "https://cdn.example.com/hero.jpg", "text": "Two hikers" }
ts· web · ts
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", "");
Note
Choose per surface: essential imagery renders a bundled asset when the URL is missing or fails the CDN allowlist; optional imagery hides. The SDK returns null for an unknown key or missing URL.

banner

Top-of-page promotional strip with copy, optional image, CTA, and action.

Key payload fields: text, subtitle, cta, image_url, action_kind, action_value
In the portaltype = banner·key = home.banner
Configure: text, subtitle, cta, image_url, action_kind, action_value
json· dashboard payload (platform-neutral)
{
  "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"
}
ts· web · ts
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.

Key payload fields: text, icon, sort_order, action_kind, action_value, badge, enabled
In the portaltype = drawer_item·key = drawer.whats_new
Configure: text, icon, sort_order, action_kind, action_value, badge, enabled
json· dashboard payload (platform-neutral)
{
  "text": "What's new",
  "icon": "sparkles",
  "sort_order": 10,
  "badge": "NEW",
  "action_kind": "in_app_route",
  "action_value": "/whats-new"
}
ts· web · ts
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.

Key payload fields: text, icon, action_kind, action_value, sort_order, enabled
In the portaltype = menu_item·key = menu.report
Configure: text, icon, action_kind, action_value, enabled
json· dashboard payload (platform-neutral)
{ "text": "Report a problem", "icon": "flag", "action_kind": "in_app_route", "action_value": "/support" }
ts· web · ts
if (ctrl.isVisible("menu.report")) list.push({
  label: ctrl.text("menu.report", "Report"),
  onSelect: () => { ctrl.track("menu.report", "click"); routeSafely(ctrl.link("menu.report")); },
});

Plain hyperlink. action_value must be a valid URL — the server validates this.

Key payload fields: text, action_value (URL)
In the portaltype = link·key = footer.changelog
Configure: text, action_kind=external_url, action_value (URL)
json· dashboard payload (platform-neutral)
{ "text": "Read the changelog", "action_kind": "external_url", "action_value": "https://ctrlapp.krdcode.com/docs/changelog" }
ts· web · ts
// 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.

Key payload fields: text, subtitle, image_url, cta, action_kind, action_value
In the portaltype = card·key = home.card.dashboard
Configure: text, subtitle, image_url, cta, action_kind, action_value
json· dashboard payload (platform-neutral)
{
  "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"
}
ts· web · ts
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.

Key payload fields: text, subtitle, cta, action_kind, action_value, image_url
In the portaltype = sheet·key = push.opt_in
Configure: text, subtitle, cta, action_kind, action_value
json· dashboard payload (platform-neutral)
{ "text": "Enable notifications?", "subtitle": "Get shipping updates.", "cta": "Enable", "action_kind": "in_app_route", "action_value": "/settings/notifications" }
ts· web · ts
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.

Key payload fields: text, subtitle, cta, action_kind, action_value, image_url
In the portaltype = modal·key = legal.tos_modal
Configure: text, subtitle, cta, action_kind, action_value
json· dashboard payload (platform-neutral)
{ "text": "New Terms of Service", "subtitle": "Please review before continuing.", "cta": "Review", "action_kind": "external_url", "action_value": "https://ctrlapp.krdcode.com/legal/tos" }
ts· web · ts
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.

Key payload fields: visible, enabled
In the portaltype = gate·key = settings.beta_section
Configure: visible, enabled
json· dashboard payload (platform-neutral)
{ "visible": true, "enabled": true }
ts· web · ts
if (ctrl.isVisible("settings.beta_section")) {
  render(<BetaSection disabled={!ctrl.isEnabled("settings.beta_section")} />);
}
Note
Do not use 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.

Key payload fields: value_type, value, options (enum), min/max (number), description
In the portaltype = config_value·key = editor.autosave_seconds
Configure: value_type, value, min, max, options
json· dashboard payload (platform-neutral)
{
  "value_type": "number",
  "value": 15,
  "min": 1,
  "max": 60,
  "description": "Autosave interval, seconds"
}
ts· web · ts
// 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;
Note
value is unknown JSON; the dashboard editor's value_type / min / max / options are authoring metadata, not runtime guarantees. Always narrow (typeof/Zod) and clamp/allowlist before use.

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.

Key payload fields: visible, enabled, variant (presentation hint), flag_kind, default_treatment, treatments[]
In the portaltype = feature_flag·key = home.new_layout
Configure: visible, enabled, variant (hint), variant_key (A/B assignment)
json· dashboard payload (platform-neutral)
{
  "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 }
  ]
}
ts· web · ts
// 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;
Note
Honest limitation: 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).

Key payload fields: title, subtitle, submit_label, submit_action, fields[]: { key, label, type, required, placeholder, options, min, max, pattern, help, accept, max_size_mb, visible_if }
In the portaltype = form_schema·key = support.contact
Configure: title, subtitle, submit_label, submit_action, fields[]
json· dashboard payload (platform-neutral)
{
  "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 }
  ]
}
ts· web · ts
// 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);
Note
Do not import internal aliases like @/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.

Key payload fields: anything (opaque record) — reserved keys are still lifted
In the portaltype = custom·key = home.custom_layout
Configure: arbitrary JSON; reserved keys are lifted, rest lands on extra
json· dashboard payload (platform-neutral)
{ "layout": "grid", "columns": 3, "highlight": ["a", "b"] }
ts· web · ts
const layout = ctrl.payload<string>("home.custom_layout", "layout") ?? "list";
const cols   = ctrl.payload<number>("home.custom_layout", "columns") ?? 1;
Note
Treat every field as unknown until you validate it (typeof/Zod). Never eval remote strings, and never inject remote strings straight into HTML, CSS, or router calls.

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.

ts· src/lib/route-safely.ts
// 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, plus custom.<key>.
  • Operators: eq, neq, in, nin, gte, lte, semver_gte, semver_lt, regex, exists.
  • match: all (AND) or any (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, legacy rollout_percent) are evaluated in addition to normalized targeting — a control passes only if BOTH pass.
What the /controls delivery path actually sees today

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, and segments.
  • Accepted by the schema/preview but NOT wired into production delivery today: user_id, locale, and custom.*. 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, negative exists). 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` in evaluateTargeting(). Evaluated only when rollout_percent < 100.
  • Legacy ui_controls.rollout_percent — bucketed by FNV-1a of `${device_uid}:${control_id}` in legacyMatches(), 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 to ui_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 explicit ctrl.flushEvents() call. ctrl.dispose() drops the queue rather than flushing it — on pagehide call ctrl.flushEvents() for a best-effort keepalive send (do NOT dispose in that handler); on application teardown await ctrl.flushEvents() before calling ctrl.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().

tsx· src/components/PromoBanner.tsx
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>
  );
}
Verify it
  • 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

SymptomLikely cause / fix
Key returns nullControl disabled/killed, outside schedule window, targeting excludes device, or key mistyped. Check /controls response for the key.
Control hidden even though shippedpayload.visible is false, or targeted-out for this device.
Button reads text but click does nothingpayload.enabled is false, or action.value is null / rejected by your allowlist.
Stale contentPoll interval is 60s by default. Call ctrl.load() on resume, or lower pollIntervalMs. There is no ~2s guarantee for control updates.
Wrong localeThe device sent no locale, or no locale row exists for that language. Add an exact or base-language override.
Wrong audienceLegacy 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 variantVariants are sticky per (device_uid, control_id). Delete the assignment or use dashboard preview to force a variant.
Invalid payload rejected on savePer-type schema in control-schemas.ts caught bad shape — the error names the failing field.
Analytics show no clicksYou 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 URLsYou 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: 10 before 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: impression fires when the visible state becomes true or the effective variant_key changes, click on interaction, dismiss when closable.
  • pagehide triggers a best-effort ctrl.flushEvents() (keepalive; do not dispose in that handler). Application teardown awaits the flush before ctrl.dispose().
  • Preview every change in the dashboard, then verify on a real registered device before enabling — preview is not identical to delivery.
Common mistakes
  • If you conflate control kill with the app-level /session blockthey are separate systems — control kill removes one key from the next resolved /controls map; the block/force-update decision lives on ctrl.blocked().
  • If you expected a kill to reach an already-offline devicethe 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(...) valuealways call routeSafely() with exact host/route allowlists — prefix-match regexes are exploitable.
  • If you cast extra to a type without checkingnarrow with Zod or typeof — the wire is unknown JSON.
  • If you read ctrl.flag(key).variant expecting payload.variantthey are different — ctrl.flag().variant is the assigned variant_key; payload.variant is a free-form label on CtrlValue.variant.
Back to top