Self-hosted platform for persistent Linux VMs with integrated PicoClaw agent runtime, preserving the Shelley web UI, coding prompts, tools and conversation storage. A self-hosted alternative to exe.dev.
See agent architecture, build and migration for the preserved contracts and existing-VM upgrade procedure.
The web dashboard puts VM status, resources, published ports, custom domains and nested-container settings in one place.
Create a VM and give its agent the first task
Choose CPU, RAM and disk, then describe what the agent should build at first boot.
Manage the same VMs over SSH: open the management shell, run ls and stat,
or request JSON from a one-shot command.
Read the console transcript · Replay the VHS tape · Regenerate the screenshots and recording
Captured from the actual dashboard and SSH gateway using local sample data. The demo does not start Incus containers or call an LLM.
The PicoClaw runtime with Shelley web UI and HTTP API is available as a standalone package in agent/. Build it with make -C agent build, or export it with make -C agent package for use in another project. It does not require the svkexe gateway or Incus.
To let an external LLM agent use your svkexe installation for development tasks, give it the svkexe skill. For Codex, copy skills/svkexe/ into ~/.codex/skills/; then provide the agent with your gateway host and a registered SSH identity. The skill discovers the live command catalogue and works inside the VMs your account can access.
- Persistent Linux VMs via Incus LXC containers with native systemd
- PicoClaw coding agent with Shelley UI per container — web-based, multi-conversation, multi-model AI assistant
- Dynamic subdomain routing — your workload at
https://{name}.yourdomain.com, the agent athttps://agent-{name}.yourdomain.com - WebSocket/SSE proxy for real-time PicoClaw interactions
- Web Shell (xterm.js) for browser-based terminal access
- SSH Gateway — a management shell with the dashboard's full surface for any login backed by a known key, direct connect (
ssh vm@host), one-shot commands and ahelp --jsoncatalogue for LLM agents - Named VM access — invite users by email and SSH key with use-only guest permissions; see Access
- Shared links (Discord-style) for temporary container access
- LLM reverse proxy to OpenRouter with automatic model fallback
- LLM key management with AES-GCM encryption, per-container isolation
- Built-in web login — cookie-based sessions, bcrypt password hashing, first-run admin bootstrap via env
- Admin panel for user and container management
- Per-user rate limiting (token bucket)
- Prometheus metrics + optional Grafana (dashboards configured by the operator)
- Backup scripts — SQLite + running-container Incus snapshots with 7-day retention; scheduling is manual
- Nested containers — Docker, buildah and nested Incus work inside a VM; on by default, switchable per VM by its owner and platform-wide by an admin
Use your own domain: example.com below is a placeholder. Set DOMAIN to
that domain and point its base and wildcard DNS records to your server.
The installer supports Ubuntu 24.04/26.04 LTS and Debian 12/13 on amd64/arm64, with root access and systemd:
curl -fsSL https://raw.githubusercontent.com/skrashevich/svkexe/main/scripts/install.sh \
| sudo env DOMAIN=example.com bashFrom a local checkout: sudo env DOMAIN=example.com ./scripts/install.sh.
It installs packages, Go (the highest version required by the gateway and agent
modules), Docker, Incus, the VM image and both binaries. It enables the systemd
gateway but leaves it stopped so you can review /etc/svkexe/gateway.env.
Set the bootstrap admin credentials and domain in that file, configure TLS,
then start sudo systemctl start svkexe-gateway. Login at
https://example.com/login. For temporary HTTP testing keep
GATEWAY_COOKIE_SECURE=0 and use http://example.com:8080/login; logging in by
IP when DOMAIN is set gives a mismatched cookie domain.
The deployment guide includes the complete bare-metal TLS setup, skip flags, Docker Compose alternative, updates, backups, and end-to-end verification. TLS and backup scheduling are separate setup steps.
Docker packages the gateway and reverse proxy. Incus runs directly on the Linux
host and creates the VMs there, outside Docker. The gateway calls the host's
Incus API through the mounted /var/lib/incus/unix.socket; it creates system
containers from svkexe-base using the svkexe-default profile, svkexe-pool
storage and svkexe-br0 network. These are LXC system containers sharing the host
kernel, not hardware-virtualized guests.
Browser → Caddy (Docker) → Gateway (Docker)
│ mounted Unix socket: Incus API
▼
Incus (Linux host)
├── VM: systemd + agent + user apps
└── VM: systemd + agent + user apps
Incus images and VM disks stay in host-managed storage; the gateway database has its own Docker volume. Restarting/replacing the gateway container does not delete VMs, but the gateway's database and encryption key must be preserved to manage them. This setup requires host-level Incus preparation; it is not a standalone Compose stack that can create VMs on an arbitrary Docker server.
Follow the Compose instructions
and configure deploy/.env from deploy/.env.example, then run
docker compose up -d --build in deploy/. The stack builds Caddy with the
Cloudflare DNS module and uses the gateway's own login; optional monitoring is
under the monitoring profile. It is an alternative to the systemd gateway.
make build builds both the gateway and its Linux agent. Install Go sufficient
for both modules (currently gateway 1.26.2, agent 1.27.1), Python 3, make and
Node/npm. make gateway builds only the gateway; make test tests the gateway
module; make test-agent runs agent compatibility tests.
A runnable Linux deployment also needs Incus with svkexe-base, writable data
and secrets directories, persistent SSH keys, GATEWAY_ENC_KEY, and a bootstrap
admin account. See the deployment guide, rather than running
an unconfigured binary as a production service.
Admins get a System entry in the dashboard navigation (/dashboard/system). It shows the installed
versions of every component — gateway binary, its git commit and build date, the PicoClaw agent version
and the pinned Shelley commit — and offers two controls:
- Check for updates — asks the GitHub API whether
skrashevich/svkexehas a newer build than the running binary, and links to the remote commit or release. - Update now — starts the same
scripts/update.shrun as the command line, with live progress and a log tail on the page.
The gateway restarts itself as part of the update, so the page briefly becomes unreachable and then
resumes polling on its own. Progress survives that restart because the update writes its state to
/var/lib/svkexe/update-status.json rather than reporting it over the HTTP request that started it.
How the privileged part works. The gateway service runs as the unprivileged svkexe user with
NoNewPrivileges=true, so it cannot elevate — and it cannot be the parent of a process that restarts it.
Instead it writes a trigger file, /var/lib/svkexe/update.trigger. A root-owned svkexe-update.path
systemd unit watches that file and starts the oneshot svkexe-update.service, which runs
scripts/update.sh; the script deletes the trigger before doing any work, so the watcher re-arms and one
click means exactly one run. A trigger older than 15 minutes is discarded without updating, so a request
nobody picked up cannot fire a surprise rebuild at the next boot.
Both units come from scripts/install-update-units.sh, which install.sh and update.sh both run — so
updating by either route also refreshes the units. That script writes /var/lib/svkexe/update-watcher
only once the watcher is actually enabled, and the gateway requires that marker before it will offer the
button. A deployment without a watcher therefore reports "cannot install updates itself" instead of
accepting a click that would never be acted on.
sudo /opt/svkexe/scripts/update.shOr remotely:
curl -fsSL https://raw.githubusercontent.com/skrashevich/svkexe/main/scripts/update.sh | sudo bashThe script pulls the latest code, rebuilds the gateway and PicoClaw agent, rebuilds the base image when agent/build sources change, and restarts the service. Running VM agents are migrated on gateway startup; stopped VMs migrate on their next start. Optional: SVKEXE_BRANCH=... (default: main), SKIP_RESTART=1 (install without restarting the gateway; image rebuilds still run).
The supplied Compose deployment builds from source: update the checkout and run
docker compose up -d --build in deploy/. There is no systemd
watcher inside the container, so the watcher marker is absent and the Update now button reports the
deployment as unable to self-update — that is enforced by the marker check, not merely assumed. Set
SVKEXE_UPDATE_COMMAND to a command that performs the update for your setup if you want the button to
work there; the update check and the version listing work regardless.
All configuration is via environment variables. For bare-metal installs, edit /etc/svkexe/gateway.env.
| Variable | Default | Description |
|---|---|---|
GATEWAY_ADDR |
:8080 |
HTTP listen address |
GATEWAY_DB_PATH |
/var/lib/svkexe/gateway.db |
SQLite database path |
GATEWAY_ENC_KEY |
AES-256 key for API key encryption (hex, openssl rand -hex 32) |
|
GATEWAY_COOKIE_SECURE |
0 |
Set to 1 when served over HTTPS |
DOMAIN |
Base domain for subdomain routing | |
GATEWAY_PUBLIC_IPS |
(resolved from DOMAIN) | Comma-separated public addresses of the gateway, used to verify custom domains. Set it when DOMAIN does not resolve to the address visitors reach (load balancer, NAT) |
INCUS_SOCKET |
/var/lib/incus/unix.socket |
Incus API socket |
METADATA_ADDR |
10.100.0.1:8081 |
Where the gateway listens for instance metadata requests; VMs reach the service at 169.254.169.254:80, which the host redirects here. off disables it |
SSH_ADDR |
:2222 |
SSH gateway listen address |
SSH_HOST_KEY_PATH |
/var/lib/svkexe/ssh_host_key |
ED25519 host key (auto-generated if missing) |
SECRETS_BASE_PATH |
/var/lib/svkexe/secrets |
Key materialization directory |
RATE_LIMIT_RPS |
10 |
Requests per second per user |
RATE_LIMIT_BURST |
20 |
Burst size |
BOOTSTRAP_ADMIN_EMAIL |
Admin account email (created/updated on startup) | |
BOOTSTRAP_ADMIN_PASSWORD |
Admin account password (re-hashed on every restart — rotate by changing this value) | |
OPENROUTER_API_KEY |
OpenRouter API key (enables LLM proxy) | |
OPENROUTER_MODELS |
anthropic/claude-sonnet-4,openai/gpt-4o,google/gemini-2.5-flash |
Models to try in order (comma-separated) |
LLM_INTERNAL_TOKEN |
Bearer token for PicoClaw → gateway auth | |
LLM_PROXY_URL |
(derived from DOMAIN) | LLM proxy URL as seen from containers. If unset and DOMAIN is configured, defaults to https://$DOMAIN/api/llm/v1 |
| Variable | Default | Description |
|---|---|---|
SVKEXE_UPDATE_OWNER |
skrashevich |
GitHub account checked for new builds |
SVKEXE_UPDATE_REPO |
svkexe |
GitHub repository checked for new builds |
SVKEXE_UPDATE_BRANCH |
main |
Branch tracked by the branch channel |
SVKEXE_UPDATE_CHANNEL |
branch |
branch (compare commit SHAs) or release (compare release tags; falls back to branch when the repo has no releases) |
SVKEXE_UPDATE_API_BASE |
https://api.github.com |
GitHub API root |
SVKEXE_UPDATE_CACHE_TTL |
15m |
How long an update check result is reused before hitting the API again |
SVKEXE_GITHUB_TOKEN |
(falls back to GITHUB_TOKEN) |
Optional token; unauthenticated GitHub API calls are limited to 60/hour per IP |
SVKEXE_UPDATE_TRIGGER |
/var/lib/svkexe/update.trigger |
File the gateway writes to request an update; watched by svkexe-update.path |
SVKEXE_UPDATE_STATUS |
/var/lib/svkexe/update-status.json |
Machine-readable progress file written by update.sh and read by the dashboard |
SVKEXE_UPDATE_WATCHER |
/var/lib/svkexe/update-watcher |
Marker proving a watcher is installed. Without it the Update button is disabled |
SVKEXE_UPDATE_MAX_AGE_SECONDS |
900 |
Discard an update request older than this instead of acting on it |
SVKEXE_UPDATE_LOG |
/var/lib/svkexe/update.log |
Full update log; the status file carries a bounded tail of it |
SVKEXE_UPDATE_COMMAND |
Run this command directly instead of using the trigger file. For deployments where the gateway is already privileged (Docker, development) |
The gateway reports its own build metadata from ldflags stamped by make; a binary built with plain
go build reports version dev and the update check says it has no commit to compare against.
In Dashboard → LLM, choose a preset or Custom OpenAI-compatible.
OpenRouter uses https://openrouter.ai/api/v1 by default. For custom connections,
enter a unique name (e.g. local), the complete API Base URL (e.g.
http://10.0.0.10:8000/v1), and comma-separated model IDs. The URL must be reachable
from the VM; localhost refers to that VM. Do not append the operation path.
A custom endpoint may omit its API key. Model IDs must match the provider exactly.
Pick the protocol the endpoint actually serves — gateways differ, and the wrong one fails only when the model is first used:
| Protocol | Request goes to | Use for |
|---|---|---|
openai (default) |
{base_url}/chat/completions |
OpenRouter, DeepSeek, vLLM, Ollama, most local servers |
openai-responses |
{base_url}/responses |
OpenModel (https://api.openmodel.ai/v1) |
anthropic |
base_url verbatim |
Anthropic-compatible gateways; give the full messages URL |
gemini |
base_url verbatim |
Gemini-compatible gateways |
Multiple named custom connections can coexist. Save the same provider/name again to replace its settings. Keys remain encrypted in the gateway database. Saving or deleting settings reloads models and restarts the agent in running VMs; stopped VMs receive changes on their next start. Sync failures are reported and can be retried by restarting the VM.
Default model. The same page picks which of your models every VM on the account opens with — the ones running now and the ones you create later. Leave it on Auto to let the gateway pick your most recently configured model. A choice that stops being reachable (you edit or delete the connection behind it) is dropped rather than left dangling, and the VMs fall back to a model you do have.
The REST API accepts the same settings (use your own domain and obtain
cookies.txt through API login):
# Add a connection
curl -b cookies.txt -H 'Content-Type: application/json' -X POST https://example.com/api/keys -d '{"provider":"custom-openmodel","base_url":"https://api.openmodel.ai/v1","models":"deepseek-v4-flash,deepseek-v4-pro","protocol":"openai-responses","key":"om-..."}'
# See what you can pick, and pick one
curl -b cookies.txt https://example.com/api/llm/models
curl -b cookies.txt -H 'Content-Type: application/json' -X PUT https://example.com/api/llm/default -d '{"model":"svkexe_user:custom-openmodel:deepseek-v4-pro"}'User endpoints are independent of the gateway-wide OPENROUTER_API_KEY fallback.
When an owner has their own models, those are what a VM opens with and what the
initial task runs on; the OPENROUTER_MODELS list stays available in the VM as a
fallback for owners without keys.
When creating a VM you can describe, in plain text, what should be on it. The text is handed to the agent as its first instruction once the VM is up, in a new conversation, and runs with your own LLM key — so it starts spending your quota right away. The agent treats it as a normal message, which means it may answer or ask for clarification instead of building everything unattended.
Delivery happens once, and the VM card then follows the work itself:
| State | Means |
|---|---|
pending |
the VM is not up yet |
sent |
the agent accepted the task and is starting |
working |
the agent is running the task right now |
done |
the agent finished its turn without an error |
failed |
delivery failed, or the agent ended on an error — the reason is shown |
The gateway polls the agent every 15 seconds for as long as a task can still
change state, and stops once it is done or failed. Only a running VM is
polled; while one is off, its card says the task is on hold and picks the real
state back up on the next start. A task the gateway cannot trace to a
conversation — a VM whose task was handed over before the gateway recorded
conversations — is found again by its opening message, and only reported
failed when the agent has no such conversation. A failure is most often
no configured model, which you fix by adding an LLM key and pressing Retry; a
failed task can be retried explicitly from its VM card. Before declaring failure,
the gateway may automatically resume an agent-reported transient error up to three
times. These resumed turns can consume additional model quota. While a task is
live the card links straight to its conversation in the
agent's own interface.
The REST API takes the same text as initial_task on POST /api/containers,
and POST /api/containers/{id}/task/retry re-queues a failed one.
Each VM exposes two different things, on two separate hosts:
| URL | Serves | Who can reach it |
|---|---|---|
https://{name}.{domain}/ |
your service, on the VM's configured port (default 3000) |
owner, named VM members, share links, and anyone when public |
https://{port}-{name}.{domain}/ |
your service on any other port | owner, named VM members, and share links |
https://agent-{name}.{domain}/ |
the PicoClaw web interface | owner and named VM members |
Set the port and the Private/Public switch on the VM card in the dashboard, or
via PUT /api/containers/{id}/publish with {"port":3000,"public":true}.
Public serves the configured port to anonymous visitors — use it when the
service is meant to be public or does its own authentication. It applies to that
one port and nothing else: {port}-{name} hosts and the agent always require
your session. Switching back to Private closes access immediately.
The workload never receives X-ExeDev-* identity headers in either mode, so it
cannot mistake a gateway header for a signed-in user.
Because these hosts are single-label, a wildcard *.{domain} certificate covers
all of them. VM names therefore cannot start with agent- or a port prefix like
3000-.
A VM can also answer on domains you own, so a service can live at
https://app.example.org/ instead of https://{name}.{domain}/.
- Create a DNS record for the hostname pointing at the gateway — a
CNAMEto{domain}is the usual choice, anArecord to the gateway's address works too. - Add the hostname on the VM card in the dashboard, or with
POST /api/containers/{id}/aliasesand{"hostname":"app.example.org"}. - The gateway resolves the hostname and compares it with its own addresses. If they overlap, the alias is verified and starts serving. If DNS has not propagated yet the hostname is still saved, with the reason shown on the card — fix the record and press Re-check.
TLS is issued automatically. Caddy asks the gateway (GET /api/tls/check)
before requesting a certificate and only proceeds for a verified alias, which is
what keeps the deployment from being used to request certificates for hostnames
nobody here controls.
What an alias does not do:
- It never exposes the agent. Custom domains reach the workload port only.
PicoClaw stays on
agent-{name}.{domain}behind your session. - It only serves a published workload. Your session cookie is scoped to
{domain}and is never sent to a domain you own, so there is no way to sign in on a custom domain. A private workload answers403there; publish the port, or open the custom domain through a share link. - Verification is re-run hourly in the background, and whenever you press Re-check. A hostname that has been repointed away stops being routed and stops being a certificate the gateway renews. A check that cannot reach a conclusion — DNS temporarily unavailable, or the gateway unable to resolve its own address — changes nothing, so a DNS outage never takes working domains offline.
A hostname cannot fall under {domain} itself (those names are already routed
by subdomain), and each VM is limited to 10.
A verified hostname belongs to one VM platform-wide. A hostname that is only pending reserves nothing: two VMs may both have it waiting, and whichever one's DNS actually points here first gets it. Competing pending claims are then deleted. This is deliberate on both counts — adding a domain you do not control must not lock out the person who does, and a claim left parked on someone else's domain must not survive as an option on it.
The DNS check proves that a hostname points at this gateway, not who owns it, so on a shared gateway any tenant can satisfy it for any name. Exclusivity comes from getting there first and keeping the record pointed here. That leaves a window: between pointing your DNS at the gateway and adding the hostname, anyone who knows the name could claim it.
When that happens, an administrator can take the name back:
# See who holds it
curl -b session.txt https://$DOMAIN/api/admin/aliases
# Free it platform-wide, so the rightful owner can add it
curl -b session.txt -X DELETE https://$DOMAIN/api/admin/aliases/app.example.orgReleasing deletes every claim on that hostname, verified or pending, and the action is logged with the administrator's email.
If the gateway sits behind a load balancer or NAT, DOMAIN may not resolve to
the address visitors actually reach, and verification would reject every alias.
Set GATEWAY_PUBLIC_IPS to the real public addresses instead.
Every VM can ask the platform about itself at http://169.254.169.254/latest/meta-data/,
the address EC2 uses — so cloud-aware tooling, provisioning scripts and the VM's
own agent find it without being configured.
# Inside a VM
curl -s http://169.254.169.254/latest/meta-data/
curl -s http://169.254.169.254/latest/meta-data/instance-id
curl -s http://169.254.169.254/latest/meta-data/svkexe/app-port
# IMDSv2, if your tooling prefers a session token
TOKEN=$(curl -sX PUT http://169.254.169.254/latest/api/token \
-H 'X-aws-ec2-metadata-token-ttl-seconds: 21600')
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/local-ipv4A path ending in / lists what is under it, one entry per line with directories
suffixed by /. A value is plain text with no trailing newline.
Key under /latest/meta-data/ |
Value |
|---|---|
ami-id |
The image VMs are built from (svkexe-base) |
ami-launch-index, instance-action, instance-life-cycle |
0, none, on-demand — present so EC2 tooling finds what it expects |
hostname, local-hostname |
The Incus instance name, svkexe-{owner}-{name} |
instance-id |
The VM's platform id, the same one /api/containers uses |
instance-type |
The VM's shape, e.g. svkexe.c2-m2048 |
local-ipv4 |
The VM's address on the bridge |
mac |
The hardware address of that interface |
network/interfaces/macs/<mac>/… |
EC2's per-interface tree, keyed on the MAC |
placement/region, placement/availability-zone |
svkexe, svkexe-a |
public-hostname |
<name>.$DOMAIN |
public-ipv4 |
The first of GATEWAY_PUBLIC_IPS |
public-keys/<index>/openssh-key |
The owner's SSH public keys |
reservation-id, security-groups |
r-<instance-id>, default |
services/domain, services/partition |
$DOMAIN, svkexe |
svkexe/app-port, svkexe/app-public |
The published port and whether it is public |
svkexe/nesting, svkexe/nesting-applied |
Whether nested containers are allowed here, and whether the running VM booted with them |
svkexe/aliases |
The owner's verified custom domains, one per line |
svkexe/agent-host, svkexe/gateway-domain |
The agent's own host and the platform domain |
svkexe/owner-id, svkexe/container-name, svkexe/created-at, svkexe/disk-gb |
The rest of the VM's own record |
svkexe/initial-task, svkexe/initial-task-state |
The task the VM was created with, if any |
/latest/dynamic/instance-identity/document returns the same facts as JSON.
/latest/user-data answers 404: the platform publishes no user-data, so a
guest cloud-init cannot be handed a script nobody wrote.
Two host-side pieces, both installed by scripts/install.sh and refreshed by
scripts/update.sh — see scripts/install-metadata-units.sh:
-
svkexe-metadata.serviceredirects traffic to169.254.169.254:80arriving onsvkexe-br0to the port the gateway listens on (METADATA_ADDR, by default the bridge's own10.100.0.1:8081— change the bridge subnet and you must changeMETADATA_ADDRand the script'sSVKEXE_METADATA_PORTwith it). A VM's ordinary default route already carries the address to the host, so nothing in the guest has to be configured; the gateway additionally pins a/32route inside each VM, which only matters for a guest carrying a zeroconf169.254.0.0/16route that would otherwise swallow it.The host never takes
169.254.169.254as an address of its own, and that is deliberate. Doing so would route the host's traffic to that address locally — and AWS, GCP, Azure, Oracle, Hetzner and DigitalOcean all serve their own instance metadata there, so installing svkexe on a cloud VPS would cut the host off from its IAM credential refresh, guest agent and OS Login key propagation. A redirect scoped to the bridge keeps the host's own access intact, needs no privileged port, and cannot be reached from the host's LAN. It also closes a hole that predates this feature: on such a VPS a tenant VM's request to169.254.169.254used to be forwarded to the provider's metadata service. -
Anti-spoof filtering on the VM NIC.
security.ipv4_filteringandsecurity.mac_filteringare set on thesvkexe-defaultprofile, so Incus pins each VM to the address and MAC it was allocated. This is a prerequisite, not a hardening extra: identity here is the source address and a tenant is root inside their own VM. The gateway refuses to answer any VM whose NIC is unfiltered, and says so in the log. Profile filtering changes can apply to running instances immediately; verify their networking after a change. A VM that needs a second address of its own — bridged nested networking rather than Docker's default NAT — cannot have one while this is on.
Set METADATA_ADDR=off where the gateway has its own network namespace and could
never receive a VM's request — the Docker Compose variant does this. A gateway
that cannot bind the address logs the reason and keeps serving everything else.
The service has no credentials: a request is attributed entirely to the address it arrives from.
- That address is resolved against Incus's live view of which instance holds
it, not against the
containers.ip_addresscolumn, which is only refreshed when something happens to a VM and would hand a reassigned address to the wrong holder. An address two instances both claim resolves to neither. - It is only trusted because Incus pins it (see above). A VM whose NIC is not filtered is refused outright rather than taken at its word.
- If Incus cannot be reached, the last known view is served for a short while and then refused rather than trusted indefinitely — a stale map is how an address that has changed hands gets misattributed.
- A caller the platform cannot attribute to a VM gets
403and learns nothing about what exists. One VM presenting another's IMDSv2 token gets401; tokens are signed, carry the VM they were issued to, and are stored nowhere. - Any request carrying
X-Forwarded-Foris refused with421— stricter than EC2, which only refuses it on the token endpoint — because a proxy inside a VM being talked into fetching this address is the classic way instance data leaks. - One VM's requests are rate limited on their own budget, so a runaway loop cannot spend the database pool the dashboard and the API share.
- Nothing secret is published: no LLM keys, no session tokens, no password hashes, and a test walks the entire tree to prove it.
┌─────────────────────────────────────────────────────┐
│ Caddy (Reverse Proxy) │
│ Wildcard TLS + identity-header stripping │
├─────────────────────────────────────────────────────┤
│ Go API Gateway (:8080) │
│ Ownership enforcement, rate limiting, Prometheus │
├─────────────────────────────────────────────────────┤
│ SSH Gateway (:2222) │ htmx Dashboard │
│ Interactive VM menu │ VM / Keys / Shell │
├──────────────────────────┴──────────────────────────┤
│ Incus (LXC Containers) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ VM 1 │ │ VM 2 │ │ VM N │ │
│ │ PicoClaw │ │ PicoClaw │ │ PicoClaw │ │
│ │ :9000 │ │ :9000 │ │ :9000 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────┘
| Component | Technology |
|---|---|
| VM Runtime | Incus (LXC) |
| Coding Agent | PicoClaw v0.3.1 runtime + pinned Shelley UI/prompts/tools |
| API Gateway | Go, chi, SQLite (WAL) |
| Reverse Proxy | Caddy |
| Auth | Built-in web login (bcrypt + session cookies) |
| Dashboard | Go templates + htmx |
| Web Shell | xterm.js + WebSocket |
| SSH Gateway | gliderlabs/ssh |
| Monitoring | Prometheus + Grafana |
cmd/gateway/ Entry point (HTTP + SSH servers)
internal/
api/ REST API, middleware, admin endpoints
dashboard/ htmx pages (VMs, Keys, SSH Keys, Shell)
db/ SQLite with WAL, all CRUD operations
llmproxy/ LLM reverse proxy to OpenRouter with model fallback
proxy/ Dynamic reverse proxy (WebSocket/SSE)
runtime/ ContainerRuntime + ShellRuntime interfaces
secrets/ LLM key materialization (encrypted DB -> env file)
metadata/ EC2-compatible instance metadata service for the VMs
picoclaw/ Agent setup, migration, gateway models and backups
sshgw/ SSH gateway: management shell + direct VM access
metrics/ Prometheus metrics + middleware
ratelimit/ Per-user token bucket rate limiter
ui/templates/ Go HTML templates
deploy/ Caddy, Docker Compose, optional monitoring
scripts/ Host setup, image build, backup/restore
docs/ Deployment guide, API reference
Common user-facing endpoints (see docs/API.md for the full reference including admin routes):
GET /api/containers List user's containers
POST /api/containers Create container (body: nesting=false opts out of nested containers)
GET /api/containers/{id} Get container details
POST /api/containers/{id}/start Start container
POST /api/containers/{id}/stop Stop container
POST /api/containers/{id}/recreate Recreate container (backup data → rebuild → restore)
DELETE /api/containers/{id} Delete container
POST /api/containers/{id}/share Create shared link
GET /api/containers/{id}/shares List shared links
DELETE /api/shares/{token} Revoke shared link
GET /api/containers/{id}/aliases List custom domains
POST /api/containers/{id}/aliases Add a custom domain
POST /api/containers/{id}/aliases/{aliasId}/verify Re-run the DNS check
DELETE /api/containers/{id}/aliases/{aliasId} Remove a custom domain
GET /api/tls/check?domain= Caddy on-demand TLS ask (unauthenticated)
GET /api/admin/aliases List every custom domain and who holds it (admin)
DELETE /api/admin/aliases/{host} Free a custom domain platform-wide (admin)
GET /api/keys List LLM API keys
POST /api/keys Create LLM API key
DELETE /api/keys/{id} Revoke LLM API key
GET /api/ssh-keys List SSH keys
POST /api/ssh-keys Add SSH key
DELETE /api/ssh-keys/{id} Remove SSH key
GET /api/me Current user info
POST /api/llm/v1/chat/completions LLM proxy (OpenRouter)
GET /api/llm/v1/models List available models
GET /metrics Prometheus metrics (unauthenticated)
The gateway's SSH port (2222 by default) is a management shell as well as a way into your VMs. It authenticates by SSH key alone — the login name is only a shortcut — and offers the same operations as the dashboard.
# Management shell. Any login works; the key says who you are.
ssh -p 2222 example.com
ssh -p 2222 svkexe@example.com # the reserved login: always the shell
# A login naming one of your own VMs opens a shell inside it.
ssh -p 2222 dev@example.com
# One command per connection, for scripts and LLM agents.
ssh -p 2222 example.com "ls --json"
ssh -p 2222 example.com "help --json" # the whole command catalogue, machine-readable
ssh -p 2222 dev@example.com "uptime" # runs inside the VMhelp lists the commands your key may run, grouped by area; help <command>
explains one; help --json describes all of them — arguments, flags,
subcommands and examples — in a single JSON document meant for programs. Read
commands accept --json, an unknown flag is refused by name, a destructive
command needs --force in a one-shot, and the exit status is 0, 1 or 127
(unknown command). See docs/SSH.md for the full reference.
- All incoming
X-ExeDev-*headers stripped by Caddy before auth - User-to-container ownership verified before every proxy request
- The agent is not a multi-tenancy boundary — isolation is at the LXC container level
- Nested containers (
security.nesting) are enabled by default so Docker works inside a VM. This relaxes the VM-to-host boundary: a tenant that can create containers reaches more kernel surface than one that cannot. Untrusted tenants? Turn it off under System → Nested containers, which overrides every per-VM switch. VMs pick the change up on their next start. - LLM keys encrypted with AES-GCM in the gateway DB; provider environment files are mode 0400, endpoint-backed credentials are seeded in the agent DB. The default secrets directory is on disk, not automatically mounted as tmpfs.
- Shared links scoped to specific containers with optional expiration
- Deployment Guide
- API Reference
- SSH Interface
- Named VM access and guest accounts
- Historical Implementation Plan
Apache-2.0; the bundled agent includes additional notices in agent/licenses.


