← All guides

2026-10-11

Create Your Own Daytona

If you are running a multi-tenent agent harness, isolation systems are required infrastructure, not just for security but to protect against noisy neighbor problems, provide scalability and help with packaging. The de facto standard agent isolation is a micro-vm with hardware level isolation, commonly via KVM on Linux. The firecracker vm driver provides an abstraction over KVM and prioritizes performance and density (read: speed and cost). This guide will walk you (or your agent) through implementing this pattern on Heyo Computer.

hint: replace the ".html" with ".txt" in the browser and you get an all text version to provide to your coding agent

What you will need:

  • A Heyo account and a namespace, sign-up here
  • An auth token scoped to that namespace, launch the dashboard after creating a namespace to make one
  • heyctl or use either the Rust or TypeScript SDKs

Plugins

Heyo Computer provides a control plane for managing VMs, networks, certs, and security that we call the app-lb. On top of the app-lb we also provide plugins to manage additional feature sets like authorization, CI, and observability.

Prefer the console? Everything in this section can be done from your namespace's dashboard on the app-lb (Heyo opens it after you sign in): press Plugins in the top bar, use a plugin's Install button, and Open dashboard on the ones that have a page. The rest of the guide uses the CLI and the SDK, because that is how your application will do it.

In this guide we'll use the following:

Plugin Name In your namespace What?
obs Observability auto-installed Logs, metrics and error alerts for every VM in the namespace.
artifacts Artifacts auto-installed Your <namespace>/ corner of the artifact store, where artifact images come from.
secrets Secrets auto-installed A page for the namespace's secrets: the API keys and credentials deployments read with env_from. Key names only, never values.
auth-heyo Heyo sign-in on The sign-in provider heyo, so a deployment requires Heyo sign-in with one setAuth call. A managed namespace has it from the start.
remote Git auto-installed Git repositories for the namespace, with repo tokens a sandbox can push with.

If you are using the heyctl CLI:

export HEYO_TOKEN=applb_…          # from the dashboard
heyctl login --server https://admin.us5.heyo.work --token "$HEYO_TOKEN"
heyctl plugins list -n acme      # each should show `installed`

Or via SDK (@heyocomputer/hws on npm, hws on crates.io):

import { Hws } from "@heyocomputer/hws";

const lb = new Hws({ server: "https://admin.us5.heyo.work", token: process.env.HEYO_TOKEN });
const store = lb.artifacts("acme");
console.log(await store.tags());   // the sandbox templates you have published
const logs = await lb.obs("acme").logs("runner", { level: "error", limit: 50 });

// A sandbox you give a route (a notebook, a dev server) behind Heyo sign-in:
await lb.setAuth("sb-acme-notebook", { provider: "heyo" });

A call to a plugin that is not installed fails with a ConflictError whose code is plugin_not_installed, and whose message names the install command. Running an app-lb on your own hardware instead? The operator's half is in Plugins.

setAuth puts a sandbox that serves something on a route behind sign-in; clearAuth opens it again. A sandbox with no route (routes: []) needs neither, and app-lb refuses a gate on one: only exec and the shell reach it, with the namespace token.

Pull an Image from the Hub

A sandbox needs a root filesystem to boot. You don't have to build one: the Heyo hub publishes base images that boot fast, these are good ones to start with for agents:

Name Image
heyo/debian:latest Debian 13 (trixie)
heyo/ubuntu:latest Ubuntu LTS (26.04, 24.04)
heyo/alpine:latest Alpine 3.24, the smallest
heyo/bun:latest Bun on Debian 13, with git

Each has a data disk at /workspace when you give the sandbox one. A sandbox is a deployment with no route, so it takes no HTTP traffic and is reached only through exec and the shell. Name the hub as the image's store, pull it, and scale the sandbox up:

import { Hws } from "@heyocomputer/hws";

const lb = new Hws({ server: "https://admin.us5.heyo.work", token: process.env.HEYO_TOKEN });

