Husk
GitHub

Computers

Concepts

What a computer is, how one gets created, and the small interface every provider has to satisfy.

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

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

FieldTypeDefaultWhat it does
namestringgenerated idHuman label, and the container name when unique
providerstringbest availabledocker, podman, local, ssh, fly
imagestringfrom flavorExplicit image reference. Overrides flavor
flavorenumbasebase, python, node, full. The rendered browser needs full — see below
cpusnumber2CPU allocation
memoryMbnumber2048Memory ceiling
diskMbnumber2048Size of the writable /work layer
idleTimeoutSecnumber900Destroy after this long with no exec. 0 disables
maxLifetimeSecnumber0Hard ceiling regardless of activity. 0 disables
networkobject{ mode: 'egress' }See Networking
envrecord{}Variables passed in explicitly
mountsarray[]Host paths. Read-only unless readonly: false
workdirstring/workDefault working directory
userstring1000:1000The unprivileged user inside the machine
persistbooleanfalseKeep the filesystem across restarts
packagesarray[]Installed on first boot by the flavor's package manager
setupstringA shell snippet run once after creation
labelsrecordFree-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.

FlavorImageWhat you get
basedebian:bookworm-slimA Debian 12 userland and apt. Add anything else with packages
pythonpython:3.12-slimPython 3.12 and pip, on that same Debian 12
nodenode:22-slimNode 22 and npm, on that same Debian 12
fullmcr.microsoft.com/playwright:v1.59.1-nobleUbuntu 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:

  • base and full: apt-get install -y --no-install-recommends $PKGS
  • python: uv pip install --system $PKGS when uv is present, pip install otherwise
  • node: 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:

huskinfo

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

ProviderWhere "what exists" is stored
docker, podmanContainer labels. The engine is the registry
local, ssh, flyJSON 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.