Personal Developer Keys
User-bound nxd_ credentials for the Query and Trace HTTP APIs, separate from gateway API keys
Esta página aún no está disponible en tu idioma.
Personal developer keys are user-bound credentials for CLI, SDK, and automation clients that need Nexus Query, Trace, and control-plane APIs without a browser session.
In the console they appear under Account → Personal developer keys (/account).
The auth service stores them as developer access tokens and issues secrets with the nxd_… prefix.
They are not gateway API keys (nxs_…) and not trace ingest keys.
Gateway keys send model traffic; personal keys act as the issuing user — reading observability data, writing request feedback, and, with management scopes, creating and revoking keys and changing settings — inside the scope the key is bound to.
Deployed application (primary)
Section titled “Deployed application (primary)”On a Traefik-backed deployment, consumers and tenants reach Nexus through the published hostnames, not the internal service ports.
| Public hostname (env) | Typical role | Example |
|---|---|---|
NEXUS_CONSOLE_HOSTNAME |
Tenant console (UI + same-origin BFF) | https://console.example.com |
NEXUS_HOSTNAME |
Gateway edge (model traffic, /v1/traces) |
https://api.example.com |
Auth (auth:14453) and Query (query:14461) stay on the private Compose network.
The console container calls them with AUTH_HTTP_ENDPOINT and QUERY_HTTP_ENDPOINT.
Stock Traefik labels publish console and gateway (plus optional Grafana / path-routed trace ingest on the gateway host).
They do not publish auth or query directly.
What that means for you:
- Mint your first key in the console UI on the console hostname (works with OIDC or password login, depending on deployment policy). After that, a key may mint, list, and revoke your other keys itself.
- Send model traffic to the gateway hostname with a gateway API key (
nxs_…). - Call Query/Trace with
nxd_…only against a Query HTTP base URL your operator has made reachable to you (published Query hostname, VPN, or private network). The stock public edge does not put Query on the gateway hostname. - Manage keys and settings with
nxd_…against the console hostname: the console’s control-plane and developer-key routes forward anAuthorization: Bearer nxd_…header to the private services.
Browser sessions in the console do not need a personal key: the console BFF under /api/query/** forwards the signed-in session cookie to Query on the private network.
# Set these to your deployment's public URLs.CONSOLE_URL="${CONSOLE_URL:-https://console.example.com}"GATEWAY_URL="${GATEWAY_URL:-https://api.example.com}"
# Query base URL only if your operator publishes Query HTTP to clients.# Stock Traefik compose leaves query on the private network.QUERY_URL="${QUERY_URL:-https://query.example.com}"What is available today
Section titled “What is available today”| Capability | Available to a deployed-app user |
|---|---|
| Create / list / revoke in the console UI | Yes |
Create / list / revoke with another nxd_… key |
Yes — same user, inside the key’s binding and scopes |
| Org-wide, single-project, or whole-workspace binding | Yes (workspace binding needs workspace manager/auditor) |
| Expiry (default 30 days, bounded by the effective maximum key age) | Yes |
| Scoped permissions (see below) | Yes |
Authorization: Bearer nxd_… against Query HTTP (when Query is reachable) |
Yes |
| Control-plane routes through the console hostname with management scopes | Yes |
| Use against the gateway for chat/completions | No — use nxs_… |
| Rotate in place (edit secret) | No — mint a new key, then revoke the old one |
| Machine / service-account tokens (non-user) | No |
| Create workspaces or accept invitations | No — those need a console session |
| Call Query via the public gateway hostname | No (gateway does not serve /api/requests, …) |
Call Query via console /api/query/** with an nxd_… bearer |
No — that BFF uses the browser session cookie |
Effective access on every use is the intersection of:
- Scopes stored on the token
- The token’s binding (workspace, organization, or project)
- The user’s current org/project/workspace membership and role
- Token not revoked or expired; user still active
- Policy (PDP) allow for the specific read or feedback action
Demotion, membership removal, disablement, expiry, or revocation takes effect on the next verification. Services do not cache a verified personal key, so the next request after any of those changes sees it. Personal keys are not subject to console session idle timeout.
Bindings
Section titled “Bindings”Every key is bound to exactly one scope, chosen at creation:
| Binding | Create with | Reaches |
|---|---|---|
| Organization | org_id |
That organization and its projects, at your role there (including manager or auditor inherited from its workspace). |
| Project | org_id + project_id |
That project only. An organization-wide role applies to the project, never to the organization itself. |
| Workspace | workspace_id |
That workspace and every organization in it, at your workspace role. Requires workspace manager or auditor. |
Anything outside the binding answers 403, even when your console session could reach it.
An organization or project binding never carries workspace authority: workspace limits, members, wallet, and invitations need a workspace binding or a session.
Scopes
Section titled “Scopes”The console scope picker offers these values. Creation rejects any scope the caller’s effective role does not hold.
| Scope | Console label | Unlocks |
|---|---|---|
query:read |
Request metadata | Request rows, sessions, session timelines |
trace:read |
Trace metadata | Trace envelopes and safe tool summaries |
trace_payload:read |
Full trace payloads | Stored bodies and full tool payload refs when tracing captured them |
metrics:read |
Metrics | Usage, metrics overview, panel exports |
feedback:write |
Feedback | Submit request feedback from SDKs or automation |
Management scopes, unchecked by default in the console:
| Scope | Console label | Unlocks |
|---|---|---|
control_plane:read |
Organization read | List organizations, projects, members, and key metadata; create and revoke gateway keys you issue |
control_plane:write |
Organization write | Settings changes, other members’ keys, trace ingest keys, and the other control-plane mutations your role allows |
provider_keys:write |
Provider credentials | Create and revoke provider connections (secrets are write-only) |
model_registry:read |
Model registry | Models, endpoints, and pricing |
audit:read |
Audit | Audit events |
A management scope never widens your role: every control-plane route still applies its own role check (for example, provider connections need project manager) inside the key’s binding.
Role ceilings for mintable scopes:
| Role | Mintable scopes |
|---|---|
manager |
Every scope above |
auditor |
query:read, trace:read, metrics:read, feedback:write, control_plane:read, model_registry:read, audit:read |
user |
query:read, trace:read, metrics:read, feedback:write, control_plane:read, model_registry:read |
The role is the one you hold at the key’s binding: your project role for a project binding, your organization role for an organization binding, and your workspace role for a workspace binding. On every use the stored scopes are intersected with your current role there, so a demotion removes scopes from existing keys.
Full payload reads also require a successful trace_payload.read PDP decision and stored payload references.
Metadata reads remain available with query:read, trace:read, or metrics:read as appropriate.
Secrets are shown once
Section titled “Secrets are shown once”- Generated secrets —
nxd_…, gatewaynxs_…, and trace ingest keys — are returned only in the response that creates them (and, for trace ingest keys, the rotate response). List and revoke responses carry metadata only, and there is no route that reveals a stored secret. - Provider secrets are write-only. Create accepts
secret; the create response and every listing return connection metadata (id, provider, name, base URL, expiry, revoked) and never the secret.
Credential families
Section titled “Credential families”| Prefix | Purpose | Where you send it |
|---|---|---|
nxd_… |
Personal developer key (this doc) | Query HTTP base URL (when published / reachable); console hostname for management routes |
nxs_… |
Gateway API key | Gateway hostname (/v1/…, /anthropic/…) |
| Trace ingest key | Project-scoped OTLP write | Gateway hostname /v1/traces (path-routed) |
Query HTTP rejects gateway API keys with HTTP 401 (gateway api keys cannot read query APIs).
Create a key in the console
Section titled “Create a key in the console”- Open the tenant console on your deployment’s console hostname (for example
https://console.example.com). - Sign in (OIDC and/or password, per that deployment’s auth config).
- Open Account (
/account). - Select the organization, then New key.
- Set name, optional project scope (or, as a workspace manager or auditor, Bind to workspace), expiry days, and permissions. The permission list shows only the scopes your role allows at that binding. The expiry field defaults to the lesser of 30 days and the effective maximum key age for the selected workspace, organization, or project, and the dialog shows that maximum when one is configured.
- Copy the secret once — it is not shown again.
The raw secret is returned only on create. The Account list is a searchable, paginated table that defaults to Active (hides revoked and past-expiry keys); switch the state filter to Revoked / Expired / All when needed. For the selected organization it lists keys bound to that organization or its projects, plus keys bound to its workspace. List rows show name, prefix, scope, permissions, status (Active / Expires in N days / Expired / Revoked), and last-used time. Revoke stays available for every status except already-revoked keys.
This is the path that works on OIDC-only deployments (password login disabled).
Calling Query HTTP with a personal key
Section titled “Calling Query HTTP with a personal key”Use the personal key as a bearer token against Query HTTP, not against the gateway hostname.
Every list/detail route requires org_id (and respects optional project_id filters and the token’s project grant).
QUERY_URL="${QUERY_URL:?set QUERY_URL to the Query HTTP base your operator published}"NEXUS_DEV_TOKEN="nxd_…" # secret shown once at create timeORG_ID="org_…"
# Recent requestscurl -sS "$QUERY_URL/api/requests?org_id=$ORG_ID&limit=20" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" | jq .
# One requestREQUEST_ID="req_…"curl -sS "$QUERY_URL/api/requests/$REQUEST_ID?org_id=$ORG_ID" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" | jq .
# Sessionscurl -sS "$QUERY_URL/api/sessions?org_id=$ORG_ID&limit=20" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" | jq .
# Session timelineSESSION_ID="sess_…"curl -sS "$QUERY_URL/api/sessions/$SESSION_ID/timeline?org_id=$ORG_ID" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" | jq .
# Metrics overview (needs metrics:read)curl -sS "$QUERY_URL/api/metrics/overview?org_id=$ORG_ID" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" | jq .
# Trace metadata for a request (needs trace:read)curl -sS "$QUERY_URL/api/traces/requests/$REQUEST_ID?org_id=$ORG_ID" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" | jq .
# Stored body (needs trace_payload:read + stored body + PDP allow)curl -sS "$QUERY_URL/api/body/by-request/$REQUEST_ID?org_id=$ORG_ID&kind=response" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN"
# Feedback (needs feedback:write)curl -sS -X POST "$QUERY_URL/api/requests/$REQUEST_ID/feedback" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" \ -H 'Content-Type: application/json' \ -d "$(jq -n \ --arg org "$ORG_ID" \ --arg project "$PROJECT_ID" \ '{org_id:$org, project_id:$project, score:1, label:"helpful", comment:"worked well"}')"score must be -1, 0, or 1.
Live session stream (SSE)
Section titled “Live session stream (SSE)”Requires query:read and membership for the org/session.
Project-scoped keys only see events for their project.
curl -N "$QUERY_URL/api/sessions/$SESSION_ID/live?org_id=$ORG_ID" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" \ -H 'Accept: text/event-stream'Python example
Section titled “Python example”import osimport urllib.parseimport urllib.request
query_url = os.environ["QUERY_URL"] # operator-published Query HTTP basetoken = os.environ["NEXUS_DEV_TOKEN"] # nxd_…org_id = os.environ["ORG_ID"]
params = urllib.parse.urlencode({"org_id": org_id, "limit": 20})req = urllib.request.Request( f"{query_url}/api/requests?{params}", headers={"Authorization": f"Bearer {token}"},)with urllib.request.urlopen(req) as resp: print(resp.read().decode())TypeScript example
Section titled “TypeScript example”const queryUrl = process.env.QUERY_URL!; // operator-published Query HTTP baseconst token = process.env.NEXUS_DEV_TOKEN!; // nxd_…const orgId = process.env.ORG_ID!;
const url = new URL("/api/requests", queryUrl);url.searchParams.set("org_id", orgId);url.searchParams.set("limit", "20");
const response = await fetch(url, { headers: { Authorization: `Bearer ${token}` },});if (!response.ok) { throw new Error(`${response.status} ${await response.text()}`);}console.log(await response.json());Query routes personal keys can use
Section titled “Query routes personal keys can use”When the token carries the required scope(s) and Query HTTP is reachable, these routes accept nxd_… bearers:
| Method | Path | Typical scopes |
|---|---|---|
GET |
/api/requests |
query:read |
GET |
/api/requests/:request_id |
query:read |
GET |
/api/sessions |
query:read |
GET |
/api/sessions/:session_id/timeline |
query:read |
GET |
/api/sessions/:session_id/live |
query:read |
GET |
/api/usage |
metrics:read |
GET |
/api/metrics/overview |
metrics:read |
GET |
/api/exports/panel |
metrics:read |
POST |
/api/requests/:request_id/feedback |
feedback:write |
GET |
/api/body/by-request/:request_id |
trace_payload:read |
GET |
/api/traces/requests/:request_id |
trace:read (+ payload scope for full refs) |
GET |
/api/traces/sessions/:session_id |
trace:read |
GET |
/api/traces/tools/:request_id |
trace:read |
GET |
/api/traces/:trace_id/tree |
trace:read |
GET |
/api/traces/:trace_id/spans/:span_id |
trace:read |
GET |
/api/traces/:trace_id/artifacts/:artifact_id |
trace:read / trace_payload:read as applicable |
GET |
/api/requests/:request_id/trace |
trace:read |
GET |
/api/sessions/:session_id/traces |
trace:read |
GET |
/api/sessions/:session_id/traces/live |
trace:read |
Canonical route descriptions live in ../architecture/wire-contracts.md.
GET /api/reports/workspace requires query:read, metrics:read, and control_plane:read on the key, and refuses project-bound keys.
Managing Nexus with a personal key
Section titled “Managing Nexus with a personal key”The console hostname forwards an Authorization: Bearer nxd_… header on every route that proxies the control plane (for example /api/orgs/**, /api/projects/**, /api/workspaces/**, /api/api-keys/**, /api/trace-ingest-keys/**, /api/provider-keys/**, and /api/models/**), on file and vector-store routes under /api/artifacts/orgs/:org/projects/:project/…, on /api/users/lookup, and on its developer-key routes (/api/auth/developer-tokens…).
The console’s Query proxies (/api/query/**), session routes (/api/session/**), and /api/audit/request-viewed use only the browser session cookie.
A request carrying a developer key never uses the browser session cookie.
The control plane applies the same handlers and role checks a console session gets, clipped to the key’s binding, so each route needs its usual scope and role plus a binding that contains the target.
CONSOLE_URL="${CONSOLE_URL:-https://console.example.com}"NEXUS_DEV_TOKEN="nxd_…" # bound to the org, with control_plane:readORG_ID="org_…"PROJECT_ID="proj_…"
# Mint a gateway key for a project (the nxs_ secret is in this response only).curl -sS -X POST "$CONSOLE_URL/api/api-keys" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" \ -H 'Content-Type: application/json' \ -d "$(jq -n --arg org "$ORG_ID" --arg project "$PROJECT_ID" \ '{org_id: $org, project_id: $project, name: "ci-gateway"}')"
# List gateway keys (metadata only).curl -sS "$CONSOLE_URL/api/api-keys?org_id=$ORG_ID&project_id=$PROJECT_ID" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" | jq .
# Revoke one.curl -sS -X DELETE "$CONSOLE_URL/api/api-keys/ak_…" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" -o /dev/null -w '%{http_code}\n'Provider connections need provider_keys:write on the key and project manager authority; the secret is accepted on create and never returned:
curl -sS -X POST "$CONSOLE_URL/api/provider-keys" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" \ -H 'Content-Type: application/json' \ -d "$(jq -n --arg org "$ORG_ID" --arg project "$PROJECT_ID" --arg secret "$OPENAI_API_KEY" \ '{org_id: $org, project_id: $project, provider: "openai", name: "primary", secret: $secret}')"Routes that act outside any single scope — creating a workspace and accepting an invitation — answer 403 for a developer key.
Scripted lifecycle
Section titled “Scripted lifecycle”Personal keys are minted, listed, and revoked through /api/auth/developer-tokens on the console origin.
The caller is either a console session or another nxd_ key belonging to the same user.
A key acting as the caller:
- mints only keys bound inside its own binding (its workspace or an organization in it; its organization or a project in it; its own project),
- may request only scopes it holds itself, still capped by your role at the new binding,
- lists and revokes only keys bound inside its own binding, including itself.
Mint your first key in the console (or with a console session), then rotate from automation: mint the replacement with the current key, switch to it, and revoke the old one.
CONSOLE_URL="${CONSOLE_URL:-https://console.example.com}"NEXUS_DEV_TOKEN="nxd_…"ORG_ID="org_…"
# Create a narrower key with an existing key.curl -sS -X POST "$CONSOLE_URL/api/auth/developer-tokens" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" \ -H 'Content-Type: application/json' \ -d "$(jq -n \ --arg org "$ORG_ID" \ --arg name "CI observability reader" \ '{ org_id: $org, project_id: null, name: $name, scopes: ["query:read","trace:read","metrics:read","feedback:write"], expires_at: "2026-12-31T00:00:00Z" }')"Successful create response shape:
{ "id": "dt_…", "token": "nxd_…", "prefix": "…", "workspace_id": null, "org_id": "org_…", "project_id": null, "scopes": ["query:read", "trace:read", "metrics:read", "feedback:write"], "expires_at_unix": 1770000000}Store token immediately.
Only the hash and prefix are persisted.
Project-scoped create:
curl -sS -X POST "$CONSOLE_URL/api/auth/developer-tokens" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" \ -H 'Content-Type: application/json' \ -d "$(jq -n \ --arg org "$ORG_ID" \ --arg project "$PROJECT_ID" \ --arg name "CI project reader" \ '{ org_id: $org, project_id: $project, name: $name, scopes: ["query:read","metrics:read"], expires_at: "2026-12-31T00:00:00Z" }')"Workspace-bound create sends workspace_id instead of org_id and project_id; it requires workspace manager or auditor:
curl -sS -X POST "$CONSOLE_URL/api/auth/developer-tokens" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" \ -H 'Content-Type: application/json' \ -d "$(jq -n --arg workspace "$WORKSPACE_ID" \ '{workspace_id: $workspace, name: "workspace automation", scopes: ["control_plane:read"]}')"expires_at is an optional RFC3339 timestamp.
When it is omitted, the token expires after 30 days.
A workspace, organization, or project maximum key age — or NEXUS_MAX_KEY_AGE_DAYS — caps the requested timestamp and lowers the 30-day default; a request beyond the cap is rejected with HTTP 400 and {"message":"key expiration exceeds the maximum key age"}. With no cap configured, any future timestamp is accepted.
Create also returns 400 with a message for empty names, scopes the caller’s role cannot mint, scopes the calling key does not hold, or a project outside the organization, and 403 with a message when the caller has no membership in the requested organization, lacks workspace authority for a workspace binding, or is a key minting outside its own binding.
Those validation failures do not invalidate the caller’s session or key.
List and revoke (omit org_id to list every binding; workspace_id filters workspace-bound keys):
curl -sS "$CONSOLE_URL/api/auth/developer-tokens?org_id=$ORG_ID" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" | jq .
TOKEN_ID="dt_…"curl -sS -X DELETE "$CONSOLE_URL/api/auth/developer-tokens/$TOKEN_ID" \ -H "Authorization: Bearer $NEXUS_DEV_TOKEN" \ -o /dev/null -w '%{http_code}\n'A console session cookie works on the same paths in place of the bearer header.
Auth also exposes the same lifecycle paths on its private HTTP listener (POST|GET /api/auth/developer-tokens, DELETE /api/auth/developer-tokens/{token_id}) for operators with network access to that service.
Failure modes
Section titled “Failure modes”| Situation | Typical result |
|---|---|
| Missing / bad bearer on Query | HTTP 401 |
Gateway nxs_… key on Query |
HTTP 401 |
Hitting /api/requests on the gateway hostname |
HTTP 404 (not a Query route there) |
| Scope missing for the route | HTTP 403 |
| Org/project outside membership or token grant | HTTP 403 |
| Control-plane target outside the key’s binding | HTTP 403 |
| Workspace creation or invitation accept by key | HTTP 403 |
| Requested create scopes exceed role | HTTP 400 {"message":"scope … is not available to your role"} |
| Key requests a scope it does not hold | HTTP 400 {"message":"scope … is not held by this developer key"} |
| Key mints outside its own binding | HTTP 403 {"message":"a developer key cannot mint a key outside its own binding"} |
| Workspace binding without workspace authority | HTTP 403 {"message":"workspace developer keys require a workspace manager or auditor role"} |
| Create expiry exceeds maximum key age | HTTP 400 {"message":"key expiration exceeds the maximum key age"} |
| Create without membership in the org | HTTP 403 {"message":"no membership in organization"} |
| Revoke unknown, already-revoked, or out-of-binding token | HTTP 404 {"message":"developer token not found"} |
| Token revoked or expired | Verification fails |
| User disabled or membership removed | Verification fails / 403 |
| Full body when tracing did not store it | HTTP 404 (body not stored) |
Audit and correlation
Section titled “Audit and correlation”Create and revoke emit developer_token.created and developer_token.revoked, with the binding (workspace_id, org_id, project_id) and, when another key made the call, acting_developer_token_id.
Verified principals carry an internal developer_token:<id> scope for correlation, and control-plane audit events for actions a key takes name the issuing user as actor.
Query reads emit data_access.* events (for example data_access.request_viewed, data_access.trace_payload_viewed) that include the token id without the raw secret.
last_used_at updates on successful verification.
Local contributor stack
Section titled “Local contributor stack”When you run the stock local Compose stack with published host ports (see ../reference/ports.md), the same APIs are also reachable as http://127.0.0.1:14449 (console), http://127.0.0.1:14453 (auth), and http://127.0.0.1:14461 (query).
Prefer the deployed hostname model above when writing client integrations; use localhost only for contributor debugging on a machine that can reach those ports.
Related docs
Section titled “Related docs”../architecture/services.md— auth, query, and console responsibilities../architecture/wire-contracts.md— Query HTTP surface../reference/ports.md— local Compose ports../reference/user-glossary.md— operator-facing terms../dev/observability.md— Traefik hostname defaults for local stacksend-to-end.md— minting a gatewaynxs_…key and sending a chat requestintegrations/— point coding clients at the gateway withnxs_…(not personal keys)
