Displays
Full desktop environments accessible via URL: run VS Code, browsers, any GUI application.
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.
Command execution:
command, id, timeout, wait, cwd (per-command working directory), envterminal_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)terminal_id, command_id, command, status, stdout, stderr, stdout_truncated, stderr_truncated, exit_code, timed_out, cancelled{ "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 }{ "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"}System control:
System introspection:
protocol, user, port, ip, skip_program, http_only, hoody_onlyname, command, enabled, boot, autorestartname, connected, resolution, scale, is_internalWebSocket:
Health:
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=:5With that pairing in place, GUI programs you launch in terminal-5 appear in display-5. There is no automatic terminal_id ⇒ DISPLAY mapping: pass the display field explicitly (or export DISPLAY=:3 inside the shell) to target a given display. 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 the 39 customization options.
Web terminal sessions are also reachable from SSH. tmux sessions are shared between web and SSH access, and the terminal number in the URL maps to the tmux session ID:
# Web terminal-3: https://{project}-{container}-terminal-3.{server}.containers.hoody.com# 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 terminal sessions exec --command "ls -la /app" --wait
# Run a long command with a timeout (still waits for completion; --wait is on by default)hoody terminal sessions exec --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.execution.execute( { 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", "stdout": "total 48\ndrwxr-xr-x 5 user user 4096 Nov 9 14:30 .\n...", "stderr": "", "exit_code": 0, "timed_out": false}With wait: false the call returns as soon as the command starts, and carries no output:
{ "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, "timed_out": false}Prefer wait: false for anything long-running: each wait: true call occupies one of the service’s worker slots for the whole duration of the command, and the pool is small (4 by default).
The whole shell is now reachable by:
The terminal number in the URL determines which session executes the command:
terminal-1.hoody.com/execute → Executes in terminal session 1terminal-2.hoody.com/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)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 a cd first:
// Execute in /var/log without changing session's working directoryawait fetch(terminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'tail -f app.log', cwd: '/var/log', wait: false })});
// Session's working directory unchanged; next command runs in original locationawait fetch(terminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'pwd' }) // Still in /home/user});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 overrides the directory for that one command only; the session’s working directory is untouched. If the terminal is already running with a different working directory, cwd still applies to just that single command.
To guarantee a specific starting directory, use ?reset=true:
# Guarantee terminal starts in /app directoryhttps://PROJECT-CONTAINER-terminal-1.hoody.com/?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, session stays in /home/user}Reset and cwd together:
// Reset terminal to clean stateawait fetch(terminalUrl + '?reset=true');
// Now session is in /home/user// Use cwd for specific directory execution without changing sessionawait fetch(terminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'npm run build', cwd: '/app/dist' // Runs in /app/dist, session remains in /home/user })});?reset=true restarts the shell session from scratch:
https://PROJECT-CONTAINER-terminal-1.hoody.com/?reset=trueA reset:
/home/user.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: All processes killed, 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 web terminal reconnects automaticallyDELETE /terminal/{id} - Kills the session; the next request creates a fresh oneCommon scenarios:
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.
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 and attaches the dunst 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.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.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.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 terminalUrl = 'https://PROJECT-CONTAINER-terminal-5.hoody.com' + '?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(terminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'pg_dump production > backup.sql' })});
await fetch(terminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'du -sh backup.sql' })});
await fetch(terminalUrl + '/api/v1/terminal/execute', { 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://docs-server-terminal-1.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-monitor.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) { const terminalUrl = `https://PROJECT-CONTAINER-terminal-deploy.hoody.com` + `?ssh_host=${server}` + `&ssh_user=deploy` + `&ssh_key=${deployKey}`;
await fetch(terminalUrl + '/api/v1/terminal/execute', { method: 'POST', body: JSON.stringify({ command: 'cd /app && git pull && systemctl restart app', wait: true, timeout: 300 }) });
console.log(`Deployed to ${server}`);}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-backup.hoody.com' + '?ssh_host=db-primary.internal' + '&ssh_user=postgres' + '&ssh_key=' + pgKey;
// This command runs on db-primary.internal, not Hoody containerawait fetch(backupUrl + '/api/v1/terminal/execute', { 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-audit.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 until you reset:
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 serverwindow.location = terminalUrl + '?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'));Key points:
/keys/), not 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 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.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-1.hoody.com &panel-width=40%What 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.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-1.hoody.com &panel-width=35%
# Terminal-2 with agent-2 (configured for backend context)https://PROJECT-CONTAINER-terminal-2.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-2.hoody.com &panel-width=35%
# Terminal-3 with agent-3 (configured for DevOps context)https://PROJECT-CONTAINER-terminal-3.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-3.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.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-1.hoody.com &panel-width=40%Debugging:
https://PROJECT-CONTAINER-terminal-2.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-2.hoody.com &panel-width=35%tail -f /var/log/app.logInfrastructure management:
https://PROJECT-CONTAINER-terminal-3.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-3.hoody.com &panel-width=40% &ssh_host=production-server.com &ssh_user=adminCode review and testing:
https://PROJECT-CONTAINER-terminal-4.hoody.com/ ?panel=https://PROJECT-CONTAINER-agent-4.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.hoody.com/?panel=https://PROJECT-CONTAINER-agent-1.hoody.com"
# Backend terminal with agent-2 (configured for Go/databases)open "https://PROJECT-CONTAINER-terminal-2.hoody.com/?panel=https://PROJECT-CONTAINER-agent-2.hoody.com"
# Database terminal with agent-3 (configured for PostgreSQL)open "https://PROJECT-CONTAINER-terminal-3.hoody.com/?panel=https://PROJECT-CONTAINER-agent-3.hoody.com&ssh_host=db.internal"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 terminal system resources
# List running processes sorted by CPUhoody terminal processes list --sort cpu --limit 10
# Check what ports are listeninghoody terminal system ports
# View X11 displayshoody terminal system display-info// Get system resource statsconst resources = await containerClient.terminal.system.getResources();console.log(`CPU: ${resources.data.cpu.usage_percent}%, Memory: ${resources.data.memory.used_percent}%`);
// List running processes sorted by CPUconst procs = await containerClient.terminal.system.listProcesses({ sort: 'cpu', limit: 10 });
// Check listening portsconst ports = await containerClient.terminal.system.listPorts();
// View X11 displaysconst displays = await containerClient.terminal.system.getDisplayInfo();# 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 terminal sessions exec \ --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.execution.execute( { command: 'systemctl status nginx', wait: true }, { ssh_host: 'prod.example.com', ssh_user: 'admin', ssh_password: process.env.SSH_PASSWORD });
const result = await containerClient.terminal.execution.getResult(started.data.command_id as string);console.log(result.data.stdout);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://demo-terminal.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 automaticallyconst steps = [ 'git pull origin main', 'npm install --production', 'npm run build', 'pm2 restart app'];
for (const cmd of steps) { await fetch(terminalUrl + '/api/v1/terminal/execute?terminal_id=4', { 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 (result.status === 'completed') { console.log(`Build finished with 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 }) });
const result = await response.json();
if (result.exit_code !== 0) { console.error(`Failed at: ${command}`); console.error(result.stderr); 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'];
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/CDimport requests
terminal_url = os.environ['HOODY_TERMINAL_URL']
response = requests.post( f'{terminal_url}/api/v1/terminal/execute', params={'terminal_id': '5'}, 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://demo-terminal.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('...terminal-1.hoody.com/api/v1/terminal/execute', { body: JSON.stringify({ command: 'cd /app' })});
await fetch('...terminal-2.hoody.com/api/v1/terminal/execute', { body: JSON.stringify({ command: 'npm install' })});// Runs in ~ (terminal-2 has its own separate session)terminal-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 immediately with a command_id to poll (wait defaults to true):
// Async for buildsawait fetch('.../api/v1/terminal/execute', { body: JSON.stringify({ command: 'npm run build', wait: false, timeout: 300 })});
// Sync would block for minutesPrevent runaway processes with sensible timeouts:
{ command: 'npm install', timeout: 300, // 5 minutes max wait: false}Don’t assume success; verify exit codes:
const result = await execute({ command: 'npm test', wait: true });
if (result.exit_code !== 0) { console.error('Tests failed:', result.stderr); // 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('.../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. Configure the user when creating the container or use the user parameter when starting the terminal session. 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.
There is no fixed limit; in practice, hundreds of concurrent sessions work, and each consumes minimal RAM. Use different terminal_id values for isolated sessions or the same terminal_id for shared/multiplayer 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?terminal_id=1&format=png. 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 all commands executed, their exit codes, and execution times. Useful for auditing, debugging, or working out what changed in a session.
Check that the container is running:
curl "https://api.hoody.com/api/v1/containers/{id}?runtime=true" \ -H "Authorization: Bearer $HOODY_TOKEN"Verify status: "running" and that 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.comFor long-running commands, use async mode:
// Correct - Async for npm install{ command: 'npm install', wait: false, timeout: 300}
// Wrong - Sync will timeout{ command: 'npm install', wait: true // Blocks for minutes}Increase the timeout if needed:
{ timeout: 600 } // 10 minutes for large buildsCheck stderr, not just exit_code:
const result = await execute({ command: 'npm test', wait: true });
// Some commands exit 0 but write errors to stderrif (result.stderr.includes('ERROR') || result.stderr.includes('FAIL')) { console.error('Command had errors:', result.stderr);}Make sure you use the same terminal_id:
// Correct - Same session (terminal_id must be numeric 1-65535)terminal_id: "42" // State persists
// Wrong - Different sessionsterminal_id: Math.random() // New session each timeSessions 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
Full desktop environments accessible via URL: run VS Code, browsers, any GUI application.
Browser
Chrome 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: