Terminal & SSH
WebSocket-bridged terminal access to running agent containers, plus VNC for GUI agents and dynamic subdomain routing for any service the agent exposes.
Every running agent container ships with sshd (in the shared base image), and the platform exposes it through a couple of bridges so you can reach the agent’s working environment from a browser tab — or any SSH client — without exposing the container directly to the internet.
Web terminal
GET /terminal/{instanceId} is the WebSocket endpoint the web UI’s terminal tab connects to (internal/server/router/router.go). The bridge:
- Authenticates the request as the user (or workflow) that owns the instance
- Opens an SSH connection to the container’s
:22(the password is generated server-side per execution and never shown to the user) - Pipes stdin/stdout/stderr through the WebSocket as raw bytes
- Tears the SSH session down on disconnect
Client-side rendering is xterm.js with the fit addon (the web/ package depends on @xterm/xterm plus @xterm/addon-fit). The session lands inside the container’s auto-attached tmux session — the agent’s running workflow output is visible immediately, and you can split panes, scroll back, or just watch.
Multiple terminals can attach to the same instance simultaneously. Every attach lands in the same agent tmux session, so everyone sees the same screen — useful for pair debugging.
SSH directly into a container
For agents you want to drive from a desktop SSH client (VS Code Remote-SSH, vim, your shell), the instance API surfaces SSH connection info:
pfai api GET /api/v1/instances/{id}/ssh
Response:
{
"host": "127.0.0.1",
"port": 32768,
"username": "root",
"password": "auto-generated-per-execution"
}
The host + port are reachable depending on your deployment topology:
| Deployment | What’s reachable |
|---|---|
| Docker Compose (local) | Host’s 127.0.0.1:<random> — Docker publishes the container’s :22 to a random host port |
| Single binary with Docker | Same as above |
| Kubernetes (Enterprise) | Cluster-internal IP/port — use pfai cloud port-forward to tunnel |
pfai cloud port-forward <instance-id> 22 creates a local TCP tunnel to the container’s SSH port — the same UX as kubectl port-forward, no network policy edits needed.
SSH passwords are randomly generated per agent execution and rotated on container teardown. They’re not user passwords — so there’s nothing to manage and nothing to leak permanently. For repeatable access, configure SSH key auth via deploy keys on the repo the agent clones.
Remote desktop (VNC)
For agents that need a graphical environment — Cursor, browser-driving agents, computer-use scenarios — the container can run an X server with a VNC bridge. The web UI uses noVNC (@novnc/novnc 1.7.0-beta) to render the desktop in a <canvas>:
- Mouse + keyboard input streamed over WebSocket
- Screenshot-based for the model’s vision tools (
screenshot,computer,browser_action— see Chat Modes) - Multiple viewers can attach; the agent and you both see the same desktop
Reachable from the agent execution detail page → Desktop tab when the underlying agent image is one that ships an X server. Currently that’s cursor and claude-code with the GUI variant. The minimal agent images (aider, gemini-cli, opencode, copilot) are headless.
Subdomain routing
When an agent starts a service inside its container — a dev server, a database, a webhook test endpoint — the platform’s port detector notices the new listener and registers an Envoy route. The service then reaches:
{instance-id}-{port}.{BASE_DOMAIN}
| Layer | What it does |
|---|---|
Port detector (internal/instances/portdetect) | Watches container netstat for new LISTEN sockets |
WS-TCP bridge (:8082) | Tunnels arbitrary TCP through WebSockets — used by the web UI and pfai cloud port-forward |
xDS gRPC (:18000) | Pushes Envoy route updates dynamically |
Envoy (:8080) | The actual public proxy in front of agent containers |
BASE_DOMAIN defaults to localhost in OSS (so the URL is inst-abc-3000.localhost — works locally without DNS). In production, point a wildcard *.dev.example.com at your Envoy and set BASE_DOMAIN=dev.example.com.
WebSocket connections survive the same routing — connect to inst-abc-3000.dev.example.com and the WS handshake passes through Envoy unchanged. TLS termination happens on Envoy.
When to use what
| You want to… | Use |
|---|---|
| Watch the agent run, occasionally type a command | Web terminal (Terminal tab) |
| Edit the agent’s files in your local VS Code | SSH + Remote-SSH extension |
| See the agent’s GUI session | Web Desktop tab (noVNC) |
| Test a dev server the agent started | Subdomain ({id}-{port}.{BASE_DOMAIN}) |
| Pull a one-off file or run one command | pfai exec command --instance <id> -- <cmd> |
See also
The container lifecycle that produces these instances — runtime modes, agent images, port detection.
The xDS + WS-TCP bridge design that makes subdomain routing work.
K8s-aware deploy, exec, port-forward, and db-dump tooling for apps you ship from your forge repos.
Inside-agent CLI — runs alongside the agent in the same container.