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

# Preview URLs

> Reach applications running inside a workspace through tokenized preview URLs

Preview URLs let you open an application running inside a workspace from your browser. When a server starts on a port, Aether's gateway proxies public traffic to that port inside the workspace VM.

## URL Format

A preview URL is built from three parts in the subdomain:

```
https://{port}-{workspaceIdPrefix}-{token}.preview.runaether.dev
```

* **`port`** — the port the server listens on inside the workspace (1–65535)
* **`workspaceIdPrefix`** — the first 8 hex characters of the **workspace** ID
* **`token`** — the workspace preview token, exactly 32 `[a-z0-9]` characters

For example, a Vite dev server on port 5173 in a workspace whose ID starts with `abc12345`:

```
https://5173-abc12345-1234567890abcdef1234567890abcdef.preview.runaether.dev
```

<Warning>
  All three segments are required. There is no token-less form — a URL without a valid 32-character token is rejected. The prefix is the workspace ID, not the project ID.
</Warning>

## Getting a Preview URL

The web app shows preview URLs automatically when it detects an open port. Each port in the workspace's **Preview** popover has a **Copy link** button (and, on devices with a share sheet, a **Share** button), and the in-app preview panel's address bar has the same copy control. To build a URL from the CLI:

```bash theme={"dark"}
aether workspace preview-url <workspace-id> --port <port>
```

This connects to the workspace, reads its preview token, and prints the full URL.

Preview links are made to be shared: they work in any browser with no Aether login, so you can paste one into a pull request, drop it in Slack, or open it on your phone. **Anyone with the link can open the preview while the workspace is running** — treat it like a password. The token is stable for the workspace's lifetime, so a shared link keeps working across reconnects and restarts.

## Authenticated previews

