Datum is the Kontour portfolio's AI provider/model/role registry. Define your
providers, models, and roles once; resolve them everywhere; generate the native
config every tool wants. Datum answers a single question — which backend,
which model, whose key, what base URL — for a role name or a model@provider
ref, and hands back a plain { provider, kind, baseUrl?, apiKey, model } object.
It abstracts configuration resolution, not invocation: it imports no AI SDK,
wraps no runtime, and makes no model calls (with four narrow, opt-in
exceptions: datum doctor --probe, datum discover, datum test-connection,
and datum catalog refresh).
npm install @kontourai/datumNode >= 22. The only runtime dependency is the exact @kontourai/bearing
contract package; Datum imports no AI SDK or schema engine.
Status:
@kontourai/datumis published. CI and Docs run on every pull request and push tomain; the release workflows (Release Please and Publish NPM) are manualworkflow_dispatchruns. See Publishing.
Two files, deep-merged. User-level ~/.config/kontour/datum.json is the base;
repo-level .datum/config.json overlays it and wins per-key; environment
escape hatches override both.
Datum reads repository configuration from .datum/config.json. Commit
that file with the project; credentials remain references to secrets held
elsewhere.
Other Kontour tools also write under .kontourai/, which is Git-ignored
but not uniformly disposable. Flow keeps resumable run state under
.kontourai/flow/runs/
and documents backup steps for it, and Flow Agents keeps in-progress workflow
state and evidence under
.kontourai/flow-agents/
so a session can resume. Do not treat deleting all of .kontourai/ as routine
cache cleanup; check the owning product's documentation before removing a
subdirectory.
{
"providers": {
"zai": { "kind": "anthropic-compatible", "baseUrl": "https://api.z.ai/api/anthropic", "auth": { "env": "ZAI_API_KEY" }, "models": ["glm-5.2", "glm-4.6"] },
"anthropic": { "kind": "anthropic-compatible", "auth": { "env": "ANTHROPIC_API_KEY" }, "models": ["claude-sonnet-5", "claude-haiku-4-5"] }
},
"roles": {
"extraction-default": "glm-5.2@zai",
"worker": "claude-sonnet-5@anthropic"
}
}-
Auth is by reference only. Never the key itself — one of three references:
{ "env": "VAR_NAME" }— the name of an environment variable.{ "keychain": { "service": "…", "account": "…"? } }— a macOS Keychain generic-password lookup (darwin-only).{ "op": "op://vault/item/field" }— a 1Password secret reference.
The validator rejects a key-looking literal in any auth field (long token, no spaces → error).
-
kindis an open enum.anthropic-compatibleandopenai-compatibleare implemented; any string is accepted so new kinds can land.baseUrlis optional (absent = the SDK default for that kind). -
Model refs are
model@provideror baremodel(an error if a bare model is offered by more than one provider).
The normative schema is datum.schema.json.
A role can instead be a closed policy object. Datum gives Bearing the concrete, rankable partition of the caller-supplied runtime inventory and combines durable requirements/preferences with per-request additions. It then applies Datum's provider-model, auth, and locality checks to Bearing's deterministic ranking. No candidate outside the complete caller inventory can be selected.
{
"roles": {
"interactive": {
"policy": {
"requirements": [{ "measurementKey": "model.context.max_tokens", "aggregation": "fact", "operator": "gte", "value": 32768 }],
"preferences": [{ "measurementKey": "quality", "aggregation": "mean", "direction": "maximize", "weight": 1 }],
"advisories": [{ "id": "context-projection", "measurementKey": "context.projection", "aggregation": "fact" }],
"locality": "local-only",
"fallback": "qwen3@ollama"
}
}
}
}Use the offline API resolveCapabilityRole(role, request, opts) or
datum resolve-policy <role> --request request.json --json. A request binds
an exact schemaVersion: "datum.capability-role.request/v1" and binds each
opaque candidate id to its Datum provider id, provider model id, locality,
Bearing model identity, and caller-observed execution profile. Unknown runtime
or tool surface is preserved as null; toolSurface: [] remains
known-empty. Datum sends only candidates with a concrete runtime and tool
surface to Bearing. Other launchable candidates remain in the result with
DATUM_EXECUTION_PROFILE_INCOMPLETE rather than being discarded or coerced.
Session fixed overrides,
then DATUM_ROLE_<NAME>, then a durable fixed role take precedence and bypass
Bearing ranking, but still must match exactly one supplied candidate and pass
Datum checks. Request locality is a caller claim, not sufficient evidence by
itself: local-only also requires the provider's effective configured base URL,
including DATUM_BASEURL_<PROVIDER>, to name a loopback endpoint. Providers
without an explicit loopback route cannot be proven local. Those fixed paths may
explicitly select an incomplete execution profile without inventing capability.
A policy fallback is considered only for missing/stale catalog
state and is subject to the same inventory boundary. Results include catalog
provenance, evidence, uncertainty, exclusions, and explicit override/fallback
state; they never materialize secrets or fetch a catalog. Policy and request
advisories are additive. Datum asks Bearing rank v2 to project them for every
ranked or excluded candidate and passes the resulting status, value/unit,
evidence, and uncertainty through unchanged. It does not infer advisory ids,
measurement keys, or recommendation meaning from model names or catalog
internals. Fixed, override, and fallback resolution return no advisories because
they bypass Bearing. The combined durable and request set must use unique ids,
contain at most 64 advisories, and produce at most 1,024 inventory projection
cells over the concrete rankable partition sent to Bearing. Rank reasons retain Bearing's execution applicability, so partial facts
apply only to candidates matching the dimensions their source actually asserts.
Embedding runtimes that already own provider credentials may pass
providerBindings in the resolve options. This bounded, per-call map is the
authoritative provider universe for that call and contains only provider kind,
an optional credential-free base URL, allowed model ids, and
auth: { kind: "host", ref: "<authority>", available: boolean }. It never
contains a credential value. Durable Datum roles and catalog configuration
still supply policy, while Datum still enforces provider/model membership,
host-reported auth readiness, fixed/override/fallback bounds, and locality. An
ambient DATUM_BASEURL_* override cannot rewrite a host-owned binding. When
providerBindings is omitted, existing Datum provider config and secret-ref
availability behavior are unchanged.
Datum can retain one validated Bearing capability-catalog snapshot alongside its provider registry. Declare exactly one source — a remote snapshot URL or a local snapshot path — and optionally bound its age:
{
"capabilityCatalog": {
"remoteUrl": "https://catalog.example/snapshot.json",
"maxAgeSeconds": 86400
}
}Remote URLs must be credential-free http(s) endpoints without userinfo,
query parameters, or fragments. Use a separately controlled endpoint rather
than committing a signed URL or token to durable config.
Config precedence remains per-key: a repo-level remoteUrl or localPath
replaces the user-level source discriminator without creating an invalid mixed
source, while maxAgeSeconds inherits unless the repo overrides it.
localPath must be repository-relative and remain under the real Datum working
directory after symlink resolution. Local snapshots are read and validated
directly. Remote snapshots are fetched only by explicit
refresh, then validated with Bearing and saved under the disposable,
source-keyed cache <cwd>/.kontourai/datum/bearing. Snapshots are immutable and
content-addressed by their Bearing digest. State candidates are also immutable;
Datum selects the greatest catalog asOf, so concurrent refresh processes
cannot regress the active catalog by finishing out of order. Different digests
at one asOf are a typed conflict; publishers must advance asOf for every
revision.
The default remote transport resolves and validates every address, then pins
the actual connection to those validated results at each redirect hop. Each
hop has a 30-second overall deadline; library callers may set
requestTimeoutMs explicitly. An injected transport receives the validated
address set and must connect only to those addresses. Remote source metadata
exposes only the origin plus a redacted path marker.
catalog status and library loading never use the network. If refresh fails, a
valid non-stale cache is returned with a typed fallback diagnostic; otherwise
the typed failure is raised.
Keychain and 1Password refs stay reference-only and are materialized lazily:
only explicit resolve(), materializeAuthRef(), or doctor --probe operations
read the value — via
security find-generic-password -w or op read. resolveRef, datum list, and
datum sync never invoke the backing tool; they only report the auth kind
and whether the tool/var is available, without reading the secret.
{
"providers": {
"anthropic": { "kind": "anthropic-compatible", "auth": { "keychain": { "service": "datum-anthropic", "account": "work" } }, "models": ["claude-sonnet-5"] },
"zai": { "kind": "anthropic-compatible", "baseUrl": "https://api.z.ai/api/anthropic", "auth": { "op": "op://Private/zai/credential" }, "models": ["glm-5.2"] }
}
}Missing/unavailable backends surface as typed errors (MISSING_ENV,
SECRET_BACKEND_UNAVAILABLE off darwin or when op is not installed, and
SECRET_LOOKUP_FAILED when the item is absent or empty) — only when an explicit
materialization operation is requested.
Embedders that own their own provider records can use Datum's same strict reference grammar without adopting Datum config loading:
import { materializeAuthRef, parseAuthRef } from "@kontourai/datum";
const auth = parseAuthRef({ env: "STATION_PROVIDER_KEY" });
const apiKey = materializeAuthRef(auth, { env: process.env });parseAuthRef(value) accepts exactly one { env }, { keychain }, or { op }
reference and rejects unknown keys and pasted secret literals. materializeAuthRef
performs exactly that selected read and propagates Datum's typed errors. Neither
parseAuthRef nor describeAuth reads a secret. Embedders retain ownership of
secret storage, grants, audit, and model invocation; Datum owns only reference
grammar, validation, and explicit materialization.
With the profile above and ZAI_API_KEY exported, resolve the extraction role:
$ datum resolve extraction-default
ref: extraction-default
provider: zai
kind: anthropic-compatible
baseUrl: https://api.z.ai/api/anthropic
model: glm-5.2
auth: env ZAI_API_KEY (set)The secret is never printed. --json gives you the same structure (with an
auth descriptor, plus apiKey only under --reveal); --env prints export
lines for a shell.
Wire it into traverse — datum's
{ baseUrl, apiKey, model } lines up 1:1 with traverse's
createAnthropicExtractionProvider options:
import { resolve } from "@kontourai/datum";
import { createAnthropicExtractionProvider } from "@kontourai/traverse/anthropic";
const provider = createAnthropicExtractionProvider({ ...resolve("extraction-default") });
// -> talks to api.z.ai with glm-5.2 and the key from ZAI_API_KEYNeed to route only, without materializing the secret? Use resolveRef — it
returns an auth status (kind + reference + availability) instead of the value.
datum resolve <ref> [--json|--env] [--reveal] [config flags] Resolve a role or model ref
datum resolve-policy <role> --request <json-file> [--json] [config flags] Resolve a capability role offline
datum list [config flags] Providers + roles, with auth status
datum doctor [--probe] [--allow-insecure] [config flags] Diagnose config; --probe makes ONE live call/provider
datum discover <provider> [--json] [--allow-insecure] [config flags] Fetch the live model list from an openai-compatible provider
datum test-connection <provider> [--allow-insecure] [config flags] Validate auth + reachability for one provider; exits non-zero on failure
datum catalog status|refresh [--json] [--allow-insecure] [config flags] Show catalog metadata or explicitly refresh it
datum sync opencode [--dry-run] [config flags] Generate opencode's provider block from the registry
datum sync claude-code --role <name> [--dry-run] [config flags] Generate Claude Code's settings env block for a role
- No command prints the API key value unless
--revealis passed. datum doctorchecks that files parse, every fixed role resolves, records policy roles as inventory-required, and checks every provider's key backend is reachable-in-principle (env var set, or keychain/op tool present — the secret is not read).--probemakes onemax_tokens: 1request per provider —POST /v1/messagesforanthropic-compatible,POST /chat/completionsforopenai-compatible— to confirm endpoint + key + model actually work.datum discover <provider>fetches the live model ids anopenai-compatibleprovider'sGET {baseUrl}/modelsendpoint actually offers and displays them (--jsonfor structured output). Not supported for other provider kinds.datum test-connection <provider>validates auth + reachability for any configured provider (anykind) and exits non-zero on failure, with each check's pass/fail printed and the failure distinguished into one of three classes: bad/missing credentials, an unreachable endpoint, or a response that is not shaped like the expectedopenai-compatible/modelspayload.--allow-insecure(accepted bydoctor --probe,discover,test-connection, and explicitcatalog refresh, the commands that make live requests):https://is always allowed; loopbackhttp://(localhost,127.0.0.0/8,::1,::ffff:127.x.x.x— Ollama/LM Studio-style local providers) is allowed silently;http://to any other host is blocked by default with an actionable error naming the URL.--allow-insecureoverrides the block but still printswarning: ...to stderr every time. Redirects are followed manually and the policy is re-checked on each hop, so a server cannot bounce a key-bearing request fromhttps://to a plaintexthttp://host.datum sync opencodeemits opencode's nativeproviderblock (confirmed against opencode's publishedconfig.jsonschema). It writesenv: ["VAR"], notoptions.apiKey— the key stays in the environment.datum sync claude-code --role <name>emits Claude Code's native~/.claude/settings.jsonenvblock (ANTHROPIC_BASE_URL+ANTHROPIC_MODEL, confirmed against docs.claude.com/en/docs/claude-code/settings). The API key is never written — it is emitted only as an instruction naming its backend.- Both
synctargets: live write is experimental and merges only a clearly datum-owned block; use--dry-runto preview.
Every subcommand (including catalog status and catalog refresh)
accepts the same three config-location flags, threaded straight into the
library's existing ResolveOptions/loadConfig parameters — pure plumbing, no
new resolution behavior. Flags may appear anywhere on the command line, before
or after positional arguments.
--cwd <dir> Working dir for the repo-level .datum/config.json
(default: the process's own cwd)
--repo-config-path <file> Explicit repo config path; overrides --cwd entirely
--user-config-path <file> Explicit user config path; overrides the
~/.config/kontour/datum.json default
Useful for scripting, CI, monorepos, or fixtures where the config isn't in the process's own working directory:
$ datum resolve extraction-default --cwd ../other-repo
$ datum doctor --repo-config-path ./fixtures/.datum/config.json
$ datum list --user-config-path ~/.config/kontour/work.jsonDatum's kind is open, but each consumer speaks a subset. What datum can
generate/route for each kind today:
| kind | resolve() |
opencode (sync opencode) |
Claude Code (sync claude-code) |
traverse | doctor --probe |
discover |
test-connection |
|---|---|---|---|---|---|---|---|
anthropic-compatible |
yes | yes (@ai-sdk/anthropic) |
yes | yes | POST /v1/messages |
not supported | yes (reuses doctor --probe's check) |
openai-compatible |
yes | yes (@ai-sdk/openai-compatible) |
no (Anthropic API only) | no | POST /chat/completions |
GET /models |
yes (full 3-class diagnosis) |
| other (open enum) | yes | skipped (warning) | no | n/a | skipped | not supported | skipped |
Auth-backend support per consumer: resolve(), Claude Code, and traverse work
with all three auth kinds (env / keychain / op). opencode expresses env-var
names only, so providers using keychain/op are skipped (with a warning) by
sync opencode — give such a provider an { "env": … } ref if opencode needs it.
traverse today speaks anthropic-compatible only.
- Not a gateway or proxy. It never sits in the request path. It resolves config and exits; your own SDK makes the call.
- Not a client or SDK wrapper. The core imports no AI SDK. The resolved object is inert data.
- Not a secret store. Auth is by reference; datum reads env var names / keychain / op references from config and materializes values only when asked.
- Not a router at call time. It picks the backend for a ref up front; it does not load-balance, retry, or fail over live requests.
The deliberate exceptions are datum doctor --probe, datum discover,
datum test-connection, and datum catalog refresh, each a minimal opt-in
live call made only when explicitly invoked.
Design decisions are frozen ADRs at docs/adr/
(precedence rules, the secret-reference-only decision, the
generator-not-wrapper principle, and the slice roadmap — immutable, split one
decision per file). Current/living decisions and their vocabulary are the
topic-keyed registry at docs/decisions/ and
CONTEXT.md.
CI (ci.yml) and Docs (docs.yml) run automatically on every pull request and
every push to main. Releasing is two manual workflow_dispatch workflows; no
event triggers either of them. npm run verify is the gate both CI and the
publish workflow run: it checks the exact tarball allowlist, installs that
tarball in a clean consumer, imports the public API, and runs datum --help.
prepublishOnly enforces the same gate for local publication.
- Release Please (
release-please.yml): dispatch it onmainto open or update the release pull request, which bumpspackage.jsonand the manifest and updates the changelog. After you merge that pull request, dispatch it again to create thevX.Y.Ztag and GitHub release. - Publish NPM (
publish-npm.yml): dispatch it with thevX.Y.Ztag as the ref. A read-only preflight job verifies the ref is av*tag, that it matches thepackage.jsonversion, and that the tagged commit is onmain. After the Node 22/24npm run verifymatrix passes, a no-OIDC job builds, validates, hashes, and uploads the exact tarball. The protected publish job (environmentnpm-publish, restricted tov*tags) does not check out or execute repository code: it downloads and verifies that artifact, fails closed on registry errors except for a structured npmE404, skips versions that are already published, and publishes with provenance and lifecycle scripts disabled. Publishing uses an npm trusted publisher forkontourai/datumandpublish-npm.yml, not a stored token.
Restoring automatic release triggers is tracked in issue #17.
MIT.