async function createSandbox(id: string) {
  await lb.createDeployment({
    id,
    namespace: "acme",
    routes: [],                                               // private: exec and shell only
    artifact: { store: "https://hub.heyo.work", ref: "heyo/debian:latest" },
    vm: { driver: "firecracker", port: 22, size_class: "small", disk_size_gb: 4 },  // /workspace
    health: { path: null },                                   // ready once sshd accepts a connection
    scaling: { min_replicas: 0, max_replicas: 1, scale_to_zero_after_secs: 600, idle_action: "retain" },
  });
  const pull = await lb.startPull(id);                        // the hub's public images need no credential
  await lb.waitForJob(pull.id);
  await lb.patchScaling(id, { min_replicas: 1 });
  return lb.waitForReady(id);
}

await createSandbox("sb-acme-7f3a9c");

Using the VM

Out of the box, a VM can be manipulated with exec and by getting a persistant shell session. If you need more options, such as a subscriber to a queue, you can create your own custom firecracker VMs and deploy workloads based on bespoke images.

exec does what it sounds like - takes a command and runs it on the VM, then returns STDOUT. This is easy to build tools on top of, but does carry the networking penalty of reconnecting on subsequent commands.

const run = await lb.exec("sb-acme-7f3a9c", 'cd /workspace && echo "$GREETING" && echo $((6 * 7))', {
  env: { GREETING: "hello" },
  timeoutSecs: 120,
});
console.log(run.exit_code, run.output);   // 0 "hello\n42\n"
if (run.exit_code !== 0) console.error(run.output);

Each call is a fresh process that starts in /: cd and export do not carry over, so begin the command with cd (cd /workspace/app && npm test) and pass variables in env. Read run.output, which has everything the command printed, errors included, in the order it printed it. timeoutSecs bounds the command itself; a test suite that hangs comes back as an error instead of holding your agent forever.

heyo/debian is a minimal Debian 13: it has curl and apt-get, and no python3, node or git. Install what a task needs (apt-get update && apt-get install -y python3), knowing that it lasts until the VM is rebuilt from the image, or publish your own template with your toolchain already in it.

By contrast, shell opens a persistant, interactive terminal (a real PTY over a WebSocket). This can be more performant and allows the agent some autonomy.

const sh = await lb.shell("sb-acme-7f3a9c", { cols: 120, rows: 32, cwd: "/workspace" });
sh.onData((bytes) => process.stdout.write(bytes));
sh.write("htop\n");
// … later
sh.resize(160, 48);
const exit = await sh.close();
if (!exit.clean) console.error("shell ended:", exit.code, exit.error);

File Management

A deployment allows you to mount directories at runtime and optionally to keep a copy of the mount when the VM isn't running. This is one of two ways to keep state around - the other being a bespoke VM which whose root filesystem is always backed up (robust but slightly slower boots).

The workspace disk. disk_size_gb gives the sandbox its own disk, mounted at /workspace. With idle_action: "retain" an idle sandbox is stopped rather than destroyed when it scales to zero, and the disk comes back with it on the next exec or shell: installed packages, a half-finished build, an agent's scratch files, etc should all go in the workspace. idle_action: "destroy" (the default) throws the disk away with the VM, which is what you want for one-shot code execution.

import { Archives, Namespace } from "@heyocomputer/sdk";

const archive = await Archives.fromDir("./starter-project");      // tar.gz, uploaded once
const lb = Namespace.connect("acme").workloads();                 // through Heyo Cloud, which resolves the archive

await lb.createDeployment({
  id: "sb-acme-7f3a9d",
  namespace: "acme",
  routes: [],
  artifact: { store: "https://hub.heyo.work", ref: "heyo/debian:latest" },
  vm: { driver: "firecracker", port: 22, disk_size_gb: 4, workspace_archive: { archive_id: archive.id } },
  health: { path: null },
  scaling: { min_replicas: 0, max_replicas: 1, idle_action: "retain" },
});

workspace_archive is resolved by Heyo Cloud, so create these sandboxes through Namespace.workloads() from @heyocomputer/sdk (0.2.0 or later), which talks to the same load balancer through Cloud with your Heyo API key.

Git as Storage

Heyo Computer additionally includes a git remote plugin backed by object storage. This lets your agents create and consume single purpose repositories for tracking changes on work.

Create a repository and a write token scoped to it from your backend. The git remote accepts your namespace token:

