voxal docs

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.

  1. 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.
  2. Warm or boot. If hello is already running, voxal reuses it. If not, it boots the app's sandbox, which takes a few milliseconds.
  3. Connect. voxal fires your onConnect handler with a connection object that carries the terminal size (conn.cols, conn.rows). This is where you do your first render.
  4. Input. Every keystroke fires onKey with the decoded input. A terminal resize fires onResize, and conn.cols / conn.rows are updated for you first.
  5. Output. Your conn.write(...) calls send text (and ANSI escapes) back down the SSH channel to that user's terminal.
  6. Close. When the user disconnects, onClose fires 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

ConcernWhere 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 networkingThe host
console.logServer logs (not the user's terminal)

Next steps

On this page