Skip to content
Hoody.com

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:

  • Web terminal UI - A full browser terminal that replaces SSH for most use cases
  • GUI launch - Type firefox & and the program appears in a browser display, with nothing to configure
  • Command execution - Run any shell command via POST request and get stdout, stderr, and exit codes
  • Persistent sessions - Stateful terminals that remember working directory and environment
  • Multiplayer sessions - Multiple users typing in the same terminal simultaneously
  • System monitoring - Query CPU, memory, disk, processes, and ports via HTTP
  • WebSocket streaming - Real-time output for long-running commands
  • Screenshots - Capture terminal state as PNG, JPEG, or GIF

The 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:

  • POST /api/v1/terminal/execute - Execute shell commands
    • Body params: 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)
    • Query params: terminal_id, cwd (initial working directory for new/reset local sessions), cwd_auto_create, shell, user, ssh_host, ssh_user, ssh_password, ssh_key, reset
    • Modes: Synchronous (wait: true) or Asynchronous (wait: false); mode: "raw" requires wait: true
  • GET /api/v1/terminal/result/{command_id} - Poll async command result
    • Returns: terminal_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")
  • POST /api/v1/terminal/execute/{command_id}/abort - Abort a running command (SIGINT or force SIGKILL)
    • Body: { "force": false }
  • POST /api/v1/terminal/write - Type raw input into a session PTY (interactive prompts, y/n, sudo password)
    • Query params: terminal_id
    • Body: { "input": "text", "enter": true }, written raw as if typed at the keyboard

Session management:

  • POST /api/v1/terminal/create - Explicitly create a terminal session
    • Body params: terminal_id, shell, user, cwd, display, ssh_host, …
  • GET /api/v1/terminal/sessions - List all active sessions
    • Returns: Array of sessions, each with terminal_id, shell, cwd, created_at, and recent command_history
  • DELETE /api/v1/terminal/{terminal_id} - Terminate session
    • Ends the session’s shell and signals its process group; a process that detached from that group keeps running
  • GET /api/v1/terminal/history/{terminal_id} - Command history
    • Returns: The session’s retained commands (100 by default, oldest evicted) with command_id, command, status, start_time, exit_code, timed_out
  • GET /api/v1/terminal/raw - Export retained output (the most recent output, up to 8 MB by default; older output is dropped)
    • Query params: terminal_id, format (download|text|html), tail
  • GET /api/v1/terminal/screenshot - Visual terminal snapshot
    • Query params: terminal_id, format (png|jpeg|gif), foreground, background, fontsize, save

Terminal automation (TUI control):

System resource monitoring:

System control:

System introspection:

File drag-and-drop:

WebSocket:

Health and diagnostics:

  • GET /api/v1/terminal/health - Service health check
  • POST /api/v1/terminal/state - Post a client-state envelope for diagnostics correlation
    • Body (all optional): { "build_id": "...", "renderer": "webgl", "reason": "connection_resumed" }, at most 4,096 bytes (a larger body is refused with 413)

Web interface:

  • GET / - Browser terminal UI
    • More than 60 query parameters for customization (session, shell, SSH, display, desktop, panel, theme, font, etc.)

This is how most users access containers, and it replaces SSH for daily work:

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

Each number is a separate terminal session in the same container:

  • terminal-1 - Your main terminal session
  • terminal-2 - A second terminal for monitoring logs
  • terminal-3 - A third terminal for running tests

All 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:

  • Files you create in terminal-1 are visible in terminal-2
  • Services you start in terminal-2 are accessible from terminal-3
  • Environment on the container remains consistent
  • The container is one computer with multiple shell sessions

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=:1
  • terminal-2 with display: "2" → DISPLAY=:2
  • terminal-5 with display: "5" → DISPLAY=:5

On 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.

