Skip to content
Hoody.com

A container’s desktop applications are reachable at HTTPS URLs. Open one in a browser on a phone, tablet, laptop, or TV and you get the running desktop, with VS Code, a web browser, LibreOffice, or any other Linux GUI program on it.

Containers created with hoody_kit: true include hoody-display, which serves that desktop over HTTP to an HTML5 client. Nothing is installed on the viewing device.


  • Desktop environments - Any GUI application runs in the browser through the HTML5 client
  • Device support - Phone, tablet, laptop, TV, or anything else with a browser
  • Multiplayer sessions - Several people view and control one desktop at the same time
  • URL parameters - Over 50 options for theming, performance, and input
  • Screenshot API - Capture the desktop as a PNG programmatically
  • Computer Use API - Mouse, keyboard, and window control over HTTP, across dozens of endpoints
  • No client software - No VNC or RDP client to install; the browser is the client
  • Video encoding - H264 support, subject to the encoders on the server and the decoders in the browser
  • Clipboard sync - Copy and paste between the local machine and the container

Full parameter, response, and example documentation for each endpoint lives in the API reference.

Web client interface:

Screenshot and thumbnail API:

System information:

Retrieval reaches the 500 newest captures per display. The listing and the /{timestamp} lookups both read that window, so a capture that has dropped out of it answers 404 SCREENSHOT_NOT_FOUND even though its file is still on disk: nothing prunes the older files, they simply stop being addressable.

Mouse control:

  • POST /api/v1/display/mouse/click - Click a mouse button
  • POST /api/v1/display/mouse/double-click - Double-click
  • POST /api/v1/display/mouse/move - Move cursor to absolute position
  • POST /api/v1/display/mouse/move-relative - Move cursor by offset
  • POST /api/v1/display/mouse/down - Press and hold mouse button
  • POST /api/v1/display/mouse/up - Release mouse button
  • POST /api/v1/display/mouse/scroll - Scroll
  • GET /api/v1/display/mouse/location - Get current cursor position

Keyboard control:

  • POST /api/v1/display/keyboard/type - Type a string of text
  • POST /api/v1/display/keyboard/key - Press key combinations (X11 keysym notation)
  • POST /api/v1/display/keyboard/key-down - Hold a key down
  • POST /api/v1/display/keyboard/key-up - Release a held key

Window management:

  • GET /api/v1/display/windows - List the windows the window manager manages (up to 200)
  • POST /api/v1/display/window/focus - Focus/activate a window
  • POST /api/v1/display/window/move - Move a window
  • POST /api/v1/display/window/resize - Resize a window
  • POST /api/v1/display/window/minimize - Minimize a window
  • POST /api/v1/display/window/restore - Restore (un-minimize) a window
  • POST /api/v1/display/window/close - Close a window
  • POST /api/v1/display/window/raise - Ask the X server to raise a window (the display server may not restack it)
  • GET /api/v1/display/window/active - Get active window ID
  • POST /api/v1/display/window/search - Search windows by title pattern
  • GET /api/v1/display/window/{windowId}/geometry - Get window position and size
  • GET /api/v1/display/window/{windowId}/name - Get window title
  • GET /api/v1/display/window/{windowId}/properties - Get extended window properties

Clipboard:

  • GET /api/v1/display/clipboard - Read clipboard text from a buffer selection
  • POST /api/v1/display/clipboard - Write text to a clipboard buffer

Compound actions (Computer Use):

  • POST /api/v1/display/input/click-at - Move cursor and click
  • POST /api/v1/display/input/type-at - Move, click, and type in one operation
  • POST /api/v1/display/input/drag - Drag from one position to another
  • POST /api/v1/display/input/select - Select a range via click + shift-click
  • POST /api/v1/display/input/act - Execute one action and return a screenshot (on by default)
  • POST /api/v1/display/input/wait - Wait, optionally returning a screenshot
  • POST /api/v1/display/input/wait-until - Wait for a window to appear or disappear
  • POST /api/v1/display/input/batch - Execute up to 50 actions in order, stopping at the first failure
  • POST /api/v1/display/input/reset - Emergency release all held inputs
  • GET /api/v1/display/input/display-geometry - Get display dimensions

A container can run several display instances at once, each with its own URL:

https://{project}-{container}-display-1.{server}.containers.hoody.com
https://{project}-{container}-display-2.{server}.containers.hoody.com
https://{project}-{container}-display-3.{server}.containers.hoody.com

The usual arrangement is one display per application:

  • display-1 - Main IDE (VS Code)
  • display-2 - Web browser (Firefox/Chrome)
  • display-3 - Office applications (LibreOffice)
  • display-4 - Graphics editor (GIMP)
  • display-5 - Database tools

Each display runs independently, with its own:

  • Screen resolution
  • Window manager state
  • Application set
  • Performance profile

Any of them opens in any browser, on a phone, tablet, laptop, or TV, with nothing to install and nothing to configure.

Terminal integration: A terminal session with display: "5" gets DISPLAY=:5 exported into its shell, so firefox run there appears in display-5. A session opened in the browser at terminal-5 gets that automatically: the proxy sets display=5 from the hostname. A session created with POST /api/v1/terminal/create gets a display only if the request body sets display (or its alias display_id) or sets DISPLAY in env, such as "env": {"DISPLAY": ":5"}; its terminal ID alone does not pick one. Ephemeral sessions never get one: they drop any configured DISPLAY.

