# Ports and previews (/docs/environments/ports-and-previews)

Reach a dev server inside an environment through a preview URL or a local port forward, and decide who may open it.

Every port that listens inside an environment is reported by the daemon every 15 seconds. Nothing is reachable from outside until you **publish** it with a visibility; then it gets a preview URL on `preview.codai.ro` and can be forwarded to your machine. All traffic travels over the daemon's outbound WebSocket — the environment never opens an inbound port.

## Preview URLs [#preview-urls]

```text
https://{port}-{slug}.preview.codai.ro/
        3000-brivio-k3xq.preview.codai.ro
```

`slug` is the environment's globally unique slug (3–40 lowercase letters, digits or dashes). The relay proxies HTTP/1.1 to `127.0.0.1:{port}` inside the machine, preserves `Host`, adds `X-Forwarded-For/Proto/Host`, and passes the response through unchanged (cookies, CSP, caching) plus an `X-Codai-Environment` header.

WebSockets work, so hot reload works: Vite needs `server.allowedHosts: ['.preview.codai.ro']`; Next.js accepts the forwarded host as is. An upstream that itself speaks HTTPS (`{port}s-{slug}`) is not supported yet and answers `501 https_upstream_not_supported`.

## Visibility [#visibility]

| Visibility | Who may open the port |
| --- | --- |
| `owner` | The environment owner only. **This is the default for any port you have not published.** |
| `org` | The owner and members of the environment's org. |
| `authenticated` | The owner and every member of **this environment** (any role) — not every codai user. |
| `public` | Anyone, without signing in. |

Developers may publish up to `authenticated`; `public` needs `maintainer` or higher. Every change is recorded as a `port_visibility_changed` event.

<Tabs items={['Desktop', 'CLI', 'API']}>
<Tab value="Desktop">

Environments view → the environment's ⋯ menu → **Ports**. Each listening port shows its process and a visibility dropdown (*Only me*, *Organisation*, *Signed-in users*, *Public*), plus **Copy preview URL** and **Open preview**.

</Tab>
<Tab value="CLI">

```bash
codaid env ports brivio-k3xq                                   # listening + published ports
codaid env ports set brivio-k3xq 3000 --visibility org --label web
```

</Tab>
<Tab value="API">

```bash
curl -X PUT https://ai.codai.ro/v1/environments/$ENV_ID/ports/3000 \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{ "visibility": "org", "label": "web", "protocol": "http" }'
```

`201` for a new row, `200` when updating; `DELETE …/ports/3000` unpublishes (the port falls back to `owner`).

</Tab>
</Tabs>

The hub does not have a port panel yet; use the desktop, the CLI or the API.

## Opening a non-public preview [#opening-a-non-public-preview]

A browser visiting a non-public preview must prove who it is. The relay accepts, in order:

1. **A preview token** in `?token=` or the `X-Preview-Token` header — bound to one environment and one port, valid 60 s by default (up to 24 h). It grants what the person who minted it could see.
2. **The `codai_preview` cookie** on `.preview.codai.ro` (12 h) — it carries only your identity; visibility is checked again on every request.
3. **`Authorization: Bearer codai_…`** — for scripts and native clients.

The simplest route is a token link:

```bash
codaid env preview brivio-k3xq 3000 --ttl 600
# https://3000-brivio-k3xq.preview.codai.ro/?token=codai_pv1.…
```

or directly against the relay:

```bash
curl -X POST https://relay.codai.ro/v1/environments/$ENV_ID/preview-token \
  -H "Authorization: Bearer $CODAI_API_KEY" -H "Content-Type: application/json" \
  -d '{ "port": 3000, "ttl_s": 600 }'
```

```json
{ "token": "codai_pv1.…", "expires_at": "2026-09-25T10:10:00.000Z", "ttl_s": 600, "port": 3000,
  "host": "3000-brivio-k3xq.preview.codai.ro", "url": "https://3000-brivio-k3xq.preview.codai.ro/?token=codai_pv1.…" }
```

To set the cookie instead, a client calls `GET https://preview.codai.ro/v1/environments/{id}/preview-cookie` with a bearer key (optionally `?redirect=` to a preview URL on the same domain for a `302`). Only the owner and members can mint cookies or tokens; others get `404`.

A visitor without credentials gets JSON, not a login redirect:

| Status | `code` | Meaning |
| --- | --- | --- |
| `401` | `preview_auth_required` | Non-public port and no valid token, cookie or key. |
| `403` | `preview_forbidden` | Signed in, but the visibility excludes you. |
| `404` | `environment_not_found` | No environment with that slug (a new slug can take up to 30 s to resolve). |
| `404` | `bad_preview_host` | The hostname does not match `{port}-{slug}.preview.codai.ro`. |
| `503` | `environment_offline` | The daemon is not connected (`Retry-After: 5`). |
| `503` | `env_on_other_instance` | The daemon is on another relay instance (`Retry-After: 2`); retry. |
| `429` | `max_streams` | More than 256 open streams on this environment. |
| `502` | `upstream_error` | Nothing answered on the port — usually the server is not listening yet. |

## Forward a port to your machine [#forward-a-port-to-your-machine]

```bash
codaid env forward brivio-k3xq 5432 --local 15432
# 127.0.0.1:15432 → 5432 in the environment
psql "postgres://app@127.0.0.1:15432/app"
```

Each local TCP connection becomes one stream through the relay, subject to the same visibility check (a refused port closes with `port_forbidden`). Plain TCP works — databases, Redis, anything. The desktop app has no forward UI yet.

## Limits [#limits]

| Limit | Value |
| --- | --- |
| Open streams per environment | 256 |
| Unsent data per stream | 256 KB, then the stream closes with `backpressure` |
| Idle TCP/HTTP stream | 10 min (terminals never idle out) |
| Longest single connection | 1 hour — the relay's request timeout; clients reconnect |
| Daemon drop | open streams close 30 s after the daemon disconnects; they are not resumed |

Tailscale as an optional direct path is planned; today all traffic goes through the relay.
