> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crewship.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Port Expose

> Capability URLs for exposing in-container ports to the operator's browser without modifying the agent's environment.

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}`](#capability-url-reverse-proxy)         | Public capability URL — reverse-proxies to the in-container port |
| POST   | [`/api/v1/internal/port-expose`](#sidecar-request)          | Internal: sidecar requests a new expose                          |
| GET    | [`/api/v1/crews/{crewId}/port-expose`](#list)               | List a crew's port-exposes                                       |
| POST   | [`/api/v1/crews/{crewId}/port-expose/{id}/revoke`](#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
```

<Note>
  **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.
</Note>

**Request body:**

```json theme={null}
{
  "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`

```json theme={null}
{
  "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).

```json theme={null}
[
  {
    "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):**

```json theme={null}
{ "reason": "debugging finished" }
```

`reason` is optional (max 500 chars) and recorded on the audit row.

**Response:** `200 OK`

```json theme={null}
{ "status": "revoked" }
```

| 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.

## Related

* [Devcontainers — Container actuals](/guides/devcontainers#container-actuals) — pairs with port expose for "this agent installed Node and started a server".
* [Architecture — Sidecar Proxy](/architecture#sidecar-proxy) — the surrounding sidecar surface.