ShellDescriptionLaunch
bashDefault shell, universal compatibilityDefault or ?shell=bash
zshModern shell, oh-my-zsh compatible, better completion?shell=zsh or exec zsh
fishFriendly shell, syntax highlighting, autosuggestions?shell=fish or exec fish
tmuxTerminal multiplexer, shared between web and SSH?shell=tmux
shBourne shell, minimal, POSIX-compliant?shell=sh

Switch shells from inside a session:

Terminal window
exec zsh # Switch to zsh
exec fish # Switch to fish
tmux # Launch tmux

When switching shells with the ?shell= parameter on an existing session, add ?reset=true so the new shell starts clean:

# Recommended: Reset when changing shells
https://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 shell

URL 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 docs

See 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:

Terminal window
# Web terminal-3: https://{project}-{container}-terminal-3.{server}.containers.hoody.com/?shell=tmux
# SSH to same session:
ssh user@container
tmux attach -t 3
# Now you're in the exact same session as the web terminal
# Edit files, see command history; everything synced

This lets you drive web terminal sessions from traditional SSH clients while keeping full session state.

Scripts and AI agents use the HTTP API directly:

Terminal window
# Execute a command in your container
hoody -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 300

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:

  • The session is an SSH session. wait: true is always forced off there; see Remote SSH execution.
  • The request sets defer_pid, so the command is held back until that process exits.
  • 256 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:

  • AI agents (execute commands via HTTP)
  • Mobile devices (POST from your phone)
  • Other containers (cross-container orchestration)
  • Embedded iframes (terminals in documentation)
  • Automation scripts (no SSH setup needed)

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 1
  • PROJECT-CONTAINER-terminal-2.SERVER.containers.hoody.com/api/v1/terminal/execute → Executes in terminal session 2
  • Each session is isolated but in the same container

Sessions persist across requests:

// Execute in terminal-1
const 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 directory
await 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:

  • Files created in terminal-1 are immediately accessible in terminal-2
  • Processes started in terminal-2 can be seen from terminal-3
  • All terminals share the same filesystem, users, and services; it is one computer

State persists within each terminal session:

  • Current working directory (cd commands remembered)
  • Environment variables (exports remain; variables passed in an /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)
  • Shell history
  • Background processes
  • Open file descriptors

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/log
await 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 too
await fetch(terminalUrl + '/api/v1/terminal/execute', {
method: 'POST',
body: JSON.stringify({ command: 'pwd' }) // /var/log
});

Practical uses:

  • One-off commands in specific directories - Check logs, run tests, inspect files without navigating
  • Parallel operations - Different terminals in different directories simultaneously
  • CI/CD workflows - Execute build commands in consistent locations regardless of session state
  • Scripts - Always run from the correct directory without cd chains
// Build frontend and backend simultaneously in different directories
const 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 directory
https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/?cwd=/app&reset=true
# Then use cwd parameter to execute in specific locations
POST /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=true

A reset:

  • Terminates the session’s shell and signals its process group; a process that detached from that group keeps running
  • Clears environment variables
  • Resets the working directory to the 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)
  • Starts a fresh shell history buffer, leaving history already written to the session’s history file in place
  • Re-executes .bashrc / .zshrc

Typical 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 project

Two 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 session
await 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 again
  • DELETE /terminal/{id} - Kills the session; the next request creates a fresh one. A display the session started keeps running (see Display lifetime)
  • Both give you a fresh shell; choose based on whether you’re using the web UI or the API

Common scenarios:

  • Development cycles - Reset between test runs to avoid state contamination
  • User demos - Start each demo with a clean environment
  • CI/CD stages - Reset before each deployment step
  • Troubleshooting - Eliminate environment issues by starting fresh

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:

  • No display is assigned. 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.
  • It cleans itself up. Its idle timeout is 60 seconds while it holds no command results, and --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.
  • It kills only its own shell. Teardown signals the session’s shell rather than the whole process group, so a process the session deliberately detached survives it.

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.com

Each connected client:

  • Sees the same terminal output (the PTY broadcasts to every attached client)
  • Can type commands simultaneously (all share one shell/screen)
  • Can attach read-only with ?readonly=true (input is blocked for that client only; read-only viewers and read-write collaborators can share one session)

This suits:

  • Pair programming (both typing in the same session)
  • Teaching Linux (instructor and students share a terminal)
  • Customer support (solve issues together)
  • Team debugging (everyone sees live output)

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:

Terminal window
# In a terminal paired with display 2 (created with display: "2"), run Firefox
firefox &
# It appears in display-2
# Open: https://{project}-{container}-display-2.{server}.containers.hoody.com

Pair 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:

Terminal window
# In terminal-3
code /app # VS Code opens in display-3
libreoffice report.pdf # LibreOffice opens in display-3
gimp photo.jpg # GIMP opens in display-3
chrome # Chrome opens in display-3

For 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:

  • Terminal URL on a phone → type code .
  • Display URL on a tablet → watch VS Code run
  • Control from anywhere, view from anywhere

Common uses:

  • Quick GUI app testing in the browser
  • Mobile development workflows (command on phone, view on tablet)
  • Teaching (instructor types, students see the GUI appear)
  • Documentation (embed a terminal and display showing a live app)

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:

  1. Install SSH client on your device
  2. Generate SSH keys
  3. Copy keys to server
  4. Remember server addresses and ports
  5. Use command line to connect

The Hoody workflow:

  1. Open terminal URL in browser
  2. Add SSH parameters to URL
  3. You’re connected

Password authentication:

https://PROJECT-CONTAINER-terminal-1.SERVER.containers.hoody.com/
?ssh_host=production-server.com
&ssh_user=admin
&ssh_password=your_password

SSH 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 server
const 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 URL
const 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 container
await 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=readonly

Send this URL to your team. They can:

  • Execute commands on docs.internal
  • Connect without supplying credentials themselves, as long as the container already has a usable key, agent, or passwordless login for that host
  • View output in the browser
  • Never see the actual SSH credentials

Access control comes from proxy permissions:

  • IP whitelist for production servers
  • Password protection for staging access
  • JWT tokens for automated deployments
  • See Permissions →

Monitoring production logs from a phone:

# base64 -w0 /keys/aws-prod.pem | jq -sRr @uri → use that value for ssh_key
https://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.log

Bookmark 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 loop
import { readFileSync } from 'fs';
const deployKey = encodeURIComponent(readFileSync('/secure/deploy.key', 'base64'));
// Deploy to all servers via HTTP
for (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 backup
const 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 container
await 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_key
https://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 -v

Open 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 path
const sshKeyB64 = readFileSync('/path/to/key', 'base64');
// Build the execute URL with SSH params as query string
const execUrl = terminalUrl + '/api/v1/terminal/execute?ssh_host=server.com&ssh_user=admin&ssh_key=' + encodeURIComponent(sshKeyB64);
// First command: Connects via SSH
await 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 overhead

Reset to change servers:

// Switch to different server
await 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:

  • SSH connections are maintained by the Hoody container
  • Your device only speaks HTTP (browser or fetch)
  • Sessions persist, so repeated commands are fast
  • Scripts running in the container can read keys from the container’s own filesystem (for example /keys/), so the key need not live on the connecting device
  • Access control comes from proxy permissions, not SSH keys
  • Any device with a browser works

Together 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:

FieldMeaning
socks5_hostProxy hostname or IP. Setting it turns the feature on
socks5_portProxy port. Defaults to 1080
socks5_userProxy username, optional
socks5_passProxy 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=left

What you get:

  • Left 40% - Hoody Agent (chat interface, suggestions, explanations)
  • Right 60% - Terminal (command execution)
  • Same container - Agent and terminal share filesystem, processes, and state

A typical loop:

  1. Ask the agent: “How do I find large files?”
  2. The agent responds: du -sh * | sort -h
  3. Execute the command in the terminal (right side)
  4. Paste the result to the agent and it offers a next step
  5. Repeat

Each 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:

  • Is configured for a specific context (via profiles, memory, system prompt)
  • Runs next to the terminal in the same container
  • Can see files and processes in the container
  • Provides specialized assistance based on its configuration

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%
  • Ask the agent to explain commands before running them
  • The agent provides context, flags, and common patterns
  • Execute in the terminal, see results
  • The agent explains output and suggests next steps

Debugging:

https://PROJECT-CONTAINER-terminal-2.SERVER.containers.hoody.com/
?panel=https://PROJECT-CONTAINER-agent-2.SERVER.containers.hoody.com
&panel-width=35%
  • Describe the problem to the agent: “API returning 500 errors”
  • The agent suggests diagnostic commands: tail -f /var/log/app.log
  • Execute the commands in the terminal
  • The agent analyzes logs and suggests fixes
  • Apply fixes; the agent monitors results

Infrastructure 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=admin
  • Combined SSH, agent, and terminal
  • SSH to the production server (via HTTP)
  • The agent helps with server management commands
  • Safe infrastructure changes with AI assistance

Code 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%
  • The agent reviews code in /app/src
  • It suggests improvements and test commands
  • Execute tests in the terminal: npm test
  • The agent analyzes test output and suggests fixes
  • Iterate until tests pass

From the panel the agent can:

  • Read files in the container
  • Execute commands (if you grant permission)
  • Analyze output from the terminal
  • Suggest solutions based on context
  • Remember the conversation across page reloads (via agent state)
  • Access container services (databases, web servers, and so on)

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 responses

Pick a width to match the work: debugging needs more terminal space, learning needs more agent space.

Larger projects can run several agents:

Terminal window
# 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:

  • agent-1: React/TypeScript context (via profiles and memory)
  • agent-2: Go/API context (via profiles and memory)
  • agent-3: PostgreSQL/DBA context (via profiles and memory)

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:

  1. Open terminal application
  2. Open separate AI chat in browser
  3. Switch between them
  4. Copy/paste commands and output
  5. Context lost between apps

The Hoody setup:

  1. Open one URL (terminal + agent)
  2. Agent runs beside the terminal in the same container
  3. Paste command output into the agent when you want it analyzed
  4. Suggest, execute, analyze in one window
  5. Context preserved automatically

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:

  • Teaching - The instructor shares a terminal-and-agent URL; students learn with AI assistance
  • Pair programming - Both developers see the terminal and the agent’s suggestions
  • Support - Share a debugging session with the AI already analyzing the problem
  • Documentation - Embed a terminal and agent showing live examples

See Web Terminal UI → for all panel customization options.

Query the container’s state via HTTP:

Terminal window
# Get CPU/memory/disk stats
hoody -c <container-id> terminal system stats
# List running processes sorted by CPU
hoody -c <container-id> terminal processes list --sort cpu --limit 10
# Check what ports are listening
hoody -c <container-id> terminal system ports list
# View X11 displays
hoody -c <container-id> terminal system displays list

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:

Terminal window
hoody -c <container-id> terminal commands run \
--command "systemctl status nginx" --wait \
--ssh-host prod.example.com \
--ssh-user admin \
--ssh-password "$SSH_PASSWORD"

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:

  • SSH client required (not available on phones, tablets, watches)
  • Key management (private keys, known_hosts, permissions)
  • Port forwarding complexity
  • Not embeddable (you can’t iframe SSH)
  • Not multiplayer (one session per connection)
  • AI can’t use it (binary protocol)
Any HTTP Client → hoody-terminal URL → Shell (immediately)

Advantages:

  • Any device with a browser works (phones, tablets, watches, TVs)
  • No key management (the URL is the credential)
  • Naturally embeddable (<iframe src="terminal-url" />)
  • Multiplayer by default (sharing the URL shares the session)
  • AI-native (LLMs understand HTTP requests)
  • Observable (all HTTP requests logged)
  • MITM-able via hoody-exec (enhance any command automatically)

Because terminals are HTTP:

  1. 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.

  2. Phone executes production commands

    // From mobile browser
    await fetch(terminalUrl + '/api/v1/terminal/execute', {
    method: 'POST',
    body: JSON.stringify({
    command: 'pm2 restart api',
    wait: true
    })
    });
  3. 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 })
    });
    }
  4. Cascading execution

    // Terminal A executes command that triggers Terminal B
    await 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 tasks
