holmd serves the REST API under /v1. The wire types are in the holm-api crate, and holm-client is a Rust client for it.
To start the server and set a token, see The server.
Conventions
Base URL. http://127.0.0.1:8080 unless HOLM_SERVER_ADDR changes it.
Authentication. When the server has a token, send Authorization: Bearer <HOLM_SERVER_TOKEN> on each request, or a workspace token or API key (holm_sk_…) from a console. See Workspaces. These endpoints need no bearer token:
GET /v1/health- The viewer socket, which takes a short-lived token in the query
- The
/v1/cdp/{token}/…proxy, which takes a short-lived token in the path
Bodies. Requests and responses are JSON. Request bodies refuse unknown keys, so a misspelled key gives an error.
Screens. {screen} is the screen number, from 0.
Tabs. tab is a tab ID or label, from GET /v1/boxes/{id}/pages or an open_url action. With no tab, the endpoint uses the page at the front.
Points. A point is {"x": 640, "y": 400}, in device pixels. (0, 0) is the top-left corner.
Idempotency. POST /v1/boxes, POST …/actions, and POST …/fork accept an idempotency-key header. The same key with the same body returns the first result, and does not do the work again. The server keeps keys in memory.
Errors
All errors have this body:
{ "code": "denied", "message": "…", "retryable": false }code |
HTTP status | Meaning |
|---|---|---|
bad_request |
400 | The request is not valid. |
unsupported |
400 | The box or runtime cannot do this. |
not_found |
404 | No such box, runtime, or object. |
gone |
410 | The box was removed. |
denied |
409 | Refused. Usually a person has control of the screen. |
screen_unavailable |
409 | The screen cannot be used now. |
failed |
422 | The action ran and did not succeed. |
transport |
502 | The server could not reach the box. |
unavailable |
503 | The runtime or store is not available. |
timeout |
504 | The action did not finish in time. |
internal |
500 | A server error. |
retryable tells you if the same request can succeed later.
Endpoints
Health
| Method | Path | Effect |
|---|---|---|
GET |
/v1/health |
Returns {"ok": true, "service": "holm-server"}. No token. |
Boxes
| Method | Path | Effect |
|---|---|---|
GET |
/v1/boxes |
List boxes. |
POST |
/v1/boxes |
Create a box. See Create a box. |
GET |
/v1/boxes/{id} |
Get one box. |
DELETE |
/v1/boxes/{id} |
Remove a box. Needs the header x-holm-confirm-delete: true. The server keeps the record of a removed box for its history: the box leaves the list, and each later call to it answers 410. |
POST |
/v1/boxes/{id}/pause |
Pause the box. |
POST |
/v1/boxes/{id}/resume |
Resume a paused or stopped box. |
POST |
/v1/boxes/{id}/stop |
Stop the box and keep its files. |
POST |
/v1/boxes/{id}/apps |
Install catalog applications in a running box. Body: {"apps": ["gimp"]}. |
POST |
/v1/boxes/{id}/fork |
Make a new box from the trace. See Fork. |
GET |
/v1/catalog |
List the application names in the catalog. |
A box in a response:
{
"id": "box_9cf78792…",
"runtime": "docker",
"spec_digest": "…",
"state": "ready",
"screens": 1,
"width": 1280,
"height": 800,
"viewer_url": "…",
"devtools_url": "…",
"created_at_ms": 1790000000000,
"expires_at_ms": 1790003600000,
"owner": "ws_…",
"spec": { "desktop": { … }, "apps": { … }, "policy": { … } },
"placement": { "runtime": "docker", … }
}state is ready, paused, stopped, unreachable, gone, starting, or failed. reason is present when the state needs an explanation. When the server queues its jobs (HOLM_SERVER_JOBS=queue), POST /v1/boxes answers 202 with a box in the starting state. Read the box until it is ready or failed. POST /v1/boxes/{id}/fork answers 202 in the same way, with an empty replay report. A box that is starting or failed refuses other calls with 409, and a failed box stays until it is deleted. owner is the workspace that launched the box, and is absent for a box launched with the server token. spec and placement are what the box was launched with, so the same box can be launched again.
Screen
| Method | Path | Effect |
|---|---|---|
POST |
/v1/boxes/{id}/screens/{screen}/actions |
Run a batch of actions. See Actions. |
GET |
/v1/boxes/{id}/screens/{screen}/frame |
Get a screenshot. |
GET |
/v1/boxes/{id}/screens/{screen}/cursor |
Get the pointer position. |
GET |
/v1/boxes/{id}/screens/{screen}/clipboard |
Read the clipboard. Query: selection = clipboard (default) or primary. |
PUT |
/v1/boxes/{id}/screens/{screen}/clipboard |
Set the clipboard. Body: {"text": "…", "selection": "clipboard"}. |
GET |
/v1/boxes/{id}/screens/{screen}/recording |
Get the recording state. |
POST |
/v1/boxes/{id}/screens/{screen}/recording |
Start a recording. Body: {"fps": 12}. Needs the video feature. |
DELETE |
/v1/boxes/{id}/screens/{screen}/recording |
Stop the recording. Returns the file path in the box. |
POST |
/v1/boxes/{id}/screens/{screen}/desktop/node |
Act on native widgets. See Native widgets. |
frame query parameters:
| Parameter | Effect |
|---|---|
have |
The hash of the last frame. If the screen did not change, the response has unchanged: true and no image. |
window |
Capture one window. |
x, y, width, height |
Capture a rectangle. |
scale |
Percent of full size, 1 to 400. |
A frame in a response:
{ "hash": "…", "unchanged": false, "png_base64": "…" }Windows
| Method | Path | Effect |
|---|---|---|
GET |
/v1/boxes/{id}/screens/{screen}/windows |
List windows. |
GET |
/v1/boxes/{id}/screens/{screen}/windows/active |
Get the window that receives keyboard input. |
POST |
/v1/boxes/{id}/screens/{screen}/windows/wait |
Wait for a window. Body: {"class": "gimp", "within_ms": 30000}. |
POST |
/v1/boxes/{id}/screens/{screen}/windows/{window}/focus |
Bring a window to the front. |
POST |
/v1/boxes/{id}/screens/{screen}/windows/{window}/arrange |
Move or resize a window. See below. |
DELETE |
/v1/boxes/{id}/screens/{screen}/windows/{window} |
Close a window. |
arrange bodies:
{ "how": "at", "to": { "x": 0, "y": 0 } }
{ "how": "size", "width": 800, "height": 600 }
{ "how": "maximise" }
{ "how": "minimise" }
{ "how": "restore" }Human control
| Method | Path | Effect |
|---|---|---|
POST |
/v1/boxes/{id}/screens/{screen}/takeover |
Give the screen to a person. Body: {"shared": false}. Returns {url, exclusive, screen}. |
DELETE |
/v1/boxes/{id}/screens/{screen}/takeover |
Take the screen back. |
GET |
/v1/boxes/{id}/screens/{screen}/viewers |
Returns {watching, driving, person_driving, taken_over}. |
POST |
/v1/boxes/{id}/screens/{screen}/viewer/ticket |
Make a short-lived token for the viewer socket. Returns {ticket, expires_at_ms}. Valid for 15 minutes. For a box with a signed viewer, the reply also has view_socket and, for a member or higher, control_socket: WebSocket URLs that go to the box directly. |
GET |
/v1/boxes/{id}/screens/{screen}/viewer/socket |
The viewer WebSocket, through the server. Query: ticket, and mode = view (default) or control. No bearer token. |
See Human control.
Files and commands
| Method | Path | Effect |
|---|---|---|
POST |
/v1/boxes/{id}/exec |
Run a command. Body: {"argv": ["ls", "-la"], "timeout_ms": 10000}. Returns {code, stdout, stderr, timed_out}. Maximum 10 minutes. |
GET |
/v1/boxes/{id}/files |
Read a file. Query: path. Returns {path, contents_base64}. |
PUT |
/v1/boxes/{id}/files |
Write a file. Body: {"path": "/tmp/a.txt", "contents_base64": "…"}. |
GET |
/v1/boxes/{id}/files/list |
List a directory. Query: path. |
POST |
/v1/boxes/{id}/files/grep |
Search in files. Body: {"pattern": "…", "path": "/etc", "include": "*.conf", "ignore_case": false, "limit": 200}. |
GET |
/v1/boxes/{id}/files/glob |
Find files by name. Query: pattern, path, limit. |
grep and glob return cut: true when they stop at the limit.
Pages
These endpoints use Chrome DevTools. On remote runtimes, see Control modes.
| Method | Path | Effect |
|---|---|---|
GET |
/v1/boxes/{id}/pages |
List tabs. |
DELETE |
/v1/boxes/{id}/pages/{tab} |
Close a tab. |
POST |
/v1/boxes/{id}/pages/{tab}/focus |
Bring a tab to the front. |
GET |
/v1/boxes/{id}/page |
Read the page. Query: format (markdown, text, raw), limit, max_links, tab. |
GET |
/v1/boxes/{id}/page/find |
Find elements. Query: q, limit, scroll, exact, tab. |
GET |
/v1/boxes/{id}/page/snapshot |
List the controls on the page. Query: scope, limit, delta, quiet_ms, tab. |
POST |
/v1/boxes/{id}/page/element |
Act on an element. Query: settle_ms, tab. See Element operations. |
POST |
/v1/boxes/{id}/page/evaluate |
Run JavaScript. Query: tab. Body: {"expression": "document.title", "timeout_ms": 5000, "limit": 2000}. |
POST |
/v1/boxes/{id}/page/screenshot |
Capture the page. Body: {"full": false, "format": "png", "quality": 70, "annotate": false, "tab": null}. |
POST |
/v1/boxes/{id}/page/pdf |
Print the page. Body: {"landscape": false, "no_background": false, "path": "/tmp/page.pdf"}. |
POST |
/v1/boxes/{id}/page/console |
Read the console. Body: {"errors": false, "clear": false, "limit": 200}. |
Browser state
| Method | Path | Effect |
|---|---|---|
POST |
/v1/boxes/{id}/state/save |
Save cookies and storage. Body: {"origins": [], "name": "work", "session_storage": false, "indexed_db": false, "no_local_storage": false}. With no name, the response has the state in session_json. |
POST |
/v1/boxes/{id}/state/load |
Load state. Body: {"name": "work"}, or {"session_json": "…"} for state from a file. |
GET |
/v1/states |
List saved names. |
DELETE |
/v1/states/{name} |
Remove a saved name. |
GET |
/v1/boxes/{id}/cookies |
List cookies. Query: url. |
POST |
/v1/boxes/{id}/cookies |
Set cookies. See below. |
DELETE |
/v1/boxes/{id}/cookies |
Clear cookies. Query: url, or none for all. |
Set cookies:
{
"url": "https://example.com",
"cookies": [
{ "name": "session", "value": "…", "http_only": true, "secure": true, "same_site": "Lax", "expires": 1790000000 }
]
}domain and path are also accepted on each cookie.
Chrome DevTools
| Method | Path | Effect |
|---|---|---|
POST |
/v1/boxes/{id}/cdp |
Make a CDP address with a short-lived token. Query: ttl_secs (default one hour, maximum 24 hours). Returns {url, ws_url, expires_at_ms}. |
| any | /v1/cdp/{token}/json, /v1/cdp/{token}/json/… |
The DevTools HTTP endpoints, through the server. No bearer token. |
GET |
/v1/cdp/{token}/devtools/… |
The DevTools WebSocket, through the server. No bearer token. |
Give url to Playwright connectOverCDP or browser-use cdp_url. Give ws_url to a library that needs the WebSocket address.
History
| Method | Path | Effect |
|---|---|---|
GET |
/v1/boxes/{id}/trace |
Read the trace. Query: after (sequence number), limit (maximum 500). |
GET |
/v1/boxes/{id}/trace/frames/{hash} |
Get a frame that the trace refers to. |
Runtimes and images
| Method | Path | Effect |
|---|---|---|
GET |
/v1/runtimes |
List runtimes. |
GET |
/v1/runtimes/{name} |
Get one runtime, with its capabilities under can. |
POST |
/v1/runtimes |
Add a remote vendor. See below. |
PATCH |
/v1/runtimes/{name} |
Change fields or secrets. Body: {"fields": {…}, "secrets": {"api_key": "…"}}. |
DELETE |
/v1/runtimes/{name} |
Remove a vendor. Refused while a box uses it. |
POST |
/v1/runtimes/{name}/image |
Build the image for a spec before a box needs it. Body: {"spec": {…}}. Returns {runtime, image, spec_digest}. When the server queues its jobs, it answers 202 with state: "building" and an empty image. |
GET |
/v1/runtimes/{name}/images |
List the images built for a runtime. |
GET |
/v1/runtimes/{name}/images/{digest} |
Read one image. state is building or failed (with reason), and is absent when the image is built. |
DELETE |
/v1/runtimes/{name}/images/{digest} |
Remove an image. Refused while a box uses it. |
GET |
/v1/images |
List all images the server built. |
Add a vendor:
{ "name": "cloud", "provider": "e2b", "fields": { "max_lifetime_secs": 86400 }, "secrets": { "api_key": "…" } }The server encrypts secrets before it stores them and never returns them. See Runtimes.
Events
The server keeps a log of what happens to boxes, screens and images, for usage and for webhooks.
| Method | Path | Effect |
|---|---|---|
GET |
/v1/events |
Read events in order. Query: after (the next of the last read, default 0) and limit (default 200, maximum 1000). Returns {events, next, more}. |
Each event has seq, at_ms, kind, and, when they apply, owner, box_id, runtime and data. A workspace reads its own events. The server token reads all of them. An event shows about two seconds after it happens, and the server keeps events for 7 days.
| Kind | When |
|---|---|
box.created |
A box was launched. box.ready follows it. |
box.ready |
A box is ready, after a launch, a resume or a start. |
box.paused, box.stopped |
A box was paused or stopped. |
box.removed |
A box was deleted, or it passed its deadline. |
box.unreachable |
The runtime no longer has the box. |
box.failed |
A queued box did not start. data.why says why. |
screen.taken_over, screen.given_back |
A person took a screen, or it was taken back. |
image.built, image.removed |
An image was built or removed on a runtime. |
Scheduled work
For a server that cannot keep a loop alive, such as a serverless function. Set HOLM_SERVER_SCHEDULE=external, and call these routes from a scheduler. Each accepts GET and POST, with the server token or CRON_SECRET as the bearer token.
| Method | Path | Effect |
|---|---|---|
GET, POST |
/v1/jobs/reap |
Remove the boxes that are past their deadline. Returns {removed}. Call it each minute. |
GET, POST |
/v1/jobs/prune |
Remove old frames, trace entries and expired notes. Returns {frames, entries, boxes}. Call it each hour. |
GET, POST |
/v1/jobs/run |
Do the queued launches, forks and builds. It stops taking new jobs after 50 seconds. Returns {ran}. Call it each minute. |
When HOLM_PUBLIC_URL and CRON_SECRET are set, the server calls its own /v1/jobs/run when it queues a job, so a job does not wait for the scheduler. On Vercel, the schedule goes in vercel.json:
{
"crons": [
{ "path": "/v1/jobs/run", "schedule": "* * * * *" },
{ "path": "/v1/jobs/reap", "schedule": "* * * * *" },
{ "path": "/v1/jobs/prune", "schedule": "0 * * * *" }
]
}MCP
/mcp serves MCP over Streamable HTTP, with the same bearer token. See the MCP tools reference.
Create a box
POST /v1/boxes
{
"spec": {
"desktop": {
"server": "x11",
"width": 1280,
"height": 800,
"screens": 1,
"features": ["wide_fonts", "video", "dock", "accessibility", "audio"],
"packages": ["jq"]
},
"policy": { "network": true }
},
"placement": {
"runtime": "docker",
"memory": "2g",
"cpus": "2",
"expires_after_secs": 3600,
"idle_timeout_secs": 900,
"profile": "work"
}
}All fields are optional. {} creates a box with the defaults.
spec.desktop:
| Field | Values |
|---|---|
server |
x11 (default) or wayland |
width, height |
Screen size. Default: the image's size. |
screens |
Number of screens. Default 1. |
features |
The whole set of wide_fonts, audio, video, dock, x11_apps, accessibility. Left out: wide_fonts, video, dock, accessibility, and x11_apps on Wayland. [] is the bare desktop. |
packages |
Apt packages |
spec.policy:
| Field | Values |
|---|---|
network |
false removes network access. Default true. |
auth |
Viewer access: none (default), password, or token |
bind |
loopback (default) or any |
advertise |
The host name to put in viewer URLs |
spec.apps defines applications that are not in the catalog.
placement:
| Field | Effect |
|---|---|
runtime |
A runtime name. Default: the server's default. |
memory, cpus |
Limits, such as "2g" and "2" |
expires_after_secs |
Remove the box after this time. Minimum 60. |
idle_timeout_secs |
Remove the box after this time with no use. Minimum 60. |
persistent |
true keeps the files of the box when it stops, so that POST /v1/boxes/{id}/resume brings it back. Default false. A runtime that cannot do this refuses the box. |
profile |
A browser profile name. Container runtimes only. |
The response is the box.
Actions
POST /v1/boxes/{id}/screens/{screen}/actions
A batch holds the screen for all its actions and returns one result.
{
"actions": [
{ "type": "open_url", "url": "https://example.com" },
{ "type": "wait_still", "settle_ms": 400, "within_ms": 10000 },
{ "type": "click", "at": { "x": 640, "y": 81 } }
],
"want": ["frame", "cursor"],
"settle_ms": 400,
"have_frame": "…",
"keep_going": false
}| Field | Effect |
|---|---|
actions |
The steps, in order. |
want |
frame and cursor add the final screen and pointer position to the result. |
settle_ms |
Wait this long after the last step. |
have_frame |
The hash of the last frame. If the screen did not change, the frame has unchanged: true and no image. |
keep_going |
Continue after a step is refused. Default: stop at the first refusal. |
The result:
| Field | Meaning |
|---|---|
results |
One result for each step that ran. A step that reads data returns it here. |
stopped_at |
The index of the step that stopped the batch, or null. |
frame, cursor |
Present when want asks for them. |
windows, tabs |
The windows and tabs after the batch. |
released, released_keys |
Buttons and keys that the server released at the end. |
holding, holding_keys |
Buttons and keys still down, with the time the server will release them. |
Action types
Each action has a type. Pointer actions accept motion (instant, smooth, human) and seed. button is left (default), right, or middle. held is a list of shift, ctrl, alt, and super.
Input
type |
Fields |
|---|---|
move |
to, motion, seed, pause_ms |
click |
at, button, held, motion, seed |
double_click |
at, button, motion, seed |
drag |
from, to, button, held, motion, seed |
path |
through (list of points), button, held, motion, seed |
mouse_down |
at, button, motion, seed, hold_ms |
mouse_up |
at, button |
scroll |
at, dx, dy (notches; positive is down and right) |
type |
text, delay_ms |
press |
chord, then, held |
key_down |
key, hold_ms |
key_up |
key |
With no at, a click or button action occurs at the pointer. The server releases a button or key after hold_ms (default 10 seconds, maximum 60), when the batch ends, or when a person takes control.
Waits
type |
Fields |
|---|---|
wait |
ms (maximum 30 seconds) |
wait_still |
settle_ms (default 400), within_ms (default 10000). Both stop at 30 seconds. |
await_window |
what: {class, within_ms} |
Screen and windows
type |
Fields |
|---|---|
capture |
what: {window, region, scale, pointer, tab} |
cursor |
None |
windows |
active |
on_window |
window, what: {"do": "focus" | "close" | "arrange", "how": …} |
launch |
app (a catalog name), args |
apps |
None |
record |
what: {"do": "start", "fps": 12}, {"do": "stop"}, or {"do": "status"} |
clipboard |
selection, text (sets it when present) |
Pages
type |
Fields |
|---|---|
open_url |
url, target (blank or current), label |
tabs |
None |
on_tab |
tab, close |
on_page |
what: an element operation |
look |
what: {query, limit, scroll, exact, role, tab} |
snapshot |
what: {scope, limit, tab, delta, quiet_ms} |
read |
what: {format, limit, max_links, tab} |
evaluate |
what: {expression, timeout_ms, limit}. timeout_ms defaults to 5000 and stops at 30 seconds. |
page_shot |
what: {full, format, quality, tab, annotate} |
dialog |
accept, text |
Native widgets
type |
Fields |
|---|---|
on_node |
what: a widget operation |
Files and commands
type |
Fields |
|---|---|
exec |
what: {argv, timeout_ms} |
read_file |
path |
write_file |
what: {path, contents_base64} |
list_files |
path |
grep |
what: {pattern, path, include, ignore_case, limit} |
glob |
what: {pattern, path, limit} |
Element operations
The body of POST /v1/boxes/{id}/page/element, and the what of an on_page action. Each has an op.
op |
Fields |
|---|---|
click |
query, button, double, new_tab, motion, seed |
drag |
from, to, button, motion, seed |
fill |
query, text |
focus |
query |
check |
query, on |
options |
query |
choose |
query, options, drop (true removes the named options) |
upload |
query, paths (paths in the box) |
hover |
query, motion, seed |
highlight |
query, ms |
wait_for |
query, gone, within_ms, or, exact, quiet_ms, enabled, load, until |
history |
go (back, forward, reload) |
scroll |
query, to (by, top, bottom), dx, dy |
{ "op": "fill", "query": "Email", "text": "[email protected]" }The result includes the element, the URL, whether the page navigated, and the controls that changed.
Native widgets
The body of POST /v1/boxes/{id}/screens/{screen}/desktop/node, and the what of an on_node action. Needs a box with the accessibility feature.
op |
Fields |
|---|---|
tree |
app, depth |
find |
node, limit |
focus |
node |
invoke |
node, action |
set |
node, value |
node is a query: {"query": "Street", "role": "text"}.
Fork
POST /v1/boxes/{id}/fork
{ "mode": "replay", "up_to": 40, "placement": { "expires_after_secs": 1800 } }| Field | Effect |
|---|---|
mode |
replay (default). snapshot is refused. |
up_to |
Repeat the trace up to this sequence number. |
placement |
A placement for the new box. Default: the original's. |
The result is {"box": {…}, "replay": {attempted, ok, stopped_at, truncated, skipped}}. Each entry in skipped has seq, kind, and why.
Example
BASE=http://127.0.0.1:8080
BOX=$(curl -fsS "$BASE/v1/boxes" \
-H 'content-type: application/json' \
-H 'idempotency-key: create-demo-1' \
-d '{"spec": {"desktop": {"width": 1280, "height": 800}}, "placement": {"expires_after_secs": 3600}}' |
python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
curl -fsS "$BASE/v1/boxes/$BOX/screens/0/actions" \
-H 'content-type: application/json' \
-d '{
"actions": [
{ "type": "open_url", "url": "https://example.com" },
{ "type": "on_page", "what": { "op": "wait_for", "query": "Example Domain" } },
{ "type": "on_page", "what": { "op": "click", "query": "More information" } }
],
"want": ["frame"]
}'
curl -fsS -X DELETE "$BASE/v1/boxes/$BOX" -H 'x-holm-confirm-delete: true'