Manual display selection: Set the DISPLAY environment variable to target a specific display:

Terminal window
# In any terminal
export DISPLAY=:5
# Now GUI programs open in display-5
firefox & # Opens in display-5
code . # Opens in display-5

Applications can be spread across displays and driven from any terminal.

Display lifetime: A display belongs to the container, not to the terminal session that started it. Deleting that session leaves the display running, with its desktop environment and the applications opened from the desktop, so other terminals and anyone viewing it keep working. A GUI program started from the deleted terminal’s own shell belongs to that session and closes with it, unless it was detached from the shell (for example with setsid or nohup). A session that asks for desktop_env on a display whose desktop is already running reuses it rather than starting a second one. To end a display, call POST /api/v1/system/displays/{display}/stop on the terminal service: it shuts the display down with everything on it and answers once it is gone (404 DISPLAY_NOT_FOUND if nothing is running there). The next session created with that display starts it again.

The same URL works on every device that has a browser:

  • Laptop browser → the full desktop
  • Phone browser → the same desktop, touch-optimized
  • Tablet → useful for presentations
  • TV → the desktop on a large screen
  • Smart watch → monitoring dashboards
  • Smart glasses (future) → AR overlays of your infrastructure

The browser is the only thing running on the device. Applications execute on the server, so the device’s own processor and memory do not limit what you can run.

Share the URL and everyone who opens it sees and controls the same desktop:

https://{project}-{container}-display-1.{server}.containers.hoody.com/?sharing=true

Everyone connected:

  • Sees the same screen in real time
  • Can type and click at the same time as everyone else
  • Sees the shared pointer and the effect of everyone’s input (pointers are not labelled per person)
  • Sees each change as it happens

Common uses:

  • Team debugging, where everyone is looking at the same error
  • Teaching, with instructor and students on one desktop
  • Pair programming in a single IDE
  • Customer support, working inside the customer’s own session

See: Multiplayer by Default → for the collaboration model.

The client takes its configuration from the query string:

# Read-only dashboard
?readonly=true&decorations=false&toolbar=false&reconnect=true
# Low-bandwidth mode
?encoding=jpeg&bandwidth_limit=1000000&video=false&sound=false
# Collaborative session
?sharing=true&steal=false
# macOS user setup
?swap_keys=true&keyboard_layout=us
# Dark mode with floating menu
?floating_menu=true&dark_mode=true

Over 50 parameters control the UI, performance, input, feature flags, and session behavior. encoding requests the stream encoding when the viewer connects, and the encoding chosen in the saved viewer settings reaches the server too. The server chooses within the encodings supported by both ends, so an explicit choice still depends on the server’s encoders and the browser’s decoders.

See: Web Client Interface → for the full list.

Capture the current desktop state over HTTP:

Terminal window
# Capture current screenshot
GET /api/v1/display/screenshot?displayId=1
# Get as base64 for AI vision
GET /api/v1/display/screenshot?base64=true
# Lightweight thumbnail
GET /api/v1/display/thumbnail/last

Typical uses:

  • Vision-model analysis of UI state
  • Verification in automated tests
  • Documentation screenshots
  • Monitoring dashboards
  • Time-lapse recordings

See: Screenshot API →

A display is an ordinary URL, so it loads in an <iframe>:

<!-- Embed a display in a web page -->
<iframe src="https://{project}-{container}-display-1.{server}.containers.hoody.com"
width="1280" height="720"></iframe>
<!-- Several displays in one page: two from one container, one from another -->
<iframe src="https://{project}-{container}-display-1.{server}.containers.hoody.com"></iframe>
<iframe src="https://{project}-{container}-display-2.{server}.containers.hoody.com"></iframe>
<iframe src="https://{project}-{other-container}-display-1.{server}.containers.hoody.com"></iframe>

Composing iframes gives you a custom dashboard whose panels are live desktops rather than screenshots of them.

See: Embeddability Revolution →


RDP Client (installed) → RDP Server (configured) → Desktop (complex)

Where that model gets awkward:

  • A client has to be installed, which is difficult on mobile devices
  • Ports and firewall rules have to be configured
  • Embedding the session in a web page needs a separate gateway
  • Sharing one session between people needs server-side configuration
  • The protocol is binary, so reading the screen needs a client or bridge rather than an HTTP call
  • Setup is involved
Any Browser → Display URL → Desktop (immediately)

What changes with a display URL:

  • The browser is the client, so there is nothing to install
  • Traffic arrives over HTTPS on port 443 through Hoody Proxy, so no ports are configured
  • The URL embeds directly: <iframe src="display-url"></iframe>
  • Sharing the URL shares the session once it carries ?sharing=true (add steal=false to keep existing viewers connected)
  • Screenshots and the HTTP API make the desktop readable by an AI agent
  • Every Computer Use call is an HTTP request, so API-driven sessions can be logged (a browser viewer carries its input and screen updates over a WebSocket instead)
  • Phone browsers work without extra configuration

A phone browser opening a display URL gets whatever runs in that desktop:

// Phone browser opens display URL
https://{project}-{container}-display-1.{server}.containers.hoody.com
// Inside that desktop:
- Full VS Code IDE
- Chrome browser with DevTools
- Terminal sessions
- Database tools
- Any Linux GUI application

None of these applications runs on the phone. The container runs them and the browser renders the result.


Mouse input, keyboard input, window management, the clipboard, and compound actions each have HTTP endpoints, across dozens of REST routes.

