How voxal works
The host and sandbox split, the lifecycle of an SSH connection, and the model your app runs in
voxal turns one SSH connection into one session of your app. Understanding the path a connection takes, and the boundary your code runs behind, makes everything else in these docs click into place.
The core idea: host and sandbox
voxal is built on a strict split between a trusted host and an untrusted sandbox.
- The host is the platform. It owns the dangerous capabilities: the SSH connections and real network access.
- Your app runs in a sandbox that can do pure JavaScript and nothing else, except
through two narrow bridges the host provides: writing to the terminal, and a guarded
fetch.
Everything your app does that touches the outside world goes through the host. The sandbox
has no filesystem, no process spawning, no raw sockets, and no global fetch. That boundary
is what makes it safe to run anyone's app next to anyone else's. See
Sandbox and security for the guarantees.
The lifecycle of a connection
When someone runs ssh hello@voxal.sh, here is what happens.
- Handshake. voxal accepts the SSH connection. The username (
hello) is the app name, not a Unix account. No shell and no command execution are ever granted; the channel is wired only to your app. - Warm or boot. If
hellois already running, voxal reuses it. If not, it boots the app's sandbox, which takes a few milliseconds. - Connect. voxal fires your
onConnecthandler with a connection object that carries the terminal size (conn.cols,conn.rows). This is where you do your first render. - Input. Every keystroke fires
onKeywith the decoded input. A terminal resize firesonResize, andconn.cols/conn.rowsare updated for you first. - Output. Your
conn.write(...)calls send text (and ANSI escapes) back down the SSH channel to that user's terminal. - Close. When the user disconnects,
onClosefires so you can clean up that session's state.
One app, many users
There is exactly one sandbox per app, and every user connected to that app runs inside
it. Each connection has its own conn object and its own id, but they share the same module
scope.
That dual nature is the key thing to design around:
- Per-connection state (one user's cursor, draft, or view) should be keyed by
conn.id, or in the UI layer it lives naturally in that connection's own React tree. - Shared state (a chat room, a leaderboard, who is online) is a plain module-level variable, visible to everyone connected.
The Share state across connections guide shows both patterns.
The sandbox
Each app runs in its own isolate: a separate heap and event loop, not a shared one. One app's infinite loop or memory spike cannot affect another app or the host. The isolate enforces a memory cap and bounds CPU time per event, and timers run inside it under their own limits. The exact numbers are on Limits and quotas.
Because the sandbox is pure JavaScript, your app must be pure JavaScript too: no native npm modules. The CLI bundles your app, the SDK, and any pure-JS dependencies into a single file before deploying.
Scale to zero
An app with no active connections is dropped after a short idle period and booted again on the next connection. Boots are fast (a few milliseconds), so this is invisible to users. The practical consequence: do not rely on in-memory state surviving while nobody is connected. Treat each boot as a fresh start.
What runs where
| Concern | Where it runs |
|---|---|
Your app code (onConnect, render, key handling) | The sandbox |
Writing to the terminal (conn.write) | Bridged to the host |
HTTP requests (fetch) | The host, behind SSRF guards |
| SSH connections and networking | The host |
console.log | Server logs (not the user's terminal) |
Next steps
- Read the SDK overview and start with createApp and lifecycle.
- Prefer components? See the UI layer.
- Review the limits and security guarantees.