A repository can declare a per-port [`auth_command`](/guides/environment-json#previews) that mints a development session inside the workspace. There are two ways to apply it.

### Zero-click from a plain preview link

Set `auto: true` beside `auth_command`. On the first visit, the ordinary preview URL—including the plain link in a pull-request preview comment—starts the hook and applies its result, so a hook that navigates or reloads lands the visitor authenticated without a decorated URL or an Aether login. The request is asynchronous and application initialization can continue before its result arrives; startup-only clients that return `local_storage` must also return `reload: true`.

The preview runtime records its attempt in `sessionStorage` before it calls the hook. It therefore tries silently at most once per tab session at that preview origin: if the hook, network, response, or session application fails, the visitor stays on the plain preview and later reloads in the same tab do not retry. During a mixed-fleet rollout, older workspaces simply do not bootstrap; their plain previews continue to work unchanged.

### On demand with `--authed`

To run the hook explicitly from the CLI:

```bash theme={"dark"}
aether workspace preview-url <workspace-id> --port 5173 --authed
```

The CLI connects to the workspace so the hook receives every currently discovered sibling port, calls the authenticated Aether API, and prints a bootstrap URL when the hook returns the `url` shape. Open that URL to land in the application with the session the repository minted.

For a `local_storage` result, the CLI prints a clear explanation to stderr and the complete structured JSON response to stdout. This keeps the result script-consumable instead of dropping entries that require client-side application. Pass `--json` to suppress the explanation and emit only JSON.

In the web app, open a workspace preview and select **Open logged-in preview** (the key icon) in the preview toolbar. Aether mints on each click and opens a returned bootstrap URL in a new tab. If the hook returns `local_storage`, the web app explains that the current flow cannot apply it across origins and directs you to the CLI JSON instead.

<Warning>
  A workspace created before authenticated previews shipped does not have the internal receiver route. The API and CLI report **workspace predates preview auth; restart it**. Restart the workspace to pick up the current workspace-service image; Aether never hides this as a generic server error.
</Warning>

Authenticated links require an authenticated Aether user who can read the workspace's owning organization. They are never added to the sticky pull-request preview comment or any other GitHub-bound surface; PR comments continue to contain only ordinary preview links.

### Supabase template

The Aether repository's [`scripts/preview-auth.ts`](https://github.com/Aether-Runtime/aether/blob/main/scripts/preview-auth.ts) is the canonical Supabase example. It strictly parses the hook input, uses the Supabase password grant through the supplied or canonically derived `54321` sibling preview, validates the bounded response, and emits a `5173` URL fragment that `supabase-js` consumes:

```json .aether/environment.json theme={"dark"}
{
  "terminals": [
    { "name": "Web", "command": "bun run dev", "port": 5173 }
  ],
  "previews": {
    "5173": {
      "auth_command": "bun scripts/preview-auth.ts",
      "auto": true
    }
  }
}
```

The session-minting core of that maintained template is:

```ts scripts/preview-auth.ts theme={"dark"}
const input = parseInput(JSON.parse(await Bun.stdin.text()));
const authOrigin =
  input.sibling_origins["54321"] ?? deriveSiblingOrigin(input.preview_origin, 54321);
const session = await passwordGrant(authOrigin);

process.stdout.write(
  `${JSON.stringify({
    url: loginUrl(input.preview_origin, session),
  })}\n`
);
```

The fallback derives the `54321` sibling by replacing only the port prefix of a canonical preview origin. This covers services that are reachable through the preview gateway but are not declared or discovered when the hook runs. Copy the complete canonical script rather than the excerpt: its strict origin checks, sibling-origin derivation, output bounds, and password-grant response validation are part of the security boundary. Replace only the seeded development credentials and publishable key for your local Supabase stack.

## Sleeping Workspaces

Idle workspaces suspend to save resources, but preview links stay valid. Opening a link to a suspended workspace lands on a **start page**; once the workspace is ready, the page redirects to the preview automatically. Actively using a preview also keeps its workspace awake — preview traffic defers idle suspension in 15-minute windows (up to a hard ceiling, so an abandoned tab can't keep a VM alive forever).

**Waking a workspace requires its owner.** Anonymous access works only while the workspace is running: the start page asks visitors to sign in, and the start action is scoped to the workspace's owner — someone you shared a link with cannot wake your workspace. If the workspace is already starting when the page loads, no click is needed; the page waits and redirects on its own.

## Preview Links on Pull Requests

Declare a `port` on a terminal in [`.aether/environment.json`](/guides/environment-json#terminals) and Aether maintains a sticky **Preview** comment on the task's pull request:

```json theme={"dark"}
{
  "terminals": [
    { "name": "Web", "command": "npm run dev", "port": 3000 },
    { "name": "API", "command": "npm run api", "port": 8080 }
  ]
}
```

The comment lists each declared service as `name → url` and is edited in place as the task progresses — one comment per task, never a stream of new ones. Removing the declarations clears it. Reviewers can click straight through to the running app while the workspace is awake; if it has suspended, the link lands on the start page, and [waking it is up to the workspace's owner](#sleeping-workspaces).

The declared port is the contract: the link is published whether or not the service is currently listening, the same way deploy tooling posts your deploy URL regardless of your app's health. A link to a declared port with nothing listening yet returns the gateway's connect error until the service is up.

## How Routing Works

Previews are served by the gateway proxy, separate from the main API. For each request it:

1. **Parses the subdomain** into port, workspace ID prefix, and token. A malformed subdomain returns `400`.
2. **Looks up a ready workspace** by its ID prefix. On a hit, the **token is validated** with a constant-time comparison — a mismatch returns `403` — and the request is **proxied** to the workspace's private IP on the requested port. Preview traffic also records activity, which keeps the workspace awake.
3. **If no ready workspace matches**, the gateway checks whether the workspace exists at all (matching both prefix and token). If it doesn't, the request returns `404`.
4. **If the workspace exists but isn't ready** (suspended or starting), a browser *navigation* is redirected (`302`) to the start page — where [the workspace's owner can wake it](#sleeping-workspaces); non-navigation requests (assets, XHR, a reloading HMR client) return `503` instead of an HTML page.

```mermaid theme={"dark"}
flowchart TD
    A[Browser request] --> B[Parse subdomain]
    B --> C{Ready workspace<br/>for prefix?}
    C -- yes --> F{Token valid?}
    F -- no --> G[403]
    F -- yes --> H["Record activity (keep-alive)<br/>Proxy to private IP:{port}"]
    C -- no --> I{Workspace exists?<br/>prefix + token}
    I -- no --> J[404]
    I -- yes --> K{Browser navigation?}
    K -- yes --> L[302 → start page]
    K -- no --> M[503]
```

## Port Discovery

Aether watches for listening ports inside the workspace and emits open/close events to the web app, so a preview becomes reachable as soon as a server starts — no configuration needed. See the [ports WebSocket channel](/api-reference/websocket/ports).

## Host Header Rewriting

Before forwarding, the gateway rewrites the `Host` header to `localhost:{port}`. Dev servers that validate the host (Vite's default config, for example) accept the request without extra configuration.

## WebSocket Support

WebSocket upgrade requests are proxied through to the workspace, so:

* **Hot Module Replacement (HMR)** works through the proxy for Vite, Next.js, and webpack dev servers
* **WebSocket-based apps** (chat, live dashboards) work without modification

## CORS Headers

The gateway adds CORS headers to preview responses, which enables embedding previews in iframes and making cross-port requests between previews (a frontend preview calling a backend preview on another port).

## Preview Runtime

The gateway serves a small JavaScript runtime at `/_aether/preview-runtime.js` and injects it into proxied HTML responses. To keep injection deterministic, HTML responses are proxied uncompressed.

## Troubleshooting

| Issue | Cause | Fix |
| - | - | - |
| `400 Invalid preview URL format` | Malformed subdomain or missing token | Use the full `{port}-{prefix}-{token}` form; get it with `aether workspace preview-url` or the Copy button in the web app |
| `403 Unauthorized` | Wrong preview token for a running workspace | Re-copy the URL from the web app or CLI |
| `404 Not Found` | No workspace matches the URL's prefix and token (mistyped, or the workspace was deleted) | Re-copy the URL; if the workspace was deleted, the link is permanently dead |
| Redirected to a start page | The workspace is suspended | Sign in as the workspace's owner and click **Start** — the page returns you to the preview once it's ready. If the workspace is already starting, no click is needed. Non-owners must ask the owner to wake it |
| `503 Workspace is not ready` | An asset/XHR request hit a suspended workspace | Open the preview URL directly in the browser to reach the start page |
| `502 Bad Gateway` | The workspace is running but no server is listening on that port (service still starting, crashed, or the declared port doesn't match) | Start the server, or make the `port` in `environment.json` match what the command actually serves |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.