# Klor, full reference Remote config, feature flags, and mobile update gating for React and React Native. Flags are edited in a dashboard and do not reach apps until they are published. Publishing compiles an environment into immutable JSON snapshots served from Cloudflare's edge. The SDK downloads a snapshot and evaluates locally, which is why reads are synchronous, work offline from cache, and never send user context to Klor. --- Integrate Klor (klor.dev) into this project for remote config and feature flags. Package: @klor/react, one package, works in React and React Native. ## Steps 1. Install it: pnpm add @klor/react 2. Create the client once. It owns the refresh schedule, the disk cache, and the telemetry buffer, so creating it per render would restart all three. import { createKlorClient } from '@klor/react' export const klor = createKlorClient({ apiKey: KLOR_PUBLIC_KEY }) Module scope is the simplest way to get "once". If you are server-rendering and want to seed it with a snapshot the server already fetched, create it in a useState initialiser instead, still once per mount, not per render: const [klor] = useState(() => createKlorClient({ apiKey: KLOR_PUBLIC_KEY, initialSnapshot }), ) 3. Wrap the app root. The context is who the current user is; rules are matched against it. import { KlorProvider } from '@klor/react' 4. Read flags where they are used: import { useFlag } from '@klor/react' const showNewCheckout = useFlag('checkout_v2', false) ## Rules - The second argument to useFlag is the value served when Klor has nothing to say, before the first fetch, on a dead network, or if the key does not exist. Choose the behaviour you want if Klor were not installed at all. - useFlag is synchronous and already returns that fallback. Do not gate the UI on a loading state, and do not wrap it in useMemo or component state. - Keys beginning klor_pub_ are public and may ship in client code. Keys beginning klor_sec_ must never appear in anything sent to a browser or bundled into an app, server only, through @klor/react/server. - Flags marked sensitive in the dashboard are stripped from the payload public keys receive. Do not rely on a public key to hide anything. ## React Native only @klor/react has no react-native dependency, so two things must be passed in: import AsyncStorage from '@react-native-async-storage/async-storage' import { AppState } from 'react-native' createKlorClient({ apiKey: KLOR_PUBLIC_KEY, storage: AsyncStorage, subscribeToForeground: (refresh) => { const sub = AppState.addEventListener('change', (s) => s === 'active' && refresh()) return () => sub.remove() }, }) Without storage, a cold start with no network serves fallbacks instead of the last known config, which on mobile is the case most worth covering. For update gating, useVersionGate() returns { status, message, storeUrl }. Klor ships no UI; render your own prompt from it. ## If you need more The complete reference (every client option, every rule operator, the version comparison rules, and the HTTP endpoints) is one fetch away as plain text: https://klor.dev/llms-full.txt There is also https://klor.dev/llms.txt, a short index linking each docs page, if you would rather read only the part you need. ## Before you start Ask me for the Klor public API key and the flag keys I want to read. Do not invent key names or commit a placeholder that looks real. --- ## API reference ### createKlorClient(options) - `apiKey` (string, required), a public key on clients, a private key on servers. - `refreshInterval` (number), poll interval in ms. Default 300000, minimum 30000. `0` refreshes only on mount and on foreground. - `storage` (object | false); anything with `getItem`/`setItem`. Defaults to `localStorage` in a browser, memory elsewhere. `false` disables persistence. - `subscribeToForeground` ((refresh) => cleanup), how to learn the app returned to the foreground. Required on React Native; defaults to `visibilitychange` on the web. - `initialSnapshot` (Snapshot), a snapshot fetched on the server, so hydration does not flash fallback values. Only pass one fetched with a public key. - `telemetry` ({ enabled?: boolean }), anonymous usage counters, on by default. - `onError` ((error) => void), called when a refresh fails. The previous snapshot keeps serving. ### Hooks - `useFlag(key, fallback): T`, synchronous, never suspends, never throws. - `useFlagDetail(key, fallback): { value, reason, ruleId? }`, reason is one of `rule`, `default`, `disabled`, `unknownFlag`, `notReady`, `typeMismatch`. - `useKlor(): { isReady, isStale, lastSyncedAt, seq, refresh, flushTelemetry }` - `useVersionGate(options?): { status, reason, message?, storeUrl?, latestVersion?, currentVersion? }` , status is `none`, `optional`, or `forced`. Klor renders no UI. ### Server entrypoint, @klor/react/server `createKlorServerClient({ apiKey, ttlMs?, baseUrl?, fetchImpl?, onError? })` returns `getFlag`, `getFlagDetail`, `getAllFlags`, `getVersionGate`, `getSnapshot`, `refresh`. No React import; runs on Node, Bun, and Workers. A private key also sees flags marked sensitive. --- ## Flags and rules A flag holds a boolean, string, number, or JSON. The type is fixed at creation and every value served is checked against it. A flag is defined once per project and configured separately per environment, which is what makes promoting dev to prod a value change rather than a create. Rules are ordered and matched top to bottom; the first rule whose conditions all match wins, and anyone matching none gets the default. A rule with no conditions matches everyone who reaches it. Conditions match against the context passed to the provider. `userId` and `deviceId` are reserved and read from the top level; everything else comes from `attributes`. An attribute that was not sent never matches, including with "is not", because Klor cannot prove a user is not on iOS when it was never told what they are on. Operators: eq, neq, in, notIn, contains, notContains, startsWith, endsWith, gt, gte, lt, lte, semverEq, semverGt, semverGte, semverLt, semverLte, exists, notExists. Percentage rollouts apply a rule to a share of the users who match it; everyone else falls through to the next rule. Bucketing is a stable hash of the flag key, a per-flag salt, and the bucketing attribute, which gives three properties worth relying on: the same user lands in the same bucket on every launch and platform; raising 10% to 20% only ever adds people; and two flags at 10% hit different users. A user with no value for the bucketing attribute falls through rather than being assigned at random. Flags marked sensitive are stripped from the payload public keys receive. That is the only confidentiality boundary, a public key ships inside your app and can be extracted. --- ## Update gating iOS and Android only. Per platform, per environment: - `minSupportedVersion`, below this the verdict is `forced`. - `latestVersion`, below this but at or above the minimum, the verdict is `optional`. - Blocked builds force an update off one specific version even when it clears the floor. This is the incident lever. A minimum above the latest version is refused: it would force people onto a build that is itself below the floor. Version comparison is deliberately more permissive than strict semver. Leading numeric segments are compared element-wise with missing parts treated as zero, so `2.4` equals `2.4.0`, four-part Android builds like `2.4.0.1187` compare correctly, and `2.10.0` beats `2.9.0`. A leading `v` and `+build` metadata are ignored. A version Klor cannot parse produces no prompt at all. Failing open is deliberate: locking users out over a malformed version string is worse than missing one prompt. --- ## Publishing Editing stages a change; publishing sends it. Publishing writes two immutable payloads, public (sensitive flags stripped) and private, under the next sequence number. Rollback republishes an earlier payload under a *new* number. History is append-only, so rolling back to #41 creates #44 carrying #41's content. It restores the published payload, not the dashboard rows behind it: the edits that caused the problem are still there, now marked unpublished. Publish notifications: each project can email its workspace (members with confirmed addresses) and send a webhook on every publish and rollback, per environment (production by default). Webhooks are a signed JSON POST (Standard Webhooks headers: webhook-id, webhook-timestamp, webhook-signature) with type `snapshot.published`, `snapshot.rolled_back` or `test` and data { project, environment, seq, rolledBackFromSeq, note, publishedBy, changes, url }. Non-2xx deliveries are retried with backoff. Each environment keeps its newest 100 snapshots and every snapshot from the last 90 days, whichever is more. Older ones are removed daily and can no longer be restored. Audit entries are kept for a year. SDKs refresh on mount, on foreground, and on an interval, each as a conditional request, an unchanged snapshot costs a 304 with no body. A change is typically live within a minute, and immediately for anyone who backgrounds the app and returns. React Native also needs `subscribeToBackground`, the mirror of that hook, wired to the same `AppState` listener for any state that is not `active`. Usage counters are buffered in memory and flushed on a timer; the web additionally flushes on `visibilitychange`, which React Native does not have, so without this hook an app backgrounded inside the flush interval loses what it counted. --- ## HTTP API `GET /v1/config` with `Authorization: Bearer ` returns the snapshot for that key's environment. Honours `If-None-Match` and answers 304. The payload is the ruleset, not an answer; evaluation is the client's job, which is what keeps user context on the device. `POST /v1/events` with `{"events":[{"flagKey","variant","reason","count"}]}` records anonymous usage counters. No user or device identifier is accepted or stored. Sending nothing is supported. Responses: 304 not modified, 401 missing or unrecognised key, 404 environment never published. A rule may carry `variants` instead of a single value: an array of `{ "value", "weight" }` that splits the users the rule applies to across several values. Weights are relative and need not total 100. The rollout decides whether a user is in the rule; the variants decide which value they get, bucketed on a different hash so widening the rollout never re-shuffles the arms. `rule.value` always carries the first variant, which is what an SDK released before variants existed will serve. --- ## Devtools `import { KlorDevtools } from '@klor/react/devtools'` renders a panel listing every flag with the value it serves and why, and lets you force a value locally. Web only (it renders DOM); React Native has the same overrides through `client.setOverride(key, value)` and `client.clearOverrides()`. The panel lists flags the app has actually read as well as the ones in the snapshot, so a key your code asks for that was never published, or that is marked sensitive and therefore absent from a public payload, shows up with a warning instead of silently returning the fallback. Overrides never leave the device, win over the published snapshot, are reported with `reason: 'override'`, and are never counted as usage. An override whose type does not match the flag is ignored rather than served. Pass `persistOverrides: true` to keep them across reloads. --- ## Management API Changing configuration is a separate surface at `https://klor.dev/api/v1`, authenticated with a `klor_adm_` management key. It is scoped to one environment, never reaches the read plane, and is refused if the request carries an `Origin` header: servers and CI only, never a browser. - `GET /api/v1/me` which project and environment the token points at - `GET /api/v1/flags` and `GET /api/v1/flags/:key` read configuration - `PATCH /api/v1/flags/:key` with `{"enabled","defaultValue","rules"}` changes it - `GET /api/v1/changes` what publishing would change; empty means up to date - `POST /api/v1/publish` with an optional `{"note"}` compiles and publishes - `POST /api/v1/rollback` with `{"targetSeq"}` republishes an earlier snapshot under a new one - `GET /api/v1/snapshot` the live payload, `?visibility=public` for the one clients receive - `GET /api/v1/snapshots` the last fifty publishes Editing does not change what apps read; publish is still a separate act. Errors carry a code: `missing_token`, `invalid_token`, `wrong_key_type`, `browser_request`, `not_found`, `invalid_value`, `snapshot_gone`.