This document specifies the HTTP/WS API contract that the t3code-relay Caddy module must implement.
It is derived from the upstream T3 Code source (pingdotgg/t3code) by tracing actual code paths.
Where the shape is uncertain or not fully traceable, the entry is marked ASSUMPTION.
The rest of this document maps the full upstream surface for reference. The section immediately below is the authoritative, reduced contract the module actually implements and that the fork's client/server patches must conform to.
In the self-hosted relay we drop Clerk, DPoP, OAuth token-exchange, signed-JWT mint proofs, and managed Cloudflare tunnels. Two secrets replace all of it:
- Client → Relay:
Authorization: Bearer <token>where<token>∈RELAY_TOKENS. - Relay → Server:
X-Relay-Secret: <secret>(fromshared_secret_file), injected by the relay on every proxied request. The forked server treats a validX-Relay-Secretas an authenticated admin session (environmentAuthenticatedAuthLayer,apps/server/src/auth/http.ts).
The module exposes two surfaces on one Caddy instance (one listener, :443):
| Method | Path | Auth | Behaviour |
|---|---|---|---|
| GET | /health |
none | { "ok": true, "service": "relay" } |
| GET | /v1/environments |
Bearer | List environments from SQLite. Each record matches contracts RelayClientEnvironmentRecord exactly: { environmentId, label, endpoint, linkedAt } — environmentId is the T3 server descriptor id from /.well-known/t3/environment when available, while the relay may keep a separate internal devcontainer row key. label is the relay's container-derived name (t3relay.host override or Docker container name), never the server probe label. label and linkedAt are non-empty (the client decodes with Effect Schema). endpoint is context-aware: requests that entered through the embedded tailnet bridge prefer an available Tailscale HTTPS endpoint advertised by the environment descriptor; normal published Caddy ingress returns https://<name>.t3.<domain> (surface B). Per-environment status/platform are read from /status, not this record. |
| POST | /v1/environments/:id/status |
Bearer | Probe the container (GET /.well-known/t3/environment with X-Relay-Secret); return { environmentId, endpoint, status: online|offline, checkedAt, descriptor }. environmentId must match the listed environment id and, when present, descriptor.environmentId. The relay rewrites descriptor.label to the container-derived name for consistent UI display. |
| POST | /v1/environments/:id/connect |
Bearer | Return { environmentId, endpoint, credential, expiresAt }. environmentId must match the listed environment id. The relay mints a short-lived pairing credential from the environment server over Docker networking using X-Relay-Secret; the client then exchanges that credential directly with endpoint. |
| GET | /v1/environments/:id/exposures |
Bearer or X-Relay-Secret |
List non-expired on-demand port exposures for one environment. |
| POST | /v1/environments/:id/exposures |
Bearer or X-Relay-Secret |
Register or refresh an on-demand port exposure. Body: { port, name?, scheme?, ttlSeconds? }. scheme currently supports only http; ttlSeconds defaults to 3600 and is capped at 86400. Returns { environmentId, name, host, url, scheme, port, expiresAt }. |
| DELETE | /v1/environments/:id/exposures/:name |
Bearer or X-Relay-Secret |
Remove one on-demand exposure from an environment. |
| DELETE | /v1/environments/:id |
Bearer | Forget an environment row from SQLite. This is intended for stopped environments that will never return. If the matching devcontainer is still running and discoverable, the next discovery pass will recreate the row. Returns 204 when deleted and 404 for an unknown environment. |
| OPTIONS | * |
none | CORS preflight (headers per the CORS section below). |
POST /v1/client/dpop-token, the OAuth metadata endpoints, link-challenge /
link / unlink, mobile registration, and agent-activity are out of scope —
the fork's client must not call them. If the client still requests OAuth
metadata, the relay MAY return a minimal stub (see ASSUMPTION notes below).
A single request-time handler (no per-container route generation; see
docs/decisions/0003-route-merge-mechanism.md):
- If
Authorization: Bearer <token>matchesRELAY_TOKENS, treat the request as relay-admin traffic and translate it toX-Relay-Secret. Otherwise pass the request through so environment-issued credentials continue to work. - Resolve
Hostto either an environment row (<name>.t3.<domain>) or an on-demand exposure (<name>--<exposure>.t3.<domain>). - Reverse-proxy (HTTP and WebSocket upgrade) to the environment server
port (default 3773) or the registered exposure port,
removing
Authorizationand settingX-Relay-Secretonly for relay-admin traffic. - 404 if the host is unknown, 502/504 if the container is unreachable.
On-demand exposure hostnames intentionally stay one DNS label under
t3.<domain> so the existing *.t3.<domain> wildcard certificate remains
valid. The delimiter is --, for example:
myrepo.t3.example.com -> T3Code server on :3773
myrepo--vite.t3.example.com -> registered preview server on :5173
myrepo--3000.t3.example.com -> registered preview server on :3000
This handler carries the stock server session flow transparently: the client's
calls to <httpBaseUrl>/oauth/token, /api/auth/websocket-ticket, and the
WebSocket /api/rpc?wsTicket=... all traverse surface B and succeed because the
injected X-Relay-Secret short-circuits server auth.
relay.t3.<domain> is matched more specifically than *.t3.<domain>, and
web.t3.<domain> (caddy-docker-proxy label route) likewise — so the wildcard
only ever catches devcontainer hosts. Non-devcontainer label services are
unaffected.
Client (web/desktop)
|
| Authorization: Bearer <relay-secret> (our replacement for Clerk)
v
t3code-relay (Caddy module) <-- implements this API
|
| Authorization: Bearer <environment-credential> or shared-secret probe
v
t3code server (apps/server) running inside devcontainer, port 3773
The relay acts as a control plane. Actual application traffic (WebSocket RPC) uses the endpoint selected for the request context: local/non-tailnet clients stay on the Caddy relay endpoint, while tailnet clients may go directly to an advertised private-network endpoint after obtaining an environment credential.
All relay endpoints (except /health and OAuth metadata) require:
Authorization: Bearer <relay-bearer-token>
In the stock T3 Code cloud, this is a Clerk session or DPoP-bound access token. In our self-hosted relay, we replace this with a simple shared bearer token configured on the relay.
For DPoP-protected operations (connect, status, mobile), the stock client also sends:
DPoP: <dpop-proof-jwt>
ASSUMPTION: For the initial self-hosted implementation we will accept a plain Authorization: Bearer <secret> without DPoP requirements. The client fork must be patched to remove DPoP for these endpoints, or the relay must bypass DPoP verification.
The relay calls the environment server using the environmentCredential it issued when the environment was linked. This is a bearer token:
Authorization: Bearer <environment-credential>
For probe calls (health/mint), the relay sends a JWT proof field in the JSON body signed with the relay's cloudMintPrivateKey (Ed25519). In our self-hosted relay, we replace this with a simpler X-Relay-Secret shared header:
X-Relay-Secret: <configured-secret>
The server must be patched at apps/server/src/auth/http.ts:162 (environmentAuthenticatedAuthLayer) to accept this header as equivalent to a valid admin session.
The relay uses this to discover and health-check environment servers.
Target: Environment server (apps/server) directly
Auth: None required
Port: 3773 (default, DEFAULT_PORT in apps/server/src/config.ts:17)
Response: 200 OK
{
"environmentId": "<opaque-string>",
"label": "<human-readable-label>",
"platform": {
"os": "linux",
"arch": "x64"
},
"serverVersion": "0.0.27",
"capabilities": {
"repositoryIdentity": true
}
}Schema source: packages/contracts/src/environment.ts:28-35 (ExecutionEnvironmentDescriptor)
Use this endpoint to:
- Confirm a server is alive at the expected address
- Read
environmentId(stable identity key for the environment) - Read
label(display name) - Read
serverVersion(for compatibility checks)
Base URL: https://<relay-host>
Auth: None
Response: 200
{ "ok": true, "service": "relay" }Auth: None
Response: 200
{
"issuer": "https://<relay-host>",
"token_endpoint": "https://<relay-host>/v1/client/dpop-token",
"grant_types_supported": ["urn:ietf:params:oauth:grant-type:token-exchange"],
"token_endpoint_auth_methods_supported": ["none"],
"dpop_signing_alg_values_supported": ["ES256"],
"scopes_supported": ["environment:connect", "environment:status", "mobile:registration"]
}Auth: None
Response: 200
{
"resource": "https://<relay-host>",
"authorization_servers": ["https://<relay-host>"],
"scopes_supported": ["environment:connect", "environment:status", "mobile:registration"],
"dpop_bound_access_tokens_required": true,
"dpop_signing_alg_values_supported": ["ES256"]
}ASSUMPTION: For self-hosted relay without DPoP, dpop_bound_access_tokens_required should be false.
In stock T3 Code: exchanges a Clerk bearer token for a DPoP-bound access token.
In self-hosted relay: ASSUMPTION — this endpoint may be bypassed entirely if the client is patched to use a plain bearer token. If implemented, the relay should issue its own access token.
Headers:
DPoP: <dpop-proof-jwt>
Content-Type: application/x-www-form-urlencoded
Body (form-urlencoded):
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange
&subject_token=<clerk-or-relay-bearer-token>
&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt
&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token
&resource=https%3A%2F%2F<relay-host>
&scope=environment%3Aconnect+environment%3Astatus
&client_id=t3-web
Response: 200
{
"access_token": "<dpop-bound-token>",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "DPoP",
"expires_in": 1800,
"scope": "environment:connect environment:status"
}Schema source: packages/contracts/src/relay.ts:633-659 (RelayDpopAccessTokenRequest, RelayDpopAccessTokenResponse)
Auth: Authorization: Bearer <clerk-token> (or relay bearer in self-hosted)
Response: 200
{
"environments": [
{
"environmentId": "<opaque-string>",
"label": "<human-readable>",
"endpoint": {
"httpBaseUrl": "https://<tunnel-or-direct-url>",
"wsBaseUrl": "wss://<tunnel-or-direct-url>",
"providerKind": "cloudflare_tunnel"
},
"linkedAt": "2026-01-01T00:00:00.000Z"
}
]
}Schema source: packages/contracts/src/relay.ts:582-593 (RelayClientEnvironmentRecord, RelayListEnvironmentsResponse)
Self-hosted relay implementation: Enumerate discovered devcontainers. For each running t3code server, include it in the list. The endpoint.httpBaseUrl and wsBaseUrl should be the relay's proxy URL for that container (e.g. https://<relay>/<container-id>/). The providerKind can be t3_relay (a valid value in the upstream schema) or manual.
Used by the server CLI to register with the relay. In self-hosted mode, this flow is replaced by automatic discovery. If implemented:
Issues a challenge for the environment to sign.
Auth: Bearer
Body:
{
"notificationsEnabled": true,
"liveActivitiesEnabled": false,
"managedTunnelsEnabled": true
}Response: 200
{
"challenge": "<random-string>",
"expiresAt": "2026-01-01T00:05:00.000Z"
}Auth: Bearer
Body:
{
"proof": "<environment-signed-JWT>",
"notificationsEnabled": true,
"liveActivitiesEnabled": false,
"managedTunnelsEnabled": true
}Response: 200
{
"ok": true,
"cloudUserId": "<user-id>",
"environmentId": "<env-id>",
"endpoint": {
"httpBaseUrl": "https://...",
"wsBaseUrl": "wss://...",
"providerKind": "cloudflare_tunnel"
},
"endpointRuntime": {
"providerKind": "cloudflare_tunnel",
"connectorToken": "...",
"tunnelId": "...",
"tunnelName": "..."
},
"relayIssuer": "https://<relay-host>",
"environmentCredential": "<bearer-token-for-env-to-use>",
"cloudMintPublicKey": "<ed25519-pem-public-key>"
}Schema source: packages/contracts/src/relay.ts:241-265 (RelayEnvironmentLinkResponse)
Self-hosted relay: The environmentCredential is a token the relay issues that the environment server stores and uses to authenticate its outbound requests back to the relay. The cloudMintPublicKey is the relay's Ed25519 public key used to sign health/mint proofs.
The relay checks whether the environment server is online by calling POST /api/t3-connect/health on the environment server.
Auth: Authorization: DPoP <access-token> + DPoP: <proof>
Path param: environmentId (string)
Body: empty
Response: 200
{
"environmentId": "<env-id>",
"endpoint": {
"httpBaseUrl": "https://...",
"wsBaseUrl": "wss://...",
"providerKind": "cloudflare_tunnel"
},
"status": "online",
"checkedAt": "2026-01-01T00:00:00.000Z",
"descriptor": {
"environmentId": "...",
"label": "...",
"platform": { "os": "linux", "arch": "x64" },
"serverVersion": "0.0.27",
"capabilities": { "repositoryIdentity": true }
}
}Response when offline: same shape but "status": "offline" and optional "error" field.
Schema source: packages/contracts/src/relay.ts:707-715 (RelayEnvironmentStatusResponse)
Self-hosted relay implementation: Instead of the JWT-proof handshake, probe GET /.well-known/t3/environment on the container (using X-Relay-Secret header) to populate descriptor and confirm status: "online".
The descriptor may include advertisedEndpoints. When an endpoint is available,
uses HTTPS, and is identified as Tailscale/private-network reachability, the
relay returns that direct endpoint to clients with provider kind manual.
Otherwise the relay returns the existing relay-proxied t3_relay endpoint.
This is the core broker operation. The client requests a short-lived credential to authenticate directly with the environment server.
Auth: Authorization: DPoP <access-token> + DPoP: <proof>
Path param: environmentId
Body:
{
"clientProofKeyThumbprint": "<JWK-thumbprint>",
"deviceId": "<optional-device-id>"
}Relay internally calls POST /api/auth/pairing-token on the environment server with X-Relay-Secret. The environment server mints a short-lived pairing credential and returns it.
Response: 200
{
"environmentId": "<env-id>",
"endpoint": {
"httpBaseUrl": "https://...",
"wsBaseUrl": "wss://...",
"providerKind": "manual"
},
"credential": "<12-char-pairing-token>",
"expiresAt": "2026-01-01T00:02:00.000Z"
}Schema source: packages/contracts/src/relay.ts:696-702 (RelayEnvironmentConnectResponse)
Self-hosted relay implementation: Instead of the signed JWT mint flow, the relay calls the server's /api/auth/pairing-token endpoint with X-Relay-Secret to generate a short-lived pairing credential. Return the credential to the client.
The client then exchanges the credential directly with the environment server:
POST <httpBaseUrl>/oauth/token
subject_token=<credential>
subject_token_type=urn:ietf:params:oauth:token-type:jwt
requested_token_type=urn:ietf:params:oauth:token-type:access_token
...
Auth: Bearer
Response: 200 { "ok": true }
These are called by the relay on the environment server, not the other way around.
Auth: JWT proof in body (relay-signed)
Body: { "proof": "<relay-signed-JWT>" }
Response: RelayEnvironmentHealthResponse
Self-hosted relay: Replace with X-Relay-Secret header check in the server middleware. Skip JWT proof verification.
Auth: JWT proof in body (relay-signed)
Body: { "proof": "<relay-signed-JWT>" }
Response:
{
"credential": "<12-char-pairing-token>",
"expiresAt": "2026-01-01T00:02:00.000Z",
"proof": "<env-signed-JWT>"
}Self-hosted relay: Replace with X-Relay-Secret header; skip JWT proof verification and response proof.
The environment server calls this to push agent activity state (for mobile push notifications).
Auth: Authorization: Bearer <environment-credential>
Body:
{
"state": {
"environmentId": "...",
"threadId": "...",
"projectTitle": "...",
"threadTitle": "...",
"phase": "running",
"headline": "...",
"modelTitle": "...",
"updatedAt": "...",
"deepLink": "..."
},
"proof": "<environment-signed-JWT>"
}State may be null to clear a previously published state.
Response: 200
{
"ok": true,
"deliveries": []
}Self-hosted relay: Can be stubbed to return { "ok": true, "deliveries": [] } if mobile push is not needed.
These endpoints are for iOS mobile push notifications and are not required for the core relay functionality.
POST /v1/mobile/devices— Register devicePOST /v1/mobile/live-activities— Register live activityDELETE /v1/mobile/devices/:deviceId— Unregister device
1. Client → Relay: GET /v1/environments
← list of environments with endpoint URLs
2. Client → Relay: POST /v1/environments/:environmentId/status (optional)
← online/offline status + descriptor
3. Client → Relay: POST /v1/environments/:environmentId/connect
body: { clientProofKeyThumbprint: "..." }
← { credential: "ABCDEF123456", endpoint: { httpBaseUrl, wsBaseUrl }, expiresAt }
4. Client → Environment Server: POST <httpBaseUrl>/oauth/token
body: subject_token=ABCDEF123456&...
← { access_token: "...", token_type: "Bearer", expires_in: 2592000, scope: "..." }
5. Client → Environment Server: POST <httpBaseUrl>/api/auth/websocket-ticket
Authorization: Bearer <access_token>
← { ticket: "...", expiresAt: "..." }
6. Client → Environment Server: WS <wsBaseUrl>/api/rpc?wsTicket=<ticket>
(all further application traffic is over this WebSocket)
The relay must respond with:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS
Access-Control-Allow-Headers: authorization, b3, traceparent, content-type, dpop
Access-Control-Expose-Headers: traceparent, www-authenticate
Access-Control-Max-Age: 86400
All errors follow a tagged error schema:
{
"_tag": "RelayAuthInvalidError",
"code": "auth_invalid",
"reason": "invalid_bearer",
"traceId": "<trace-id>"
}HTTP status codes:
- 401:
RelayAuthInvalidError,RelayEnvironmentLinkProofExpiredError,RelayAgentActivityPublishProofExpiredError - 400:
RelayEnvironmentLinkProofInvalidError - 403:
RelayEnvironmentConnectNotAuthorizedError - 500:
RelayInternalError,RelayEnvironmentLinkFailedError - 502:
RelayEnvironmentEndpointUnavailableError - 503:
RelayEnvironmentLinkUnavailableError - 504:
RelayEnvironmentEndpointTimedOutError
Schema source: packages/contracts/src/relay.ts:324-487