const 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 result
const 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 deployments
const 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 minutes
setInterval(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:

  1. Share the terminal URL: https://{project}-{container}-terminal-2.{server}.containers.hoody.com
  2. A senior dev opens the URL on a phone while in a meeting
  3. Types the fix directly in the shared session
  4. The bug is resolved in 30 seconds

There 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 API
const 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 os
import 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_id

Support agent and customer solve issues together in real time. Both open:

https://{project}-{customerContainer}-terminal-1.{server}.containers.hoody.com

Both 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 nginx
  • tail -f /var/log/app.log
  • docker ps
  • htop

A 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.


Section titled “Use the same terminal URL for related tasks”

Group related commands under the same terminal URL to keep session context:

// Good - Same terminal URL maintains state
const 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 sessions
await 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 it

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 without waiting for the command, with a command_id to poll (wait defaults to true):

// Async for builds
await 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 minutes

Bound 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:

Terminal window
# List active sessions
GET /api/v1/terminal/sessions
# Delete old sessions
DELETE /api/v1/terminal/{terminal_id}

For debugging or exploration, open the web URL directly:

https://{project}-{container}-terminal-1.{server}.containers.hoody.com

It 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.

How do I execute commands on remote servers via SSH?

Section titled “How do I execute commands on remote servers via SSH?”

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.

Can AI agents really use terminals effectively?

Section titled “Can AI agents really use terminals effectively?”

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.

What happens to running commands if I close my browser?

Section titled “What happens to running commands if I close my browser?”

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.

How many terminal sessions can one container have?

Section titled “How many terminal sessions can one container have?”

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.

Does the web terminal work on mobile devices?

Section titled “Does the web terminal work on mobile devices?”

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.

Can I capture screenshots of terminal sessions?

Section titled “Can I capture screenshots of terminal sessions?”

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.

How do I get the complete command history for a session?

Section titled “How do I get the complete command history for a session?”

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:

Terminal window
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:

Terminal window
curl -X POST "https://api.hoody.com/api/v1/containers/{id}/start" \
-H "Authorization: Bearer $HOODY_TOKEN"

Possible causes:

  1. Container not running - Start it via the Hoody API
  2. Wrong URL - Verify terminal-1 (not terminal1 or terminal)
  3. Proxy permissions - Check whether terminal access is restricted
  4. Browser blocking iframe - Some browsers block cross-origin iframes

Quick test:

Terminal window
# Direct browser access (not iframe)
https://{project}-{container}-terminal-1.{server}.containers.hoody.com

If 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=dom
https://{project}-{container}-agent-1.{server}.containers.hoody.com/?rendererType=dom
  • webgl 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 builds

Check 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 stdout
if (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:

  • Explicit DELETE request
  • Container restart
  • Terminal service restart
  • Automatic idle cleanup: a session with no connected clients and no running process is removed after it has been idle for a while, and an idle ephemeral session is removed even if its process is stuck

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:

Terminal window
# Test from container first
ssh admin@prod.example.com

Common issues:

  • Firewall blocking the SSH port
  • Wrong credentials
  • Host or network unreachable from the container (host key verification is not an issue: the kit connects with 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.

Explore Displays →

Browser

Chromium and Firefox automation as a REST API: control browsers via HTTP, scrape websites, run tests.

Explore Browser →

Exec

Turn any script into an HTTP endpoint; your code becomes an API automatically.

Explore Exec →

Terminal API reference: