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

# Preview URLs

> Expose machine HTTP and WebSocket services through signed preview URLs.

Preview URLs expose one machine port through Nullspace ingress. The SDK returns
signed HTTP and WebSocket URLs when edge ingress is active; use those returned
URLs directly instead of constructing them by hand.

For compatibility, the Python SDK still exposes helpers named
`machine.get_url(port)` and `machine.get_websocket_url(port)`. They return
preview URLs. Older docs and scripts may call this surface "public URLs"; the
current product name is Preview URLs.

## Start a server

```python theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
from nullspace import Machine

with Machine.create(template="base", timeout=300) as machine:
    machine.files.write("/workspace/index.html", "hello\n")
    server = machine.commands.run(
        "python3 -m http.server 8080 --bind 0.0.0.0",
        shell=True,
        cwd="/workspace",
        background=True,
    )
    try:
        print(f"Preview URL: {machine.get_url(8080)}")
        input("Open the URL, then press Enter to stop the server and destroy the machine...")
    finally:
        server.kill()
```

## Host information

<CodeGroup>
  ```python Python SDK theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
  info = machine.get_host_info(8080)
  print(info.host)
  print(info.url)
  print(info.websocket_url)
  print(info.access_token_expires_at)
  ```

  ```typescript TypeScript SDK theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
  const info = await machine.getHost(8080);
  console.log(info.host);
  console.log(info.url);
  console.log(info.websocketUrl);
  console.log(info.accessTokenExpiresAt);
  ```

  ```bash CLI theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
  nullspace machine host mch_123 8080 --json
  ```

  ```bash HTTP API theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
  curl -X POST "${NULLSPACE_API_URL}/v1/machines/mch_123/host" \
    -H "Authorization: Bearer ${NULLSPACE_API_KEY}" \
    -H "Content-Type: application/json" \
    -d '{ "port": 8080 }'
  ```
</CodeGroup>

`machine.get_url(port)` returns `info.url` when a signed HTTP URL is present
and otherwise falls back to the bare host with the API scheme. `get_websocket_url`
does the same for WebSocket URLs.

## WebSocket URL for exposed ports

The Python SDK exposes a dedicated `get_websocket_url` helper. The CLI selects
the signed WebSocket URL with `--websocket`, and the HTTP API returns
`websocket_url` from the same host call. The TypeScript SDK does not have a
separate WebSocket-URL helper; read `websocketUrl` from `machine.getHost(port)`
shown above.

<CodeGroup>
  ```python Python SDK theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
  ws_url = machine.get_websocket_url(8080)
  print(ws_url)
  ```

  ```bash CLI theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
  nullspace machine url mch_123 8080 --websocket
  ```

  ```bash HTTP API theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
  curl -X POST "${NULLSPACE_API_URL}/v1/machines/mch_123/host" \
    -H "Authorization: Bearer ${NULLSPACE_API_KEY}" \
    -H "Content-Type: application/json" \
    -d '{ "port": 8080 }'
  # read websocket_url from the response
  ```
</CodeGroup>

Use this for WebSocket servers running inside the machine. Direct HTTP preview
continuation cookies do not authorize WebSocket upgrades; use the signed
`websocket_url` returned by the SDK or CLI. For SSH, use [`SSH Access`](../access/ssh)
instead of preview URL routing. Only older port-22 fallback deployments use SSH
as an exposed WebSocket service.

## Behavior

The returned URL includes a signed edge token. Treat it as a bearer credential
and rotate by requesting a fresh preview URL when needed. After a browser opens
the signed HTTP URL, Nullspace edge can use a scoped HTTP-only continuation
cookie so normal navigation, refreshes, relative links, redirects, and assets
do not need to preserve the query token. The service inside the machine must
bind to `0.0.0.0`, not only `127.0.0.1`.

## Self-hosted ingress

The single-host OSS appliance uses API-compatible ingress, not `apps/edge`.

In localhost/no-domain mode, the appliance leaves
`NULLSPACE_PUBLIC_HOSTNAME` unset. Caddy serves the console at `/console` and
proxies API/WebSocket routes to the local API, but signed public preview
hostnames are not available. Use `machine.get_host(port)` or
`machine.get_host_info(port)` for direct local port mappings in private
operator-only environments.

In owned-domain mode, operators set `NULLSPACE_PUBLIC_HOSTNAME` to a DNS name
they control and configure wildcard DNS so preview subdomains resolve to the
single host. Signed preview URLs use
`https://{PORT}-{MACHINE_ID}.${NULLSPACE_PUBLIC_HOSTNAME}/?edge_token=...` for
HTTP and the matching `wss://` URL for WebSocket. Caddy obtains exact-host
preview certificates on demand through a loopback-only API ask endpoint, then
proxies those hosts to `nullspace-api` while preserving the original `Host` and
forwarded client/proto headers so the API can parse the machine and port without
`apps/edge`.

The launch gate records the difference explicitly. In `--mode localhost`, it
asserts direct `get_host`/`get_host_info` mappings and no signed URL metadata.
In `--mode owned-domain`, it asserts signed HTTP and WebSocket preview URL
metadata and Caddy routing without `apps/edge`.

## Embedding

Direct preview links are not iframe targets. Their browser continuation cookie
is host-scoped and uses `SameSite=Lax`. No SameSite=None direct preview cookie
mode is available for third-party iframe embedding. Customer-run preview
proxies are the supported path for embedding, custom browser sessions, and
custom-domain presentation.

## Warnings and CORS

Nullspace does not add a preview warning or interstitial before serving direct
preview traffic. Treat signed preview URLs as bearer credentials and share them
only with trusted recipients.

Preview CORS and browser response headers come from the machine service or the
customer-run proxy in front of it. Nullspace preview edge does not inject a
separate CORS policy for direct preview links.

## Expiry, inventory, and revocation

Use `machine.create_signed_preview_url(port, expires_in_seconds=...)` or
`nullspace machine preview-url create <id> <port> --expires 15m` when you need a
durable grant with an explicit expiry. Use `machine.list_preview_urls()` or
`nullspace machine preview-url list <id>` to inspect active and recent grants,
and `machine.revoke_preview_url(grant_id)` or
`nullspace machine preview-url revoke <id> <grant-id>` to revoke a grant before
it expires.

Grant inventory is token-redacted. It includes status, expiry, first and last
use, validation use count, HTTP request count, WebSocket connection count,
byte-in and byte-out counters, last error code/time, and disabled time when an
operator has temporarily disabled a grant. Operator audit records use the same
safe identifiers and never store raw preview tokens.

Direct preview URLs use query-token bootstrap auth for HTTP and WebSocket URLs.
Custom preview proxy targets use header-token auth instead, so customer-run
proxies can keep Nullspace bearer credentials out of browser-visible URLs.

Agent service deployments reuse the same ingress path. `nullspace agent url`
and the SDK service URL helper return a dynamic URL only after the service is
ready and `permissions.public_url` is enabled. Edge environments return either
a host-style or path-style URL depending on operator configuration; both forms
can include a short-lived `edge_token` query parameter. Do not store these URLs
in deployment config, logs, or source files. If service permissions disable
public traffic, ingress may also require the `x-nullspace-traffic-access-token`
header documented in [Access control](./access-control).

The Console Preview tab exposes the same workflow for a machine: create a
direct preview link, open it in a new browser tab, copy raw secret values only
after confirmation, create a custom preview proxy target, inspect grant
inventory, revoke grants, and run readiness diagnostics.

## Port policy

Preview URLs support normal application ports in the `1`-`65535` range,
including privileged HTTP ports such as `80` and `443`. Generic preview URLs do
not expose platform-owned guest ports:

| Port          | Use                                                                    |
| ------------- | ---------------------------------------------------------------------- |
| `22`          | SSH access; use [`SSH Access`](../access/ssh) instead of preview URLs. |
| `5900`-`5999` | Desktop/VNC traffic; use the managed desktop viewer.                   |

Host service ports such as the API, host-agent, and edge listener ports are
operator configuration, not guest preview policy. Common app ports such as
`3000` and `8080` remain valid machine preview ports.

## Readiness

Creating or resolving a preview URL confirms that routing exists; it does not
wait for the server process inside the machine to listen. Use the SDK or CLI
wait helpers when startup is asynchronous. The TypeScript SDK does not expose a
preview readiness helper, so use the Python SDK, CLI, or HTTP API:

<CodeGroup>
  ```python Python SDK theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
  readiness = machine.wait_for_preview(8080, timeout_secs=30)
  if not readiness.ready:
      print(readiness.error)
      print(readiness.suggested_action)
  ```

  ```bash CLI theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
  nullspace machine url mch_123 8080 --wait --json
  nullspace machine preview-url create mch_123 8080 --expires 15m --wait --json
  ```

  ```bash HTTP API theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
  curl -X POST "${NULLSPACE_API_URL}/v1/machines/mch_123/ports/8080/readiness" \
    -H "Authorization: Bearer ${NULLSPACE_API_KEY}" \
    -H "Content-Type: application/json" \
    -d '{ "kind": "tcp", "timeout_secs": 30, "interval_ms": 500 }'
  ```