Anything that can make an HTTP request can drive a GUI application through them, whether that is an automation script, a remote operator, or an AI agent working from screenshots. Keyboard input needs a viewer connected to the display (see Input delivery); screenshots do not.

Every Computer Use request acts on one display. On a display-N hostname, that display is N. A request can also name the display with the displayId query parameter (1 to 999999), which takes precedence over the hostname; the SDK and CLI send it from their displayId and --display-id options. A request with neither gets 400 NO_DISPLAY_CONTEXT.

The HTTP examples below set the host once in DISPLAY_URL. Every Computer Use, screenshot, window, and clipboard route also answers without the /api/v1/display prefix, so /mouse/click and /api/v1/display/mouse/click are the same endpoint.

Absolute and relative cursor movement, clicks, and scrolling, at pixel precision:

Terminal window
# Move cursor to position
hoody display mouse move --x 640 --y 480 --display-id 10 -c <container-id>
# Click at current position
hoody display mouse click --button 1 --display-id 10 -c <container-id>
# Double-click
hoody display mouse click --double --button 1 --display-id 10 -c <container-id>
# Scroll down
hoody display mouse scroll --direction down --clicks 5 --display-id 10 -c <container-id>

Mouse buttons: 1 (left), 2 (middle), 3 (right). Buttons 4 to 7 are the scroll wheel (up, down, left, right); mouse/scroll sends one of them per click.

Type text and send any key combination the OS understands:

Terminal window
# Type text
hoody display keyboard type --text "Hello, World!" --delay 50 --display-id 10 -c <container-id>
# Press key combination
hoody display keyboard press --keys "ctrl+s" --display-id 10 -c <container-id>
# Hold key
hoody display keyboard down --key "Shift_L" --hold-ms 2000 --display-id 10 -c <container-id>

List the windows on a desktop, then focus, move, resize, or close any of them:

Terminal window
# List visible windows
hoody display windows list --only-visible --display-id 10 -c <container-id>
# Focus a window
hoody display windows focus --window-id 83886081 --display-id 10 -c <container-id>
# Move a window
hoody display windows move --window-id 83886081 --x 100 --y 100 --display-id 10 -c <container-id>
# Resize a window
hoody display windows resize --window-id 83886081 --width 1024 --height 768 --display-id 10 -c <container-id>
# Search for windows by name
hoody display windows search --pattern "Firefox" --name --only-visible --display-id 10 -c <container-id>

click, type, drag, and select (the input/click-at, input/type-at, input/drag and input/select routes) each run their steps as a single input command, so a click-then-type sequence costs one round trip. act runs one action and returns a screenshot of the result. It takes the same action names as a batch, window actions included; an unknown name or invalid params answers 400 VALIDATION_ERROR. batch runs a list of actions in order; see Batches for how it reports failures.

Terminal window
# Click at specific position
hoody display input click --x 640 --y 480 --button 1 --display-id 10 -c <container-id>
# Type at position
hoody display input type --x 300 --y 400 --text "Hello" --delay 50 --display-id 10 -c <container-id>
# Drag between positions
hoody display input drag --start-x 100 --start-y 100 --end-x 300 --end-y 300 --display-id 10 -c <container-id>
# Execute action with screenshot
hoody display input act --action mouse/click --params button=1 --screenshot --display-id 10 -c <container-id>

An agent finds the browser window, navigates to a login page, fills the form, and submits it:

