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.
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.
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.
/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.
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.
Time-boxed share links (#780)
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.
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.
/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.
Legal / static
GET /impressum, GET /datenschutz, GET /nutzungsbedingungen — real operator facts, not
placeholders. GET /llms.txt — the machine-readable onboarding doc, plain text.