A computer is a disposable Linux machine an agent can drive. It has an id, a name, a provider, a state, a working directory, and a small set of things you can do to it.
The interface is deliberately narrow, and completeness matters more than surface area:
if the shell tool behaved differently on Docker than on SSH, every husk.yaml would
become provider-specific and the abstraction would be worth nothing.
The interface
Everything a caller can do to a machine:
interface Computer {
readonly id: string;
readonly info: ComputerInfo;
refresh(): Promise<ComputerInfo>;
exec(req: ExecRequest): Promise<ExecResult>;
writeFile(path, content, opts?): Promise<void>;
readFile(path): Promise<Uint8Array>;
readTextFile(path, maxBytes?): Promise<string>;
listDir(path): Promise<DirEntry[]>;
stat(path): Promise<DirEntry | null>;
remove(path, opts?): Promise<void>;
upload(hostPath, targetPath): Promise<void>;
download(path, hostPath): Promise<void>;
exposePort(port): Promise<PortBinding>;
stop(): Promise<void>;
start(): Promise<void>;
destroy(): Promise<void>;
// Optional. Callers feature-detect rather than catching.
snapshot?(name?): Promise<{ id: string; sizeBytes?: number }>;
restore?(snapshotId): Promise<void>;
}snapshot and restore are optional because not every backend can do them. They are
implemented on the container providers (docker and podman, through the shared OciComputer
base) and absent elsewhere. Feature-detect with if (computer.snapshot); a catch around
a missing method is a bug waiting to be misread as a runtime failure.
States
creating, running, paused, stopped, destroyed, error.
husk ps shows running machines; husk ps --all includes stopped ones. A destroyed
machine disappears from the list rather than lingering as a tombstone.
Creation is lazy
Nothing runs inside a machine until something needs it. husk up allocates the workspace
or starts the container and writes the record; the first process starts on the first
exec.
That matters most for MCP: adding the server to Claude Code has to cost nothing, or
people uninstall it. @husk-ai/mcp creates no machine at startup and none on tools/list.
The first shell call is what brings one up.
One conversation, one machine
ComputerManager.ensure(key, spec) maps a stable key to a machine:
const computer = await manager.ensure('mcp', { flavor: 'python' });Call it again with the same key and you get the same machine, with the same filesystem
and the same installed packages. That is what lets a Claude Code session keep its /work
across forty tool calls without the caller tracking ids.
The mapping lives in ~/.husk/computers/bindings.json. Two concurrent ensure calls with
the same key share one in-flight create rather than racing into two machines, and a
binding whose machine has been destroyed is cleared and recreated.
The quota
Eight live machines, by default. Creating a ninth throws:
error already running 8 computers (limit 8)
hint: destroy one with `husk rm <name>`, or raise maxComputers in ~/.husk/config.jsonmaxComputers in ~/.husk/config.json raises it. Only running and creating machines
count.
The spec
Every field a computer can be created with. In a husk.yaml these live under
computer:; see the full reference.
| Field | Type | Default | What it does |
|---|---|---|---|
name | string | generated id | Human label, and the container name when unique |
provider | string | best available | docker, podman, local, ssh, fly |
image | string | from flavor | Explicit image reference. Overrides flavor |
flavor | enum | base | base, python, node, full. The rendered browser needs full — see below |
cpus | number | 2 | CPU allocation |
memoryMb | number | 2048 | Memory ceiling |
diskMb | number | 2048 | Size of the writable /work layer |
idleTimeoutSec | number | 900 | Destroy after this long with no exec. 0 disables |
maxLifetimeSec | number | 0 | Hard ceiling regardless of activity. 0 disables |
network | object | { mode: 'egress' } | See Networking |
env | record | {} | Variables passed in explicitly |
mounts | array | [] | Host paths. Read-only unless readonly: false |
workdir | string | /work | Default working directory |
user | string | 1000:1000 | The unprivileged user inside the machine |
persist | boolean | false | Keep the filesystem across restarts |
packages | array | [] | Installed on first boot by the flavor's package manager |
setup | string | — | A shell snippet run once after creation |
labels | record | — | Free-form metadata, carried on the machine |
Flavors
A flavor picks the image. Each one resolves to a single public image maintained by the people who already keep that language or that browser patched. Husk publishes none of its own, and that is a decision rather than an unfinished task: an image you publish is an operating system you have promised to patch, and a stale one carrying known CVEs that people pull because your README told them to is worse than no image at all.
| Flavor | Image | What you get |
|---|---|---|
base | debian:bookworm-slim | A Debian 12 userland and apt. Add anything else with packages |
python | python:3.12-slim | Python 3.12 and pip, on that same Debian 12 |
node | node:22-slim | Node 22 and npm, on that same Debian 12 |
full | mcr.microsoft.com/playwright:v1.59.1-noble | Ubuntu 24.04, Node 22, and the shared libraries Chromium needs |
full is the only flavor that can run the rendered browser, and that is why it is
Playwright's image and not a fatter Debian. @husk-ai/browser downloads Chromium at
runtime, but the binary links against roughly twenty shared libraries the slim images
do not carry — and a container computer mounts its root filesystem read-only on
purpose, so they cannot be installed afterwards. Playwright's image already carries
exactly that set, on glibc, maintained by people who track Chromium's dependencies for
a living. It is heavier than the rest. That is the trade: full is the flavor you ask
for when you want everything, and the browser is part of everything.
HUSK_REGISTRY points a flavor at a mirror instead — <registry>/husk-<flavor>:<tag>,
with the public image still there to fall back on if that pull fails, and
HUSK_IMAGE_TAG sets the tag. Left unset there is nothing to fall back from, because
the public image is the image. The Dockerfiles in sandbox/ are what you build if you
want to host that mirror yourself.
These are upstream images, so what is in them is not husk's to promise: no huskinfo,
and no locked root account. The container still runs as uid 1000, because -u 1000:1000
is passed regardless.
Package installation is per-flavor:
baseandfull:apt-get install -y --no-install-recommends $PKGSpython:uv pip install --system $PKGSwhen uv is present,pip installotherwisenode:npm install -g $PKGS
uv rather than bare pip because an agent installing a package should wait two seconds, not forty.
huskinfo
The images in sandbox/ ship a huskinfo script, so an agent on one of those can learn
where it is in one call instead of five:
The computer_info tool runs huskinfo when it exists and reconstructs what it can from
uname, id, nproc and df when it does not — which is the default path, since the
public images do not carry it. The reconstruction is close, not equal: husk's own version
and flavor are not discoverable from inside the machine, so a computer on a public image
knows slightly less about itself than one built from sandbox/.
Where the records live
| Provider | Where "what exists" is stored |
|---|---|
docker, podman | Container labels. The engine is the registry |
local, ssh, fly | JSON files under ~/.husk/computers/, one per machine |
The JSON files are written with write-then-rename, so husk ps works from another
process and a crash mid-write cannot corrupt the registry. A corrupt entry is skipped
rather than crashing the listing.