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

ClientCredentialHeader setWhereReads env vars
@cuecrux/clientoptions.tokenAuthorization: Bearer <token>Constructor, once (index.ts:61)No
corecrux-clienttoken= argumentAuthorization: Bearer <token>Constructor, once (client.py:30)No
@cuecrux/memory (SDKCrux)token option, else CRUX_AGENT_TOKENauthorization: Bearer <token>Per request (client.ts:95)Yes, three
@cuecrux/engine-client (SDKCrux)nonenone-No
FactoryClient (SDKCrux)apiKey optionauthorization: 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).

VariableMeaningRead by
CRUX_AGENT_TOKENBearer token for the daemon@cuecrux/memory only
CRUX_DAEMON_URLExplicit daemon base URL, checked before the local probe@cuecrux/memory only
CRUX_REMOTE_URLFallback 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.

ModeWhat you pass as tokenNotes
offAnything, including nothingEvery scope check passes (auth.rs:1321)
dev_scopesA literal, space- or comma-separated scope list, not a credentiale.g. token: "facts:write query:read" (auth.rs:385)
jwt_hs256An HS256 JWTSecret, issuer and audience are operator-side (auth.rs:35)
jwt_jwksA 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 methodsRouteScopes accepted (any)Source
healthz, readyz, versionGET /healthz, /readyz, /v1/versionnone, unauthenticated probeshealth.rs:64
storeFact, storeFacts, deleteFactPUT /v1/facts, PUT /v1/facts/bulk, DELETE /v1/facts/{id}facts:write, admin:writefacts.rs:139
getFact, getFactsByEntity, queryFacts, exportFacts, getSessionGET /v1/facts…, GET /v1/sessions/{id}/statequery:read, admin:readfacts.rs:130
putSessionPUT /v1/sessions/{id}/statesessions:write, admin:writefacts.rs:146
textSearch, textSearchExpand, graphExpand, timeRangePOST /v1/query/…query:read, scoped to the tenant you namedquery.rs:95
subscribeEventsGET /v1/events/streamquery:readevents.rs:48
Receipt reads, no Crux SDK covers theseGET /v1/receipts/{id} and sub-routesreceipts:read, tenant-scopedreceipts.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:

SchemeTransportObtained from
ApiKeyAuthRequest header X-API-KeyThe CueCrux console
BearerAuthAuthorization: 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 fetch you 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.

StatusMeaningBody signatureWhat to do
400The request is structurally wrong for this routee.g. private facts require MCP agent identity (facts.rs:229)Fix the payload. Never retry
401You 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
403You 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
404No such fact, session or routeStandard problem bodyBoth Crux SDKs convert this to null or false on getFact, getSession and deleteFact
429Quota exhausted on a hosted surfaceCarries 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)
503The 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.

RuleWhyEvidence
The token is never written to a log line or an error message by any clientCoreCruxError, CruxDaemonError and FactoryClientError all carry status, path or request id, never headerserrors.ts:2, index.ts:36
No client persists a token to diskNone of them has a cache, a keyring integration or a config-file readerVerified by source read, 2026-07-27
No client refreshes or rotates a tokenThere is no refresh flow anywhere. An expired JWT produces a 401 until you replace itVerified by source read, 2026-07-27
A token is fixed for the client's lifetimeBoth Crux clients set the header in the constructorindex.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