These two endpoints run commands inside a deployment's VM: exec runs one command and returns its output, and shell opens an interactive terminal over a WebSocket.
Back to the API reference.
Both are CRUD tier and work on VM deployments only; static deployments and sites answer 400. They are the only way into a deployment with no routes.
Exec
POST /deployments/:id/exec
Crate: Client::exec(id, &ExecRequest) -> ExecOutput
| Field | Default | Meaning |
|---|---|---|
command |
required | Run through sh -c in the guest. Must not be blank; the crate refuses a blank command with Error::Invalid before sending. |
cwd |
guest default | Working directory. |
env |
none | Object of extra environment variables. |
timeout_secs |
60 |
Clamped to 1–3600. Bounds app-lb's call to the daemon. It does not kill the command. |
wake |
true |
Boot or resume a VM if none is running, waiting up to cold_start_timeout_secs. With false, no running VM is a 409. |
sandbox_id |
pool's choice | Run in this VM. Not in the deployment: 404. Not started or draining: 409. A named VM is never woken. The crate's ExecRequest does not set this field. |
use hws::ExecRequest;
let out = lb.exec("demo", &ExecRequest::new("ls /nope; ls /")
.cwd("/")
.env("RUST_LOG", "debug")
.timeout_secs(30)).await?;
if !out.ok() {
eprintln!("exit {}: {}", out.exit_code, out.stderr);
}
{
"sandbox_id": "applb-sandbox-a1b2c3",
"exit_code": 1,
"stdout": "total 0\n",
"stderr": "ls: cannot access '/nope': No such file or directory\n",
"output": "total 0\nls: cannot access '/nope': No such file or directory\n"
}
| Field | Meaning |
|---|---|
sandbox_id |
The VM that ran it. After a resume or rebuild this is a different VM than last time. |
exit_code |
The command's exit status. ExecOutput::ok() is exit_code == 0. |
stdout, stderr |
Each stream on its own. |
output |
Both streams interleaved in the order the guest wrote them. |
A command that fails is still 200, and the crate returns it as Ok. Output is buffered until the command exits; there is no streaming and no cancel. If no VM has passed its health check yet, the command runs in one that is still booting. An open exec counts as in-flight work, so the VM is not reaped under it.
| Status | hws::Error |
When |
|---|---|---|
400 |
Api |
Not a VM deployment, or an empty command. |
404 |
NotFound |
No such deployment, or sandbox_id is not in it. |
409 |
NoRunningVm |
wake: false and nothing is running. |
409 |
Conflict |
The named VM is not ready or is draining, or the deployment is frozen for retirement. |
502 |
Upstream |
The daemon could not run the command, or app-lb's call timed out. On a timeout the command is still running in the guest. |
503 |
ColdStartTimeout |
No VM became ready within cold_start_timeout_secs. Retryable. |
Client deadline
The crate sets its own deadline on an exec request so that it never gives up before app-lb does. ExecRequest::patience() is timeout_secs (default 60, clamped to 1–3600), plus 120 seconds when wake is set (app-lb's default cold_start_timeout_secs), plus a 15-second margin. A deployment configured with a longer cold start needs .patient_for(d).
Shell
GET /deployments/:id/shell (WebSocket upgrade)
Crate: Client::shell(id, &ShellOptions) -> Shell
Opens an interactive PTY. The crate builds ws:// or wss:// from the client's base URL and authenticates the upgrade with the same Authorization header as every other request. A browser cannot set headers on a WebSocket, so app-lb also accepts ?app_token=applb_… on this route only. Query strings end up in logs, so mint a short-lived token for that.
| Query | Default | Meaning | ShellOptions |
|---|---|---|---|
cols, rows |
80, 24 |
Initial terminal size. | .size(cols, rows) |
cwd |
guest default | Working directory. | .cwd(path) |
wake |
true |
As for exec. |
.no_wake() |
sandbox_id |
pool's choice | As for exec. |
.vm(sandbox_id) |
app_token |
App-token for clients that cannot set headers. | not used |
Every refusal (404, 409, 502, 503) is an ordinary HTTP response before the upgrade, and the crate maps it as it would for exec. Once the socket is open, the framing is:
| Direction | Frame | Content |
|---|---|---|
| server → client | text | {"type":"ready","sandbox_id":"…"}, sent once, first. |
| client → server | binary | 0x01 followed by stdin bytes. |
| client → server | text | {"type":"resize","cols":N,"rows":N} |
| server → client | binary | 0x02 followed by output bytes. The PTY merges stderr into stdout. |
| server → client | text | {"type":"exit","code":N}, then the server closes. |
| server → client | text | {"type":"error","message":"…"} |
Client binary frames that do not start with 0x01 are dropped.
use hws::{ShellEvent, ShellOptions};
let mut sh = lb.shell("demo", &ShellOptions::default().size(120, 40)).await?;
println!("connected to {}", sh.sandbox_id());
sh.write(b"uname -a\nexit\n").await?;
while let Some(ev) = sh.next().await {
match ev {
ShellEvent::Output(bytes) => print!("{}", String::from_utf8_lossy(&bytes)),
ShellEvent::Error(msg) => eprintln!("error: {msg}"),
}
}
let exit = sh.exit().cloned();
Shell::write frames stdin, Shell::resize sends a resize, and Shell::next yields output and errors until the session ends. Shell::exit() then returns a ShellExit { code, error }, and ShellExit::is_clean() is true only when the code is 0 and no error was reported.
app-lb reports an unknown exit status as 0, which is also what a VM dying under the session looks like, so treat an error before the exit as unclean. There is no resume: if the socket drops, the session is gone, and reconnecting opens a new shell.