const git = "https://git.us5.heyo.work";
const auth = { authorization: `Bearer ${process.env.HEYO_TOKEN}`, "content-type": "application/json" };

await fetch(`${git}/api/repos/acme`, { method: "POST", headers: auth, body: JSON.stringify({ name: "sb-7f3a9c" }) });
const { token } = await (await fetch(`${git}/api/tokens`, {
  method: "POST", headers: auth,
  body: JSON.stringify({ namespace: "acme", repos: ["sb-7f3a9c"], access: "write", name: "sandbox-7f3a9c" }),
})).json();

Then give the sandbox git and the token. heyo/debian ships without git, and an apt-get install can outlast a single exec, so install it in the background and wait for it (or bake git into your own template). The token goes into a credential store outside /workspace, so it never ends up in the repository:

const id = "sb-acme-7f3a9c";
await lb.exec(id, "command -v git || nohup sh -c 'apt-get update -qq && apt-get install -y -qq git' >/tmp/git-install.log 2>&1 &");
while (!(await lb.exec(id, "command -v git || true")).stdout.trim()) await new Promise((r) => setTimeout(r, 2000));

const creds = Buffer.from(`https://x-access-token:${token}@git.us5.heyo.work\n`).toString("base64");
await lb.exec(id, [
  `echo ${creds} | base64 -d > /etc/git-credentials && chmod 600 /etc/git-credentials`,
  `git config --global credential.helper "store --file=/etc/git-credentials"`,
  `git config --global user.name "acme sandbox" && git config --global user.email sandbox@acme.invalid`,
  `cd /workspace && git init -q -b main && git remote add origin https://git.us5.heyo.work/acme/sb-7f3a9c.git`,
].join(" && "));

Saving is a commit and a push:

await lb.exec(id, `cd /workspace && git add -A && git commit -qm "checkpoint" && git push -q origin main`);

And a fresh sandbox picks up where the last one stopped: run the same setup against it, then pull into its workspace:

await lb.exec("sb-acme-7f3a9d", "cd /workspace && git pull -q origin main");

Every checkpoint is a commit, so forking a sandbox is cloning at a ref, and the history is there to browse in the git remote's pages.

Cleaning Up

A sandbox you are finished with should be deleted, not just left to scale to zero: a stopped sandbox with idle_action: "retain" keeps its /workspace disk, and with it your namespace's share of the host's disk.

Delete a sandbox. deleteDeployment drains the sandbox, stops and removes its VM, throws away its /workspace disk, and removes the deployment. It is permanent: copy out anything you need first (see Files In, Files Out above).

await lb.deleteDeployment("sb-acme-7f3a9c");
heyctl delete deployment sb-acme-7f3a9c

Delete them in bulk. Sandboxes are deployments like any other, so list your namespace's and delete the ones you own. Give sandbox ids a prefix you can match, like sb-acme-, so cleanup never touches your other deployments:

const sandboxes = (await lb.deployments()).filter((d) => d.spec.id.startsWith("sb-acme-"));
for (const d of sandboxes) await lb.deleteDeployment(d.spec.id);

A scheduled job that deletes sandboxes idle for longer than a day is the usual safety net for ones an agent forgot.

Reset a sandbox instead of deleting it. To give a sandbox a clean slate but keep its id, evict its VM. The next exec or shell boots a fresh one from the image, with an empty /workspace (or a freshly seeded one, if the sandbox has a workspace_archive):

const { vms } = await lb.deployment("sb-acme-7f3a9c");
for (const vm of vms) await lb.evictVm("sb-acme-7f3a9c", vm.sandbox_id, true);   // force: stop it now
heyctl delete vm <sandbox-id> -d sb-acme-7f3a9c --force

Without force, the VM finishes the requests it is serving before it goes.

What is left behind. The image a sandbox was pulled from stays cached on the host, so the next sandbox from it starts without a download; the load balancer clears unused images on its own. Archives you uploaded to seed workspaces stay in your account until you delete them with Archives.delete(archive.id) from @heyocomputer/sdk. Secrets you created for sandboxes stay until you delete them (lb.deleteSecret(id, false, "acme")).