Read configuration from your self-hosted KeyKosh (K2) platform. Zero runtime
dependencies — uses Node's built-in fetch and node:crypto. Node 18+.
The SDK talks to exactly one host — your own platform. There is no vendor default URL
and no callback home; baseUrl is required.
npm install @keykosh/sdkimport { createClient } from '@keykosh/sdk';
// Reads K2_BASE_URL / K2_TOKEN / K2_ENV from the environment; options override.
const k2 = createClient({
baseUrl: 'https://k2.acme.com',
token: process.env.K2_TOKEN, // SDK token minted in the admin UI → Tokens
env: 'prod',
cacheTtlMs: 30_000, // optional in-memory TTL cache
});
const cfg = await k2.getConfiguration(); // full config for the default env
const dbUrl = cfg.getString('db.url', 'jdbc:postgresql://localhost/app');
const debug = cfg.getBoolean('feature.debug', false);
const poolSize = await k2.getProperty('prod', 'pool.size'); // single property
// Live updates: called whenever an admin changes this env's config.
const unsubscribe = k2.watch('prod', (fresh) => applyConfig(fresh));Everything below follows from which of these you are:
In production (the default — K2_OFFLINE=false): the platform is the source of truth. The
SDK fetches from it, and writes what it got to k2config-<env>.json as a side effect. If the
platform is later unreachable, that file is served instead of throwing, so a K2 outage cannot
stop your app from booting. You don't manage the file; the SDK does.
On a laptop (K2_OFFLINE=true): there is no server at all. You own
k2config-<env>.json, the SDK only reads it, and no network call is ever made.
K2_OFFLINE=falsedoes not mean "no offline support". It is the mode that gives you the offline fallback — the SDK keeps the file current for you.K2_OFFLINE=truemeans "never contact the server", which is a different thing entirely.
To go from the first to the second, run once online and take ownership of what the SDK wrote:
K2_BASE_URL=https://k2.acme.com K2_TOKEN=… K2_ENV=dev node app.js # writes the file
cp ~/.k2/config/k2config-dev.json ./k2config-dev.json # repo-local wins
# edit "_k2": { "managed": false } — the SDK will now never overwrite it
K2_OFFLINE=true node app.js # no serverNo CLI is needed for any of this: the SDK is the generator.
- Token-scoped reads against
GET /api/config/token/{env}/currentand/properties/{key}, authenticated withAuthorization: Bearer <token>(andX-API-Token). - Hot reload (
watch()) — subscribes to the platform's change stream over Server-Sent Events and calls your handler when an admin edits config, with no polling. Reconnects with backoff, and falls back to polling oncacheTtlMsif a proxy strips SSE, so a blocked stream degrades instead of failing. Each push also refreshes the local file. - TTL cache (
cacheTtlMs) so repeated reads don't hit the network each call; serves the last value through a failed refresh. - Local config file (
k2config-<env>.json, on every tier) — plaintext, self-describing, mode0600, written atomically. Auth errors (401/403/404/421) always surface: they are not availability blips, so a file on disk is deliberately declined.
{
"_k2": { "org": "acme", "app": "billing", "env": "prod",
"managed": true, "fetchedAt": "2026-08-03T18:04:11Z", "sdk": "node/1.2.0" },
"properties": { "db.url": "jdbc:postgresql://localhost:5432/billing", "db.pool": 20 }
}Resolution order, first hit wins: $K2_CONFIG_FILE (alone, when set) → ./k2config-<env>.json
→ $K2_CONFIG_DIR or ~/.k2/config. Naming an explicit location is exclusive of the machine
default: K2_CONFIG_DIR replaces ~/.k2/config rather than preceding it, so a stale file in
your home directory can never quietly satisfy a read. It does not suppress the working-directory
candidate, which is always searched first — only K2_CONFIG_FILE does that. All three K2 SDKs
order these identically.
The app identity lives inside the file rather than in its name, which keeps K2_APP optional
and gives you one predictable string to gitignore. Set K2_APP anyway: it is what turns
"which app is this?" into a checked invariant, so two apps sharing a config directory fail with
K2_FILE_APP_MISMATCH naming both instead of silently serving each other's config.
The file holds secret values in clear. Add
k2config-*.jsonto your.gitignore. Commit one only when it holds no real secrets.
Every failure throws K2Error with a stable, greppable code (plus statusCode: the HTTP
status, or -1 for transport/config/file errors). err.isAvailabilityError() is true only for
K2_UNREACHABLE, K2_TIMEOUT and K2_SERVER_ERROR — the codes eligible for the local file.
| Code | Means |
|---|---|
K2_MISSING_BASE_URL / K2_MISSING_TOKEN / K2_INVALID_MODE |
misconfiguration — raised at construction, not on first read |
K2_TOKEN_FILE_UNREADABLE |
K2_TOKEN_FILE names a file that is missing, unreadable, or empty once trimmed — never downgraded to K2_MISSING_TOKEN |
K2_MISSING_ENV |
no environment passed and no K2_ENV — raised on read, since one client can serve several envs |
K2_FILE_NOT_FOUND |
K2_OFFLINE=true and no file; the message lists every path searched |
K2_FILE_MALFORMED |
the file isn't valid K2 JSON — a bug, not an outage, so no fallback to the server |
K2_FILE_APP_MISMATCH |
the file belongs to another app (set K2_CONFIG_DIR per app) |
K2_FILE_STALE |
older than K2_OFFLINE_MAX_AGE |
K2_FILE_UNMANAGED |
_k2.managed is false — you own it, so the SDK refused to overwrite |
K2_FILE_NOT_WRITABLE |
read-only filesystem; logged once at WARN, never thrown |
K2_UNAUTHORIZED / K2_FORBIDDEN / K2_NOT_FOUND / K2_HOST_NOT_LICENSED |
the platform refused — never served from the file |
K2_UNREACHABLE / K2_TIMEOUT / K2_SERVER_ERROR |
the platform was unreachable — the file is served when present |
K2_REQUEST_FAILED |
an unexpected non-2xx; reachable and refusing, so no fallback |
| Var | Meaning |
|---|---|
K2_BASE_URL |
platform URL, e.g. https://k2.acme.com |
K2_TOKEN |
SDK token (the one secret — never commit it) |
K2_TOKEN_FILE |
path to a file holding the token — the Docker/Kubernetes secret-mount shape (1.2.0+) |
K2_ENV |
default environment for the no-arg reads |
K2_APP |
app slug — optional, but set it: it makes the file's app a checked invariant |
K2_OFFLINE |
true ⇒ never contact the server (default false) |
K2_OFFLINE_CACHE |
false ⇒ keep nothing on disk (default true) |
K2_HOT_RELOAD |
false ⇒ watch() polls instead of subscribing (default: on when online) |
K2_CONFIG_DIR |
directory holding k2config-<env>.json (default ~/.k2/config) |
K2_CONFIG_FILE |
one exact path — wins over everything else |
K2_OFFLINE_MAX_AGE |
e.g. 7d — hard-refuse a file older than this (default: no limit) |
K2_CACHE_TTL |
in-memory TTL, and the watch() polling interval when SSE is unavailable |
K2_OFFLINE=true with K2_OFFLINE_CACHE=false is contradictory and raises K2_INVALID_MODE
at construction.
Credential precedence: an explicit token option → K2_TOKEN → K2_TOKEN_FILE →
K2_TOKEN_ENC. A token file is trimmed of surrounding whitespace (mounted secrets end in a
newline) and read once, at token resolution — not per request. A missing, unreadable or blank
file raises K2_TOKEN_FILE_UNREADABLE naming the path, never a silent fallthrough to "no token".
K2_TOKEN_FILE is honoured from 1.2.0; earlier versions ignore it.
Cross-language note: the Java SDK throws K2Exception (not K2Error), classifies codes with
a K2ErrorCode.Kind enum, adds snapshot(env) / getOfflineCacheAllowed(), does not honour the
deprecated aliases above from its K2Client.fromEnv(), and is not zero-dependency (Jackson).
Codes and current variable names are otherwise identical across all three. See
USER_MANUAL.md → Cross-language differences.
Deprecated, honored for one minor release (each logs a WARN): K2_SOURCE → K2_OFFLINE
(file→true, server→false, auto→true iff a file exists), and K2_CACHE_DIR →
K2_CONFIG_DIR.
npm test # file store, error taxonomy, SSE hot reload, config parsing — no network needed