Terminal Automation
Section titled “Terminal Automation”The automation endpoints treat the terminal as a programmable screen: you read what’s on it, press keys into it, and wait for it to settle. Every one of them runs over HTTP and works from the terminal’s rendering state.
Traditional terminal automation sends raw bytes and infers what happened. hoody-terminal instead maintains a server-side terminal emulator that mirrors the screen state a human would see. Every automation endpoint operates on that parsed screen, which makes control deterministic for full-screen TUI applications like vim, htop, and tmux, and for any other interactive program.
The design is inspired by tui-use, with the terminal emulator running server-side. A client needs nothing beyond the ability to make HTTP requests. There is no client library to install and no browser in the loop.
Programs that take over the screen
Section titled “Programs that take over the screen”Traditional terminal scripting (/api/v1/terminal/execute) works well for commands that produce output and exit. Programs that take over the screen need a different kind of control:
- vim: navigate, type, save, quit
- htop: select processes, sort columns, send signals
- python3: feed expressions to a REPL and read the results
- ssh: wait for a password prompt, type credentials, then wait for the shell
- tmux: create panes, switch windows, send commands to specific panes
These programs don’t write to stdout/stderr. They draw on the terminal screen using escape sequences. The automation endpoints let you interact with them the way a human would: by reading the screen and pressing keys.
Throughout this page, all examples use a $TERMINAL variable for the base URL:
TERMINAL="https://$PROJECT-$CONTAINER-terminal-1.$SERVER.containers.hoody.com"Set this once and every curl/fetch example below works as-is.
Quick start
Section titled “Quick start”This workflow launches Python, runs a calculation, and reads the result.
Step 1: Start a Python REPL
# Start python3 (it takes over the screen, so don't wait)curl -X POST "$TERMINAL/api/v1/terminal/execute" \ -H "Content-Type: application/json" \ -d '{"command": "python3", "wait": false}'const TERMINAL = `https://${PROJECT}-${CONTAINER}-terminal-1.${SERVER}.containers.hoody.com`;
// Start python3 (it takes over the screen, so don't wait)await fetch(`${TERMINAL}/api/v1/terminal/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'python3', wait: false })});One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Starts python3 without waiting for it to exit, since it takes over the screen instead.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/execute&method=POST&json={"command":"python3","wait":false}&response=transparent Step 2: Wait for the Python prompt
# Wait until ">>>" appears on screencurl -X POST "$TERMINAL/api/v1/terminal/wait?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"mode": "regex", "pattern": ">>>[ ]+$", "timeout_ms": 5000}'// Wait for the ">>>" promptawait fetch(`${TERMINAL}/api/v1/terminal/wait?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'regex', pattern: '>>>[ ]+$', timeout_ms: 5000 })});One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Blocks until the ”>>>” prompt appears, then returns the atomic snapshot from that moment.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/wait?terminal_id=1&method=POST&json={"mode":"regex","pattern":">>>[%20]%2B$","timeout_ms":5000}&response=transparent Step 3: Type a calculation and press Enter
# Paste the expressioncurl -X POST "$TERMINAL/api/v1/terminal/paste?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"text": "2 ** 256"}'
# Press Entercurl -X POST "$TERMINAL/api/v1/terminal/press?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"key": "enter"}'// Paste the expressionawait fetch(`${TERMINAL}/api/v1/terminal/paste?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: '2 ** 256' })});
// Press Enterawait fetch(`${TERMINAL}/api/v1/terminal/press?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'enter' })});One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Pastes the expression, then presses Enter to run it. Run both links in order.
# Paste
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/paste?terminal_id=1&method=POST&json={"text":"2%20**%20256"}&response=transparent
# Press Enter
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/press?terminal_id=1&method=POST&json={"key":"enter"}&response=transparent Step 4: Wait for the result and read the screen
# Wait for the prompt to come back (the typed expression has replaced the old# prompt row, so this matches the new prompt once the result is printed)curl -X POST "$TERMINAL/api/v1/terminal/wait?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"mode": "regex", "pattern": ">>>[ ]+$", "timeout_ms": 5000}'
# Read the screencurl "$TERMINAL/api/v1/terminal/snapshot?terminal_id=1"// Wait for the next promptconst waitResult = await fetch(`${TERMINAL}/api/v1/terminal/wait?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'regex', pattern: '>>>[ ]+$', timeout_ms: 5000 })});const { snapshot } = await waitResult.json();
// The wait response includes the snapshot; no separate call is neededconsole.log(snapshot.lines);// [..., ">>> 2 ** 256", "115792089237316195423570985008687907853269984665640564039457584007913129639936", ">>>", ...]// (one entry per screen row; trailing spaces are trimmed)One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Waits for the result to print, then reads the screen. The wait response already includes the snapshot, so the second link is only needed for a fresh read afterward.
# Wait for prompt
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/wait?terminal_id=1&method=POST&json={"mode":"regex","pattern":">>>[%20]%2B$","timeout_ms":5000}&response=transparent
# Read screen
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/snapshot?terminal_id=1&method=GET&response=transparent API endpoints summary
Section titled “API endpoints summary”Every endpoint below lives under /api/v1/terminal/. The terminal-N hostname selects the session: on proxied calls the proxy fills in terminal_id from the hostname and overwrites any value you send, so you may omit the parameter entirely. Where examples on this page include it, the value simply mirrors the hostname’s index — passing a different value has no effect.
| Endpoint | Method | Description |
|---|---|---|
/api/v1/terminal/snapshot | GET | Rendered viewport: lines, cursor, title, fullscreen state, highlights, sequence counter |
/api/v1/terminal/find | GET | PCRE2 regex search on rendered screen with cell-coordinate hits |
/api/v1/terminal/press | POST | Send named key presses (mode-aware: respects DECCKM/DECKPAM) |
/api/v1/terminal/write | POST | Raw byte injection when /press and /paste don’t fit |
/api/v1/terminal/paste | POST | Bracketed paste with full UTF-8 support |
/api/v1/terminal/mouse | POST | Cell-based mouse events (move, down, up, click, scroll); coordinates are terminal cells |
/api/v1/terminal/wait | POST | Async wait until stable/regex-match/either, returns atomic snapshot |
/api/v1/terminal/keys | GET | List the named keys /press accepts, with their aliases (single printable characters are accepted too but not listed) |
The Terminal automation API reference has the complete OpenAPI docs with all parameters, response schemas, and error codes.
Snapshot
Section titled “Snapshot”GET /api/v1/terminal/snapshot
Returns the terminal screen as a human would see it: a grid of text lines, the cursor position, the window title, and whether the program is in fullscreen (alt-screen) mode.
One thing is deliberately not shown. The service brackets every command it runs for you with bookkeeping lines (COMMAND_COMPLETED_MARKER_... and HOODY_SHELL_READY_...), and it hides them so they do not leak into your automation. A rendered screen shows such a row as an empty string, both here and in the snapshot that /wait returns. A regex search over it, in /find or in /wait, instead replaces the text with spaces of the row’s original width, so hit columns stay aligned and a whitespace or anchor pattern still matches that row like any other blank one. Detection is a prefix test on the first non-blank text in the row, so a line of your own output that starts with one of those prefixes is hidden the same way, while the echoed command line, where the marker appears mid-row, stays visible.
Snapshot parameters
Section titled “Snapshot parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
terminal_id | string | required | Terminal session ID (1-65535). Supplied automatically by the terminal-N hostname on proxied calls; any value you send is overwritten |
include_colors | boolean | false | Include ANSI SGR colored_lines array alongside plain text |
include_highlights | boolean | true | Include reverse-video highlight spans |
scroll_offset | integer | 0 | Lines into scrollback (0 = live viewport) |
Snapshot example
Section titled “Snapshot example”# Basic snapshotcurl "$TERMINAL/api/v1/terminal/snapshot?terminal_id=1"
# With colors and scrollbackcurl "$TERMINAL/api/v1/terminal/snapshot?terminal_id=1&include_colors=true&scroll_offset=10"const res = await fetch(`${TERMINAL}/api/v1/terminal/snapshot?terminal_id=1`);const snap = await res.json();
console.log(snap.lines); // ["$ ls -la", "total 16", "drwxr-xr-x 3 user user 4096 .", "..."]console.log(snap.cursor); // { row: 2, col: 0, visible: true }console.log(snap.title); // "bash"console.log(snap.is_fullscreen); // false (true when a program has switched to the alternate screen: vim, htop, etc.)console.log(snap.seq); // 42 (monotonic counter; advances when the rendered cells change)One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Reads the current screen. The second link also requests ANSI color spans and looks 10 lines back into scrollback.
# Basic snapshot
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/snapshot?terminal_id=1&method=GET&response=transparent
# With colors and scrollback
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/snapshot?terminal_id=1%26include_colors=true%26scroll_offset=10&method=GET&response=transparent Snapshot response
Section titled “Snapshot response”{ "terminal_id": "1", "cols": 80, "rows": 24, "lines": [ "$ ls -la", "total 16", "drwxr-xr-x 3 user user 4096 .", "" ], "cursor": { "row": 2, "col": 0, "visible": true }, "title": "bash", "is_fullscreen": false, "scroll_offset": 0, "seq": 42, "highlights": [ { "row": 0, "col": 2, "length": 5 } ]}Key fields:
lines: array of strings, one per visible row. Empty rows appear as empty strings.cursor: row/col position (0-indexed) and visibility. Programs like vim move the cursor; shell prompts park it at the input position.is_fullscreen:truewhen the program has switched to the alternate screen buffer (vim, htop, less, tmux). It reports alternate-screen state only: interactive programs such as a Python REPL or an SSH session run on the normal screen, sofalsedoes not prove a shell prompt is waiting.seq: monotonic sequence counter that advances when the rendered cells change. Use it to detect whether the text on screen has changed between two snapshots without comparing all lines. It does not advance for a cursor move, a cursor show/hide, or a window-title change, so comparecursorandtitleas well when they matter.highlights: reverse-video spans (used by search highlights, selection, etc.). Each entry hasrow,col,length.
Scrollback
Section titled “Scrollback”Set scroll_offset to read lines that have scrolled off the top of the screen. A value of 10 means “show me what was on screen 10 lines ago.” The viewport is rows lines tall, so scroll_offset=24 on an 80x24 terminal shows the previous full page.
The scrollback buffer holds up to 500 lines by default (configurable with --vterm-scrollback-lines, max 10000).
GET /api/v1/terminal/find
Search the rendered terminal screen for a PCRE2 regular expression. It returns cell-coordinate hits with the matched text. Use it to locate specific output, error messages, or UI elements on the screen.
Find parameters
Section titled “Find parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
terminal_id | string | required | Terminal session ID. Supplied automatically by the terminal-N hostname on proxied calls; any value you send is overwritten |
pattern | string | required | PCRE2 regex pattern (max 1024 bytes) |
scope | string | "screen" | Where to search: screen, scrollback, or all |
limit | integer | 100 | Max hits to return (max 1000) |
case_insensitive | boolean | false | Case-insensitive matching |
scroll_offset | integer | 0 | Scrollback offset for screen scope (0 = live viewport). |
Find example
Section titled “Find example”# Find all error messages on screencurl "$TERMINAL/api/v1/terminal/find?terminal_id=1&pattern=error&case_insensitive=true"
# Find IP addresses in scrollback# (backslashes doubled because the URL is in double quotes; the shell would otherwise eat single backslashes)curl --globoff "$TERMINAL/api/v1/terminal/find?terminal_id=1&pattern=\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}&scope=all"
# Find the shell prompt# (the regex is \$[ ]{1,}$: a literal dollar, one or more spaces, end of line;# {1,} instead of + because a raw + in a query string reads as a space)curl --globoff "$TERMINAL/api/v1/terminal/find?terminal_id=1&pattern=\\\$[%20]{1,}\$"// Find all lines containing "ERROR" or "error"const res = await fetch( `${TERMINAL}/api/v1/terminal/find?terminal_id=1` + `&pattern=error&case_insensitive=true`);const result = await res.json();
for (const hit of result.hits) { console.log(`Found "${hit.text}" at row ${hit.row}, col ${hit.col}`);}// Found "error" at row 5, col 12// Found "Error" at row 8, col 0One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Three searches: plain text on the screen, a regex over screen and scrollback, and a shell prompt pattern. The prompt pattern uses {1,} rather than +, because a raw + in a query string reads as a space.
# Find error messages
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/find?terminal_id=1%26pattern=error%26case_insensitive=true&method=GET&response=transparent
# Find IP addresses in scrollback
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/find?terminal_id=1%26pattern=\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}%26scope=all&method=GET&response=transparent
# Find the shell prompt
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/find?terminal_id=1%26pattern=\$[%20]{1,}$&method=GET&response=transparent Find response
Section titled “Find response”{ "pattern": "error", "scope": "screen", "hits": [ { "row": 5, "col": 12, "length": 5, "text": "error" }, { "row": 8, "col": 0, "length": 5, "text": "Error" } ], "total": 2, "truncated": false, "deadline_exceeded": false}Key fields:
hits: array of matches with cell coordinates and the matchedtext. Withscope=screen,rowis 0-indexed from the top of the viewport you asked for, so withscroll_offsetabove 0 a hit on a scrolled-back line has an ordinary non-negativerowandscrollback: true. In the scrollback part of ascrollbackorallsearch,rowis negative, counted from the live viewport:-1is the line just above it, and those hits carryscrollback: truetoo.colandlengthare counted in terminal cells, not Unicode characters, so a wide CJK glyph is two and a combining mark is zero.- Trailing blanks: a regex search sees each row at its full width, padded with spaces, even though
/snapshotshows the same row without them. A pattern anchored at the end of a row therefore has to allow for the padding: use[ ]+$after a prompt character (\$[ ]+$), not$ $. Thetextof a hit that runs to the end of the row includes those spaces. truncated:trueif the number of hits reached thelimit. Increaselimitor narrow your pattern.scope: echoes back which scope was searched.deadline_exceeded:trueif the scan hit its internal time budget (500 ms) before completing. Shape patterns to terminate quickly, or use anchored expressions.search_errorandsearch_error_code: present only when the regex engine gave up mid-scan (a match or depth limit, or out of memory). Both are absent on a normal search.
Search scopes
Section titled “Search scopes”| Scope | Description |
|---|---|
screen | Visible viewport only (default). Fast, covers what a user would see. |
scrollback | Only the scrollback buffer (lines that scrolled off the top). |
all | Both screen and scrollback. Use when you’re not sure where the match is. |
POST /api/v1/terminal/press
Send named key presses to the terminal. Key presses respect the terminal’s current keyboard modes (DECCKM for cursor keys, DECKPAM for keypad). This means arrow keys, function keys, and ctrl sequences automatically generate the correct byte sequences for whatever program is running.
Press request body
Section titled “Press request body”{ "key": "enter"}Or send multiple keys in sequence:
{ "keys": ["escape", ":", "w", "q", "enter"]}Press parameters
Section titled “Press parameters”| Parameter | Type | Description |
|---|---|---|
terminal_id | string (query) | Terminal session ID (required). Supplied automatically by the terminal-N hostname on proxied calls; any value you send is overwritten |
key | string (body) | Single key name |
keys | string[] (body) | Array of key names to press in sequence (max 256) |
Press example
Section titled “Press example”# Press Entercurl -X POST "$TERMINAL/api/v1/terminal/press?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"key": "enter"}'
# Press Ctrl+C (interrupt)curl -X POST "$TERMINAL/api/v1/terminal/press?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"key": "ctrl+c"}'
# Type ":wq" and press Enter (save and quit vim)curl -X POST "$TERMINAL/api/v1/terminal/press?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"keys": ["escape", ":", "w", "q", "enter"]}'
# Navigate with arrow keyscurl -X POST "$TERMINAL/api/v1/terminal/press?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"keys": ["arrow_down", "arrow_down", "arrow_down", "enter"]}'// Press Ctrl+Cawait fetch(`${TERMINAL}/api/v1/terminal/press?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'ctrl+c' })});
// Type ":wq" and press Enter in vimawait fetch(`${TERMINAL}/api/v1/terminal/press?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys: ['escape', ':', 'w', 'q', 'enter'] })});One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Four independent /press calls: a single key, a control combination, a key sequence that saves and quits vim, and a sequence of arrow presses.
# Press Enter
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/press?terminal_id=1&method=POST&json={"key":"enter"}&response=transparent
# Press Ctrl+C
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/press?terminal_id=1&method=POST&json={"key":"ctrl%2Bc"}&response=transparent
# Save and quit vim
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/press?terminal_id=1&method=POST&json={"keys":["escape",":","w","q","enter"]}&response=transparent
# Navigate with arrows
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/press?terminal_id=1&method=POST&json={"keys":["arrow_down","arrow_down","arrow_down","enter"]}&response=transparent Press response
Section titled “Press response”{ "status": "ok", "bytes_written": 7}Supported keys
Section titled “Supported keys”Key names are case-insensitive, and many keys have aliases. A single character is the exception: it is typed as given, so "A" types an uppercase A. GET /api/v1/terminal/keys returns the same vocabulary as JSON if you would rather read it from the service than from this table.
Special keys:
| Key | Aliases | Description |
|---|---|---|
enter | return, cr, ctrl+m, c-m | Enter/Return key |
tab | ctrl+i, c-i | Tab key |
escape | esc, ctrl+[, c-[ | Escape key |
backspace | bs | Backspace key, sends DEL (0x7F). ctrl+h is a separate key, not an alias |
space | Space bar | |
backtab | shift+tab, s-tab | Shift+Tab (reverse tab) |
Arrow keys:
| Key | Aliases | Description |
|---|---|---|
arrow_up | up | Up arrow |
arrow_down | down | Down arrow |
arrow_left | left | Left arrow |
arrow_right | right | Right arrow |
Navigation:
| Key | Aliases | Description |
|---|---|---|
home | Home key | |
end | End key | |
page_up | pgup, pageup | Page Up |
page_down | pgdn, pagedown | Page Down |
insert | ins | Insert key |
delete | del | Delete key |
Function keys:
| Key | Description |
|---|---|
f1 through f12 | Function keys F1-F12 |
Ctrl combinations:
| Key | Aliases | Byte | Description |
|---|---|---|---|
ctrl+a through ctrl+z | c-a through c-z | 0x01-0x1A | Ctrl+letter. ctrl+i -> tab, ctrl+m -> enter |
ctrl+h | c-h | 0x08 | Raw BS. Not the same key as backspace, which sends DEL (0x7F) |
ctrl+space | c-space, ctrl+@, c-@ | 0x00 | NUL byte |
ctrl+j | c-j | 0x0A | Raw line feed (LF); distinct from enter, which may send CR |
ctrl+\ | c-\ | 0x1C | SIGQUIT in shell (in a JSON body: "ctrl+\\") |
ctrl+] | c-] | 0x1D | Ctrl+Right bracket |
ctrl+^ | c-^ | 0x1E | Ctrl+Caret |
ctrl+_ | c-_ | 0x1F | Ctrl+Underscore |
ctrl+? | c-? | 0x7F | DEL character |
Modified keys:
| Key | Description |
|---|---|
shift+arrow_up/down/left/right | Shift+Arrow (text selection in some programs) |
ctrl+arrow_up/down/left/right | Ctrl+Arrow (word navigation in some programs) |
alt+enter | Alt+Enter |
alt+backspace | Alt+Backspace (delete word in zsh/bash) |
Single characters:
Any single printable ASCII character (! through ~, plus space) can be used as a key name. For example, {"key": "a"} presses the letter “a”, {"key": "!"} presses exclamation mark.
POST /api/v1/terminal/paste
Paste text into the terminal with optional bracketed paste mode. This is the preferred way to send multi-character text (commands, code snippets, file content) into the terminal.
Paste request body
Section titled “Paste request body”{ "text": "echo 'Hello, World!'", "bracketed": true}Paste parameters
Section titled “Paste parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
terminal_id | string (query) | required | Terminal session ID. Supplied automatically by the terminal-N hostname on proxied calls; any value you send is overwritten |
text | string (body) | required | Text to paste (UTF-8) |
bracketed | boolean (body) | true | Use bracketed paste mode if the program supports it |
Paste example
Section titled “Paste example”# Paste a command (bracketed paste protects against auto-indent)curl -X POST "$TERMINAL/api/v1/terminal/paste?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"text": "echo Hello, World!"}'
# Paste multi-line code into vimcurl -X POST "$TERMINAL/api/v1/terminal/paste?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"text": "def hello():\n print(\"Hello!\")\n\nhello()"}'
# Paste without bracketed mode (raw keystrokes)curl -X POST "$TERMINAL/api/v1/terminal/paste?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"text": "ls -la", "bracketed": false}'// Paste a multi-line Python scriptawait fetch(`${TERMINAL}/api/v1/terminal/paste?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'for i in range(10):\n print(f"Item {i}")\n', bracketed: true })});One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Three /paste variations: a single-line command, a multi-line Python snippet, and text sent as raw keystrokes with bracketed paste turned off.
# Paste a command
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/paste?terminal_id=1&method=POST&json={"text":"echo%20Hello,%20World!"}&response=transparent
# Paste multi-line code
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/paste?terminal_id=1&method=POST&json={"text":"def%20hello():\n%20%20%20%20print(\"Hello!\")\n\nhello()"}&response=transparent
# Paste without bracketed mode
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/paste?terminal_id=1&method=POST&json={"text":"ls%20-la","bracketed":false}&response=transparent Paste response
Section titled “Paste response”{ "status": "ok", "bytes_written": 42, "bracketed_active": true, "esc_neutralized": 0}Key fields:
bytes_written: number of bytes written to the terminal PTY.bracketed_active:trueif the program had DECSET 2004 enabled and bracketed paste markers were actually sent.falseif the program doesn’t support bracketed paste (the text was still sent, just without markers).esc_neutralized: count of CSI-starter codepoints (7-bit ESCU+001Band 8-bit C1 CSIU+009B) replaced withU+FFFDinside the bracketed-paste envelope, so an embedded\e[201~in the text can’t end the frame early. Always0whenbracketedisfalseor the envelope isn’t in effect.
Paste vs press
Section titled “Paste vs press”| Use | Paste | Press |
|---|---|---|
| Multi-character text | Preferred: single HTTP call with bracketed paste protection | Works but requires one key per character |
| Special keys | Not meant for them. In a bracketed paste a newline is plain pasted text and ESC is replaced; with "bracketed": false, control characters in text are sent as typed (\r presses Enter, \u0003 sends Ctrl+C) | Designed for this |
| Code with newlines | Handles \n correctly with bracketed paste | Each line would need separate Enter presses |
| UTF-8 / emoji / CJK | Full support | Single printable ASCII only |
| Speed | Fast: single write | Sequential key-by-key |
Bracketed paste mode
Section titled “Bracketed paste mode”When bracketed is true (default), the text is wrapped in escape sequences (\e[200~ … \e[201~) if the running program has opted in via DECSET 2004. Most modern programs support this:
- zsh, bash (readline), fish: yes. Prevents auto-execution of pasted newlines.
- vim, neovim: yes. Prevents auto-indent mangling of pasted code.
- python REPL: no (unless using IPython). Text is pasted as raw keystrokes.
- htop, top: no. These aren’t text input programs.
When bracketed is false, the text is sent as raw keystrokes regardless of the program’s paste mode setting.
Write (raw bytes)
Section titled “Write (raw bytes)”POST /api/v1/terminal/write
/write is the raw-byte escape hatch for terminal automation. It injects bytes directly into the session’s PTY master fd, exactly as if typed at a physical keyboard. The input is a JSON string, so it cannot carry a NUL byte (one is rejected); send NUL with /press and ctrl+space. Use it when you need to send escape sequences the /press key table doesn’t cover, or when you want byte-level control over what hits the shell.
Write request body
Section titled “Write request body”{ "input": "y", "enter": true}Write parameters
Section titled “Write parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
terminal_id | string (query) | required | Terminal session ID. Supplied automatically by the terminal-N hostname on proxied calls; any value you send is overwritten |
input | string (body) | required | Text to type (UTF-8). Empty string is valid: it sends just an Enter if enter=true. |
enter | boolean (body) | true | Auto-append a newline after input. Set to false for raw-keystroke input. |
Write example
Section titled “Write example”# Answer a y/n promptcurl -X POST "$TERMINAL/api/v1/terminal/write?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"input": "y"}'
# Send raw bytes without an auto-Entercurl -X POST "$TERMINAL/api/v1/terminal/write?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"input": "\u001b[6~", "enter": false}'
# Just press Entercurl -X POST "$TERMINAL/api/v1/terminal/write?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"input": ""}'// Answer a y/n promptawait fetch(`${TERMINAL}/api/v1/terminal/write?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: 'y' })});
// Send raw bytes without an auto-Enterawait fetch(`${TERMINAL}/api/v1/terminal/write?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: '\x1b[6~', enter: false })});
// Just press Enterawait fetch(`${TERMINAL}/api/v1/terminal/write?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: '' })});One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Three /write variations: answering a prompt, sending raw bytes without an auto-appended Enter, and sending just an Enter with empty input.
# Answer a y/n prompt
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/write?terminal_id=1&method=POST&json={"input":"y"}&response=transparent
# Raw bytes, no auto-Enter
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/write?terminal_id=1&method=POST&json={"input":"\u001b[6~","enter":false}&response=transparent
# Just press Enter
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/write?terminal_id=1&method=POST&json={"input":""}&response=transparent Write response
Section titled “Write response”{ "success": true, "terminal_id": "1", "bytes_written": 2 }POST /api/v1/terminal/wait
Block until a condition is met, then return an atomic snapshot of the screen at the moment of resolution. It replaces sleep-polling in terminal automation scripts.
Atomic is meant literally: the snapshot is captured at the same instant the condition resolves. If you called /wait and /snapshot as two separate requests, the screen could change between them (a TOCTOU race). With /wait, the snapshot in the response reflects the exact state that matched your condition.
Wait request body
Section titled “Wait request body”{ "mode": "regex", "pattern": "\\$[ ]+$", "timeout_ms": 10000, "debounce_ms": 100}Wait parameters
Section titled “Wait parameters”| Parameter | Type | Default | Description |
|---|---|---|---|
terminal_id | string (query) | required | Terminal session ID. Supplied automatically by the terminal-N hostname on proxied calls; any value you send is overwritten |
mode | string (body) | "stable" | Wait mode: stable, regex, or either |
pattern | string (body) | none | PCRE2 regex (required for regex and either modes, max 1024 bytes) |
timeout_ms | integer (body) | 5000 | Hard deadline in ms (10-300000) |
debounce_ms | integer (body) | 100 | Stable mode debounce in ms (10-60000) |
search_scope | string (body) | "screen" | Where to search: screen, scrollback, or all |
include_colors | boolean (body) | false | Include colored_lines in snapshot |
include_highlights | boolean (body) | true | Include highlights in snapshot |
Wait modes
Section titled “Wait modes”stable: wait until the screen stops changing. The endpoint watches the terminal’s sequence counter (seq) and resolves when no cell changes arrive for debounce_ms consecutive milliseconds (a cursor move alone does not count as a change); debounce_ms is how long the screen must stay quiet to count as settled. Use this mode when you don’t know what the output will look like, but you know the program will eventually stop printing.
# Wait until output settles (500ms of quiet)curl -X POST "$TERMINAL/api/v1/terminal/wait?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"mode": "stable", "debounce_ms": 500, "timeout_ms": 30000}'const result = await fetch(`${TERMINAL}/api/v1/terminal/wait?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'stable', debounce_ms: 500, timeout_ms: 30000 })});const { status, snapshot } = await result.json();// status: "stable" or "timeout"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Waits until the screen has been quiet for 500ms, up to a 30-second deadline.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/wait?terminal_id=1&method=POST&json={"mode":"stable","debounce_ms":500,"timeout_ms":30000}&response=transparent regex: wait until a PCRE2 pattern matches on the screen. It resolves the instant the match appears and returns the match coordinates alongside the snapshot.
# Wait for shell promptcurl -X POST "$TERMINAL/api/v1/terminal/wait?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"mode": "regex", "pattern": "\\$[ ]+$", "timeout_ms": 10000}'
# Wait for a specific error messagecurl -X POST "$TERMINAL/api/v1/terminal/wait?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"mode": "regex", "pattern": "BUILD (SUCCESS|FAILED)", "timeout_ms": 60000}'const result = await fetch(`${TERMINAL}/api/v1/terminal/wait?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'regex', pattern: '\\$[ ]+$', timeout_ms: 10000 })});const { status, match, snapshot } = await result.json();// status: "matched" or "timeout"// match: { row: 10, col: 11, length: 69, text: "$" followed by 68 spaces }One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Two regex-mode waits: one for a shell prompt, one for a build’s success or failure message.
# Wait for shell prompt
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/wait?terminal_id=1&method=POST&json={"mode":"regex","pattern":"\\$[%20]%2B$","timeout_ms":10000}&response=transparent
# Wait for a build result
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/wait?terminal_id=1&method=POST&json={"mode":"regex","pattern":"BUILD%20(SUCCESS|FAILED)","timeout_ms":60000}&response=transparent either: resolve on a regex match or on stability, whichever comes first. Useful when you’re not sure whether the program will produce a specific prompt or just stop printing.
# Wait for either a prompt or 2 seconds of quietcurl -X POST "$TERMINAL/api/v1/terminal/wait?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"mode": "either", "pattern": "[\\$#>][ ]+$", "debounce_ms": 2000, "timeout_ms": 30000}'const result = await fetch(`${TERMINAL}/api/v1/terminal/wait?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'either', pattern: '[\\$#>][ ]+$', debounce_ms: 2000, timeout_ms: 30000 })});const { status, snapshot } = await result.json();// status: "matched", "stable", or "timeout"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Resolves on whichever comes first: a prompt-like character followed by trailing blanks, or 2 seconds of screen quiet.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/wait?terminal_id=1&method=POST&json={"mode":"either","pattern":"[\\$%23>][%20]%2B$","debounce_ms":2000,"timeout_ms":30000}&response=transparent Wait response
Section titled “Wait response”{ "status": "matched", "elapsed_ms": 423, "match": { "row": 10, "col": 11, "length": 69, "text": "$ " }, "snapshot": { "terminal_id": "1", "cols": 80, "rows": 24, "lines": ["..."], "cursor": { "row": 10, "col": 13, "visible": true }, "title": "bash", "is_fullscreen": false, "seq": 42 }}Status values:
| Status | Meaning |
|---|---|
matched | Regex pattern matched on screen |
stable | Screen was stable for debounce_ms (no regex match in either mode) |
timeout | Neither condition met before timeout_ms |
exited | Underlying process died mid-wait. Includes snapshot. |
vterm_reinit | VT parser was torn down and re-initialized mid-wait (memory-cap resize). Client should retry; no match or snapshot returned. |
search_error | PCRE2 gave up on the pattern (match limit, depth limit, or out of memory), so the screen could not be evaluated. Includes snapshot, search_error: true, and the PCRE2 code in search_error_code. |
Always check all six statuses; treating only matched/stable as success and ignoring the rest can cause silent failures. search_error in particular is not a miss, and retrying it unchanged will not help: the failure is deterministic in the pattern. Simplify or anchor the pattern instead.
Error handling
Section titled “Error handling”Every automation endpoint returns standard HTTP status codes. Branch on them in scripts rather than assuming success.
Status codes
Section titled “Status codes”| Code | Meaning | When it happens |
|---|---|---|
| 200 | Success | Request completed normally |
| 400 | Bad request | Invalid key name, malformed regex, or missing required parameter |
| 404 | Session not found | The terminal_id doesn’t exist or the session has been terminated |
| 405 | Read-only session | /press and /paste reject input (session_readonly) on a session attached to an existing process by PID when that attachment cannot take input: it was opened with readonly=true, or the terminal of the process could not be taken over for input |
| 403 | SSH-only server | The terminal belongs to a hoody-daemon program and the server runs in SSH-only mode, which reaches no local process |
| 409 | No running process | /write has no live process to write to, or the terminal belongs to a hoody-daemon program that is not running (DAEMON_PROGRAM_NOT_RUNNING) or whose id a local session still holds (DAEMON_TERMINAL; delete that session) |
| 413 | Payload too large | Request body exceeds the --max-body-size cap (default 8 MB) |
| 429 | Too many waiters | More concurrent /wait requests on the same session than the limit allows (16 by default) |
| 500 | Write failed | The underlying PTY write failed. /paste also answers 500 when the text would produce more than 1 MB of terminal input; part of it may already have been written |
| 503 | Resource exhausted | Terminal emulator memory cap exceeded: the global --vterm-memory-cap-mb ceiling was hit |
Error response format
Section titled “Error response format”All errors return a JSON body with an error field describing the failure. For an unknown key name, the response also echoes the offending key and lists the supported key names so you can discover the vocabulary programmatically:
{ "error": "Unknown key name", "key": "invalid_key", "supported": ["enter", "tab", "escape", "backspace", "arrow_up", "arrow_down", "arrow_left", "arrow_right"]}Error handling in scripts
Section titled “Error handling in scripts”# Check HTTP status code with -wHTTP_CODE=$(curl -s -o /tmp/resp.json -w '%{http_code}' \ -X POST "$TERMINAL/api/v1/terminal/press?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"key": "enter"}')
case "$HTTP_CODE" in 200) echo "Key sent" ;; 400) echo "Bad request: $(cat /tmp/resp.json)" ;; 404) echo "Session does not exist" ;; 429) echo "Too many waiters -- wait for existing ones to resolve" ;; 503) echo "Server overloaded -- back off and retry" ;;esacasync function pressKey(key) { const res = await fetch(`${TERMINAL}/api/v1/terminal/press?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) });
if (res.ok) return res.json();
const err = await res.json(); switch (res.status) { case 400: throw new Error(`Bad request: ${err.error}`); case 404: throw new Error('Terminal session not found'); case 429: throw new Error('Too many concurrent waiters'); case 503: throw new Error('Server overloaded, retry later'); default: throw new Error(`Unexpected ${res.status}: ${err.error}`); }}One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Sends the same Enter key press used in the status-code branching example above.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/press?terminal_id=1&method=POST&json={"key":"enter"}&response=transparent Common patterns
Section titled “Common patterns”Reusable building blocks that show up in most automation scripts. Copy these into your projects.
Type a string key by key
Section titled “Type a string key by key”If you need to type text character by character instead of pasting (some TUI programs don’t support paste), split the string into individual key presses:
# Type "hello" key by keycurl -X POST "$TERMINAL/api/v1/terminal/press?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"keys": ["h", "e", "l", "l", "o"]}'// Helper: type a string as individual key pressesasync function typeString(text) { const keys = [...text]; // split into characters await fetch(`${TERMINAL}/api/v1/terminal/press?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys }) });}
await typeString(':wq');One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Types “hello” as five individual key presses.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/press?terminal_id=1&method=POST&json={"keys":["h","e","l","l","o"]}&response=transparent Run a command and wait for the result
Section titled “Run a command and wait for the result”The most common pattern: paste a command, press Enter, wait for the prompt, read the output.
# Paste command, press Enter, wait for prompt, read screencurl -X POST "$TERMINAL/api/v1/terminal/paste?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"text": "uname -a"}'
curl -X POST "$TERMINAL/api/v1/terminal/press?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"key": "enter"}'
RESULT=$(curl -s -X POST "$TERMINAL/api/v1/terminal/wait?terminal_id=1" \ -H "Content-Type: application/json" \ -d '{"mode": "regex", "pattern": "\\$[ ]+$", "timeout_ms": 10000}')
echo "$RESULT" | jq -r '.snapshot.lines[]'// Helper: run a command and return the screen once a prompt is on it.// A prompt row still on screen from earlier also matches; see the note under "Wait modes".async function runCommand(cmd, promptPattern = '\\$[ ]+$', timeoutMs = 10000) { await fetch(`${TERMINAL}/api/v1/terminal/paste?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: cmd }) }); await fetch(`${TERMINAL}/api/v1/terminal/press?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'enter' }) }); const res = await fetch(`${TERMINAL}/api/v1/terminal/wait?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'regex', pattern: promptPattern, timeout_ms: timeoutMs }) }); return res.json();}
const { status, snapshot } = await runCommand('uname -a');if (status === 'matched') { console.log(snapshot.lines);}One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
The three-step pattern from above: paste the command, press Enter, then wait for the prompt to return. Run them in order.
# Paste command
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/paste?terminal_id=1&method=POST&json={"text":"uname%20-a"}&response=transparent
# Press Enter
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/press?terminal_id=1&method=POST&json={"key":"enter"}&response=transparent
# Wait for prompt
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/wait?terminal_id=1&method=POST&json={"mode":"regex","pattern":"\\$[%20]%2B$","timeout_ms":10000}&response=transparent Check if a TUI is running
Section titled “Check if a TUI is running”Use the is_fullscreen field from a snapshot as a hint that a full-screen program (vim, htop, less, tmux) has switched to the alternate screen. It reports alternate-screen state, not the identity of the foreground process: interactive programs such as a Python REPL or an SSH session run on the normal screen, so false does not prove a shell prompt is waiting:
# Check if we're in a TUI or at a shell promptIS_TUI=$(curl -s "$TERMINAL/api/v1/terminal/snapshot?terminal_id=1" | jq '.is_fullscreen')
if [ "$IS_TUI" = "true" ]; then echo "A full-screen program is running -- press 'q' or Ctrl+C to exit first"else echo "Alternate screen is not active -- inspect the screen or match a known shell prompt before sending commands"fiasync function isTuiRunning() { const res = await fetch(`${TERMINAL}/api/v1/terminal/snapshot?terminal_id=1`); const snap = await res.json(); return snap.is_fullscreen;}
if (await isTuiRunning()) { // Exit the TUI first await fetch(`${TERMINAL}/api/v1/terminal/press?terminal_id=1`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'q' }) });}One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Reads the current snapshot; check the is_fullscreen field in the response to see whether a full-screen program is active.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/snapshot?terminal_id=1&method=GET&response=transparent Drive multi-step prompts in a loop
Section titled “Drive multi-step prompts in a loop”For multi-step interactions (wizards, installers, interactive prompts), use a loop that waits for a condition, inspects the screen, and decides the next action. This example assumes wait, paste, typeString, and press helpers like those defined above:
// Drive an interactive installer step by stepconst steps = [ { wait: 'Accept license\\?', action: async () => { await typeString('yes'); await press('enter'); } }, { wait: 'Install directory', action: async () => { await paste('/opt/app'); await press('enter'); } }, { wait: 'Confirm\\?', action: async () => press('enter') }, { wait: '\\$[ ]+$', action: null } // done, back at shell];
for (const step of steps) { const { status } = await wait({ mode: 'regex', pattern: step.wait, timeout_ms: 30000 }); if (status !== 'matched') throw new Error(`Did not see "${step.wait}" (status: ${status})`); if (step.action) await step.action();}Practical workflows
Section titled “Practical workflows”Edit and save a file in vim
Section titled “Edit and save a file in vim”TID="terminal_id=1"
# Open a file in vimcurl -X POST "$TERMINAL/api/v1/terminal/execute" \ -H "Content-Type: application/json" \ -d '{"command": "vim /tmp/hello.py", "wait": false}'
# Wait for vim to draw: the ~ markers on its empty linescurl -X POST "$TERMINAL/api/v1/terminal/wait?$TID" \ -H "Content-Type: application/json" \ -d '{"mode": "regex", "pattern": "^~", "timeout_ms": 5000}'
# Enter insert modecurl -X POST "$TERMINAL/api/v1/terminal/press?$TID" \ -H "Content-Type: application/json" \ -d '{"key": "i"}'
# Type some codecurl -X POST "$TERMINAL/api/v1/terminal/paste?$TID" \ -H "Content-Type: application/json" \ -d '{"text": "#!/usr/bin/env python3\nprint(\"Hello from vim automation!\")\n"}'
# Exit insert mode, save, and quitcurl -X POST "$TERMINAL/api/v1/terminal/press?$TID" \ -H "Content-Type: application/json" \ -d '{"keys": ["escape", ":", "w", "q", "enter"]}'
# Wait for vim to close (back to shell prompt)curl -X POST "$TERMINAL/api/v1/terminal/wait?$TID" \ -H "Content-Type: application/json" \ -d '{"mode": "regex", "pattern": "\\$[ ]+$", "timeout_ms": 5000}'const TID = 'terminal_id=1';
const press = (keys) => fetch(`${TERMINAL}/api/v1/terminal/press?${TID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Array.isArray(keys) ? { keys } : { key: keys })});const paste = (text) => fetch(`${TERMINAL}/api/v1/terminal/paste?${TID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text })});const wait = (opts) => fetch(`${TERMINAL}/api/v1/terminal/wait?${TID}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(opts)}).then(r => r.json());
// Open vimawait fetch(`${TERMINAL}/api/v1/terminal/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'vim /tmp/hello.py', wait: false })});
// Wait for vim to draw: the ~ markers on its empty lines. A stable wait right after// starting vim can resolve on the old, still-quiet screen before vim has drawn.await wait({ mode: 'regex', pattern: '^~', timeout_ms: 5000 });
// Enter insert mode, type code, save and quitawait press('i');await paste('#!/usr/bin/env python3\nprint("Hello from vim automation!")\n');await press(['escape', ':', 'w', 'q', 'enter']);
// Wait for shell promptconst { snapshot } = await wait({ mode: 'regex', pattern: '\\$[ ]+$', timeout_ms: 5000 });console.log('Back at shell:', snapshot.lines[snapshot.cursor.row]);One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
All six steps of the vim workflow above, in order: open the file, wait for it to load, enter insert mode, type the code, save and quit, then wait for the shell prompt to return.
# Open vim
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/execute&method=POST&json={"command":"vim%20/tmp/hello.py","wait":false}&response=transparent
# Wait for vim to draw
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/wait?terminal_id=1&method=POST&json={"mode":"regex","pattern":"^~","timeout_ms":5000}&response=transparent
# Enter insert mode
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/press?terminal_id=1&method=POST&json={"key":"i"}&response=transparent
# Type the code
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/paste?terminal_id=1&method=POST&json={"text":"%23!/usr/bin/env%20python3\nprint(\"Hello%20from%20vim%20automation!\")\n"}&response=transparent
# Save and quit
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/press?terminal_id=1&method=POST&json={"keys":["escape",":","w","q","enter"]}&response=transparent
# Wait for vim to close
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/wait?terminal_id=1&method=POST&json={"mode":"regex","pattern":"\\$[%20]%2B$","timeout_ms":5000}&response=transparent Filter and kill a process in htop
Section titled “Filter and kill a process in htop”// Launch htop and wait for it to renderawait fetch(`${TERMINAL}/api/v1/terminal/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'htop', wait: false })});await wait({ mode: 'stable', debounce_ms: 500, timeout_ms: 5000 });
// Filter for "node" processes (F4 opens htop's filter)await press('f4');await paste('node');await wait({ mode: 'stable', debounce_ms: 300, timeout_ms: 3000 });
// Read the filtered listconst { snapshot } = await wait({ mode: 'stable', debounce_ms: 200, timeout_ms: 2000 });console.log('Filtered:', snapshot.lines.filter(l => l.includes('node')));
// Send SIGTERM to selected process (F9), then quitawait press(['f9', 'enter']);await press('q');Define and call a function in the Python REPL
Section titled “Define and call a function in the Python REPL”This extends the Quick start example with a multi-line function definition. Send the whole function body in a single /paste call (the server only emits bracketed-paste markers when the REPL has enabled DECSET 2004, so no extra flag is needed), then press Enter twice to end the indented block.
// Start Python and wait for prompt (see Quick start for the curl equivalent)await fetch(`${TERMINAL}/api/v1/terminal/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'python3', wait: false })});await wait({ mode: 'regex', pattern: '>>>[ ]+$', timeout_ms: 5000 });
// Paste a multi-line function; paste markers are only sent if the REPL enabled themawait paste('def fib(n):\n a, b = 0, 1\n for _ in range(n):\n a, b = b, a + b\n return a\n');await press(['enter', 'enter']); // two Enters to close the blockawait wait({ mode: 'regex', pattern: '>>>[ ]+$', timeout_ms: 5000 });
// Call the function and read the resultawait paste('fib(100)');await press('enter');// Wait for the result row itself: the second Enter above left an empty '>>> ' row// on screen, so a prompt pattern would match before fib(100) has printedconst { snapshot } = await wait({ mode: 'regex', pattern: '^[0-9]{10,}[ ]*$', timeout_ms: 5000 });
// Find the line containing the big numberconst resultLine = snapshot.lines.find(l => /^\d{10,}$/.test(l.trim()));console.log('fib(100) =', resultLine?.trim());// fib(100) = 354224848179261915075
// Exit Pythonawait press('ctrl+d');Drive an SSH connection
Section titled “Drive an SSH connection”SSH is a multi-step interactive flow: wait for the host key confirmation or password prompt, respond, then wait for the remote shell.
// Start SSH (it takes over the screen, so don't wait)await fetch(`${TERMINAL}/api/v1/terminal/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'ssh user@remote-server.example.com', wait: false })});
// Wait for password prompt or host key confirmationconst first = await wait({ mode: 'regex', pattern: '(password:|yes/no)', timeout_ms: 15000 });if (first.status !== 'matched' || !first.match) throw new Error(`SSH prompt not reached: ${first.status}`);
if (first.match.text.includes('yes/no')) { await paste('yes'); await press('enter'); const confirmed = await wait({ mode: 'regex', pattern: 'password:', timeout_ms: 10000 }); if (confirmed.status !== 'matched') throw new Error(`Password prompt not reached: ${confirmed.status}`);}
// Type password and wait for remote shell promptawait paste('my-password');await press('enter');const result = await wait({ mode: 'regex', pattern: '[\\$#][ ]+$', timeout_ms: 15000 });if (result.status !== 'matched') throw new Error(`Remote shell not reached: ${result.status}`);console.log('Connected!', result.snapshot.lines[result.snapshot.cursor.row]);Extract command output with /find
Section titled “Extract command output with /find”This combines the “run command and wait” pattern from Common patterns with /find to extract structured data:
// Run df -h and wait for completionawait paste('df -h /');await press('enter');const { snapshot } = await wait({ mode: 'regex', pattern: '\\$[ ]+$', timeout_ms: 5000 });
// Use /find to extract the disk usage percentage from the screenconst findRes = await fetch( `${TERMINAL}/api/v1/terminal/find?${TID}&pattern=${encodeURIComponent('\\d+%')}` // encode the regex: a raw '+' in a query string reads as a space).then(r => r.json());
console.log('Disk usage:', findRes.hits[0]?.text); // "42%"
// Or parse directly from the snapshot linesconst dfLine = snapshot.lines.find(l => l.includes('/dev/'));Interact with tmux
Section titled “Interact with tmux”tmux uses a prefix key (Ctrl+B by default) followed by a command key. The /press endpoint handles this since it sends keys sequentially:
// Start tmuxawait fetch(`${TERMINAL}/api/v1/terminal/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'tmux new-session -s auto', wait: false })});await wait({ mode: 'stable', debounce_ms: 500, timeout_ms: 5000 });
// Split horizontally (Ctrl+B, then %)await press(['ctrl+b', '%']);await wait({ mode: 'stable', debounce_ms: 300, timeout_ms: 3000 });
// Run a command in the new paneawait paste('watch -n1 date');await press('enter');
// Switch back to first paneawait press(['ctrl+b', 'arrow_left']);Monitor a long-running build
Section titled “Monitor a long-running build”Use "regex" mode here: match a known success or failure message, or the returned shell prompt. Treat every other resolution as failure.
// Start buildawait paste('npm run build 2>&1');await press('enter');
// Wait up to 5 minutes for completionconst { status, match } = await wait({ mode: 'regex', pattern: '(Build succeeded|ERROR|FAILED|\\$[ ]+$)', timeout_ms: 300000});
if (status === 'matched' && match && /(ERROR|FAILED)/.test(match.text)) { // Use /find to collect all error lines (including scrollback) const errors = await fetch( `${TERMINAL}/api/v1/terminal/find?${TID}&pattern=ERROR.*&scope=all` ).then(r => r.json()); console.error('Build failed with', errors.total, 'errors');} else if (status === 'matched' && match?.text.includes('Build succeeded')) { console.log('Build succeeded!');} else { throw new Error(`Build success was not observed (wait status: ${status})`);}Mode-aware key encoding
Section titled “Mode-aware key encoding”/press generates escape sequences that match the terminal’s current mode. That is why it encodes keys through the server-side terminal emulator instead of writing raw bytes.
Raw bytes and terminal modes
Section titled “Raw bytes and terminal modes”When you send arrow keys to a terminal, the correct byte sequence depends on the terminal’s mode:
| Key | Normal mode (DECCKM off) | Application mode (DECCKM on) |
|---|---|---|
| Arrow Up | \e[A | \eOA |
| Arrow Down | \e[B | \eOB |
| Arrow Left | \e[D | \eOD |
| Arrow Right | \e[C | \eOC |
| Home | \e[H | \eOH |
| End | \e[F | \eOF |
Programs like vim, htop, and less enable application cursor mode (DECCKM). If you send the wrong byte sequence, the program ignores the key or does something unexpected.
Similarly, the numeric keypad has two modes: numeric (sends digits) and application (DECKPAM, sends \eO sequences). Programs use this for cursor navigation in TUI menus.
Key encoding
Section titled “Key encoding”When you call /press, the endpoint:
- Looks up the key name in the key table
- Passes the key and its modifiers to the server-side terminal emulator.
ctrl+h,ctrl+jandctrl+?are the exception: they write their fixed bytes (BS, LF, DEL) directly - The emulator checks the terminal’s current DECCKM/DECKPAM/DECNKM state and generates the correct byte sequence
- The generated bytes are written to the terminal PTY
The same /press call therefore generates different byte sequences depending on what program is running. You send arrow_up and it works in vim, htop, bash, tmux, or any other program, without tracking terminal modes yourself.
Memory and performance
Section titled “Memory and performance”The automation subsystem maintains a server-side terminal emulator instance for each terminal session that needs one.
Lazy initialization and replay
Section titled “Lazy initialization and replay”A session’s emulator instance is created on demand: when the first automation endpoint is called for it, and also, for sessions started over WebSocket (such as the web terminal), when the process starts or a client reattaches, so a reconnecting client gets a correct redraw. A session that is never used through automation can therefore still hold VT memory.
When an instance is created for a session that already has output, the session’s output buffer is replayed through the emulator to reconstruct the terminal state (screen content, cursor position, modes, colors). The buffer is bounded: after very long output it holds only the most recent part, and state set by output older than that is not recovered. This is how /press knows the correct byte sequences: the emulator tracks the terminal’s mode from the replayed output. The replay is fast but does consume CPU proportional to the output buffer size. Subsequent calls use the already-initialized instance, kept in sync by feeding new output as it arrives.
Memory cap
Section titled “Memory cap”All emulator instances share a global memory cap (default: 512 MB, configurable with --vterm-memory-cap-mb). Each instance consumes memory proportional to cols * rows * cell_size + scrollback_lines * cols * cell_size.
A typical 80x24 terminal with 500 scrollback lines uses only a few megabytes, so the default 512 MB cap covers many concurrent automation sessions. The exact footprint scales with terminal dimensions and scrollback depth, and a single very large session can consume disproportionately more. Query /api/v1/terminal/automation/metrics for live vterm_memory_mb_used and vterm_memory_mb_cap figures.
If the cap is exceeded, new automation requests return HTTP 503. Session count is irrelevant here, since one large session can hit the ceiling on its own.
The 503 body comes in two shapes, so match on the status code rather than on the message. /snapshot returns a structured error you can branch on:
{ "error": "vterm_memory_cap", "current_mb": 514, "cap_mb": 512 }/find, /wait, /press, /paste, and /mouse return the plain string form, with no counters:
{ "error": "vterm memory cap exceeded" }Idle eviction
Section titled “Idle eviction”Emulator state that has been idle for longer than 10 minutes (configurable with --vterm-idle-ttl-sec) may be cleaned up, possibly a little after the limit, and is recreated automatically on the next automation call. Both automation calls and new terminal output count as activity. Sessions whose process is still running, sessions with a connected web terminal client, sessions with a /wait in progress, and sessions in the middle of a request are never evicted, so in practice only idle sessions that have ended are reclaimed.
Evicted instances are transparently re-initialized on the next automation call. The replay overhead is usually negligible: a few milliseconds for normal sessions, up to 100ms for sessions with very large output buffers.
Scrollback buffer
Section titled “Scrollback buffer”Each emulator instance maintains its own scrollback buffer (default: 500 lines, configurable with --vterm-scrollback-lines, max 10000). This is separate from the terminal session’s raw output buffer.
The scrollback stores rendered cells (text and attributes), so /find and /snapshot with scroll_offset read them directly instead of re-parsing.
Configuration flags
Section titled “Configuration flags”| Flag | Default | Description |
|---|---|---|
--vterm-memory-cap-mb | 512 | Global memory cap for all emulator instances |
--vterm-scrollback-lines | 500 | Scrollback lines per instance (0-10000) |
--vterm-idle-ttl-sec | 600 | Seconds of idle before eviction |
--wait-max-waiters-per-session | 16 | Max concurrent /wait requests per session (1-256) |
Best practices
Section titled “Best practices”-
Use
/waitinstead of polling/snapshotin a loop. The wait endpoint is event-driven and returns the instant the condition is met. Polling wastes HTTP round-trips and can miss transient states. -
Prefer
"mode": "regex"with a pattern that matches the expected success message, failure message, and returned shell prompt, and treat any other resolution as failure. Reserve"either"for exploratory runs where no reliable pattern exists. -
Use
/pastefor text and/pressfor actions. Paste your command text, then press Enter. Paste your code, then press Escape. This is faster and more reliable than pressing keys one by one. -
Check
is_fullscreenin snapshots as a hint that a full-screen program has switched to the alternate screen. It does not prove a shell prompt is waiting — REPLs and SSH sessions run on the normal screen — so pair it with a prompt regex when you need certainty. -
Use the
seqcounter to detect screen changes without comparing all lines. Ifseqhasn’t changed between two snapshots taken with the samescroll_offset, the cell contents are identical (the cursor position, cursor visibility, and title can still differ). -
Keep
timeout_msgenerous for operations that might take time (builds, downloads, SSH connections). The wait endpoint returns as soon as the condition is met, so a large timeout only prevents premature failure. -
Use
/findwithscope: "all"when searching for output that might have scrolled off screen. Thescrollbackscope searches only the scrollback buffer, whileallsearches both.
-
Don’t
sleep()between operations. Use/waitwith an appropriate mode instead. Sleeping creates race conditions and slows the run down for no benefit. -
Don’t send raw escape codes through
/paste. Use/pressfor special keys and control sequences. Raw escape codes can conflict with the terminal’s current mode. -
Don’t assume terminal dimensions. Read
colsandrowsfrom the snapshot response. Different sessions may have different sizes, and users can resize at any time. -
Don’t create more concurrent waiters per session than the limit allows (16 by default). The limit exists to prevent resource exhaustion. If you need to wait for multiple conditions, use
/waitsequentially or combine patterns with regex alternation (pattern1|pattern2). -
Don’t ignore the
statusfield in wait responses. A timeout is not an error; it means the condition wasn’t met. Your script should handle all six statuses:matched,stable,timeout,exited,vterm_reinit, andsearch_error.search_erroris the one to get right, because it is the only status a retry cannot fix: the pattern itself defeated PCRE2, so the same request will fail the same way. Simplify or anchor the pattern instead of looping. -
Don’t use automation endpoints for simple command execution. If you just need to run
lsand get the output, use/api/v1/terminal/executewithwait: true. Automation endpoints are for interactive programs.
What’s next
Section titled “What’s next”- Terminals: the full terminal service, covering the web UI, session management, and command execution
- Terminal automation API reference: complete OpenAPI endpoint reference with all parameters and response schemas
- SSH access: SSH as an alternative access method alongside HTTP automation
- Displays: the visual display service for GUI applications launched from terminal sessions
- The Hoody Kit: all HTTP services available in every Hoody container
- Proxy permissions: control who can reach terminal automation endpoints