# 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`.