SDKs · 3. Authentication
Both Crux SDKs authenticate the same way: you pass a token to the constructor and it is sent as Authorization: Bearer <token> on every request. Neither reads an environment variable, neither refreshes anything, and neither has a second credential type. If you are calling the hosted CueCrux Engine instead, the credential is an X-API-Key header and the published client has no way to send it, 3.5 gives the workaround.
This chapter is reference. The daemon's own auth architecture, modes, the route-auth middleware, passport headers, is chapter 1 of the daemon developer guide; this chapter covers only what a client author needs.
3.1 What each client sends
| Client | Credential | Header set | Where | Reads env vars |
|---|---|---|---|---|
@cuecrux/client | options.token | Authorization: Bearer <token> | Constructor, once (index.ts:61) | No |
corecrux-client | token= argument | Authorization: Bearer <token> | Constructor, once (client.py:30) | No |
@cuecrux/memory (SDKCrux) | token option, else CRUX_AGENT_TOKEN | authorization: Bearer <token> | Per request (client.ts:95) | Yes, three |
@cuecrux/engine-client (SDKCrux) | none | none | - | No |
FactoryClient (SDKCrux) | apiKey option | authorization: Bearer <apiKey> | Per request (client.ts:249) | No |
In both Crux SDKs the header is omitted entirely when the token is falsy, an empty string produces an unauthenticated request, not an Authorization: Bearer header. Against a daemon in off mode that succeeds; against any other mode it 401s. If you want to fail loudly on a missing token, check before you construct.
3.2 Environment variables
Three environment variable names are conventional across the portfolio. Neither Crux SDK reads any of them. Only SDKCrux's @cuecrux/memory does (discovery.ts:7).
| Variable | Meaning | Read by |
|---|---|---|
CRUX_AGENT_TOKEN | Bearer token for the daemon | @cuecrux/memory only |
CRUX_DAEMON_URL | Explicit daemon base URL, checked before the local probe | @cuecrux/memory only |
CRUX_REMOTE_URL | Fallback base URL when no local daemon answers | @cuecrux/memory only |
Using the convention from a Crux SDK is three lines you write yourself:
const client = new CoreCruxClient({
baseUrl: process.env.CRUX_DAEMON_URL ?? "http://127.0.0.1:14800",
token: process.env.CRUX_AGENT_TOKEN,
});
client = CoreCruxClient(
os.environ.get("CRUX_DAEMON_URL", "http://127.0.0.1:14800"),
token=os.environ.get("CRUX_AGENT_TOKEN"),
)
@cuecrux/memory additionally exports the names as constants, ENV_AGENT_TOKEN, ENV_DAEMON_URL, ENV_REMOTE_URL, DEFAULT_LOCAL_URL, so the strings need not be retyped. See 5.5.
3.3 What token to pass, by daemon auth mode
CORECRUXD_AUTH_MODE has no default; the daemon refuses to start without it (main.rs:307). The mode the operator chose determines what "a token" means, and this is the single most common cause of a confusing 401.
| Mode | What you pass as token | Notes |
|---|---|---|
off | Anything, including nothing | Every scope check passes (auth.rs:1321) |
dev_scopes | A literal, space- or comma-separated scope list, not a credential | e.g. token: "facts:write query:read" (auth.rs:385) |
jwt_hs256 | An HS256 JWT | Secret, issuer and audience are operator-side (auth.rs:35) |
jwt_jwks | A JWT verifiable against the configured JWKS or OIDC discovery URL | (auth.rs:44) |
The dev_scopes case surprises people: in that mode the bearer token is the scope list. A development daemon that rejects your real JWT and accepts the string admin:read admin:write is behaving correctly.
Neither SDK exposes the X-Corecrux-Scopes, X-Corecrux-Passport-Id or X-Corecrux-Tenant-Id headers, and neither accepts additional headers. If you need them, use a raw fetch or httpx call. The headers themselves are documented in developer guide 1.4.
3.4 Scopes, per SDK method
Every check is an any-of: holding either scope in the row is sufficient. The daemon has no canonical scope enum, scopes are string literals at each call site, so this table is built from the call sites themselves.
| SDK methods | Route | Scopes accepted (any) | Source |
|---|---|---|---|
healthz, readyz, version | GET /healthz, /readyz, /v1/version | none, unauthenticated probes | health.rs:64 |
storeFact, storeFacts, deleteFact | PUT /v1/facts, PUT /v1/facts/bulk, DELETE /v1/facts/{id} | facts:write, admin:write | facts.rs:139 |
getFact, getFactsByEntity, queryFacts, exportFacts, getSession | GET /v1/facts…, GET /v1/sessions/{id}/state | query:read, admin:read | facts.rs:130 |
putSession | PUT /v1/sessions/{id}/state | sessions:write, admin:write | facts.rs:146 |
textSearch, textSearchExpand, graphExpand, timeRange | POST /v1/query/… | query:read, scoped to the tenant you named | query.rs:95 |
subscribeEvents | GET /v1/events/stream | query:read | events.rs:48 |
| Receipt reads, no Crux SDK covers these | GET /v1/receipts/{id} and sub-routes | receipts:read, tenant-scoped | receipts.rs:526 |
Two asymmetries worth internalising:
Reading a session needs query:read, writing one needs sessions:write. The read path reuses the fact-read guard (facts.rs:966), so a token with sessions:write and nothing else can write session state it cannot read back.
Query routes check the scope against the tenant in the request body. tenant_id is not a filter you choose freely; a token whose tenant claim does not cover it is refused (query.rs:95).
A useful minimum for a read-only integration is query:read. For a memory-writing agent, facts:write query:read. Reach for admin:read or admin:write only when you mean it: under those scopes, and with no passport identity attached, fact reads bypass the per-agent visibility filter entirely (facts.rs:615).
3.5 The hosted plane: authenticating to the CueCrux Engine
The CueCrux Engine is a different service with a different credential. Its OpenAPI description declares two global security schemes, either of which is accepted on every route:
| Scheme | Transport | Obtained from |
|---|---|---|
ApiKeyAuth | Request header X-API-Key | The CueCrux console |
BearerAuth | Authorization: Bearer <jwt> | Issued JWT |
@cuecrux/engine-client cannot send either. createEngineClient(baseUrl, fetchImpl?) takes no options object, sets no Authorization and no X-API-Key on any of its nine methods, and offers no hook to add one (index.ts:96). Its README's advice, "Pass whatever authentication header your account requires" (README.md:20)
- is not actionable as written. The only route is to wrap the
fetchyou hand it:
import { createEngineClient } from "@cuecrux/engine-client";
const apiKey = process.env.CUECRUX_ENGINE_API_KEY;
if (!apiKey) throw new Error("CUECRUX_ENGINE_API_KEY is not set");
const authedFetch: typeof fetch = (input, init = {}) =>
fetch(input, {
...init,
headers: { ...(init.headers as Record<string, string>), "X-API-Key": apiKey },
});
const engine = createEngineClient("https://engine.cuecrux.com", authedFetch);
Note the production host is engine.cuecrux.com. The package README shows engine.cuecrux.io, which is wrong (README.md:10). This defect and its siblings are 6.3.
3.6 What a 401, a 403 and a 503 each mean
All three arrive as application/problem+json and, in the SDKs, as a thrown error, a CoreCruxError in both Crux clients. The distinction matters because the fix is different in each case, and only one of them is worth retrying.
| Status | Meaning | Body signature | What to do |
|---|---|---|---|
| 400 | The request is structurally wrong for this route | e.g. private facts require MCP agent identity (facts.rs:229) | Fix the payload. Never retry |
| 401 | You presented no usable credential. The daemon could not establish who you are | "code": "UNAUTHENTICATED", plus a hint naming the header to set (auth.rs:857) | Supply or fix the token. Never retry unchanged |
| 403 | You are known, and not allowed. Identity established, authorisation failed | "code": "MISSING_SCOPE" and "missingScopes": [...] (auth.rs:1331), or "code": "PASSPORT_HEADER_MISMATCH" when a passport header contradicts the token claim (auth.rs:1186) | Read missingScopes, get a token that carries them. Never retry unchanged |
| 404 | No such fact, session or route | Standard problem body | Both Crux SDKs convert this to null or false on getFact, getSession and deleteFact |
| 429 | Quota exhausted on a hosted surface | Carries Retry-After and X-Crux-Quota-Limit / X-Crux-Quota-Remaining (quota.rs:23) | Wait Retry-After, then retry. FLAG: the whole middleware is off unless CORECRUXD_QUOTA is enabled, default off (mod.rs:296) |
| 503 | The daemon is running but not ready, a readiness check failed | {"ok": false, "checks": [...]} (health.rs:74) | Wait and retry. This is the one authentication-adjacent status that is genuinely transient |
The single sentence to keep: 401 means "I do not know who you are", 403 means "I know, and no". Retrying either without changing the credential produces the same answer forever. Retrying a 503 usually succeeds, because readiness checks recover.
Reading missingScopes differs by SDK, because the daemon flattens problem extensions to the top level of the body rather than nesting them (corecrux-types/src/lib.rs:798):
try {
await client.storeFact({ entity: "e", key: "k", value: "v" });
} catch (err) {
if (err instanceof CoreCruxError && err.status === 403) {
const body = err.problem as unknown as Record<string, unknown>;
console.error("need:", body.missingScopes); // NOT err.problem.extensions
}
}
In Python, missingScopes is not available at all: CoreCruxError keeps only status_code, detail and type (client.py:45). detail still carries actionable text, which is the daemon's deliberate design, read it and print it rather than mapping it.
3.7 Diagnosing a 503 rather than guessing
readyz() throws on 503 in both SDKs, so the failing checks are in the error, not the return value. By default the response names each failed check; with CORECRUXD_PUBLIC_PROBES_MINIMAL set on the daemon the breakdown is withheld and only ok: false survives (health.rs:64). FLAG, default off.
Because both SDKs throw before you can read the body, the reliable move at 3am is a raw request:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:14800/readyz
curl -s http://127.0.0.1:14800/readyz | jq .
A 503 whose checks name a capacity or corruption condition is an operator problem, not a client one. Do not paper over it with retries in the client, see 4.4.
3.8 Token handling
Rules that hold across all five clients described in this set.
| Rule | Why | Evidence |
|---|---|---|
| The token is never written to a log line or an error message by any client | CoreCruxError, CruxDaemonError and FactoryClientError all carry status, path or request id, never headers | errors.ts:2, index.ts:36 |
| No client persists a token to disk | None of them has a cache, a keyring integration or a config-file reader | Verified by source read, 2026-07-27 |
| No client refreshes or rotates a token | There is no refresh flow anywhere. An expired JWT produces a 401 until you replace it | Verified by source read, 2026-07-27 |
| A token is fixed for the client's lifetime | Both Crux clients set the header in the constructor | index.ts:57, client.py:30 |
To rotate a credential you construct a new client. In Python, close the old one first or you leak its connection pool.
Never write a token into a file your code generates, and never embed one in a base URL. Source it from the environment or from a secrets manager at process start.
3.9 Two places authentication silently does not apply
Server-sent events. subscribeEvents() returns a native EventSource, which cannot carry headers, so the bearer token is not sent (index.ts:222). The route requires query:read (events.rs:48), so against any daemon not in off mode this connection is refused, and the failure surfaces as a generic onerror with no status. Use an EventSource polyfill that supports headers, or a proxy that injects the credential.
@cuecrux/engine-client. Every one of its nine methods issues an unauthenticated request unless you supplied a wrapping fetch. There is no error, no warning and no type-level hint; you simply get whatever the Engine returns to an anonymous caller.
Sources
- crates/corecruxd/src/main.rs:307, auth mode is mandatory
- crates/corecruxd/src/auth.rs:385, bearer token as a scope list under
dev_scopes - crates/corecruxd/src/auth.rs:857, 401
UNAUTHENTICATEDand its hint - crates/corecruxd/src/auth.rs:1331, 403
MISSING_SCOPEandmissingScopes - crates/corecruxd/src/http/facts.rs:130, fact-read scope guard
- crates/corecruxd/src/http/facts.rs:139, fact-write scope guard
- crates/corecruxd/src/http/facts.rs:146, session-write scope guard
- crates/corecruxd/src/http/query.rs:95, tenant-scoped
query:read - crates/corecruxd/src/http/events.rs:48, SSE requires
query:read - crates/corecruxd/src/http/receipts.rs:526,
receipts:read - crates/corecruxd/src/http/health.rs:74, 503 shape
- crates/corecruxd/src/http/quota.rs:23, 429 headers
- packages/memory/src/discovery.ts:7, the three environment variable names
- The CueCrux Engine's OpenAPI description (164 paths), read 2026-07-27, for the
X-API-Keyand bearer schemes. The Engine is closed-source, so no line-level link is available; the security schemes are part of its published contract

