# Building a Persistent Browser Terminal for Sanbox Computers

By Tanay Soni on August 10, 2026 · https://sanbox.cloud/blog/building-a-persistent-browser-terminal/

A terminal in the browser sounds straightforward until you close the tab.

We wanted every Computer to have a terminal with sane defaults out of the box.
You should be able to open it in a browser, run a coding agent like OpenCode or
Claude Code, use it to debug a running agent, close the tab, and come back later
to resume.

The basics of accessibility like selection, copy and paste, fullscreen, and
scrollback should work well too.

![The Sanbox run view with the Terminal tab open, showing three terminal tabs named claude-code, hermes-debug and terminal 1, with Claude Code running in the active tab.](https://sanbox.cloud/assets/blog/terminal-tabs.png "Claude Code running in a Computer terminal.")

Persistence was the part we cared about most. Closing the browser tab should not
affect the terminal session, and reopening that session in a new tab or on
another machine should work seamlessly. That meant the stack needed three pieces:

- **Session backend** — keeps the shell and its processes alive while nothing is
  attached.
- **Terminal emulator** — parses the output stream and maintains the screen
  state.
- **Browser client** — renders that state in the browser and captures human
  input.

The other challenge was establishing secure communication with the terminal. A
browser has to reach a terminal running inside the sandbox, and we did not want
to expose a terminal port to the internet.

After some research, we settled on the following components.

## Zellij as the session backend

[Zellij](https://zellij.dev/) is a terminal multiplexer with persistent sessions.
Its CLI can attach to an existing session or create one when it does not exist.
That lifecycle is the part Sanbox relies on; we do not expose Zellij's full
workspace interface.

Each Sanbox terminal maps to its own single-pane Zellij session. The tab bar,
status bar, and pane frames are disabled.

On disconnect, the Zellij client detaches while the session and its processes
continue to run. Reopening the terminal attaches to the same session, including
its scrollback. Explicitly closing a terminal is different: it removes the Zellij
session and stops the processes running inside.

We kept the Zellij session names internal. Zellij receives an opaque,
system-generated name, while the control plane gives the terminal a user-facing
label. Keeping the stable session ID separate from the editable label lets Sanbox
manage terminal discovery and lifecycle without depending on Zellij's display
state.

Zellij is not the security boundary or the transport. The Sanbox control plane
handles authorization, one-time connection tickets, terminal inventory, lifecycle
controls, and the private path into the microVM. Zellij's only job is to keep the
session alive when no client is attached.

## wterm in the browser

Because the session lives inside the sandbox, the browser does not need to
preserve process state. Its job is to render the current terminal state and
forward input.

We use Vercel's [wterm](https://github.com/vercel-labs/wterm) for the browser
terminal. It is still a young project, but it has been a practical base for the
current implementation.

Sanbox uses `@wterm/dom` for rendering and input. It renders terminal cells as
DOM elements, which gives us browser-native text selection, copy and paste,
find, and a better foundation for accessibility than a custom canvas renderer.

We still needed two compatibility adapters: one translates mouse-wheel events,
and another answers terminal capability queries from Zellij. The rest of the
browser integration is mostly PTY output in one direction and input, resize, and
close messages in the other.

## Ghostty as the emulator

Sanbox uses `@wterm/ghostty`, which packages Ghostty's terminal-emulation core
for WebAssembly. [`libghostty-vt`](https://github.com/ghostty-org/ghostty)
parses escape sequences and maintains terminal state: cursor position, colors,
alternate screens, Unicode behavior, and scrollback.

High-level flow of a command from the browser to the shell:

![A vertical stack crossing four boundaries. In the browser tab, wterm and the Ghostty emulator render the terminal. An authenticated WebSocket using a single-use ticket connects to the Sanbox control plane, which reaches the host daemon over a Unix domain socket. The host daemon reaches the microVM over a private vsock channel, with no listening port on the sandbox. Inside the microVM, a process opens the PTY and relays bytes between the vsock channel and the Zellij session, which contains the shell or agent.](https://sanbox.cloud/assets/blog/terminal-stack.svg "The browser renders the terminal, while the session itself lives inside the sandbox. Zellij is the layer that remains after the browser connection ends.")

The division of responsibilities looks like this:

- **Ghostty** parses terminal output and maintains the terminal state.
- **wterm** renders that state in the browser and handles input.
- **Zellij** keeps the shell and agent session alive inside the sandbox.
- **Sanbox** provides authorization, private transport, and lifecycle controls.

![One column of six layers: browser tab, WebSocket, host daemon and vsock, PTY and Zellij client, and below a line, the Zellij session and the shell or agent process. One bracket covers only the top four layers and is labelled tab closes or network drops, session survives. A second bracket covers all six and is labelled terminal deleted by an API call, session destroyed.](https://sanbox.cloud/assets/blog/terminal-detach.svg "Both cases tear down the same four layers. Only an explicit delete continues past the line.")

## Getting a connection into the Sanbox Computer

The Sanbox runtime does not expose a public terminal port accessible from the
internet. The browser connects to the Sanbox control plane, which talks to the
host daemon over a Unix socket, and the host reaches the guest through a private
vsock channel to the microVM.

Browser WebSocket APIs do not let the application set an `Authorization` header
during the upgrade. Sanbox first uses an authenticated HTTP request to issue a
short-lived, single-use ticket, then puts that ticket in the WebSocket URL. The
control plane stores only its SHA-256 digest in memory. The ticket expires after
60 seconds and is consumed on first use, limiting the value of a URL captured by
a log or intermediary.

![A sequence diagram with three lifelines: browser, control plane, and the in-memory ticket store. The browser makes an authenticated request to create a terminal session. The control plane issues a ticket and the store keeps only its SHA-256 digest, expiring in at most sixty seconds. The control plane returns the session id and WebSocket URL. The browser then upgrades to the WebSocket carrying the ticket, the control plane consumes and deletes the ticket, and replies that the PTY is attached.](https://sanbox.cloud/assets/blog/terminal-ticket.svg "The control plane stores only a digest of the ticket. A ticket is removed when consumed; reconnecting requires a new one.")

The ticket authorizes only the handshake. Once connected, the WebSocket can
remain open for a long-running command. If it drops, the command continues
inside Zellij and the browser reconnects with a new ticket.

This is what makes the terminal feel local while remaining browser-based. You
can start an agent, inspect its work, close the tab, and return later without
losing the session. The browser is only the client. The Computer remains the
source of truth.
