Displays
Linux GUI applications at a URL: VS Code, browsers, or any other GUI program.
Every Hoody container runs hoody-terminal, a service that exposes the container’s shell over HTTP. Each terminal session has a URL: open it in a browser for a full interactive terminal, POST to it to execute commands, or share it to put several people in the same session. There are no SSH keys to manage and nothing to configure.
The same sessions drive GUI programs: type firefox & in a terminal paired with a display and the window appears at the matching display URL.
hoody-terminal provides shell control over HTTP:
firefox & and the program appears in a browser display, with nothing to configurestdout, stderr, and exit codesThe API reference documents every parameter, response, and example; the summary below links each endpoint.
On calls routed through a terminal-N hostname the proxy fills the query terminal_id from the hostname label and overwrites any value you send. Only the POST /create body field and the {terminal_id} path parameters are yours to set.
Command execution:
command, id, timeout, wait, cwd (directory to run in; the session stays there afterwards), env (applies to this command only; on an SSH session the variables stay exported in the remote shell, and so they do on a session whose commands the service cannot stage as files, such as one started as another user by a server that is not running as root), mode ("raw" runs the command outside the session, without a terminal, and returns its exact bytes as base64 stdout_b64 and stderr_b64)terminal_id, cwd (initial working directory for new/reset local sessions), cwd_auto_create, shell, user, ssh_host, ssh_user, ssh_password, ssh_key, resetwait: true) or Asynchronous (wait: false); mode: "raw" requires wait: trueterminal_id, command_id, command, status, stdout, stderr, stdout_truncated, stderr_truncated, exit_code, completion, timed_out, cancelled, started. exit_code is null while the exit status is unknown (completion: "output_quiet"){ "force": false }terminal_id{ "input": "text", "enter": true }, written raw as if typed at the keyboardSession management:
terminal_id, shell, user, cwd, display, ssh_host, …terminal_id, shell, cwd, created_at, and recent command_historycommand_id, command, status, start_time, exit_code, timed_outterminal_id, format (download|text|html), tailterminal_id, format (png|jpeg|gif), foreground, background, fontsize, saveTerminal automation (TUI control):
terminal_id, include_colors, include_highlights, scroll_offsetterminal_id, pattern, scope, limit, case_insensitive, scroll_offset{ "key": "enter" } or { "keys": [...] }{ "text": "...", "bracketed": true }{ "event": { "type": "click", "row": 10, "col": 5, "button": 1 } } or { "events": [...] }{ "mode": "stable|regex|either", "pattern": "...", "timeout_ms": 5000, "debounce_ms": 100 }/pressSystem resource monitoring:
sort (cpu|memory|pid|name), limit, filter (by name)pid, name, user, state, ppid (parent), cpu_percent, mem_percent, vsize, rss, threads, starttime, cmdline, env{"pid": 12345, "signal": "SIGTERM"} or {"name": "nginx", "signal": "SIGHUP"}SIGSTOPSystem control:
System introspection:
protocol, user, port, ip, skip_program, http_only, hoody_onlyname, command, enabled, boot, autorestartdisplay, resolution, and session_name{ "display": 1, "stopped": true, "forced": false } once the display is goneFile drag-and-drop:
{ "ctx": "drop", "items": [{ "name": "hello.txt", "b64": "..." }] }WebSocket:
Health and diagnostics:
{ "build_id": "...", "renderer": "webgl", "reason": "connection_resumed" }, at most 4,096 bytes (a larger body is refused with 413)Web interface:
This is how most users access containers, and it replaces SSH for daily work:
https://{project}-{container}-terminal-1.{server}.containers.hoody.comhttps://{project}-{container}-terminal-2.{server}.containers.hoody.comhttps://{project}-{container}-terminal-3.{server}.containers.hoody.comEach number is a separate terminal session in the same container:
terminal-1 - Your main terminal sessionterminal-2 - A second terminal for monitoring logsterminal-3 - A third terminal for running testsAll of them run in one container. Switch between them by opening different URLs; each keeps its own state.
Open a terminal URL in any browser, on a phone, tablet, laptop, or TV, and you get a full Linux terminal without an SSH client or any configuration.
The number in the URL (terminal-1, terminal-2) is the terminal ID. Switching terminals doesn’t change containers; you’re opening another shell session on the same computer.
Work persists across all terminals:
To pair a terminal with a display, set the display field when the session is created; the kit then exports DISPLAY=:N into that shell. The common convention is to match the numbers:
terminal-1 with display: "1" → DISPLAY=:1terminal-2 with display: "2" → DISPLAY=:2terminal-5 with display: "5" → DISPLAY=:5On hostname-routed calls the proxy sets display from the same label as terminal_id, so a session first opened in the browser at terminal-5 starts with DISPLAY=:5 and its GUI apps land in display-5. A session created through POST /api/v1/terminal/create reads display only from the request body, so set it there; without it the session starts with no display. That is also how you pair a terminal with a different display (a ?display= query value is overwritten by the hostname). You can also export DISPLAY inside the shell. This lets you organize applications across displays while controlling them from any terminal.
Five shells are pre-installed: bash (the default), zsh, fish, tmux, and sh.
| Shell | Description | Launch |
|---|---|---|
| bash | Default shell, universal compatibility | Default or ?shell=bash |
| zsh | Modern shell, oh-my-zsh compatible, better completion | ?shell=zsh or exec zsh |
| fish | Friendly shell, syntax highlighting, autosuggestions | ?shell=fish or exec fish |
| tmux | Terminal multiplexer, shared between web and SSH | ?shell=tmux |
| sh | Bourne shell, minimal, POSIX-compliant | ?shell=sh |
Switch shells from inside a session:
exec zsh # Switch to zshexec fish # Switch to fishtmux # Launch tmuxWhen switching shells with the ?shell= parameter on an existing session, add ?reset=true so the new shell starts clean:
# Recommended: Reset when changing shellshttps://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/?shell=zsh&reset=true
# This ensures the new shell starts fresh without inheriting state from the previous shellURL parameters customize the terminal:
?shell=zsh # Shell choice&fontSize=14 # Larger text&readonly=true # View-only mode&title=Production%20Logs # Custom title&panel=https://docs.hoody.com&panel-width=40% # Side panel with docsSee Web Terminal UI → for every customization option.
Web terminal sessions opened with ?shell=tmux are also reachable from SSH. Such a session runs inside tmux, and the terminal number in the URL is the tmux session name:
# Web terminal-3: https://{project}-{container}-terminal-3.{server}.containers.hoody.com/?shell=tmux# SSH to same session:ssh user@containertmux attach -t 3
# Now you're in the exact same session as the web terminal# Edit files, see command history; everything syncedThis lets you drive web terminal sessions from traditional SSH clients while keeping full session state.
Scripts and AI agents use the HTTP API directly:
# Execute a command in your containerhoody -c <container-id> terminal commands run --command "ls -la /app" --wait
# Run a long command with a timeout (still waits for completion; --wait is on by default)hoody -c <container-id> terminal commands run --command "npm run build" --timeout 300import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// Execute a shell command (synchronous)const result = await containerClient.terminal.commands.run( { command: 'ls -la /app', wait: true }, // request body { terminal_id: '1' } // query param; matches the terminal-1 URL);console.log(result.data.stdout);curl -X POST "https://$PROJECT-$CONTAINER-terminal-1.$SERVER.containers.hoody.com/api/v1/terminal/execute" \ -H "Content-Type: application/json" \ -d '{"command": "ls -la /app", "wait": 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
Runs ls -la /app in terminal-1. Because the body sets wait: true, the link holds the
connection open and the response carries stdout, stderr, and the exit code directly.
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":"ls%20-la%20/app","wait":true}&response=transparent Response:
wait decides the shape of the response, and the two shapes are different.
With wait: true (the default) the request is held open until the command finishes, and the output comes back in that same response. There is nothing to poll:
{ "terminal_id": "1", "command_id": "42", "command": "ls -la /app", "status": "completed", "stdout": "total 48\ndrwxr-xr-x 5 user user 4096 Nov 9 14:30 .\n...", "stderr": "", "exit_code": 0, "completion": "marker", "timed_out": false, "started": true}With wait: false the call does not wait for the command to finish and carries no output. It can still be held for up to 10 seconds first when the session’s display is starting, and behind an earlier request of the same session that waits for it. status is running (with started: false while the command still waits for an earlier command in the same session to finish; it starts when that one finishes, unless it times out, is aborted, or, without a timeout, expires after 10 seconds of shell silence and never runs), or queued when the command is held back by defer_pid or waits for an SSH session to become ready:
{ "terminal_id": "1", "command_id": "42", "status": "running", "message": "Command started successfully"}Only then do you poll GET /api/v1/terminal/result/\{command_id\}, which also returns partial output while the command is still running:
// GET /api/v1/terminal/result/42{ "terminal_id": "1", "command_id": "42", "command": "ls -la /app", "status": "completed", "stdout": "total 48\ndrwxr-xr-x 5 user user 4096 Nov 9 14:30 .\n...", "stderr": "", "exit_code": 0, "completion": "marker", "timed_out": false, "started": true}completion says how the command ended: marker (it finished and exit_code is its real status), timeout, cancelled, ended (the session’s shell exited), or output_quiet (see the caution below). started is false for a command that never began running, for example one that timed out while waiting behind an earlier command in the same session.
Prefer wait: false for anything long-running: a wait: true request stays open for the whole duration of the command.
When wait: true is not honoured. The service falls back to the wait: false envelope, with the same 200 status, in three cases:
wait: true is always forced off there; see Remote SSH execution.defer_pid, so the command is held back until that process exits.wait: true calls are already in flight across the service. The 257th is admitted but answered asynchronously.In all three the command still runs, and the reply carries status and message instead of stdout, stderr, and exit_code. Code that reads result.stdout straight off an execute response gets undefined. Check whether the response has an exit_code key ('exit_code' in result, since its value can be null), and poll /api/v1/terminal/result/{command_id} when it is absent.
The whole shell is now reachable by:
The terminal number in the URL determines which session executes the command:
PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/execute → Executes in terminal session 1PROJECT-CONTAINER-terminal-2.SERVER.containers.hoody.com/api/v1/terminal/execute → Executes in terminal session 2Sessions persist across requests:
// Execute in terminal-1const terminalUrl = 'https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER_NAME.containers.hoody.com';
await fetch(terminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'cd /app', wait: true })});
// Later, same terminal-1 - still in /app directoryawait fetch(terminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'ls', wait: true })});// Lists contents of /app (working directory preserved)Each terminal URL (terminal-1, terminal-2, terminal-3) is a distinct shell session within the same container:
State persists within each terminal session:
cd commands remembered)/execute request’s env apply to that command only, except on SSH sessions and the other sessions noted under env above, where they stay exported)Work persists across the container: files, services, and databases remain regardless of which terminal you use.
The cwd parameter runs a command in a specific directory without sending a separate cd first:
// Runs in /var/logawait fetch(terminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'tail -n 20 app.log', cwd: '/var/log' })});
// The session is now in /var/log tooawait fetch(terminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'pwd' }) // /var/log});Practical uses:
cd chains// Build frontend and backend simultaneously in different directoriesconst build = async () => { // Terminal-1: Frontend build fetch(terminal1Url + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'npm run build', cwd: '/app/frontend', wait: false }) });
// Terminal-2: Backend build (same time) fetch(terminal2Url + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'go build', cwd: '/app/backend', wait: false }) });};cwd sets the directory the command runs in whatever the session’s current directory is, which is what makes it useful for one-off requests against a session you did not open. It does not restore the previous directory afterwards.
To guarantee a specific starting directory, pass cwd while resetting the session:
# Guarantee terminal starts in /app directoryhttps://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/?cwd=/app&reset=true
# Then use cwd parameter to execute in specific locationsPOST /api/v1/terminal/execute{ "command": "npm test", "cwd": "/app/frontend" // Executes in /app/frontend, and leaves the session there}Reset and cwd together:
// Reset the session to a clean state as part of the next execute request// (fetching the web page URL with ?reset=true does not reset anything)await fetch(terminalUrl + '/api/v1/terminal/execute?reset=true', { method: 'POST', body: JSON.stringify({ command: 'npm run build', cwd: '/app/dist' // Runs in /app/dist, and the session stays in /app/dist })});?reset=true restarts the shell session from scratch:
https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/?reset=trueA reset:
cwd you pass, otherwise to the directory the service is configured to start in, otherwise to the user’s home directory (/ if that does not exist).bashrc / .zshrcTypical reasons to reset:
// After testing that polluted the environment// URL: ?reset=true// Result: Clean environment, no leaked variables
// After failed deployment left processes running// URL: ?reset=true// Result: Shell and its process group stopped, fresh start
// After experimenting with system configurations// URL: ?reset=true// Result: Back to default state
// Switching between different project contexts// URL: ?reset=true// Result: No leftover environment from previous projectTwo ways to trigger it:
// Option 1: Reset via URL parameter (web terminal)window.location = terminalUrl + '?reset=true';
// Option 2: Reset via DELETE endpoint (API)await fetch(terminalUrl + '/api/v1/terminal/1', { method: 'DELETE' // Kills session, next request creates fresh one});
// Next command executes in brand new sessionawait fetch(terminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'env' }) // Clean environment});Reset versus delete:
?reset=true - Immediate clean start: the page that opens the reset URL gets the fresh shell. Other tabs already attached to the session are disconnected; press Enter or click the reconnect message in such a tab to attach againDELETE /terminal/{id} - Kills the session; the next request creates a fresh one. A display the session started keeps running (see Display lifetime)Common scenarios:
Picking a free terminal_id is a problem when several jobs create sessions at once. Send "ephemeral": true to POST /api/v1/terminal/create without a terminal_id and the service allocates an id from 40000 to 65535 that no session currently holds, and returns it. An ephemeral session runs without a DISPLAY, so no display is ever brought up for it. It is not a reserved namespace, though: the ids are ordinary terminal ids, and a terminal-40001 hostname addresses session 40001 like any other.
An ephemeral session differs from an ordinary one in three ways:
DISPLAY is stripped from the session environment after the session is configured, even if the request’s own config set it, and no matching X display is started. A GUI program therefore has nowhere to draw unless something in the session exports DISPLAY itself and points it at a display that already exists; the stripping removes the default, it is not a sandbox.--ephemeral-result-timeout (five minutes by default) once it does, so a finished command’s output normally stays fetchable through GET /api/v1/terminal/result/{command_id} for that long. Past the threshold, a session with no clients is destroyed; if its process is still running it is killed rather than left to accumulate.When the allocator finds no free id it answers with No available ephemeral terminal IDs (range 40000-65535 exhausted) rather than reusing one.
Share a terminal URL and everyone connected types in the same session. Multiple users connect to:
https://{project}-{container}-terminal-1.{server}.containers.hoody.comEach connected client:
?readonly=true (input is blocked for that client only; read-only viewers and read-write collaborators can share one session)This suits:
See Multiplayer by Default → for the design rationale.
A daemon program configured with terminal_id: N runs in terminal N. Opening that terminal attaches to the program instead of starting a shell: you see its screen, type into it and detach while it keeps running, and the program follows the smallest writable client’s window size (the smallest read-only one when no client is writable). Shell, working directory and command parameters are ignored on that terminal, running a command there answers 409 DAEMON_TERMINAL, and when the program is not running the connection closes with code 4404. hoody daemon programs attach <program> and the SDK’s client.daemon.programs.attachTerminal find the terminal from the program, and the session list (hoody terminal sessions list) shows every program terminal, opened or not, with daemon_program: true and the program’s id, name and state (a local shell already open on the id stays listed as that shell until deleted). See Watching and driving a terminal program.
Start a GUI program in a paired terminal and it appears in your browser:
# In a terminal paired with display 2 (created with display: "2"), run Firefoxfirefox &
# It appears in display-2# Open: https://{project}-{container}-display-2.{server}.containers.hoody.comPair the session with a display, type the command in the terminal URL, and the GUI appears at the matching display URL. There is nothing else to set up.
This works with any GUI program:
# In terminal-3code /app # VS Code opens in display-3libreoffice report.pdf # LibreOffice opens in display-3gimp photo.jpg # GIMP opens in display-3chrome # Chrome opens in display-3For comparison, a traditional remote desktop means configuring a VNC or RDP server, installing a client, connecting to the desktop, and opening a terminal before the program finally runs. Here, typing the command in the terminal URL is the whole procedure.
The two URLs can sit on different devices:
code .Common uses:
See Displays → for the full desktop experience.
The first time a terminal/desktop session for display N is created (CLI, SDK call, or URL), Hoody Terminal boots an X server on :N (when one is not already running and the display tooling is installed in the container) and, when the notifications plugin is present, attaches the display’s notification daemon to it. That is why launching a GUI from a paired terminal needs no setup, and why notifications sent to display N via the Notifications kit start dispatching as soon as the session exists.
hoody-terminal doubles as an SSH client: the container makes the SSH connection to the remote server, and you drive it over HTTP from a browser. There is no SSH client, key setup, or configuration on your device.
A traditional SSH workflow:
The Hoody workflow:
Password authentication:
https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/ ?ssh_host=production-server.com &ssh_user=admin &ssh_password=your_passwordSSH key authentication:
# Base64-encode the key first:# SSH_KEY=$(base64 -w0 /home/user/.ssh/id_rsa | jq -sRr @uri)https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/ ?ssh_host=192.168.1.100 &ssh_user=root &ssh_key=<base64-encoded-private-key-content>Custom port:
# Base64-encode the key first:# SSH_KEY=$(base64 -w0 /keys/deploy_key | jq -sRr @uri)https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/ ?ssh_host=server.example.com &ssh_port=2222 &ssh_user=deploy &ssh_key=<base64-encoded-private-key-content>You’re now controlling the remote server over HTTP, from any device with a browser.
For automation, the same SSH parameters work on the API. The connection persists, so you can send commands repeatedly to the same remote server:
// Connect to production database serverconst remoteSession = await fetch(terminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'systemctl status postgresql', wait: true }), headers: { 'Content-Type': 'application/json' }}).then(r => r.text());With the SSH parameters in the URL:
import { readFileSync } from 'fs';// Base64-encode the private key before embedding in the URLconst sshKey = readFileSync('/secure/postgres.key', 'base64');const execUrl = 'https://PROJECT-CONTAINER-terminal-5.SERVER.containers.hoody.com/api/v1/terminal/execute' + '?ssh_host=db-server.internal' + '&ssh_user=postgres' + '&ssh_key=' + encodeURIComponent(sshKey);Now every POST to this URL executes commands on db-server.internal:
// All of these execute on the remote server, not the Hoody containerawait fetch(execUrl, { method: 'POST', body: JSON.stringify({ command: 'pg_dump production > backup.sql' })});
await fetch(execUrl, { method: 'POST', body: JSON.stringify({ command: 'du -sh backup.sql' })});
await fetch(execUrl, { method: 'POST', body: JSON.stringify({ command: 'gzip backup.sql' })});The SSH connection stays open, and session state persists across requests.
Managing servers from a phone or tablet takes no SSH app and no terminal emulator, only a browser and a URL.
You can also share server access without sharing credentials:
https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com?ssh_host=docs.internal&ssh_user=readonlySend this URL to your team. They can:
docs.internalAccess control comes from proxy permissions:
Monitoring production logs from a phone:
# base64 -w0 /keys/aws-prod.pem | jq -sRr @uri → use that value for ssh_keyhttps://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/ ?ssh_host=prod-app-1.aws.com &ssh_user=ubuntu &ssh_key=<base64-encoded-private-key-content> &cmd=dGFpbCAtZiAvdmFyL2xvZy9hcHAubG9n # base64 of: tail -f /var/log/app.logBookmark the URL and open it on a phone to see live production logs without an SSH client.
A multi-server deployment script:
const servers = [ 'web-1.production.com', 'web-2.production.com', 'web-3.production.com'];
// Base64-encode deploy key once before the loopimport { readFileSync } from 'fs';const deployKey = encodeURIComponent(readFileSync('/secure/deploy.key', 'base64'));
// Deploy to all servers via HTTPfor (const server of servers) { // reset=true: an active session ignores a changed ssh_host, so reset to reconnect to each server const execUrl = `https://PROJECT-CONTAINER-terminal-2.SERVER.containers.hoody.com/api/v1/terminal/execute` + `?reset=true&ssh_host=${server}` + `&ssh_user=deploy` + `&ssh_key=${deployKey}`;
// SSH sessions always answer asynchronously, so this returns a command_id, not the result const { command_id } = await (await fetch(execUrl, { method: 'POST', body: JSON.stringify({ command: 'cd /app && git pull && systemctl restart app', timeout: 300 }) })).json();
// Poll until the deployment finishes, then check its exit code let result; do { await new Promise((r) => setTimeout(r, 2000)); result = await (await fetch(`https://PROJECT-CONTAINER-terminal-2.SERVER.containers.hoody.com/api/v1/terminal/result/${command_id}`)).json(); } while (result.status === 'running');
console.log(`Deployed to ${server}: exit code ${result.exit_code}`);}Database backup automation:
import { readFileSync } from 'fs';// Runs on Hoody container, connects to DB server via SSH, executes backupconst pgKey = encodeURIComponent(readFileSync('/keys/postgres.key', 'base64'));const backupUrl = 'https://PROJECT-CONTAINER-terminal-3.SERVER.containers.hoody.com/api/v1/terminal/execute' + '?ssh_host=db-primary.internal' + '&ssh_user=postgres' + '&ssh_key=' + pgKey;
// This command runs on db-primary.internal, not Hoody containerawait fetch(backupUrl, { method: 'POST', body: JSON.stringify({ command: ` pg_dump production | gzip > /backups/prod_$(date +%Y%m%d).sql.gz && aws s3 cp /backups/prod_$(date +%Y%m%d).sql.gz s3://backups/ && echo "Backup complete" `, wait: true })});A security audit from a phone:
# base64 -w0 /keys/security-audit.key | jq -sRr @uri → use that value for ssh_keyhttps://PROJECT-CONTAINER-terminal-4.SERVER.containers.hoody.com/ ?ssh_host=firewall.company.com &ssh_user=security &ssh_key=<base64-encoded-private-key-content> &cmd=aXB0YWJsZXMgLUwgLW4gLXY= # base64 of: iptables -L -n -vOpen the URL to see the firewall rules from any browser; no laptop or SSH client is involved.
Connections stay up between requests until you reset or the remote side closes them:
import { readFileSync } from 'fs';// ssh_key must be base64-encoded private key content, not a file pathconst sshKeyB64 = readFileSync('/path/to/key', 'base64');// Build the execute URL with SSH params as query stringconst execUrl = terminalUrl + '/api/v1/terminal/execute?ssh_host=server.com&ssh_user=admin&ssh_key=' + encodeURIComponent(sshKeyB64);
// First command: Connects via SSHawait fetch(execUrl, { method: 'POST', body: JSON.stringify({ command: 'pwd' }) });
// Subsequent commands: Reuses connection (fast)await fetch(execUrl, { method: 'POST', body: JSON.stringify({ command: 'ls' }) });await fetch(execUrl, { method: 'POST', body: JSON.stringify({ command: 'df -h' }) });
// Connection stays open across requests; no reconnection overheadReset to change servers:
// Switch to different serverawait fetch(terminalUrl + '/api/v1/terminal/execute' + '?reset=true' + // Closes previous SSH connection '&ssh_host=new-server.com' + '&ssh_user=deploy' + // ssh_key must be base64-encoded private key content, not a file path '&ssh_key=' + encodeURIComponent(readFileSync('/keys/deploy.key', 'base64')), { method: 'POST', body: JSON.stringify({ command: 'hostname' }) });In the browser, add the same reset=true and SSH parameters to the web terminal URL instead.
Key points:
/keys/), so the key need not live on the connecting deviceTogether this means a phone can control production servers, run database backups, monitor logs, and deploy code, all via HTTP and without an SSH client.
A host that is not routable from the container can be reached through a SOCKS5 proxy. Four fields on POST /api/v1/terminal/create configure it alongside the usual ssh_* ones:
| Field | Meaning |
|---|---|
socks5_host | Proxy hostname or IP. Setting it turns the feature on |
socks5_port | Proxy port. Defaults to 1080 |
socks5_user | Proxy username, optional |
socks5_pass | Proxy password, optional |
The service turns them into an SSH ProxyCommand that runs ncat --proxy-type socks5, adding --proxy-auth when both credentials are present. A malformed host or a port outside 1 to 65535 fails session creation with the offending value named, rather than connecting directly and looking like it worked. Put the four fields in the create body rather than on a query string, for the same reason the SSH credentials belong there.
A terminal and an AI agent can share one browser window. hoody-terminal embeds hoody-agent in a side panel: an assistant that can see your terminal, suggest commands, and help you work.
Add the agent URL as a side panel:
https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-1.SERVER.containers.hoody.com &panel-width=40% &panel-position=leftWhat you get:
A typical loop:
du -sh * | sort -hEach terminal can have its own dedicated agent:
# Terminal-1 with agent-1 (configured for frontend context)https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-1.SERVER.containers.hoody.com &panel-width=35%
# Terminal-2 with agent-2 (configured for backend context)https://PROJECT-CONTAINER-terminal-2.SERVER.containers.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-2.SERVER.containers.hoody.com &panel-width=35%
# Terminal-3 with agent-3 (configured for DevOps context)https://PROJECT-CONTAINER-terminal-3.SERVER.containers.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-3.SERVER.containers.hoody.com &panel-width=35%Each agent instance:
Bookmark these URLs to reopen a configured terminal-and-agent pair later.
Learning Linux and DevOps:
https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-1.SERVER.containers.hoody.com &panel-width=40%Debugging:
https://PROJECT-CONTAINER-terminal-2.SERVER.containers.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-2.SERVER.containers.hoody.com &panel-width=35%tail -f /var/log/app.logInfrastructure management:
https://PROJECT-CONTAINER-terminal-3.SERVER.containers.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-3.SERVER.containers.hoody.com &panel-width=40% &ssh_host=production-server.com &ssh_user=adminCode review and testing:
https://PROJECT-CONTAINER-terminal-4.SERVER.containers.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-4.SERVER.containers.hoody.com &panel-width=45%/app/srcnpm testFrom the panel the agent can:
Panel width is adjustable:
&panel-width=30% # Smaller panel, more terminal space&panel-width=50% # Equal split&panel-width=60% # Larger panel for complex agent responsesPick a width to match the work: debugging needs more terminal space, learning needs more agent space.
Larger projects can run several agents:
# Frontend terminal with agent-1 (configured for React/TypeScript)open "https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/?panel=https://PROJECT-CONTAINER-agent-1.SERVER.containers.hoody.com"
# Backend terminal with agent-2 (configured for Go/databases)open "https://PROJECT-CONTAINER-terminal-2.SERVER.containers.hoody.com/?panel=https://PROJECT-CONTAINER-agent-2.SERVER.containers.hoody.com"
# Database terminal with agent-3 (configured for PostgreSQL)open "https://PROJECT-CONTAINER-terminal-3.SERVER.containers.hoody.com/?panel=https://PROJECT-CONTAINER-agent-3.SERVER.containers.hoody.com&ssh_host=db.internal&ssh_user=admin"Each agent instance is configured differently:
All of them work in the same container: files created in one terminal are visible to the others, and services are reachable across terminals, which suits full-stack development.
A traditional setup:
The Hoody setup:
The URL carries the whole environment: share it and you share the setup, including the terminal, the agent, the SSH connection, and the panel layout.
This suits:
See Web Terminal UI → for all panel customization options.
Query the container’s state via HTTP:
# Get CPU/memory/disk statshoody -c <container-id> terminal system stats
# List running processes sorted by CPUhoody -c <container-id> terminal processes list --sort cpu --limit 10
# Check what ports are listeninghoody -c <container-id> terminal system ports list
# View X11 displayshoody -c <container-id> terminal system displays list// Get system resource statsconst resources = await containerClient.terminal.system.getStats();console.log(`CPU: ${resources.data.cpu.usage_percent}%, Memory: ${resources.data.memory.used_percent}%`);
// List running processes sorted by CPUconst procs = await containerClient.terminal.processes.list({ sort: 'cpu', limit: 10 });
// Check listening portsconst ports = await containerClient.terminal.system.listPorts();
// View X11 displaysconst displays = await containerClient.terminal.system.listDisplays();# Get CPU/memory/disk statscurl "https://$PROJECT-$CONTAINER-terminal-1.$SERVER.containers.hoody.com/api/v1/system/resources"
# List running processes sorted by CPUcurl "https://$PROJECT-$CONTAINER-terminal-1.$SERVER.containers.hoody.com/api/v1/system/processes?sort=cpu&limit=10"
# Check what ports are listeningcurl "https://$PROJECT-$CONTAINER-terminal-1.$SERVER.containers.hoody.com/api/v1/system/ports"
# View X11 displayscurl "https://$PROJECT-$CONTAINER-terminal-1.$SERVER.containers.hoody.com/api/v1/system/displays"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 links for the four introspection calls in the HTTP tab: resource stats, the top CPU processes, listening ports, and connected X11 displays.
# Resources
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/system/resources&method=GET&response=transparent
# Processes
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/system/processes?sort=cpu%26limit=10&method=GET&response=transparent
# Ports
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/system/ports&method=GET&response=transparent
# Displays
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/system/displays&method=GET&response=transparent All of this system information is served over HTTP endpoints; there is no need to SSH in and run commands.
Execute commands on other servers through hoody-terminal:
hoody -c <container-id> terminal commands run \ --command "systemctl status nginx" --wait \ --ssh-host prod.example.com \ --ssh-user admin \ --ssh-password "$SSH_PASSWORD"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });const containerClient = await client.withContainer({ id: CONTAINER_ID, project_id: PROJECT_ID, server: SERVER });
// SSH parameters ride as query params; the body carries only the commandconst started = await containerClient.terminal.commands.run( { command: 'systemctl status nginx', wait: true }, { ssh_host: 'prod.example.com', ssh_user: 'admin', ssh_password: process.env.SSH_PASSWORD });
// SSH commands always answer asynchronously: poll until the command has finishedlet result;do { await new Promise((resolve) => setTimeout(resolve, 1000)); result = await containerClient.terminal.commands.get(started.data.command_id as string);} while (!['completed', 'timed_out', 'cancelled'].includes(result.data.status as string));console.log(result.data.stdout);# The password rides in the URL, so percent-encode it first:SSH_PASSWORD=$(jq -rn --arg p "$RAW_PASSWORD" '$p|@uri')curl -X POST "https://$PROJECT-$CONTAINER-terminal-1.$SERVER.containers.hoody.com/api/v1/terminal/execute?ssh_host=prod.example.com&ssh_user=admin&ssh_password=$SSH_PASSWORD" \ -H "Content-Type: application/json" \ -d '{"command": "systemctl status nginx", "wait": 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
Runs systemctl status nginx on prod.example.com over the container’s SSH bridge.
The password rides in the query string exactly as the HTTP tab sends it, which is
the practice the caution below recommends against.
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?ssh_host=prod.example.com%26ssh_user=admin%26ssh_password=SSH_PASSWORD&method=POST&json={"command":"systemctl%20status%20nginx","wait":true}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
The container acts as an HTTP-to-SSH bridge: your phone can execute commands on your production servers via HTTP.
SSH Client (installed) → SSH Server (configured) → Shell (finally)Limitations:
Any HTTP Client → hoody-terminal URL → Shell (immediately)Advantages:
<iframe src="terminal-url" />)Because terminals are HTTP:
Embed in documentation
<iframe src="https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/?cmd=bHMgLWxh&readonly=true" />Live terminals in your docs showing actual command execution.
Phone executes production commands
// From mobile browserawait fetch(terminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'pm2 restart api', wait: true })});AI orchestrates infrastructure
// AI agent deploys automatically.// The terminal-N hostname selects the session, so target terminal-4 by name.const terminal4Url = terminalUrl.replace('-terminal-1.', '-terminal-4.');const steps = [ 'git pull origin main', 'npm install --production', 'npm run build', 'pm2 restart app'];
for (const cmd of steps) { await fetch(terminal4Url + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: cmd, wait: true }) });}Cascading execution
// Terminal A executes command that triggers Terminal Bawait fetch(containerA_terminal + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: `curl -X POST ${containerB_terminal}/api/v1/terminal/execute -d '{"command":"npm test"}'`, wait: true })});Start the task asynchronously and check on it later:
// Use terminal-3 for build tasksconst buildTerminalUrl = 'https://PROJECT_ID-CONTAINER_ID-terminal-3.SERVER_NAME.containers.hoody.com';
const response = await fetch(buildTerminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'npm run build', wait: false, timeout: 300 })});
const { command_id } = await response.json();
// Poll for resultconst checkStatus = async () => { const result = await fetch(buildTerminalUrl + `/api/v1/terminal/result/${command_id}`) .then(r => r.json());
if (['completed', 'timed_out', 'cancelled'].includes(result.status)) { console.log(`Build ${result.status}, exit code ${result.exit_code}`); console.log(result.stdout); } else { setTimeout(checkStatus, 2000); }};
checkStatus();To watch progress, open the web terminal URL in a browser: the same terminal-3 URL shows the build output live.
The two modes mix: start a command via the API (from a phone, script, or CI/CD), then follow it visually in the web terminal from any browser.
Multi-step workflows execute in sequence in the same session:
const deployCommands = [ 'cd /app', 'git pull origin main', 'npm ci', 'npm run build', 'pm2 restart api', 'pm2 logs api --lines 50'];
// Use terminal-2 for deploymentsconst deployUrl = 'https://PROJECT_ID-CONTAINER_ID-terminal-2.SERVER_NAME.containers.hoody.com';
for (const command of deployCommands) { const response = await fetch(deployUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command, wait: true }) });
let result = await response.json(); // wait: true can answer early (SSH sessions, or more than 256 calls in flight); poll until the command has finished while (!['completed', 'timed_out', 'cancelled'].includes(result.status)) { await new Promise((resolve) => setTimeout(resolve, 1000)); result = await fetch(deployUrl + `/api/v1/terminal/result/${result.command_id}`).then((r) => r.json()); }
if (result.exit_code === null) { // Exit status unknown (completion "output_quiet"): send a timeout to get the real status console.error(`Unknown result for: ${command}`); break; } if (result.exit_code !== 0) { console.error(`Failed at: ${command}`); console.error(result.stdout); // PTY merges stderr into stdout break; }
console.log(`✓ ${command}`);}
// All commands executed in terminal-2's session// Working directory persisted across commands (cd /app stayed active)Monitor container resources:
async function checkHealth(terminalUrl) { const response = await fetch(terminalUrl + '/api/v1/system/resources'); const { cpu, memory } = await response.json();
const alerts = [];
if (cpu.usage_percent > 80) { alerts.push(`WARNING: CPU at ${cpu.usage_percent}%`); }
if (memory.used_percent > 85) { alerts.push(`WARNING: Memory at ${memory.used_percent}%`); }
return alerts;}
// Run every 5 minutessetInterval(async () => { const alerts = await checkHealth(terminalUrl); if (alerts.length > 0) { console.log('Health alerts:', alerts); }}, 300000);Collaborative debugging: a team member encounters a bug. Instead of screen sharing:
https://{project}-{container}-terminal-2.{server}.containers.hoody.comThere is no screen-share setup and no “can you see this?”; both people are in the same shell.
AI agents execute while you orchestrate:
// AI agent executes commands via the terminal HTTP APIconst TERMINAL_URL = 'https://PROJECT_ID-CONTAINER_ID-terminal-1.SERVER_NAME.containers.hoody.com';
const commands = [ 'npm create vite@latest my-app -- --template react-ts', 'cd my-app && npm install', 'npm run dev' // keeps running; its reply comes after 10 s of silence with exit_code null];
for (const command of commands) { const result = await fetch(`${TERMINAL_URL}/api/v1/terminal/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command, wait: true }) }); console.log(await result.json());}
// You described what you want built. AI executed it via HTTP.Automation needs no SSH setup:
# Deploy from GitHub Actions, GitLab CI, any CI/CD.# Point HOODY_TERMINAL_URL at a terminal-5 hostname: the hostname selects the session.import osimport requests
terminal_url = os.environ['HOODY_TERMINAL_URL']
response = requests.post( f'{terminal_url}/api/v1/terminal/execute', json={ 'command': './deploy.sh production', 'wait': False, 'timeout': 600 })
command_id = response.json()['command_id']# Monitor deployment status via command_idSupport agent and customer solve issues together in real time. Both open:
https://{project}-{customerContainer}-terminal-1.{server}.containers.hoody.comBoth see the same terminal and both can type. The agent fixes the issue while the customer watches, rather than dictating commands and hoping they are typed correctly.
Administer servers from anywhere. Your phone’s browser opens the terminal URL and executes:
systemctl restart nginxtail -f /var/log/app.logdocker pshtopA full Linux shell on a phone, because the terminal is a URL.
Embed working terminals in docs:
<!-- Your documentation --><p>To check system status, run:</p>
<iframe src="https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/?cmd=c3lzdGVtY3RsIHN0YXR1cyBuZ2lueA==&readonly=true" height="400"/>Readers see actual execution, not just code blocks.
Group related commands under the same terminal URL to keep session context:
// Good - Same terminal URL maintains stateconst deployUrl = 'https://PROJECT_ID-CONTAINER_ID-terminal-2.SERVER_NAME.containers.hoody.com';
await fetch(deployUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'cd /app', wait: true })});
await fetch(deployUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'npm install', wait: true })});// Runs in /app (working directory preserved)
// Bad - Different terminal URLs = different sessionsawait fetch('https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'cd /app' })});
await fetch('https://PROJECT-CONTAINER-terminal-2.SERVER.containers.hoody.com/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'npm install' })});// Runs in terminal-2's own working directory; the cd in terminal-1 does not affect itterminal-1, terminal-2, and terminal-3 are all in the same container: same files, same services, same system. Each just has an independent shell session with its own working directory and command history.
Use wait: false for long-running commands so the request returns without waiting for the command, with a command_id to poll (wait defaults to true):
// Async for buildsawait fetch('https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'npm run build', wait: false, timeout: 300 })});
// Sync would block for minutesBound how long you wait on a command with a timeout:
{ command: 'npm install', timeout: 300, // stop waiting after 5 minutes wait: false}In a persistent session a timeout is a reporting deadline, not a kill switch: when it fires the command is recorded as timed_out with exit_code: 124 and output capture stops, but no signal is sent and the process carries on in the shell. An ephemeral command, or one sent with mode: "raw", is ended instead: SIGTERM, then SIGKILL 3 seconds later. To stop a command, call POST /api/v1/terminal/execute/{command_id}/abort while it is still running: it sends Ctrl+C (SIGINT), or {"force": true} kills the session’s process group, shell included. This also works after the timeout has fired, as long as no other input has reached that terminal since and the shell is still running. Input is any bytes sent to it: another command, keystrokes, write, press or paste, and also what an open browser sends on its own (focus reports, answers to terminal queries). Typing through a PID attachment into any terminal of the container counts too. Attaching and resizing alone do not count. The result then shows status: "cancelled" with timed_out still true. Otherwise abort answers 409 and sends nothing, because it can no longer tell what the signal would hit. Send Ctrl+C to the session with the write endpoint instead.
Don’t assume success; verify exit codes. Here execute stands for your own helper that returns the finished result, polling /api/v1/terminal/result/{command_id} when the reply has no exit_code yet (see “When wait: true is not honoured” under HTTP command execution):
const result = await execute({ command: 'npm test', wait: true });
if (result.exit_code !== 0) { console.error('Tests failed:', result.stdout); // PTY merges stderr into stdout // Handle failure} else { console.log('Tests passed!');}Delete sessions when done to prevent resource leaks:
# List active sessionsGET /api/v1/terminal/sessions
# Delete old sessionsDELETE /api/v1/terminal/{terminal_id}For debugging or exploration, open the web URL directly:
https://{project}-{container}-terminal-1.{server}.containers.hoody.comIt beats the API for typing multiple commands, colored output, and scrolling through history.
Query the resource endpoints before intensive operations:
const { cpu, memory } = await fetch('https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/api/v1/system/resources') .then(r => r.json());
if (cpu.usage_percent > 90) { console.warn('CPU at capacity - delay operation');}Yes, if the terminal is running as root or the user has sudo permissions. Set the user parameter when the session is created: in the POST /api/v1/terminal/create body, or as a query parameter on /api/v1/terminal/execute, where it applies only to a new session or one started with reset=true. For security, consider using separate containers for privileged and unprivileged operations.
Include SSH parameters in the execute request: ssh_host, ssh_user, ssh_password (or ssh_key). The hoody-terminal service establishes the SSH connection and executes the command, returning results via HTTP. Your container becomes an HTTP-to-SSH gateway.
Yes. LLMs understand HTTP natively and can construct command execution requests without special training. They can handle multi-step workflows, check exit codes, parse output, and adapt based on results, all via standard HTTP calls and without an SDK.
Commands continue executing on the server; terminal sessions are server-side, not browser-side. Close your laptop and the command keeps running. Check status later via the /result/{command_id} endpoint.
Yes. Use iframes to embed terminal URLs directly in your app. Common patterns: documentation with live command execution, dashboards showing server logs, customer portals with diagnostic terminals.
Terminal IDs range from 1 through 65535; the practical concurrent-session limit depends on available resources. Use different terminal-N hostnames for isolated sessions, or point several clients at the same terminal-N hostname for shared/multiplayer sessions. When the caller is a script that should not have to pick a free number, create the session with "ephemeral": true instead and let the service allocate one; see Ephemeral sessions.
Yes, in the native browsers on iOS and Android. The UI adapts to touch input, includes an on-screen keyboard option, and supports mobile gestures, so you get a full Linux terminal from a phone’s browser.
Yes, via GET /api/v1/terminal/screenshot?format=png on the terminal-N hostname you want to capture — the hostname supplies terminal_id, and any value you send is overwritten. It returns a visual snapshot as PNG, JPEG, or GIF, which is useful for documentation, tutorials, or monitoring dashboards showing live terminal state.
Use GET /api/v1/terminal/history/{terminal_id} to retrieve the session’s retained command records, with their exit codes and execution times. Useful for debugging or working out what changed in a session.
It is a rolling window, not an archive. A session keeps 100 command records by default, and once that fills, the oldest finished record is dropped to make room for a new one. The window grows only when every record is still active. Pull the history as you go if you need a complete record. GET /api/v1/terminal/raw is no substitute: it returns only the output the session still retains, and the oldest output is discarded once that buffer fills.
Check that the container is running:
curl "https://api.hoody.com/api/v1/containers/{id}?runtime=true" \ -H "Authorization: Bearer $HOODY_TOKEN"Verify data.status === "running" and that data.runtime_info.terminals shows active sessions.
Start it if it is stopped:
curl -X POST "https://api.hoody.com/api/v1/containers/{id}/start" \ -H "Authorization: Bearer $HOODY_TOKEN"Possible causes:
Quick test:
# Direct browser access (not iframe)https://{project}-{container}-terminal-1.{server}.containers.hoody.comIf characters show up as blocks, garbled or in the wrong place, and the same session looks right in another browser, switch the renderer for that page with rendererType. It works on agent URLs too:
https://{project}-{container}-terminal-1.{server}.containers.hoody.com/?rendererType=domhttps://{project}-{container}-agent-1.{server}.containers.hoody.com/?rendererType=domwebgl draws with the GPU. It is the default in desktop browsers except Firefox.dom draws with ordinary page elements, so it does not depend on the GPU or its driver. It is the default in Firefox and can be slower when a program prints a lot very fast.canvas is accepted and draws with WebGL.Phones, tablets and other touch-screen devices always draw with dom, even when the URL asks for webgl or canvas. Add rendererForce=webgl to try WebGL on such a device anyway.
The choice lasts as long as the URL: it applies each time that page connects, and nothing is saved. If the problem looks the same with dom and with webgl, the renderer is not the cause; the program itself is drawing those cells, so check its colours or theme.
For long-running commands, use async mode:
// Correct - Async for npm install{ command: 'npm install', wait: false, timeout: 300}
// Wrong for a long request - Sync blocks until npm install finishes{ command: 'npm install', wait: true // Blocks for minutes}Increase the timeout if needed:
{ timeout: 600 } // 10 minutes for large buildsCheck the output, not just exit_code — and check stdout, not stderr. A command runs on a PTY, where the kernel gives it one stream: everything the command writes to either stdout or stderr arrives merged in stdout. The stderr field carries service-generated diagnostics such as SSH connection failures, so a command that printed nothing but errors still leaves it empty:
const result = await execute({ command: 'npm test', wait: true });
// Some commands exit 0 but print errors; on a PTY those land in stdoutif (result.stdout.includes('ERROR') || result.stdout.includes('FAIL')) { console.error('Command had errors:', result.stdout);}To separate the two streams, redirect inside the command itself: npm test 2>/tmp/err and read the file back.
Make sure you target the same terminal-N hostname; its index selects the session:
// Correct - same session: every call hits the same hostname (index 1-65535)'https://PROJECT-CONTAINER-terminal-42.SERVER.containers.hoody.com' // State persists
// Wrong - a different terminal-N hostname is a different session'https://PROJECT-CONTAINER-terminal-43.SERVER.containers.hoody.com'Sessions are deleted on:
Check the SSH parameters:
// SSH parameters are query params on the execute URL, not the JSON body.// POST /api/v1/terminal/execute?ssh_host=...&ssh_port=22&ssh_user=...const execUrl = terminalUrl + '/api/v1/terminal/execute' + '?ssh_host=prod.example.com' + '&ssh_port=22' + // Default 22 if omitted '&ssh_user=admin' + '&ssh_password=your-password'; // Or ssh_key (base64-encoded key content)
// The body carries only the command:await fetch(execUrl, { method: 'POST', body: JSON.stringify({ command: 'ls' }) });Verify SSH connectivity:
# Test from container firstssh admin@prod.example.comCommon issues:
StrictHostKeyChecking=no, so unknown hosts are accepted automatically)Other interactive services:
Displays
Linux GUI applications at a URL: VS Code, browsers, or any other GUI program.
Browser
Chromium and Firefox automation as a REST API: control browsers via HTTP, scrape websites, run tests.
Exec
Turn any script into an HTTP endpoint; your code becomes an API automatically.
Terminal API reference: