SandboxShells and terminals

Open a shell with SSH, a PTY or a browser terminal

Get a real interactive terminal in a sandbox: SSH from your laptop, a PTY driven from your code, or a terminal in your own web page.

SSH from your terminal

tangle sandbox create --name dev --ssh
tangle ssh dev
tangle sandbox delete dev --force

--ssh with no key flag uploads the first of ~/.ssh/id_ed25519.pub, ~/.ssh/id_ecdsa.pub and ~/.ssh/id_rsa.pub, and fails if none exists. Name a key with --ssh-key <public-key>, --ssh-key-file <path> or --ssh-keys <stored-names>.

tangle ssh runs your local ssh through a proxy command that connects over the Sandbox API, so the sandbox needs no public SSH port. The API key travels in the TANGLE_SSH_PROXY_AUTH_TOKEN environment variable, never in argv. Host keys are not checked. A stopped sandbox is resumed first. Pass extra ssh arguments after the sandbox, -i <path> to pick a private key, or --print to print the command instead of connecting.

tangle ssh dev -- -L 5432:localhost:5432   # forward a port
tangle ssh dev --print

Stored keys

Store a public key once and select it by name at create:

tangle ssh-keys add laptop --key-file ~/.ssh/id_ed25519.pub
tangle ssh-keys list
tangle sandbox create --ssh-keys laptop
tangle ssh-keys delete laptop
const key = await client.sshKeys.create("laptop", publicKey);
const box = await client.create({ sshEnabled: true, sshKeyIds: [key.id] });

Build the SSH command in code

box.sshCommand() returns a ready ssh command and the environment it needs.

const { command, env } = await box.sshCommand();
⚠️

env.TANGLE_SSH_PROXY_AUTH_TOKEN holds your API key. Never log it, print it to a shared terminal, or send it to a browser.

box.ssh() throws INVALID_STATE when SSH was not enabled at create.

Drive a PTY from your code

box.terminals.attach opens a live terminal over a WebSocket. Register your handlers in the call: a reattached terminal replays its screen as soon as it connects.

const output: string[] = [];
const term = await box.terminals.attach("build-shell", {
  cols: 120,
  rows: 32,
  handlers: {
    onData: (bytes) => output.push(new TextDecoder().decode(bytes)),
    onExit: (info) => console.log("exited", info),
    onClose: (code, reason) => console.log("closed", code, reason),
  },
});
 
term.write("cd /home/agent && ls -la\n");
term.resize(160, 40);
 
await term.close();
OptionEffect
cols, rowsTerminal size. Change it later with resize(cols, rows).
commandWhat to start when the id has no live terminal. Defaults to a shell.
cwd, envWorking directory and environment for that command.
handlersonData, onReady, onExit, onError, onClose.

The connection id names the terminal. Attaching with the same id reattaches to the running terminal and replays its history; a new id starts a new one. close() detaches and leaves the terminal running on the server for term.ready.detachTimeoutMs; reattach within that window with term.reconnect() or another attach with the same id.

attach gets a short-lived terminal token for you, and each reconnect() gets a fresh one.

Send input without a socket

The REST side of box.terminals works without holding a WebSocket open:

const created = await box.terminals.create({ name: "tests", cols: 80, rows: 24 });
await box.terminals.input(created.sessionId, "make test\n");
 
for (const t of await box.terminals.list()) console.log(t.sessionId, t.isRunning, t.lastActivityAt);
const one = await box.terminals.get(created.sessionId); // null when it is gone

A terminal in your web page

There are two ways to put a terminal in a browser.

The built-in web terminal. Create the sandbox with webTerminalEnabled: true (--web-terminal in the CLI). box.url returns its address, and tangle sandbox get <sandbox> prints it as Web Terminal.

Your own terminal UI. Your server mints a short-lived token for one terminal, and the browser dials it. The token’s sessionId must equal the terminal’s connection id, and the token must not be restricted to read-only access.

// On your server, after checking the user may use this sandbox.
const scoped = await box.mintScopedToken({ scope: "session-runtime", sessionId: "web-shell" });
return Response.json({ token: scoped.token, proxyUrl: scoped.sidecarProxyUrl });
// In the browser.
import { TerminalStream } from "@tangle-network/sandbox";
 
const term = await TerminalStream.connect({
  url: `${proxyUrl}/terminals/web-shell/ws`,
  connectionId: "web-shell",
  token: async () => (await (await fetch("/api/terminal-token", { method: "POST" })).json()).token,
  cols: 100,
  rows: 30,
  handlers: { onData: (bytes) => xterm.write(bytes) },
});
xterm.onData((data) => term.write(data));

Terminal tokens are short-lived. Passing a function as token lets every reconnect fetch a new one.

Python and REST

Python has ssh(), which returns host, port and username, and the ssh_enabled, ssh_public_keys, ssh_key_ids and web_terminal_enabled create options. It has no PTY, terminal manager, stored SSH keys or web-terminal token. Call the routes with client.fetch, which returns the raw response and does not raise on an error status:

client.fetch("POST", "/v1/ssh-keys", json={"name": "laptop", "publicKey": public_key})
client.fetch("POST", f"/v1/sandboxes/{box.id}/runtime/terminals/{terminal_id}/input", json={"data": "ls\n"})

The CLI opens an interactive shell only through tangle ssh. To drive a coding agent’s own terminal UI, see Drive a coding agent interactively.

Next