When an agent runs npm run dev or starts a service inside its container, the operator wants a clickable URL to open it. Port expose creates a capability URL — https://<host>/exposed/<token> — that reverse-proxies to the in-container port. The token is the auth: anyone with the URL gets through, anyone without it gets a 404. Exposes are created by the sidecar on the agent’s behalf, then listed and revoked through the user-facing crew endpoints.
This is the right shape for ephemeral dev URLs: short-lived, easy to share with a teammate via Slack, no JWT plumbing needed. For long-lived or sensitive surfaces, do not use this — set up a proper authenticating proxy.
Implementation: internal/api/port_expose_handler.go. Capability URLs hit Go 1.22+ ServeMux with /exposed/{token} and /exposed/{token}/ (trailing slash and bare-token forms both routed to the same handler).
Endpoints
| Method | Endpoint | Purpose |
|---|
| ANY | /exposed/{token} | Public capability URL — reverse-proxies to the in-container port |
| POST | /api/v1/internal/port-expose | Internal: sidecar requests a new expose |
| GET | /api/v1/crews/{crewId}/port-expose | List a crew’s port-exposes |
| POST | /api/v1/crews/{crewId}/port-expose/{id}/revoke | Revoke an expose |
Capability URL — reverse proxy
ANY /exposed/{token}
ANY /exposed/{token}/
No auth header. The token IS the auth; presenting a valid, non-revoked, non-expired token yields proxied traffic to the registered in-container port. All HTTP verbs are forwarded (GET / POST / PUT / DELETE / PATCH / HEAD / OPTIONS) so static sites and REST APIs work.
WebSocket upgrades are not supported. A request carrying Upgrade: websocket is rejected with 426 Upgrade Required (websocket not supported); the exposed-port proxy only handles plain HTTP request/response traffic.
Errors:
| Status | Condition |
|---|
| 404 | Token unknown or revoked. (Same shape so existence isn’t leaked.) |
| 410 | Token expired (gone (expired)). |
| 426 | Upgrade: websocket request (websocket not supported). |
| 502 | Upstream (in-container) port not reachable, or any reverse-proxy error (including timeout). |
There is no rate limit on the capability URL — it forwards as fast as the underlying TCP connection allows.
Sidecar request
How a new capability URL is minted — the agent never calls this directly.
POST /api/v1/internal/port-expose
Internal only — X-Internal-Token required. The agent asks the sidecar (e.g. via a tool call), the sidecar asks the server. Agents cannot reach this endpoint directly.
Request body:
{
"port": 3000,
"description": "next-dev",
"ttl_seconds": 3600
}
| Field | Type | Description |
|---|
port | integer | Port number (1-65535) inside the crew container. |
description | string | Operator-facing name; surfaced in the audit list. Max 200 chars. |
container_id | string | Container to target. Required (the sidecar supplies it). |
chat_id | string | Optional — associates the expose with a chat session for UI surfacing. |
ttl_seconds | integer | Lifetime in seconds. Server clamps to a max of 24 hours. |
Response: 201 Created
{
"id": "pe_a1b2",
"token": "tk_…",
"url": "https://crewship.example.com/exposed/tk_…",
"expires_at": "2026-04-30T15:42:18Z"
}
The sidecar surfaces the URL back to the agent, which can include it in its tool result. The operator sees it in the chat and clicks through.
User-facing audit + revocation
List
GET /api/v1/crews/{crewId}/port-expose
Lists every active port-expose for a crew. Workspace-scoped.
By default only ACTIVE exposes are returned. Filter with ?status=active|revoked|expired|all.
Response: 200 OK — a bare JSON array (not wrapped in an exposes object).
[
{
"id": "pe_a1b2",
"agent_id": "agt_viktor",
"agent_slug": "viktor",
"container_port": 3000,
"description": "next-dev",
"status": "ACTIVE",
"created_at": "2026-04-30T11:42:18Z",
"expires_at": "2026-04-30T15:42:18Z"
}
]
| Field | Type | Description |
|---|
status | string | ACTIVE, REVOKED, or EXPIRED. |
revoked_at | string? | Present only on revoked rows (RFC3339). |
revoked_reason | string? | Present only on revoked rows, if a reason was supplied. |
description | string? | Operator-facing label set at creation. Omitted when empty. |
The token (and thus the public url) is intentionally not included — only the issuing agent receives the capability URL.
Revoke
POST /api/v1/crews/{crewId}/port-expose/{id}/revoke
Marks the expose as revoked; the next request to its capability URL returns 404. Requires MANAGER+ (the create permission). Not idempotent — a second revoke of the same expose returns 409 Conflict because the conditional update only matches ACTIVE/PENDING rows.
Request body (optional):
{ "reason": "debugging finished" }
reason is optional (max 500 chars) and recorded on the audit row.
Response: 200 OK
| Status | Condition |
|---|
| 403 | Caller below MANAGER. |
| 409 | Expose not found, or already revoked/expired. |
Tenancy and policy
crewId is validated via crewBelongsToWorkspace.
- An
AllowAllPolicy runs on every capability-URL request (currently always returns allow). Custom policies can be wired by replacing the policy object — useful for restricting expose to specific IP ranges or to authenticated browser sessions.
- The capability URL itself has no workspace context (it’s a public URL). The token-to-port lookup happens server-side and is workspace-isolated by construction.
Security notes
- Tokens are 256 bits of
crypto/rand. Brute-forcing a valid token via the 404 oracle is not feasible.
- Tokens land in URL paths, which means they end up in HTTP server logs and browser history. Do not paste a capability URL into a public chat or screenshot it without thinking.
- TLS termination is the operator’s responsibility — Crewship does not currently terminate HTTPS for
/exposed/. Run behind nginx / Caddy / a cloud load balancer that does.
- Revocation is fast but not transactional with in-flight requests. A request that is mid-stream when revoke fires completes; subsequent requests fail.