A persistent shell service that provides HTTP API access to a long-running shell session. It is designed for use by LLMs (through a tool) that need to execute shell commands while maintaining shell state (environment variables, working directory, shell functions) across multiple requests.
The shelld service includes no authentication - only a simple API key that acts as a lock token to ensure the correct client is talking to the correct shell instance. It MUST be used inside of a private network or VPC on infrastructure that does not expose the service to the public internet.
The shelld service SHOULD be executed in a container (potentially a container that is reaped after the shell is shutdown, thereby ensuring each LLM session gets a fresh container). In theory, you could run the service on your local machine to allow your own local agent to access that machine through a shell tool, but this is NOT RECOMMENDED as a local agent can take actions that will make your machine unusable.
The shelld service spawns a single bash shell process with a PTY (pseudo-terminal) and exposes it via HTTP endpoints. Unlike typical command execution where each command runs in a fresh process, shelld maintains a persistent shell session:
- Environment variables set in one request persist to subsequent requests
- Working directory changes persist
- Shell functions and aliases persist
- The shell process remains running between requests
# Build
go build -o bin/shelld ./cmd/shelld
# Create minimal config (defaults are fine)
cat > config.toml << 'EOF'
# shelld uses defaults for everything
# API key is set by first client request
EOF
# Run
./bin/shelld --config config.tomlAll endpoints except /health require the X-Shell-Key header:
curl -X POST -H "X-Shell-Key: my-session-key" http://localhost:8080/startupRequests without a key receive 401 Unauthorized.
The shell uses a first-come-first-served locking model:
- First request locks - The first request with an
X-Shell-Keyheader locks the shell to that key - Subsequent requests verify - All subsequent requests must use the same key
- No pre-configuration - The API key is not configured in advance; it is set by the first client
┌─────────────────────────────────────────────────────────┐
│ Locking Flow │
│ │
│ 1. First request: X-Shell-Key: abc123 │
│ → Shell locks to "abc123" │
│ │
│ 2. Subsequent request: X-Shell-Key: abc123 │
│ → Allowed (key matches) │
│ │
│ 3. Request with different key: X-Shell-Key: wrong │
│ → 401 Unauthorized (key doesn't match lock) │
└─────────────────────────────────────────────────────────┘
This design is intentional for LLM usage:
- One shelld instance per LLM session - Each LLM session should have its own shelld instance
- Exclusive access - The LLM "owns" the shell for the duration of the session
- State isolation - Environment variables, working directory, and shell state are private to that session
- Clean shutdown - When the session ends, the shelld instance shuts down (either via
/shutdown, idle timeout, or process termination)
Do not share a shelld instance between multiple LLM sessions or users.
The shell has four states:
| State | Description |
|---|---|
available |
Initial state. Shell process not running. Call /startup to start. |
ready |
Shell running and waiting for commands. Call /run to execute. |
busy |
Shell executing a command. Wait for completion or call /kill. |
unrecoverable |
Shell in error state. Call /recycle to recover. |
┌─────────────┐
│ available │
└──────┬──────┘
│ /startup
▼
┌─────────────────────────────────────┐
│ ready │◄────────┐
└──────┬──────────────────────────────┘ │
│ /run │
▼ │
┌─────────────┐ │
│ busy │──────────────────────────────────┘
└──────┬──────┘ command completes
│
│ error or /kill
▼
┌─────────────────┐
│ unrecoverable │
└────────┬────────┘
│ /recycle
▼
(back to ready)
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /startup |
Yes | Start the shell process |
| POST | /run |
Yes | Execute a command |
| POST | /kill |
Yes | Terminate running command/shell |
| POST | /recycle |
Yes | Kill and restart shell |
| POST | /shutdown |
Yes | Shutdown the server (or ?recycle=true to recycle shell) |
| GET | /status |
Yes | Get current shell state |
| GET | /health |
No | Health check |
Detailed Documentation:
- Endpoint Reference - Complete API documentation for each endpoint
- LLM Usage Guide - Patterns and best practices for LLM integration
- Configuration Reference - All configuration options explained
# Start shell
curl -X POST -H "X-Shell-Key: $KEY" http://localhost:8080/startup
# Run command
curl -X POST -H "X-Shell-Key: $KEY" -d "echo hello" http://localhost:8080/run
# Response: hello
# Commands persist state
curl -X POST -H "X-Shell-Key: $KEY" -d "export FOO=bar" http://localhost:8080/run
curl -X POST -H "X-Shell-Key: $KEY" -d "echo \$FOO" http://localhost:8080/run
# Response: barCommands have a configurable timeout (default: 5 minutes). If a command doesn't complete within the timeout:
- The HTTP request returns
202 Acceptedwith an error message - The shell remains in
busystate - The command continues running in the background
- When the command completes, the shell returns to
readystate
You can override the timeout per-request using the X-Command-Timeout header:
# Wait up to 30 minutes for this command
curl -X POST \
-H "X-Shell-Key: $KEY" \
-H "X-Command-Timeout: 30m" \
-d "long-running-task" \
http://localhost:8080/runThe timeout value is capped by timeout.command_maximum in the config.
For commands that may exceed the timeout:
- Set an appropriate
X-Command-Timeoutheader - If you get
202 Accepted, poll/statusuntil it returnsready - The command output is lost on timeout (the command still runs, but output isn't captured)
- To abort, call
/kill
# Start long command with short timeout
curl -X POST -H "X-Shell-Key: $KEY" -H "X-Command-Timeout: 1s" \
-d "sleep 10 && echo done" http://localhost:8080/run
# Returns 202 immediately
# Poll for completion
while [ "$(curl -s -H "X-Shell-Key: $KEY" http://localhost:8080/status)" = "busy" ]; do
sleep 1
done
echo "Command completed"Configuration is in TOML format. See config/config.example.toml for all options.
[server]
port = 8080 # HTTP port (default: 8080)
[shell]
command = "/bin/bash" # Shell to execute (default: /bin/bash)
working_directory = "" # Initial working directory (default: where shelld was launched)
[timeout]
command = "5m" # Default command timeout (default: 5m)
command_maximum = "30m" # Max allowed via header (default: 30m)
idle = "30m" # Shutdown after inactivity (default: 30m)
shutdown = "30s" # Graceful shutdown timeout (default: 30s)
kill = "5s" # SIGINT to SIGKILL grace period (default: 5s)
[hooks]
shell_command = "/bin/sh" # Shell for hooks (default: /bin/sh)
on_startup = "" # Command to run on startup
on_shutdown = "" # Command to run on shutdown
on_recycle = "" # Command to run on recycleSHELLD_CONFIG- Path to config file (alternative to--configflag)
The server automatically shuts down after timeout.idle duration of inactivity. Any authenticated request (including /status) resets the idle timer.
For long-running commands, poll /status periodically to keep the server alive:
# Keep server alive while waiting for command
while [ "$(curl -s -H "X-Shell-Key: $KEY" http://localhost:8080/status)" = "busy" ]; do
sleep 60 # Poll every minute to reset idle timer
done| Code | Meaning |
|---|---|
| 200 | Success |
| 202 | Command timed out (still running in background) |
| 400 | Bad request (empty command, invalid timeout header) |
| 401 | Unauthorized (missing or invalid API key) |
| 409 | Conflict (wrong shell state for operation) |
| 500 | Internal server error |
| Error Message | Cause | Solution |
|---|---|---|
| "The shell has not been started." | Called /run before /startup |
Call /startup first |
| "The shell is busy executing another command." | Called /run while command running |
Wait for completion or /kill |
| "The shell is already started." | Called /startup when already running |
Shell is ready, proceed with /run |
| "The shell is in an unrecoverable state and must be recycled." | Shell crashed or error occurred | Call /recycle |
Hooks run shell commands at specific lifecycle events:
[hooks]
on_startup = "echo 'Shell starting' >> /var/log/shelld.log"
on_shutdown = "cleanup-script.sh"
on_recycle = "echo 'Shell recycled'"Hooks:
- Run synchronously (server waits for completion)
- Run in a separate shell process (not the persistent shell)
- Have
SHELLD_HOOKenvironment variable set to the hook type - Errors are logged but don't block the operation
# Build
go build -o bin/shelld ./cmd/shelld
# Run all tests
./test.sh
# Run only unit tests
go test ./internal/...
# Run only integration tests
./test.sh # Runs both, but you can run individual test scripts- API Key: Use a strong, random API key. The key is transmitted in HTTP headers.
- TLS: shelld does not provide TLS. Use a reverse proxy (nginx, caddy) for HTTPS.
- Network: Bind to localhost or use firewall rules to restrict access.
- Commands: shelld executes arbitrary commands. Only allow trusted clients.
- Isolation: Run in a container or VM to limit blast radius.
┌─────────────────────────────────────────────────────┐
│ shelld │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────┐ │
│ │ HTTP Server │───▶│ Shell Mgr │───▶│ bash │ │
│ │ (net/http) │ │ (PTY) │ │ process │ │
│ └─────────────┘ └─────────────┘ └─────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Config │ │ Lifecycle │ │
│ │ (TOML) │ │ Hooks │ │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────┘
Bug reports and pull requests are welcome on GitHub at https://github.com/EndlessInternational/shelld.
This project is available as open source under the terms of the MIT License.