</CodeGroup>

If readiness fails, confirm the service is running on the requested port and
binds to `0.0.0.0` inside the machine rather than only `127.0.0.1`.

Use Custom Preview Proxy when you need your own domain, app session checks, or
proxy middleware in front of a machine preview. That flow returns a marker-only
upstream URL and a header token for your proxy to send to Nullspace edge, so
browser-visible URLs do not contain Nullspace bearer credentials. A managed
custom preview domain workflow is not part of the current launch.

If the machine was created with `on_timeout="pause", auto_resume=True`, HTTP
and WebSocket requests to signed preview URLs can wake a paused machine. When
auto-resume is disabled or the wake cannot finish in time, callers receive
`503 Service Unavailable` with `Retry-After: 5` and JSON fields for `code`,
`retryable`, and `suggested_action`.

## Troubleshooting

| Code or symptom                                                                      | What to do                                                                                                                                                                                                                     |
| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `unsupported_preview_port`                                                           | Use an application port in `1`-`65535` except `22` and `5900`-`5999`.                                                                                                                                                          |
| `preview_service_not_ready`                                                          | Confirm the server is running on that port and binding to `0.0.0.0`; use `machine.wait_for_preview(port)` while it starts.                                                                                                     |
| `edge_token_missing`                                                                 | Use the full signed URL returned by `machine.get_url(port)` or request a fresh signed preview URL.                                                                                                                             |
| `edge_token_expired`, `preview_grant_revoked`                                        | Request a fresh preview URL before retrying the direct browser request.                                                                                                                                                        |
| `preview_grant_disabled`, `preview_proxy_token_disabled`, `preview_machine_disabled` | Preview access was disabled by an operator control. Use another route or ask an operator to re-enable preview traffic before retrying.                                                                                         |
| `preview_proxy_token_missing`                                                        | Forward the `x-nullspace-preview-proxy-token` header returned by `create_preview_proxy_target`.                                                                                                                                |
| `preview_proxy_token_expired`, `preview_proxy_token_revoked`                         | Create a fresh preview proxy target and update the proxy header token.                                                                                                                                                         |
| `rate_limit_exceeded`                                                                | Wait for the `Retry-After` window before retrying. Repeated 429s usually mean too many requests from the same preview grant, port, transport, and client address.                                                              |
| `edge_runtime_host_unreachable`                                                      | Check readiness with `machine.wait_for_preview(port)`, `nullspace machine preview-url create <id> <port> --wait`, or `nullspace machine url <id> <port> --wait`, then retry.                                                   |
| `machine_paused_auto_resume_disabled`                                                | Resume the machine manually or recreate it with `on_timeout="pause", auto_resume=True`.                                                                                                                                        |
| Localhost self-host returns no signed URL metadata                                   | Expected when `NULLSPACE_PUBLIC_HOSTNAME` is unset. Use direct `get_host_info()` mappings or configure owned-domain mode.                                                                                                      |
| Owned-domain self-host returns direct mappings                                       | Confirm `NULLSPACE_PUBLIC_HOSTNAME`, `NULLSPACE_EDGE_PUBLIC_BASE_URL`, `NULLSPACE_EDGE_ACCESS_TOKEN_SIGNING_KEY`, and `NULLSPACE_PUBLIC_INGRESS_MODE=api_compat`, then rerun `nullspace-host launch-gate --mode owned-domain`. |
| Auto-resume timeout or control-plane unreachable                                     | Retry after `Retry-After`; if it repeats, check runtime capacity and machine startup logs.                                                                                                                                     |

## Create-time network controls

Machine create accepts a `network` dictionary for deployment-supported network
policy controls and an `internet_access` flag for outbound internet access:

```python theme={"theme":{"light":"one-light","dark":"one-dark-pro"}}
machine = Machine.create(
    template="base",
    internet_access=True,
    network={
        "allow_public_traffic": True,
        "mask_request_host": "localhost:${PORT}",
        "deny_out": ["0.0.0.0/0"],
        "allow_out": ["10.0.0.0/8"],
    },
)
```

Use [`Access control`](./access-control) for private preview URLs, traffic
tokens, outbound network policy, and `Host` header masking.
Use [`Token model`](./token-model) for the difference between API keys, edge
preview tokens, preview continuation cookies, proxy tokens, and private traffic
tokens.

## Related

* [Port forwarding example](../examples/port-forwarding)
* [Custom preview proxy](./custom-preview-proxy)
* [Token model](./token-model)
* [Access control](./access-control)
* [WebSockets](./websockets)
* [Auto-resume](../machines/auto-resume)
* [Share a machine URL](../guides/share-machine-url)
