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. Hosted release workflows remain manual-only while CI is out of budget; local verification is authoritative.
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.
Kontour portfolio tools use a consistent repo-level directory split:
.kontourai/is what you ignore — gitignored, per-machine, disposable runtime state (caches, logs, generated scratch output)..<product>/is what you commit — a product's durable, tracked config directory, owned and versioned like any other source file (precedent:.veritas/in the veritas repo).
Datum's repo-level config directory is .datum/, and the file datum reads
is .datum/config.json. Commit it.
{
"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 resolve() (and doctor --probe) reads 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 (SECRET_BACKEND_UNAVAILABLE
off darwin or when op is not installed; SECRET_LOOKUP_FAILED when the item is
absent or empty) — only at resolve() time.
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.
Release Please, CI, and npm publication are manual-only while hosted CI is out
of budget. npm run verify is the authoritative local gate: 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.
The publish workflow first verifies the vX.Y.Z ref, package version, and main
ancestry in a read-only job. After the Node 22/24 matrix passes, a no-OIDC job
builds, validates, hashes, and uploads the exact tarball. The protected publish
job does not check out or execute repository code: it downloads and verifies
that artifact, fails closed on registry errors except for a structured npm
E404, and publishes it with provenance and lifecycle scripts disabled.
Owner activation on npmjs.com: configure a trusted publisher for
@kontourai/datum with organization kontourai, repository datum, workflow
filename publish-npm.yml, environment npm-publish, and only the npm publish allowed action. Enable provenance display. Automatic triggers should
be restored only with explicit hosted-budget approval; this is tracked in
issue #17.
MIT.