const base = 'https://{project}-{container}-display-1.{server}.containers.hoody.com';
// 1. Find the browser window
const { windows } = await fetch(`${base}/api/v1/display/windows`).then(r => r.json());
const browser = windows.find(w => w.name.includes('Firefox'));
// 2. Focus it; sync waits for the X requests, then verify keyboard focus before sending input
const focusResponse = await fetch(`${base}/api/v1/display/window/focus`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ windowId: browser.windowId, sync: true })
});
const focus = await focusResponse.json();
if (!focusResponse.ok) {
throw new Error(`${focus.code}: ${focus.error}`);
}
if (!focus.details.inputFocus) {
throw new Error(focus.details.warning ?? 'The window did not take keyboard focus');
}
// 3. Navigate to URL via address bar
const nav = await fetch(`${base}/api/v1/display/input/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
actions: [
{ action: 'keyboard/key', params: { keys: ['ctrl+l'] } }, // Focus address bar
{ action: 'input/wait', params: { ms: 200 } },
{ action: 'keyboard/type', params: { text: 'https://app.example.com/login' } },
{ action: 'keyboard/key', params: { keys: ['Return'] } }
]
})
}).then(r => r.json());
if (!nav.success) throw new Error(`Navigation stopped at action ${nav.failed.index}: ${nav.failed.error}`);
// Wait for the page to load and capture it to check that it did
const loaded = await fetch(`${base}/api/v1/display/input/wait`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ms: 2000, screenshot: true })
}).then(r => r.json());
// 4. Fill in login form
await fetch(`${base}/api/v1/display/input/type-at`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x: 640, y: 380, text: 'user@example.com' })
});
await fetch(`${base}/api/v1/display/input/type-at`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x: 640, y: 450, text: 'supersecretpassword' })
});
// 5. Submit and capture result
const result = await fetch(`${base}/api/v1/display/input/act`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'keyboard/key',
params: { keys: ['Return'] },
screenshot: true
})
}).then(r => r.json());
// result.screenshot.image.dataUrl is the PNG as a data URI; send it to a vision model to verify the login

These calls act on the pointer and the keyboard rather than through a CLI wrapper or a browser-specific automation driver, so the same sequence works against any Linux GUI application, in any window, from anything that can make a curl request.

POST /api/v1/display/input/batch runs 1 to 50 actions in order in one request. Action names are the endpoint paths without the prefix: mouse/click, mouse/double-click, mouse/move, mouse/move-relative, mouse/down, mouse/up, mouse/scroll, keyboard/type, keyboard/key, keyboard/key-down, keyboard/key-up, input/click-at, input/type-at, input/drag, input/select, input/wait, clipboard/read, clipboard/write, window/focus, window/raise, window/minimize, window/restore, window/move, window/resize, window/close, and window/search. Each entry’s params go through the same schema its standalone route uses. Screenshots are the one thing a batch cannot do; follow the batch with input/act or input/wait and "screenshot": true.

Mouse, keyboard, input, and window entries are ordinary entries on the per-display input queue, so a batch is a way to send a sequence in one round trip, not a way to lock the queue: another request’s input can still run between two entries. input/wait and the two clipboard actions run outside that queue, so they take no slot and are not serialized against another request’s input.

A batch is not a transaction. It stops at the first action that fails, and the actions before it stay done. Only the envelope can be rejected: a missing, empty, or over-length actions array and a missing display context are 400, and a body over 2 MiB is 413. Once the request itself is valid the response is HTTP 200 whether or not every action ran, and every per-action outcome, including a rejected params, a full queue, a window that is gone, and a display that stopped answering, is reported in the body. A client that branches on the status code alone reads a failed batch as a success, so check success:

{
"success": false,
"completed": [{ "index": 0, "action": "mouse/click", "success": true }],
"failed": { "index": 1, "action": "input/wait", "error": "Validation failed for input/wait: \"ms\" must be greater than or equal to 50" },
"skipped": [2, 3]
}

Actions that read something back report it as the entry’s result: clipboard/read (the clipboard text, as a bare string), window/focus ({inputFocus}), window/restore ({windowId, previousState, state, synced}), and window/search (an array of window IDs). An action whose only outcome is that it happened carries no result key. input/act reports the same value as action.result. The batch checks its 60-second budget twice, and the two failures read differently. Before an action starts: failed.error is Batch timeout exceeded, and skipped lists that action and every one after it. And again when the action reaches the front of the input queue, so one that spent the budget waiting behind another request’s input is refused rather than delivered late: failed.error is then Command deadline passed while it waited in the queue, and because that action did reach the front, skipped starts after it, as it does on an ordinary failure.

POST /api/v1/display/input/wait-until blocks until a window appears or goes away, so an agent does not have to poll window/search over HTTP while an application starts or a dialog closes. window-present needs a pattern. window-absent takes either a pattern or an exact windowId, not both. A pattern search also takes match (name, the default, or class or classname) and onlyVisible, which filters unmapped windows rather than minimized ones. Either of them alongside windowId is rejected rather than ignored, since that is an identity check rather than a search. timeoutMs runs from 100 to 25,000 and defaults to 10,000; pollMs runs from 100 to 5,000 and defaults to 250. A pollMs you send must not exceed timeoutMs, but the default is not compared against it, so a timeoutMs under 250 on its own is a legal request for a single look rather than a 400.

A condition that stayed false for the whole timeout is a 200 with timedOut: true rather than an error, so an expired wait is something to branch on instead of something to catch. Real failures keep their own status: 503 for a display that stopped answering, 400 for an inconsistent body or a pattern that cannot be compiled, 500 for anything else. A satisfied wait answers {"success": true, "condition": "window-present", "waitedMs": 1240, "polls": 5, "windows": [83886081]}.

The poll runs outside the per-display input queue, so a wait never takes a queue slot and never delays a timed key or button release. It has its own cap instead: 4 concurrent waits per display and 16 per process, over which it answers 429 QUEUE_FULL. Each observation gets a slice of the time left, and an unfiltered search walks the whole window tree, so a single pass that overruns its slice means a busy display rather than a dead one and is retried at once. The 503 bar is evidence about the display rather than about your budget: an earlier observation overran its slice, or one observation had a full 5-second slice and still did not finish. A short timeoutMs that expires inside a single observation is an ordinary 200 with timedOut: true and polls: 0, since a healthy display on a loaded host is not an unavailable one. An observation that told you nothing is not counted in polls. timeoutMs stops at 25 seconds because the generated SDK’s transport timeout defaults to 30, and a longer server budget would abort in the client before the timedOut body could be read.

Terminal window
# Wait up to 15 s for a window whose class matches "Firefox"
hoody display windows wait --condition window-present --pattern "Firefox" --match class --timeout-ms 15000 --display-id 10 -c <container-id>
# Wait for a specific window to be gone
hoody display windows wait --condition window-absent --window-id 83886081 --display-id 10 -c <container-id>

Errors come back as {"error": ..., "code": ..., "details": {"message": ...}}, and most also carry details.help. The invalid screenshot-region error is one that does not, so treat help as optional:

StatusCodeReturned when
400VALIDATION_ERRORA body field is missing, has the wrong type, or is out of range; also an unknown action name or invalid params inside input/act
400NO_DISPLAY_CONTEXTThe request has neither a display-N hostname nor displayId
404WINDOW_NOT_FOUNDThe window ID does not exist, or the window manager does not manage that window
409NO_WINDOW_MANAGERThe display has no window manager: GET /windows, GET /window/active, POST /window/focus, and POST /window/restore cannot complete; input/act returns the same error for an affected action
429QUEUE_FULL20 commands are already waiting for this display, or input/wait-until is already running 4 waits for this display or 16 across the service
413PAYLOAD_TOO_LARGEThe JSON body is over 2 MiB
500INPUT_ACTION_FAILEDThe input command failed
500CLIPBOARD_FAILEDA standalone clipboard read or write failed
500WINDOW_LIST_FAILEDThe window list could not be read
500WINDOW_PROPERTIES_FAILEDThe window’s extended properties could not be read
503DISPLAY_NOT_AVAILABLEThe display is not running, or a command did not finish in time

Three different budgets apply, and it helps to keep them apart.

The queue. Input for a display runs one command at a time, in arrival order, with up to 20 commands waiting; a 21st is 429 QUEUE_FULL. Time spent waiting in the queue is not counted against a command. Queued: every mouse, keyboard and window action, the six input routes input/click-at, input/type-at, input/drag, input/select, input/reset and input/display-geometry, plus /window/active, /window/search, /window/{id}/geometry and /name. Not queued: input/wait, input/wait-until (which polls on its own admission cap instead), the clipboard endpoints, the window listing and /window/{id}/properties (GET /windows does make one queued call first, to find the focused window). Inside a batch the same split holds, so clipboard/* and input/wait entries never take a queue slot.

Per subprocess. Mouse, keyboard, clipboard, and ordinary window commands are killed at 5 seconds each, and an action is not always one command: window/focus runs windowactivate and then windowfocus, two budgets in sequence, so it can take up to about 10 seconds before it gives up. window/restore is budgeted on its sync instead, 3.5 seconds with it (2 of them waiting for the window manager) and 1.5 seconds without. Screenshot cropping gets 10 seconds rather than 5. Screenshot capture itself is outside the 5-second budget: the service sets no overall time limit on it.

Per request. input/wait sleeps for its full ms, up to 30 seconds. input/wait-until runs to its timeoutMs, up to 25 seconds. A batch gets 60 seconds across all its entries, and an input/wait inside one is clamped to whatever is left of that.

Request bodies are checked against these ranges:

FieldAccepted values
x, y on mouse/move, mouse/move-relative, window/move-65535 to 65535
Coordinates on click-at, type-at, drag, select0 to 65535
button1 to 7
windowId, window32-bit X window ID, as a number or 0x hex string
keys on keyboard/key1 to 20 X11 keysyms or combinations (ctrl+shift+t), using letters, digits, _, +, -, and .
text on keyboard/type, type-atUp to 10,000 characters, but the default pause between keystrokes types about 160 characters a second and the command has 5 seconds, so a call over roughly 700 characters is cut off with 503 DISPLAY_NOT_AVAILABLE after typing part of the text. Pass delay (ms per character, 0 to 1000): "delay": 0 types 10,000 characters in about a second
text on clipboard/write1 to 1,048,576 characters
The whole JSON bodyUp to 2 MiB (413 PAYLOAD_TOO_LARGE above that; malformed JSON is 400 INVALID_JSON)
ms on input/wait50 to 30,000
holdMs on mouse/down, keyboard/key-down100 to 60,000
actions on input/batch1 to 50
repeat on mouse/click and input/click-at, clicks on mouse/scroll1 to 100
delay on mouse/click, input/click-at, mouse/scroll0 to 5000, defaulting to 100 ms. The delay is waited out after every repetition, the last one included, and the call is stopped at 5 seconds, so the count times the delay must not exceed 4000 ms: repeat: 100 needs delay: 40 or less. Over that the call is rejected with 400 VALIDATION_ERROR naming both fields and the arithmetic. The count is never silently capped
x, y on mouse/scroll0 to 65535, both or neither
condition on input/wait-untilwindow-present or window-absent
pattern on input/wait-until1 to 512 characters of extended POSIX regex
timeoutMs on input/wait-until100 to 25,000 (default 10,000)
pollMs on input/wait-until100 to 5,000 (default 250). A value you send must be no larger than timeoutMs; the default is not compared

A key named like an input-tool command (exec, type, search, and the rest) is rejected with 400 in any casing, so a request cannot chain extra commands.

Responses are JSON with a success field. GET /windows returns window objects with windowId, name, class, desktop, geometry, focused, and states, while POST /window/search returns bare IDs: {"success": true, "windows": [83886081]}. An empty windows array from window/search means no window matched and nothing else: a full queue answers 429, a display that stopped answering answers 503, and a pattern that cannot be compiled answers 400. GET /window/active returns {"success": true, "windowId": 83886081}, and GET /mouse/location returns {"success": true, "x": 640, "y": 480, "screen": 0, "window": 83886081}. When act is given a screenshotRegion, the cropped image carries data and mimeType but no dataUrl.

Some input reaches the X server and still never reaches the application. These are the usual causes:

  • Choosing the window. window/focus activates a window and gives it keyboard focus, so untargeted input that follows reaches it; the response carries details.inputFocus. An API click does not move keyboard focus on this display server, so clicking inside a window is not enough: call window/focus first.
  • Targeted input. Keyboard actions and mouse/click, mouse/double-click, mouse/down, mouse/up accept an optional window ID; on mouse/move it sets the coordinate origin, and mouse/move-relative and mouse/scroll do not accept it. A targeted event that goes to a window without focus is delivered with XSendEvent, which xterm and some other applications ignore, so prefer window/focus followed by untargeted input.
  • A viewer must be connected. With no viewer attached, the display server keeps windows unmapped: keyboard focus cannot land on them, window/focus answers details.inputFocus: false, and keystrokes reach nothing. Screenshots still work. Keep a viewer (a browser tab on the display URL) open for input automation.
  • Pasted commands. A middle click pastes the PRIMARY selection, so write it with {"selection": "primary"} (POST /clipboard defaults to clipboard, which middle click does not read). The pasted text does not run even if it ends in a newline: bash 5.1 and later insert it literally. Send {"keys": ["Return"]} to keyboard/key after the paste.
  • Drags. Without steps, drag moves the pointer in one jump, which many applications do not treat as a drag. Pass steps for intermediate motion: the range is 1 to 1000, but 1 still sends a single jump, so use at least 2 (for example "steps": 8).
  • Held input. holdMs on mouse/down and keyboard/key-down schedules a release after that many milliseconds; the release is queued like any other command, so a busy display can delay it. Without holdMs the input stays held until mouse/up, keyboard/key-up, or input/reset.
  • Read-only viewers. readonly=true on a display URL blocks input from that browser viewer only. The HTTP API still accepts input.
  • Clipboard reads can time out. GET /clipboard returns "" at once when nothing owns the selection. When the display server owns it on behalf of a viewer that is not there, the read gets no answer and ends in 503 DISPLAY_NOT_AVAILABLE after 5 seconds (seen on ?selection=primary with a viewer whose clipboard sync is off). Writing the selection first with POST /clipboard gives reads a local owner, and they answer in milliseconds.

Other options change how a call behaves: repeat and delay on mouse/click and input/click-at, delay and an optional x/y pair on mouse/scroll, delay (ms per character) on keyboard/type and type-at, clearModifiers on keyboard/type and keyboard/key, relative on window/move, useHints on window/resize, name, class, classname, and onlyVisible on window/search, screenshotDelay and screenshotRegion on input/act, onlyVisible on GET /windows, selection on the clipboard endpoints, and region and cursor on GET /screenshot. The API reference documents each one.

onlyVisible is two different tests under one name. On window/search and input/wait-until it asks the X server whether a window is viewable. A window stays viewable after window/minimize even once it is reported iconic, so this does not skip minimized windows. What it does filter is unmapped windows, which on a seamless session means every window while no viewer is attached. On GET /windows it is a filter on _NET_WM_STATE_HIDDEN instead. A minimized window carries that state, so there it does exclude minimized windows.


The same container, opened from three devices in turn:

Terminal window
# Laptop: Configure display
https://PROJECT_ID-CONTAINER_ID-display-1.{server}.containers.hoody.com/?dark_mode=true&swap_keys=true
# Phone (later): Same URL, same environment
https://PROJECT_ID-CONTAINER_ID-display-1.{server}.containers.hoody.com/?dark_mode=true&swap_keys=true
# Tablet (during presentation): Same environment
https://PROJECT_ID-CONTAINER_ID-display-1.{server}.containers.hoody.com/?dark_mode=true

It is one machine reachable from a phone, laptop, tablet, or TV. Nothing is synchronized between them, because each device is attached to the same running instance.

Several people working in one desktop:

// Create the collaborative session
const displayUrl = 'https://{project}-{container}-display-1.{server}.containers.hoody.com';
const collaborativeUrl = `${displayUrl}/?sharing=true&steal=false`;
// Send the URL to the team. Everyone sees the same desktop and can:
// - Open files in the shared VS Code
// - Type in the shared terminal
// - Click in the shared browser
// - Edit in the shared applications
// Everyone is attached to one session, in real time

A support agent joins the customer’s desktop instead of starting a screen share:

Terminal window
# Customer shares the display URL
https://PROJECT_ID-CONTAINER_ID-display-1.{server}.containers.hoody.com/?sharing=true
# Support agent opens it on a phone during a commute,
# sees the customer's desktop, and types the fix directly
# There is no screen-share session to set up
# and no need to ask "can you see my screen?"

One URL keeps control, the other is read-only:

# Presenter URL (full control)
?sharing=true&steal=false&readonly=false
# Viewers URL (watch only)
?sharing=true&steal=false&readonly=true

The presenter keeps control and viewers watch in real time. Useful for:

  • Product demos
  • Architecture reviews
  • Training sessions
  • Live coding demonstrations

Capture the desktop and hand the image to a vision model:

// 1. Capture screenshot via HTTP
const response = await fetch(
'https://{project}-{container}-display-1.{server}.containers.hoody.com/api/v1/display/screenshot?base64=true'
);
const { image, info } = await response.json();
// 2. Send to a vision model via Hoody AI (any provider you've configured)
const analysis = await ai.chat.completions.create({
model: 'your-vision-model',
messages: [{
role: 'user',
content: [
{ type: 'text', text: 'What errors do you see in this IDE?' },
{ type: 'image_url', image_url: { url: `data:image/png;base64,${image.data}` }}
]
}]
});
// 3. AI describes what it sees
console.log(analysis.choices[0].message.content);
// "I see a syntax error on line 23: unclosed bracket..."

With the screenshot in hand, a model can do visual debugging, UI analysis, and accessibility testing against what is actually on screen.

Three environments side by side, each of them read-only:

<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem;">
<!-- Production -->
<iframe src="https://{project}-{prod-container}-display-1.{server}.containers.hoody.com/?readonly=true&toolbar=false"></iframe>
<!-- Staging -->
<iframe src="https://{project}-{staging-container}-display-1.{server}.containers.hoody.com/?readonly=true&toolbar=false"></iframe>
<!-- Development -->
<iframe src="https://{project}-{dev-container}-display-1.{server}.containers.hoody.com/?readonly=true&toolbar=false"></iframe>
</div>

Every panel is live, so all three environments stay visible without switching tabs. readonly=true stops input from these panels only; it does not restrict the display’s HTTP API.


The desktop runs on the server, so any machine with a browser and a connection reaches it: a café, a beach, an airport, a hotel room. If the laptop breaks, another device gets you back to the same session, and there is nothing on the laptop to carry.

One container’s desktop runs a browser window per account, each already signed in. Five team members open the same display URL and see all 20 browsers, so nobody has to be sent a password and nobody has to ask whose turn it is to post. There is nothing to synchronize between machines, because everyone is looking at one desktop.

Start coding on a laptop, continue on a tablet during the commute, finish on a phone at a café. The VS Code window, the terminal, and the open files are the same ones, because it is the same desktop rather than a synchronized copy.

An agent works against the display URL you have open. It:

  1. Takes screenshots to see what you are working on
  2. Types into the IDE through the input API
  3. Runs tests in the terminal
  4. Reads the resulting UI state from the next screenshot

The agent acts on the desktop alongside you rather than proposing edits for you to apply.

The instructor shares one display URL with 30 students. Everyone sees the same screen while the instructor demonstrates, and a student can take control when invited.

The customer sends their display URL. The agent opens it on whatever device is to hand, sees the customer’s desktop, and types the fix. There is no meeting to join and no need to talk the customer through each click.


On a fast connection:

?encoding=h264&video=true

On a slow or metered connection:

?encoding=jpeg&video=false&bandwidth_limit=500000
?readonly=true&steal=false&sharing=true

Viewers can watch but cannot type or click, which rules out accidental input during a demo. The same combination suits monitoring dashboards.

macOS keyboards need the key swap, and the client turns it on by itself when it detects a Mac, so a Mac user normally sets nothing. Pass the parameter to force the swap on a machine the client does not read as a Mac, or swap_keys=false to turn it off:

?swap_keys=true&keyboard_layout=us

That maps Cmd to Ctrl, so copy and paste keep the keys your hands expect.

The client reconnects by itself after an interruption, and does so by default, so there is nothing to switch on. It matters most for mobile use, where the connection moves between WiFi and cellular, and on unreliable networks generally. Pass the parameter to turn the behavior off:

?reconnect=false

Fetch thumbnails for a grid of previews, and a full screenshot only when someone looks closely. GET /thumbnail and GET /screenshot capture the display; the /last variants retrieve the most recent capture and answer 404 SCREENSHOT_NOT_FOUND when no capture exists. GET /thumbnail/last answers 404 THUMBNAIL_NOT_FOUND when the capture exists but has no thumbnail. Capture on a schedule if the gallery must stay current:

const base = 'https://{project}-{container}-display-1.{server}.containers.hoody.com';
// Get thumbnail (small, fast)
const thumb = await fetch(`${base}/api/v1/display/thumbnail/last`);
// Full screenshot only when needed
const full = await fetch(`${base}/api/v1/display/screenshot/last`);

Budget for the 500-capture retrieval window when you schedule captures: at one a minute a display fills it in under nine hours, after which the oldest captures stop answering by timestamp. Fetch anything you need to keep while it is still inside the window.

A thumbnail is the full capture scaled to 20% (a 1920x1080 capture gives a 384x216 thumbnail), so a gallery of thumbnails costs a fraction of the bandwidth. The capture composites the windows currently shown, so its size follows the window layout rather than the configured screen size and can exceed it when windows extend past the screen edge.

?sound=false&printing=false&clipboard=false&file_transfer=false

Each of these reduces bandwidth and CPU usage. Turn on only what the session needs.


Can I run desktop applications on my phone?

Section titled “Can I run desktop applications on my phone?”

Yes. The phone’s browser renders the Linux GUI applications running on the display, so VS Code, LibreOffice, GIMP, and browsers all work. None of them execute on the phone; the applications run in the container and the server does the work.

What’s the difference between display-1, display-2, etc.?

Section titled “What’s the difference between display-1, display-2, etc.?”

Each number is a separate X display with its own URL. display-1 might show VS Code, display-2 monitoring tools, display-3 browsers. Each is its own X session with its own windows, and one container can hold several of them. The numbers are not an access boundary: any display URL of the container can address another display with ?displayId=.

How does multiplayer handle input from several people?

Section titled “How does multiplayer handle input from several people?”

The display server holds the state and synchronizes it. When one person types, the keystroke goes to the display server, which broadcasts the result to every connected client. Input is serialized, one keystroke at a time, while the visual updates reach everyone at once. It works like Google Docs, with a desktop as the shared document.

Yes. The Computer Use API exposes mouse and keyboard control over HTTP. An agent can capture screenshots with GET /screenshot to read the desktop state, move and click the mouse at exact coordinates, type text and press key combinations, manage windows (focus, resize, move, minimize, restore, close), and chain compound actions through POST /api/v1/display/input/batch for multi-step workflows. See Computer Use API above.

What happens if my internet drops while using a display?

Section titled “What happens if my internet drops while using a display?”

With ?reconnect=true (the default), an established viewer session retries a lost connection up to 30 times, one second apart. If the connection returns within that budget, the viewer reconnects automatically; after it gives up, reopen the display URL. Raise the budget with ?reconnect_count= (1 to 1000). Losing the viewer connection does not stop the desktop, so what you lose is the view rather than the session. On reconnect you see the current state, not the state you left.

Do displays work on touch devices like tablets?

Section titled “Do displays work on touch devices like tablets?”

Yes. The HTML5 client turns touch movement into remote scrolling and taps into clicks, and brings up a virtual keyboard, which is what makes a touch-only device usable for full desktop control. The virtual keyboard is on by default wherever the client reads the browser as a mobile one. It reads that from the user agent, so a touchscreen laptop does not count: set ?keyboard=true there to get the same keyboard, or ?keyboard=false to suppress it on a phone.

Extensively, all through URL parameters: floating menu style, window decorations, toolbar visibility, and dark mode.

See: UI Theming →

How much bandwidth does a display session use?

Section titled “How much bandwidth does a display session use?”

It depends on the encoding, the resolution, and how much of the screen changes: video encoding of moving content costs far more than JPEG updates on a mostly static screen. Measure your own workload rather than budgeting from a fixed figure. ?encoding=jpeg&bandwidth_limit=1000000 caps a session at 1 Mbps.

Yes. A display URL loads in an iframe like any other page. Common patterns are dashboards showing live server state, documentation with an interactive example beside the text, and customer portals with a diagnostic desktop.

It follows the network path to your server, so distance and link quality dominate; the client’s statistics panel reports the measured latency of a live session. On an ordinary link it is comfortable for coding, document editing, and web browsing, and not suitable for gaming or high-frequency trading.


Window operations answer 409 NO_WINDOW_MANAGER

Section titled “Window operations answer 409 NO_WINDOW_MANAGER”

The display is running without a window manager, either because no desktop environment was started or because its window manager exited. Start a window manager, for example by starting a desktop session with a desktop environment. Retrying the same request does not change this state.

GET /windows, GET /window/active, POST /window/focus, and POST /window/restore answer 409 NO_WINDOW_MANAGER, with guidance in details.help. input/act returns the same error for an affected action; a batch reports it in failed inside its HTTP 200 response. window/search, window geometry and name, and mouse and keyboard actions still work without a window manager.

Display won’t load or shows a black screen

Section titled “Display won’t load or shows a black screen”

Check the container is running:

Terminal window
curl "https://api.hoody.com/api/v1/containers/{id}?runtime=true" \
-H "Authorization: Bearer $HOODY_TOKEN"

Verify that runtime_info.displays shows an active display with a PID.

Common causes:

  1. Container stopped - Start it:

    Terminal window
    curl -X POST "https://api.hoody.com/api/v1/containers/{id}/start" \
    -H "Authorization: Bearer $HOODY_TOKEN"
  2. Display service not started - Wait 30-60 seconds after container start for the services to initialize

  3. Wrong display number - Check the container’s runtime_info.displays for the available display IDs

Enable reconnect:

?reconnect=true

Reduce quality for stability:

?encoding=jpeg&video=false

Check the network:

  • Test on a different WiFi or cellular connection
  • Verify connectivity to the server
  • Check that a firewall isn’t blocking WebSocket

For macOS users:

?swap_keys=true&keyboard_layout=us

For other layouts:

?keyboard_layout=gb # UK
?keyboard_layout=de # German
?keyboard_layout=fr # French

Ask for the virtual keyboard explicitly. The client already shows it when it reads the browser as a mobile one, so this is for a touch device it does not recognize as mobile, such as a touchscreen laptop:

?keyboard=true

Enable the clipboard if it is disabled:

?clipboard=true

Check browser permissions:

  • The browser may block clipboard access
  • Try a different browser
  • Grant clipboard permissions when prompted

Some browsers restrict clipboard access for security. Copy and paste inside the remote desktop always works.

Optimize encoding:

# Fast connection
?encoding=h264
# Slow connection
?encoding=jpeg&video=false

Reduce bandwidth:

?bandwidth_limit=500000 # 500 Kbps max

Disable high-bandwidth features:

?sound=false&video=false

Check server load:

Terminal window
# Query system resources (served by the terminal kit, not the display)
curl "https://{project}-{container}-terminal-1.{server}.containers.hoody.com/api/v1/system/resources"

Check the URL parameter:

?readonly=false # Enable control

Verify permissions:

  • Proxy permissions may block the display service altogether (there is no view-only level); if the viewer loads but input does nothing, check readonly in the URL
  • Check the container’s proxy-permissions configuration
  • Try removing the permissions temporarily for testing

Use session sharing:

?sharing=true&steal=false

Without it, sharing=false admits only one user, and steal=true (the default) lets a new connection kick the current one out. Collaboration needs sharing=true in the URL; the display server that Hoody starts already allows sharing on its side.


Other visual services:

Browser

Chromium and Firefox automation as a REST API: drive a browser, scrape data, run tests.

Explore Browser →

Code

VS Code over HTTP: start an IDE on demand and share the session.

Explore Code →

Display configuration reference: