CADS-Tunnel docs
Reference

API endpoints — public-facing

Every response shape on this page was captured from a real call against https://bunsenbrenner.org, not written from the source alone.

Status and network info

GET /status — no auth. Aggregated operator counts, captured live:

{
  "ready": true,
  "tunnels": 5,
  "agents": 17,
  "accounts": 9,
  "payments_confirmed": 0,
  "pipelines_published": 2,
  "agents_directory": 8,
  "uptime_seconds": 1830,
  "oidc_enabled": true
}

ready is the same DB-reachability signal as /readyz. agents counts raw join-token redemptions; agents_directory is the distinct, smaller count of agents that separately opted into POST /registry/agents’s public searchable directory (see Publish an agent card) — a self-hosted deployment can have real enrolled agents and still show agents_directory: 0 if none of them ever registered.

oidc_enabled (added 2026-08-01) is what actually answers “is /me/* up right now” without grepping boot-time logs: false covers both CT_OIDC_ISSUER being unset and it being set but the boot-time JWKS fetch never finding a usable key — both mean the same thing for /me/*’s current availability. This field exists specifically because that failure mode is real and has recurred live on this deployment more than once: a routine control-plane restart occasionally races Keycloak’s own readiness and comes up with /me/* silently 404ing instead of the expected 401, invisible anywhere except this field (or a manual /me/account probe) until the next restart clears it. If you self-host with SSO and see oidc_enabled: false unexpectedly, a control-plane restart (docker compose restart control-plane) is the known recovery; there’s no self-healing background retry yet.

GET /network-info — no auth.

{"mesh_edge_port":4433,"channel_broker_port":4435,"channel_relay_port":4436}

Note: ports only, not full host:port strings — the host is whatever CT_AGENT_CP_URL points at. The guided setup script combines them itself; if you’re doing this by hand, do the same.

Certificate admission status

GET /agent/acme-admission/:routing_token/:hostname — authenticated by the tunnel’s own routing token in the path (no separate header).

{
  "status": "gruen",
  "may_issue_now": true,
  "assigned_ca": {
    "name": "zerossl",
    "directory_url": "https://acme.zerossl.com/v2/DV90",
    "requires_eab": true,
    "eab_kid": "...",
    "eab_hmac_key_b64url": "..."
  },
  "claim_deadline": null
}

status is one of rot, gelb, gruen. claim_deadline is set only while a Gelb→Grün claim window is open; null once the tunnel is permanently Grün or hasn’t entered the queue.

POST /agent/acme-issuance-complete/:routing_token/:hostname — same path-based auth, no body. 200 on success. Confirmed live: 403 on a bad token.

This is what actually flips a tunnel to Grün — ct-agent certificate calls it once its own Let's Encrypt/ZeroSSL order finishes, but nothing about it is coupled to that specific flow. The control plane doesn't verify a certificate exists; it trusts the routing token and reverts the edge to passthrough. This is the real, previously-undocumented mechanism behind ADR-0003's "strict/air-gapped customers may instead supply their own certificate and key directly" — install your own cert (from any CA) on your origin yourself, then call this endpoint to tell the platform you're ready, and it's genuinely Grün, no ACME order ever run against this deployment.

Enrollment (admin-gated)

These require x-ct-admin-token: <CT_CP_EDGE_ADMIN_TOKEN> — they’re for the operator’s own tooling (e.g. minting tokens for a specific tenant out-of-band), not something a regular user calls. A normal user’s join token comes from the portal’s Install button instead.

POST /enroll/issue {"tenant": "..."}{"token": "..."}

POST /registry/authorize-host/:routing_token/:hostname — no body, 200 on success. Proxies to the edge’s own admin API (loopback-only in production) so a remote pipeline maintainer holding just the shared admin token can self-serve host authorization over the public HTTPS control plane, and — this is the part that matters — records the (routing_token, hostname) pair as owned in the control plane’s own durable registry.

This recording is not cosmetic: POST /agent/acme-issuance-complete above checks exactly this record before promoting a tunnel to Grün. Calling the edge's raw /admin/authorize-host/:token/:host directly (e.g. against a loopback CT_CP_EDGE_ADMIN_URL) authorizes the hostname at the edge just as well, but skips this recording step entirely — Grün promotion then fails forever with a clean, otherwise-unexplained 403 "this token is not the recorded owner of this hostname". Reproduced live, twice, this session. Always prefer this control-plane endpoint over the raw edge one; see Authorize a new pipeline hostname for the full real-world trap and fix.

POST /admin/provision-tunnel {"subject": "...", "name": "...", "hostname": "..."}{"routing_token": "...", "hostname": "..."}

POST /accounts/open no body → {"account": "..."} (mints a fresh account, once per customer), POST /payment/intent {"account": "...", "credits": <n>}{"payment": "..."}, and POST /billing/issue {"account": "...", "price": <n>}{"token": "..."} (a minted routing token). Same x-ct-admin-token gate as the two endpoints above — these are the server-side steps a payment-provider integration runs after a real payment (open the account once, create an intent, then on the provider’s signed webhook confirming payment issue the credit), not something a customer or a customer-facing client calls directly. They also fail closed: on a deployment that hasn’t set CT_CP_EDGE_ADMIN_TOKEN, they’re absent entirely (404), not just unauthenticated, since crediting an account by name with no possession proof would otherwise be an open door. The customer-facing balance paths are the session-authed portal’s POST /portal/account/credits top-up and the OIDC-bearer-authed POST /me/issue — this admin-gated trio is deliberately not one of them.

Portal / OIDC login

GET /portal/login — redirects to Keycloak’s login form (/protocol/openid-connect/auth). Query params: kc_idp_hint (google|github|gitlab, skips straight to that provider), login_hint (pre-fills the email field), register (any value — routes to Keycloak’s registration form instead of login).

POST /me/pipelines {spec} — publish a pipeline spec, owned by the caller’s bearer-token subject. Requires an OIDC bearer token from a real portal login, not an admin token.

Getting a bearer token without a browser

Every /me/* endpoint on this page needs one. A real portal login mints one automatically; scripting against these endpoints needs the same token minted headlessly — standard OAuth2 Resource Owner Password Credentials against Keycloak’s own token endpoint, no CADS-Tunnel-specific API involved:

curl -X POST https://auth.bunsenbrenner.org/realms/ct-demo/protocol/openid-connect/token \
  -H 'content-type: application/x-www-form-urlencoded' \
  -d 'grant_type=password' \
  --data-urlencode 'client_id=admin-cli' \
  --data-urlencode 'username=<your account email>' \
  --data-urlencode 'password=<your password>'

Returns a JSON body with access_token — send it as Authorization: Bearer <token>. Confirmed live: a bad credential against this exact endpoint returns 401, not a routing error. client_id=admin-cli is Keycloak’s built-in public client with direct-access-grants already enabled for this realm, not something CADS-Tunnel had to add. Tokens are short-lived (a Keycloak realm default is minutes) — mint fresh per scripting session rather than caching one.

Getting a token this way is unaffected by the /me/* outage note below — token minting is pure Keycloak, entirely separate from the control plane's own OIDC verifier. A freshly-minted token can still come back 404 when you actually use it against /me/* if that verifier itself is down; the token being valid and the endpoint being reachable are two different things.

Service accounts (M2M credentials)

Owner-scoped, same bearer-token auth as everywhere else on this page. Full walkthrough: Create a service account.

POST /me/service-accounts {"name": "..."} — mint a new client_id/secret pair (up to 50 per account). secret is returned exactly once, here — never again, not even by the GET below.

GET /me/service-accounts — list your service accounts (client_id, name, created_at) — never includes a secret.

POST /me/service-accounts/:client_id/rotate — mint a fresh secret for the same client_id; the old one stops working immediately. 404 if you don’t own client_id (indistinguishable from “doesn’t exist”).

DELETE /me/service-accounts/:client_id — permanently delete the credential. Same owner-scoped 404 as rotate.

Self-service channel registry

The HTTP surface behind ct-agent channel register (see ct-agent CLI commands) and the self-service provisioning flow in Set up an Agent-Fabric channel. Every route below requires an OIDC bearer token, same as /me/pipelines; the owner is always the verified token subject, never a request field, so a caller can only register or manage channels they own.

POST /me/channels {"channel": "<64 hex>", "operator_pubkey": "<64 hex>", "confirm_rekey": <bool, optional>} — register a channel you own. channel is any 32-byte hex id you pick (doesn’t have to be derived — channel_id_for_link or channel_id_for_pipeline_role are just the conventions this platform’s own tooling uses to avoid an out-of-band ID exchange, not a server-enforced requirement). 403 if that channel id is already owned by a different subject.

Re-sending the same operator_pubkey for a channel you already own is an idempotent 200, nothing written. A different operator_pubkey for a channel you already own is refused with 409 Conflict, nothing written, and this exact body:

channel is already registered with a different operator_pubkey; re-send with "confirm_rekey": true to rotate it (every grant signed by the previous operator will stop verifying)

Only "confirm_rekey": true rotates the operator in place — 200, the channel keeps its id and its member rows, an admin audit entry channel_operator_rekeyed is written (actor = your subject, detail = old/new key hex), and every grant signed by the previous operator stops verifying at the broker from that moment on. That’s a deliberate, logged cut-over for the channel’s existing members, never a silent one: before #747 this route was an unconditional upsert, so the mismatch case replaced the operator with no refusal and no trail. Clients that don’t send the field at all (every ct-agent released before the flag exists) get the 409, the fail-safe direction. Fixed in #750, 2026-09-03 — the responses above are taken from the handler and its test suite rather than captured from a live call like the rest of this page.

GET /me/channels — every channel you own (hex ids), sorted. {"channels": [...]}. The missing counterpart to registering — no need to remember ids yourself.

DELETE /me/channels/:channel — deregister a channel you own (deletes members, allow-list, the lot). Owner-scoped like every other route here: a non-owner or non-existent channel gets 403, never a membership/existence leak.

POST /me/channels/:channel/grants/:holder {"grant": "<278 hex>"} — deposit an already-signed member grant for persistent, re-fetchable pickup through that member’s own portal session (GET /portal/channels/:channel/grant) — this server never holds the operator private key, only the signed bytes; a stored grant is useless without the member’s own private holder key. 400 if the grant’s embedded channel/holder ids don’t match the path.

POST /me/channels/:channel/members {"holder": "<64 hex>", "noise_pubkey": "<64 hex>", "noise_attestation": "<128 hex>"} — add a member. noise_attestation is the member’s own ed25519 signature over member_noise_attest_bytes(channel, holder, noise_pubkey) — the control plane verifies it server-side, so an owner can’t seed a forged or un-attested Noise key even for a channel they own. 400 if it doesn’t verify; 403 if you’re not the channel’s owner.

GET /me/channels/:channel/members — list a channel’s members (owner-scoped). {"members": [{"holder": "<64 hex>", "noise_pubkey": "<64 hex>|null"}, ...]}. 403 if you’re not the owner — same posture as every other route here, existence of a channel you don’t own is never leaked via an empty-list response indistinguishable from “no members yet”.

POST /me/channels/:channel/members/:holder/remove — revoke a member, no body. Same 403 if you’re not the owner.

POST /me/rooms {"operator_pubkey": "<64 hex>", "holders": ["<64 hex>", ...]} — register every pairwise channel a full-mesh room needs in one call, instead of deriving and registering C(N,2) channels by hand (ADR-0023’s video-call/multicast follow-up). Each pair’s channel id is the same channel_id_for_link derivation /me/channels itself is built on — this route is a convenience wrapper around the identical registration path, not a new primitive. 400 for fewer than 2 holders, more than 12 holders (C(12,2) = 66 channels, a sanity cap not a real room-size limit), a malformed pubkey, or a duplicate holder. Idempotent and additive — re-posting an overlapping or superset holder list re-registers existing pairs harmlessly and only adds the new ones, the natural way to grow a room. 409 if any derived pair channel is already owned by a different subject — unlike /me/channels above, this route never accepts confirm_rekey (#747): a room re-POST must never silently rotate an existing pair’s operator key. Rotate that one channel via POST /me/channels first, then re-post the room. Response: {"channels": [{"a": "<holder>", "b": "<holder>", "channel": "<channel id>"}, ...]}.

Self-service channel allow-list & claim

The self-service alternative to hand-signing a grant for every new member (the flow above, and Set up an Agent-Fabric channel’s step 4): the owner allow-lists an e-mail once, and anyone who logs into the portal with that verified e-mail can claim their own membership — no grant hex to generate, sign, or hand off out of band. Full walkthrough: Self-serve a channel membership grant.

Owner-scoped management, same bearer-token auth as /me/channels above:

POST /me/channels/:channel/allowlist {"email": "someone@example.com"} — allow-list an e-mail (stored lowercased). 403 if you’re not the channel’s owner.

GET /me/channels/:channel/allowlist — list allow-listed e-mails for a channel you own. {"emails": [...]}.

POST /me/channels/:channel/allowlist/:email/remove — de-list an e-mail, no body. Stops a future claim; does not revoke an already-claimed membership (that’s still POST /me/channels/:channel/members/:holder/remove above — allow-listing and membership are deliberately separate).

Session-cookie-authed (a real portal login, not a bearer token — this is a browser-facing surface):

GET /portal/channels — the logged-in session’s own “Your Channels” view: every channel the session’s verified e-mail is allow-listed for, each with a Pending/Claimed status. Redirects to /portal if not logged in; renders a plain-language empty state (not an error) when the session has no verified e-mail or no invitations at all.

GET /portal/channels/:channel/claim — the claim form for one channel (also linked from the row on /portal/channels).

POST /portal/channels/:channel/claim {"holder": "<64 hex>", "noise_pubkey": "<64 hex>", "noise_attestation": "<128 hex>"} — the JSON API a script can call instead of the HTML form (.../claim-form, url-encoded, same fields). Same noise_attestation verification as the owner-driven /me/channels/:channel/members above (a forged/un-attested key is rejected even though the caller isn’t the owner) — the allow-list only authorizes which e-mail may join, never what key. 403 if the session’s verified e-mail isn’t allow-listed for this channel; 401/redirect if not logged in at all.

Live-verified end to end on 2026-08-01: a real portal account, a real channel registered via POST /me/channels, the account's own e-mail allow-listed via POST /me/channels/:channel/allowlist, the channel showing up as Pending on /portal/channels, a real Ed25519-signed Noise attestation submitted through the claim form, and the status flipping to Claimed afterward — the exact round trip described above, not just read from the handler code. Test channel and account cleaned up afterward.

Owner-minted claim invites (#514)

A second way into the same claim flow above, for when the owner already knows exactly which identity should join (a demo’s waiting room, a bridge handing a participant something concrete) rather than pre-allow-listing an e-mail and waiting: the owner mints a single-use link bound to the joiner’s own holder/noise_pubkey/noise_attestation — the joiner logs in, clicks confirm, and lands with a real membership under their own account. The claim itself stays session-only: nothing here lets the owner (or a bridge) claim on someone else’s behalf.

Owner-scoped, same bearer-token auth as /me/channels above:

POST /me/channels/:channel/claim-invites {"holder": "<64 hex>", "noise_pubkey": "<64 hex>", "noise_attestation": "<128 hex>", "label": "<optional, ≤64 chars>"} — mints a 15-minute, single-use invitation. The attestation is verified at mint time (same bar as /me/channels/:channel/members), so a bad key fails here, not in front of the joiner. 404 for both “not the owner” and “unknown channel” (existence leaks nothing). Response: {"invite": "<token>", "url": "<portal base>/portal/claim?invite=<token>", "expires_at": <unix>}.

Session-cookie-authed:

GET /portal/claim?invite=<token> — shows the channel, label, holder and expiry, with a single confirm button. Nothing is claimed on GET. Not logged in → login round-trip back to the same link. Used/expired → 410, unknown/malformed token → 404.

POST /portal/claim/confirm (form: invite) — burns the invitation, then runs the exact self-service claim POST /portal/channels/:channel/claim runs, under the confirming session’s own subject: allow-lists that session’s verified e-mail under the minting owner (the same write POST /me/channels/:channel/allowlist performs — the membership shows up on both the owner’s console and the joiner’s /portal/channels like any other), then lands the claim. A guarded single-row update means two racing confirms of one link yield one claim and one 410, never two members. Success redirects to /portal/channels with a notice.

Tunnel connection history, uptime, badges & usage (#776/#778/#783)

Session-cookie-authed, owner-scoped exactly like the tunnel management routes elsewhere on this site — an unknown or foreign tunnel id 404s, never 403. All of these are fail-open on the edge: if the edge doesn’t answer, the page explains that rather than rendering zeros. Full walkthrough: Manage your tunnel.

GET /portal/tunnels/:id/uptime — uptime over 24h/7d/30d, longest outage in the last 30 days, 30-day session/byte totals, and the full session table (newest first, capped at 200 rows), plus the badge enable/disable controls below.

POST /portal/tunnels/:id/badge/enable / POST /portal/tunnels/:id/badge/disable — no body. Enabling is idempotent (repeat calls keep the same public id); disabling immediately 404s the old badge URL.

GET /badge/:public_id.svg — no auth, the whole point of a badge being embeddable. 404 for anything but an enabled badge’s exact <64 hex>.svg. Shields-style flat SVG, uptime 7d label: green ≥99%, yellow ≥95%, red below, grey n/a with no history yet. Cacheable (Cache-Control: public, max-age=300); never reveals the tunnel’s hostname, id, or routing token.

GET /portal/usage — every tunnel you own with its 30-day uptime/sessions/bytes and a totals row, one concurrent round of edge calls (a non-answering edge shows n/a for that tunnel, not a failed page). GET /portal/usage.csv — the same table as a downloadable CSV, header plus one quoted row per tunnel, raw numbers.

Dead-man alerts (#777)

Session-cookie-authed, owner-scoped (404 for foreign/unknown, never 403). Full walkthrough: Manage your tunnel.

POST /portal/tunnels/:id/alert webhook_url=<url>&threshold_minutes=<1..10080> (form-encoded) — create or replace. 400 for a bad URL (must be https://, or http:// only to 127.0.0.1/localhost/[::1]) or an out-of-range threshold. A fresh create answers with a secret-once page instead of a redirect; updating an existing alert keeps its secret.

POST /portal/tunnels/:id/alert/test — no body, sends one immediate signed tunnel.test delivery. 429 once the account’s 20-deliveries-per-hour budget is spent.

POST /portal/tunnels/:id/alert/delete — no body.

Webhook contract every delivery follows: Content-Type: application/json, headers X-CT-Timestamp (unix seconds) and X-CT-Signature: sha256=<hex> (HMAC-SHA256 over "<X-CT-Timestamp>.<raw body>", keyed with the alert’s secret — reuse any Stripe-style verifier). Body: {"event": "tunnel.down"|"tunnel.up"|"tunnel.test", "tunnel_id", "name", "since", "threshold_secs", "sent_at"}. Any 2xx acknowledges; otherwise two retries (2s, 8s) inside the same check tick.

Fleet view (#781)

GET /portal/fleet — session required, no owner-id path param (it’s always “every tunnel the caller owns”). One row per tunnel: online/transport/7-day uptime (edge lookups, same as elsewhere), bridge mode + sidecar presence, cached agent version + readiness chips from the last successful bridge/status/bridge/config probe (populated via the existing POST /portal/tunnels/:id/agent-bridge/call route, not a new endpoint), and a version-drift hint when more than one cached version is in use across the fleet. No agent is dialed on page load. Full walkthrough: Manage your tunnel.

Access windows (#779)

Session-cookie-authed, owner-scoped. Full walkthrough: Manage your tunnel.

POST /portal/tunnels/:id/access (form-encoded) — sets an expiry, a weekly schedule, both, or (with rearm=1 alone) re-opens an expired policy for 24 hours while keeping its existing schedule. Pushed to the edge immediately; also re-sent automatically after any successful authorize-host for that tunnel.

POST /portal/tunnels/:id/access/clear — no body, returns the tunnel to unrestricted.

Enforcement is entirely edge-local (no per-request control-plane round trip): outside the window, a Gelb (edge-terminated) visitor gets 503 + Retry-After + a page naming the next change time; a Grün/passthrough connection is closed right after the TLS ClientHello. The owning ct-agent’s own tunnel connection is never affected — only new visitor connections are refused.

Covers only login-gated (Gelb) hostnames — the agent-side ct-agent local-auth link covers Grün/passthrough hostnames the gate never sees (ct-agent#185). Full walkthrough: Manage your tunnel.

POST /portal/tunnels/:id/share-links (form-encoded: ttl = 1h|24h|7d, single_use checkbox, optional label ≤60 chars) — session-cookie-authed, owner-scoped; 400 when “Require login” is off or the tunnel already has 50 active links. Answers with a no-store page showing the URL exactly once; only its SHA-256 is stored.

POST /portal/tunnels/:id/share-links/:link_id/revoke — ends the link and any live session it already granted immediately.

GET /gate/share?host=<hostname>&token=<43-char base64url>[&return=/path] — no session required. 404 for an ungated host; 403 with an explanation page for an expired/already-used/ revoked/wrong-host token; otherwise 303 to https://<host><return> with a ct_gate_session cookie scoped to that one host, valid for the link’s remaining TTL. A single-use token’s URL answers 403 on a second visit; the cookie it already set keeps working until it expires.

Signed forensic receipts (#782)

GET /portal/tunnels/:id/receipts.jsonl?since=<seq> — session-cookie-authed, owner-scoped (404 for foreign/unknown, never 403); 502 with an explanation if the edge can’t supply the export. Body: a header line {"pubkey", "edge_id", "tunnel"} naming the edge’s receipts public key, then one hash-chained, ed25519-signed receipt per line, oldest first. Each receipt covers a session open/close or an hourly byte-count checkpoint — metadata only, never payload content. Full walkthrough: Manage your tunnel.

Verify a downloaded export offline with verify_receipts <file> [--pubkey <64 hex>] [--anchor <64 hex>] (from ct-agent’s agent-tools crate) — --pubkey overrides the key the file’s own header names (useful if you don’t want to trust an untrusted file to name its own verification key); --anchor checks that an export starting mid-chain (after retention pruning, or a since= partial fetch) still links to a specific earlier receipt you already hold. Exit 0 clean, 1 verification failure, 2 usage/file error.

Agent bridges v2 — portal-driven remote control of your own agent

Session-cookie-authed, owner-scoped exactly like the tunnel management routes elsewhere on this site — an unknown or foreign tunnel id 404s, never 403 (“existence leaks nothing”). Full walkthrough: Manage your tunnel.

POST /portal/tunnels/:id/agent-bridge/grant channel_id=<64 hex>&grant_hex=<hex> (form-encoded) — store a grant admitting this deployment’s shared bridge identity into the pasted channel. 400 if the hex doesn’t decode, if the grant’s own encoded channel doesn’t match the pasted channel_id, or if the grant’s holder doesn’t match this deployment’s configured bridge holder pubkey (shown on /portal/agent-bridges). 503 if this deployment hasn’t configured a bridge identity at all (CT_BRIDGE_HOLDER_KEY/CT_BRIDGE_NOISE_KEY unset). The grant’s own signature/expiry aren’t validated here — that happens at actual call time, below.

POST /portal/tunnels/:id/agent-bridge/call tool=<name>&arguments=<JSON, optional> (form-encoded) — dial the tunnel’s channel with its stored grant and invoke one bridge tool (see MCP tools over a channel for what’s callable), rendering the raw JSON result or the dial’s own error text back to the owner. 404 if no grant is stored for this tunnel yet; 503 if this deployment hasn’t configured a bridge identity.

Cross-account channel invitations

How Agent-Fabric channels’ “admitting someone else’s agent” actually works over HTTP. Unlike everything else on this page below “Portal / OIDC login”, these two are public, unauthenticated, and unaffected by the /me/* outage noted below — no bearer token, no admin token. They’re proof-gated instead: only someone holding the right signatures can do anything with them.

POST /channel/invite/challenge — no body. {"challenge": "<hex>"}, a fresh single-use nonce the invitee binds into its redemption signature (defense-in-depth against a captured redemption being replayed, independent of the invitation’s own single-use record).

POST /channel/invite/redeem {"invitation": "<hex>", "redeem_sig": "<128 hex>", "holder": "<64 hex>", "noise_pubkey": "<64 hex>", "noise_attestation": "<128 hex>", "challenge": "<64 hex, optional>"} — redeem an operator-signed invitation into real channel membership. invitation is the operator’s signed grant of entry (hex-encoded SignedChannelInvitation); redeem_sig is the invitee’s own signature proving they accepted and chose this holder key, not just that they possess someone else’s invitation. 404 on an unknown channel, 410 on an expired invitation, 403 on any other verification failure.

Honest gap: there's no ct-agent CLI command to actually issue a SignedChannelInvitation today (only ct_common::channel's library primitives) — the endpoint shapes above are cross-checked directly against the handler code and its request/response structs, but this pass didn't build a standalone signer to click-test a full round-trip the way the direct-address channel connection was. Flagged here rather than presented as verified end to end.
This and every other /me/* endpoint only exist when the control plane's OIDC verifier is configured and found a usable signing key in the realm's JWKS at boot — if either isn't true, the whole /me/* surface is silently absent (a plain 404, not 401), not just unauthorized. If you get a 404 here instead of a login-required response, that's what's happening, not a wrong path. Found live, 2026-08-01 (#328): this isn't only a misconfiguration symptom — the JWKS fetch is a one-shot check at boot with no ongoing retry, so a correctly-configured deployment that raced Keycloak at exactly the wrong moment during its own restart (e.g. Keycloak still warming up) silently loses the entire /me/* surface for the rest of that process's life, with no self-healing — confirmed on this very deployment, which had worked for hours before a routine restart hit this window. A restart of the control plane process (not the whole stack) is the actual fix once this happens; there's currently no way to tell it's happening short of an operator noticing the 404s or checking the boot log for CT_OIDC_ISSUER set but the realm JWKS had no usable RS256 key after retrying.

Pipeline registry

POST /registry/pipelines {owner?, spec} — admin-token gated, upserts a published PipelineSpec (machine-writer path; a human publishing their own pipeline uses POST /me/pipelines above instead).

GET /registry/pipelines — public, no auth. [{"id", "owner"}] for every published pipeline — what Workflow pipelines & the auction model and the landing page’s pipeline registry table both read.

GET /registry/pipelines/:id — public, no auth. The full spec:

{
  "id": "flappy-demo",
  "roles": [
    {"service": "TextGeneration", "units": 1, "tag": "physics", "selection_policy": null},
    {"service": "TextGeneration", "units": 1, "tag": "art", "selection_policy": null},
    {"service": "SafetyCheck", "units": 1, "tag": "safety_check", "selection_policy": null}
  ],
  "operator_pubkey_hex": null,
  "selection_policy": "LowestFloor"
}

selection_policy is the pipeline-wide default (LowestFloor/RoundRobin/LeastCalls); a role can override it individually via its own selection_policy, null here meaning “inherit the pipeline default.” ct-agent channel join-pipeline-role reads this to derive a role’s channel id without needing a pairwise key exchange first — see ct-agent CLI commands.

Agent directory

POST /registry/agents {"holder_pubkey", "card_url", "role_tags"?, "skill_ids"?} — admin-token gated. card_url must be https://.

GET /registry/agents?role=&skill= — public, no auth. Search by exact role/skill token.

GET /impressum, GET /datenschutz, GET /nutzungsbedingungen — real operator facts, not placeholders. GET /llms.txt — the machine-readable onboarding doc, plain text.

Found an error, or something that didn't work as documented? Open an issue →