Exposed ports#
An agent that serves a chat UI, a dashboard, or an HTTP API needs a way for people to reach it. An exposed port publishes one container port at a stable URL derived from the sandbox's slug.
curl -fsS -X POST \
-H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
-H "Content-Type: application/json" \
https://agents.example.com/v1/projects/$PROJECT_ID/sandboxes \
-d '{
"image": "ghcr.io/acme/agent:1.2",
"command": "./start.sh",
"cpu": "500m", "memory": "2Gi",
"exposedPorts": [{"name": "web", "port": 8080, "auth": "private"}]
}'
Create a sandbox in production from ghcr.io/acme/agent:1.2 running
"./start.sh" with 500m CPU and 2Gi memory. Publish port 8080 as
"web" with private auth, and give me the URL.
nexusctl sandbox create --project production \
--image ghcr.io/acme/agent:1.2 \
--command "./start.sh" \
--cpu 500m --memory 2Gi \
--port name=web,port=8080,auth=private
Or in a spec file:
exposedPorts:
- name: web
port: 8080
auth: private
A sandbox publishes at most four ports, and auth is decided per port. So one agent can serve an authenticated dashboard and an unauthenticated webhook receiver from the same container:
exposedPorts:
- name: web
port: 8080
auth: private
- name: hooks
port: 9000
auth: public
The URL#
Each port answers on its own host, built from the port name and the sandbox slug:
https://<port-name>--<sandbox-slug>.<sandboxIngress.host>
The first entry in exposedPorts also answers on the short host, and that is the URL the platform reports for it:
https://<sandbox-slug>.<sandboxIngress.host>
The slug is assigned at creation and never changes, so a URL is stable across restarts, image upgrades, and rollbacks. The port name is part of the host, so a rename changes the URL.
The short host carries the auth of the first port
The short host serves the first entry in exposedPorts, with that entry's auth. Put a public port first and the short host is open to anyone. Keep the private port first, and let the public port answer on its own named host.
Read the URLs back from the sandbox:
curl -fsS -H "Authorization: Bearer $LENS_AGENTS_TOKEN" \
https://agents.example.com/v1/projects/$PROJECT_ID/sandboxes/$SANDBOX_ID \
| jq -r '.exposedPorts[].url'
What URL is the nightly-refactor sandbox in production serving on?
nexusctl sandbox get nightly-refactor --project production -o json \
| jq -r '.exposedPorts[].url'
The URL is null without sandboxIngress.host
If the install was deployed without sandboxIngress.host, exposed ports still exist but resolve to no URL. Set the value and make sure *.<host> resolves to your ingress. See Install on Kubernetes.
Fields#
| Field | Rules |
|---|---|
name |
Lowercase DNS label fragment, ^[a-z][a-z0-9-]{0,30}$. Unique within the sandbox — it is what a URL resolves by. |
port |
TCP port 1–65535, unique within the sandbox. The supervisor dials it on 127.0.0.1 inside the container. |
auth |
private or public, decided per port. |
A duplicate name or a duplicate port number is refused, because either one makes an entry unreachable.
A sandbox created before platform 0.27.0 serves one port until it restarts
The data tunnel of an older sandbox cannot address a port by name. Rather than route a request to the listener behind a different port, the ingress answers 502 for a revision that declares more than one port. Restart the sandbox so it picks up the current loader image. A single-port revision is unaffected.
The agent binds the port on loopback inside its own container. Nothing else about the sandbox's network is opened by exposing a port — outbound traffic is still governed by policy, and no other port becomes reachable.
Authentication modes#
private#
Requests must carry a valid platform session. Anyone without one is sent to sign in, and access is checked against the project the sandbox belongs to.
This is the default and the right choice for anything beyond a demo.
The platform tells the app behind the port who is calling, over plain HTTP and WebSocket upgrades alike:
| Header | Value |
|---|---|
x-forwarded-user |
The caller's email, or their user id where there is no email. |
x-forwarded-user-id |
The OIDC subject of a signed-in person — the id the rest of the platform keys a user by. |
x-forwarded-user-display-name |
The person's name to display, percent-encoded UTF-8. |
The display name is encoded every time, plain ASCII included — Ada Lovelace arrives as Ada%20Lovelace — so decode it with decodeURIComponent. The id and display name describe a person, so only an OIDC session carries them. A header whose value the platform does not know is left off rather than sent empty.
Every inbound copy of these headers is stripped before the platform writes its own, along with the identity headers of common identity-aware proxies in front of the install. The app reads the platform's answer or nothing — never what a client claimed. A public port carries none of them. The Authorization header is a platform credential on a private port, so the app does not receive it.
Calls from another origin#
The browser session cookie counts only on a request with no Origin, such as a page navigation or a non-browser client, or on a request from the origin of a private port of the same sandbox. A page on any other origin, a public port of the same sandbox included, must send a platform token as Authorization: Bearer.
For a web app served somewhere else, list its origins in the Helm value sandboxIngress.corsOrigins:
sandboxIngress:
corsOrigins:
- https://app.example.com
- http://localhost:*
Each entry is scheme://host[:port], or scheme://host:* for any port. No other wildcard is accepted, and an invalid entry stops the server from starting. The ingress then answers the CORS preflight to a private port from those origins, allowing Authorization and Content-Type, and adds CORS headers to the errors it returns itself. The app behind the port must set Access-Control-Allow-Origin on its own responses, or the browser does not let the page read them.
public#
The URL opens with no platform session at all. Anyone who has the URL can reach the service.
The app receives the request's Authorization header, so it can check its own webhook signatures or bot tokens. A public port forwards a CORS preflight to the app too.
Use public for a local trial, a demo, or a service that does its own authentication. Treat the URL as a secret, and remember that the agent behind it is running with real credentials and real access.
Health checks pair with exposed ports#
A sandbox that serves HTTP is usually worth probing. Point an HTTP health check at the same port so the platform can tell "running" from "running and answering":
exposedPorts:
- name: web
port: 8080
auth: private
healthCheck:
type: http
http:
path: /healthz
port: 8080
Related#
- Sandbox spec — declaring ports in a file
- Storage and health — probes, revisions, and rollback
- Install on Kubernetes — wildcard ingress for
*.<sandboxIngress.host> - Sandbox isolation — what an exposed port does not open up