Exec and files
Run commands (blocking or detached), poll processes, and read/write/list files on an awake desktop's guest.
The routes below only work while the desktop is awake -- a 409 means
the desktop isn't awake, or its control claim is already held by another
client (see Streaming for claims).
Run a command
POST /v1/desktops/{id}/exec
| Param | In | Required | Description |
|---|---|---|---|
id | path | yes | Desktop id |
Request body (ExecBody):
{ "command": "echo hello", "timeout_secs": 10, "detached": false }| Field | Type | Required | Description |
|---|---|---|---|
command | string | yes | |
timeout_secs | integer | null | no | Defaults to 60s when omitted; clamped (never rejected) to 300s. Ignored when detached: true. |
detached | boolean | no | If true, the command is spawned in the guest and this route answers 202 {process_id} instead of waiting for it to exit. Defaults to false (blocking). |
curl -sS -X POST "$BASE/desktops/$DESKTOP_ID/exec" \
-H "$AUTH" -H 'Content-Type: application/json' \
-d '{"command": "echo hello", "timeout_secs": 10}'Response (ExecResponse, detached: false):
{ "exit_code": 0, "stdout": "hello\n", "stderr": "" }| Status | Meaning |
|---|---|
200 | detached: false (default): the command ran to completion in the guest |
202 | detached: true: the command was spawned in the guest; poll GET .../processes/{process_id} for its status |
400 | Malformed id/body, or empty command |
401 | Missing/invalid credentials |
404 | Desktop not found or belongs to another org |
409 | Desktop is not awake, or its control claim is already held |
503 | The desktop's host is currently unreachable -- retry |
504 | The command timed out inside the guest -- retry |
Run a command, detached
Pass "detached": true to spawn a long-running command in the background
instead of blocking on it -- see
Concepts: background processes
for the full model.
curl -sS -X POST "$BASE/desktops/$DESKTOP_ID/exec" \
-H "$AUTH" -H 'Content-Type: application/json' \
-d '{"command": "sleep 300 && echo done > /tmp/marker", "detached": true}'Response (DetachedExecResponse, 202):
{ "process_id": "p_..." }Poll a detached process
GET /v1/desktops/{id}/processes/{process_id}
| Param | In | Required | Description |
|---|---|---|---|
id | path | yes | Desktop id |
process_id | path | yes | Process id returned by a prior detached POST .../exec |
tail_bytes | query | no | How many trailing bytes of stdout/stderr to return. Omitted/0 means hostd's own default (64 KiB); capped at 512 KiB. |
curl -sS "$BASE/desktops/$DESKTOP_ID/processes/$PROCESS_ID?tail_bytes=65536" -H "$AUTH"Response (ProcessStatusResponse):
{
"process_id": "p_...",
"status": "running",
"exit_code": 0,
"stdout_tail": "",
"stderr_tail": ""
}| Field | Type | Description |
|---|---|---|
process_id | string | |
status | string | running | exited | lost |
exit_code | integer | Valid only when status == "exited" |
stdout_tail | string | Trailing tail_bytes of stdout captured so far |
stderr_tail | string | Trailing tail_bytes of stderr captured so far |
| Status | Meaning |
|---|---|
200 | The process's current status |
400 | Malformed id, or a non-numeric tail_bytes |
401 | Missing/invalid credentials |
404 | Desktop not found (or belongs to another org), or process_id is unknown |
409 | Desktop is not awake |
503 | The desktop's host is currently unreachable -- retry |
Read a file
GET /v1/desktops/{id}/files/{path}
| Param | In | Required | Description |
|---|---|---|---|
id | path | yes | Desktop id |
path | path | yes | Absolute guest path, without its leading / (e.g. home/user/notes.txt for /home/user/notes.txt) |
offset | query | no | Files v2 chunked read: return the byte window starting here instead of the whole file. The 200 response then carries X-Canto-File-Size (total size) and X-Canto-Eof (true/false) headers; an offset at or past EOF is an empty 200 body with X-Canto-Eof: true. |
length | query | no | Window length for a chunked read (requires offset); defaults to (and is capped at) 8 MiB -- 400 above the cap. |
curl -sS "$BASE/desktops/$DESKTOP_ID/files/home/user/notes.txt" -H "$AUTH"
# Chunked read of a large file:
curl -sS "$BASE/desktops/$DESKTOP_ID/files/home/user/big.log?offset=0&length=8388608" \
-H "$AUTH" -D -Response is 200 with application/octet-stream -- the whole file, or
(with offset) the requested window.
| Status | Meaning |
|---|---|
200 | Raw file content (the requested window, for a chunked read) |
400 | Malformed id, a path that's empty/contains a NUL byte/contains a .. segment, non-numeric offset/length, length without offset, or length over 8 MiB |
401 | Missing/invalid credentials |
404 | Desktop not found (or belongs to another org), or the file itself does not exist |
409 | Desktop is not awake, or its control claim is already held |
413 | Whole-file read of a file over 8 MiB -- switch to chunked reads (?offset=/length=) |
503 | The desktop's host is currently unreachable -- retry |
504 | The read timed out inside the guest -- retry |
Write a file
PUT /v1/desktops/{id}/files/{path}
| Param | In | Required | Description |
|---|---|---|---|
id | path | yes | Desktop id |
path | path | yes | Absolute guest path, without its leading / |
append | query | no | Files v2 chunked upload: append the body iff the file's current size equals offset (which becomes required with append=true). A size mismatch is a 409 whose problem body carries a currentSize extension member, making retried chunks idempotent. |
offset | query | no | Required with append=true: the byte offset this chunk expects the file to currently end at. |
Request body: raw application/octet-stream file content (one chunk, when
appending).
curl -sS -X PUT "$BASE/desktops/$DESKTOP_ID/files/home/user/notes.txt" \
-H "$AUTH" --data-binary "hello from canto"
# Chunked upload of a large file -- first chunk, then append at offset:
curl -sS -X PUT "$BASE/desktops/$DESKTOP_ID/files/home/user/big.bin" \
-H "$AUTH" --data-binary @chunk0
curl -sS -X PUT "$BASE/desktops/$DESKTOP_ID/files/home/user/big.bin?append=true&offset=8388608" \
-H "$AUTH" --data-binary @chunk1| Status | Meaning |
|---|---|
204 | File written (or chunk appended) |
400 | Malformed id, a path that's empty/contains a NUL byte/contains a .. segment, append=true without offset, or non-numeric params |
401 | Missing/invalid credentials |
404 | Desktop not found or belongs to another org |
409 | Desktop is not awake, its control claim is already held, or an append's size != offset guard failed (body carries currentSize) |
413 | Body exceeds the 8 MiB per-request limit |
503 | The desktop's host is currently unreachable -- retry |
504 | The write timed out inside the guest -- retry |
Delete a file
DELETE /v1/desktops/{id}/files/{path}
| Param | In | Required | Description |
|---|---|---|---|
id | path | yes | Desktop id |
path | path | yes | Absolute guest path, without its leading /. The filesystem root itself (/, /.) is rejected 400. |
recursive | query | no | Delete a directory tree. Without it, deleting a directory is a 409. |
curl -sS -X DELETE "$BASE/desktops/$DESKTOP_ID/files/home/user/notes.txt" -H "$AUTH"
curl -sS -X DELETE "$BASE/desktops/$DESKTOP_ID/files/home/user/tmp?recursive=true" -H "$AUTH"| Status | Meaning |
|---|---|
204 | Deleted |
400 | Malformed id, an invalid path (empty/NUL/..), or the filesystem root |
401 | Missing/invalid credentials |
404 | Desktop not found (or belongs to another org), or the path itself does not exist (a second DELETE of the same path 404s) |
409 | Desktop is not awake, or the path is a directory and recursive was not set |
503 | The desktop's host is currently unreachable -- retry |
504 | The delete timed out inside the guest -- retry |
Stat a file
GET /v1/desktops/{id}/files-meta/{path}
| Param | In | Required | Description |
|---|---|---|---|
id | path | yes | Desktop id |
path | path | yes | Absolute guest path, without its leading / |
curl -sS "$BASE/desktops/$DESKTOP_ID/files-meta/home/user/notes.txt" -H "$AUTH"Response (FileStatJson):
{
"path": "home/user/notes.txt",
"fileType": "file",
"sizeBytes": 17,
"modifiedAt": "2026-08-20T12:00:00Z",
"mode": "644"
}fileType is "file" | "directory" | "symlink" | "other"; mode
is the permission bits as octal text.
| Status | Meaning |
|---|---|
200 | The path's metadata |
400 | Malformed id, or a path that's empty/contains a NUL byte/contains a .. segment |
401 | Missing/invalid credentials |
404 | Desktop not found (or belongs to another org), or the path itself does not exist |
409 | Desktop is not awake |
503 | The desktop's host is currently unreachable -- retry |
504 | The stat timed out inside the guest -- retry |
List a directory
GET /v1/desktops/{id}/dir/{path}
| Param | In | Required | Description |
|---|---|---|---|
id | path | yes | Desktop id |
path | path | yes | Absolute guest directory path, without its leading /. The guest root is listed as GET .../dir/ (a trailing slash, empty capture) -- . works too, but URL normalization collapses it to the same thing. |
curl -sS "$BASE/desktops/$DESKTOP_ID/dir/home/user" -H "$AUTH"Response (ListDirResponse) -- direct children, sorted by name (byte
order), capped at 2000 entries:
{
"path": "home/user",
"entries": [
{ "name": "notes.txt", "fileType": "file", "sizeBytes": 17, "modifiedAt": "2026-08-20T12:00:00Z" }
],
"truncated": false
}truncated is true when the listing was cut at the 2000-entry cap (or
lost records to the guest-side output bound).
| Status | Meaning |
|---|---|
200 | The directory's direct children |
400 | Malformed id, or a path that's empty/contains a NUL byte/contains a .. segment |
401 | Missing/invalid credentials |
404 | Desktop not found (or belongs to another org), or the path itself does not exist |
409 | Desktop is not awake, or the path is not a directory |
503 | The desktop's host is currently unreachable -- retry |
504 | The listing timed out inside the guest -- retry |