Skip to main content
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 URLhttps://<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

MethodEndpointPurpose
ANY/exposed/{token}Public capability URL — reverse-proxies to the in-container port
POST/api/v1/internal/port-exposeInternal: sidecar requests a new expose
GET/api/v1/crews/{crewId}/port-exposeList a crew’s port-exposes
POST/api/v1/crews/{crewId}/port-expose/{id}/revokeRevoke 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:
StatusCondition
404Token unknown or revoked. (Same shape so existence isn’t leaked.)
410Token expired (gone (expired)).
426Upgrade: websocket request (websocket not supported).
502Upstream (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
}
FieldTypeDescription
portintegerPort number (1-65535) inside the crew container.
descriptionstringOperator-facing name; surfaced in the audit list. Max 200 chars.
container_idstringContainer to target. Required (the sidecar supplies it).
chat_idstringOptional — associates the expose with a chat session for UI surfacing.
ttl_secondsintegerLifetime 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"
  }
]
FieldTypeDescription
statusstringACTIVE, REVOKED, or EXPIRED.
revoked_atstring?Present only on revoked rows (RFC3339).
revoked_reasonstring?Present only on revoked rows, if a reason was supplied.
descriptionstring?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": "revoked" }
StatusCondition
403Caller below MANAGER.
409Expose 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.