If your team is deploying bespoke applications developed with coding agents, you face a number of common requirements on every deploy: authorization, data management, scaling, and security. Heyo Computer provides a set of plugins that solve common challenges for deploying agent driven workloads along with a control plane to manage apps in production - its the same control plane that Heyo itself runs on.
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
heyctlor use either the Rust or TypeScript SDKs- Optional: Docker and
heyvmon a Linux machine, to build the workspace image
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? |
|---|---|---|---|
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 an agent can push with. |
artifacts |
Artifacts | auto-installed | Your <namespace>/ corner of the artifact store, where artifact images come from. |
obs |
Observability | auto-installed | Logs, metrics and error alerts for every VM in the namespace. |
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. |
postgres |
Postgres | install it | A dedicated database per name, each its own microVM with its own role and password; the credentials are also written to the namespace secret pg-<name>. |
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 install postgres -n acme
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 });
await lb.installPlugin("acme", "postgres"); // the others are already installed
await lb.putSecret({ id: "model-api-key", namespace: "acme", data: { key: process.env.MODEL_API_KEY! } });
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.
Start with an Image from the Hub
A VM 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:
const NS = "acme", REGION = "us5.heyo.work";
const dep = (id: string) => `p-${NS}-${id}`; // deployment ids are unique across the load balancer
async function createProject(id: string) {
await lb.createDeployment({
id: dep(id),
namespace: NS,
routes: [{ host: `${dep(id)}.${REGION}` }], // the preview URL
artifact: { store: "https://hub.heyo.work", ref: "heyo/bun:latest" },
vm: { driver: "firecracker", port: 3000, size_class: "medium", disk_size_gb: 8 },
health: { path: null, port: 22 }, // ready once sshd answers
scaling: { min_replicas: 0, max_replicas: 1, scale_to_zero_after_secs: 900, idle_action: "retain" },
});
const pull = await lb.startPull(dep(id));
await lb.waitForJob(pull.id);
await lb.patchScaling(dep(id), { min_replicas: 1 });
await lb.waitForReady(dep(id));
}
Unlike a bare sandbox, a project has a route: its preview, p-acme-<id>.us5.heyo.work, forwards to port 3000 in the VM. The health check looks at sshd on port 22 instead, so the project is ready as soon as the VM is up, and the preview answers once the agent starts a dev server on 0.0.0.0:3000. Started from exec, the server has to detach (setsid nohup bun run dev </dev/null >/tmp/dev.log 2>&1 &), or the call waits on it. idle_action: "retain" stops an idle project instead of destroying it, so its /workspace disk survives a quiet afternoon and the next request resumes it.
Optional: Building the Workspace Image
This requires the
heyvmCLI, and Docker, on a Linux machine
Heyo Computer provides tooling for creating bespoke VM images from a Dockerfile. This lets you create your own custom Firecracker images to run apps in, with your toolchain already installed instead of installed by the agent on every new project.
heyvm builds the Dockerfile with Docker and turns the result into a Firecracker root filesystem. The image has to bring up the VM itself, so start from the recipe the hub's images use: sshd (which exec and the shell connect to), socat (which ships the start command's output to the logs), and the hub's init.sh from images/base/init.sh in the hws repository, which mounts /workspace, starts sshd and signals that the VM is up. Then add your own tools:
# Dockerfile.workspace
FROM debian:13-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-server iproute2 e2fsprogs util-linux procps ca-certificates curl socat \
git nodejs npm \
&& rm -rf /var/lib/apt/lists/*
# Key-only SSH: heyvm supplies the keys.
RUN mkdir -p /run/sshd /etc/ssh/sshd_config.d /workspace \
&& printf 'PermitRootLogin prohibit-password\nPasswordAuthentication no\nKbdInteractiveAuthentication no\n' > /etc/ssh/sshd_config.d/00-heyo.conf \
&& passwd -d root \
&& ssh-keygen -A
COPY init.sh /init.sh
RUN chmod +x /init.sh
CMD ["/init.sh"]
Build it with heyvm, from the directory holding the Dockerfile and init.sh, then publish it to the artifact store under your namespace's prefix, so every deployment in acme can pull it. heyctl artifact push goes through the app-lb you logged in to, with your namespace token:
heyvm mvm build --local-only -f Dockerfile.workspace -c . -n acme-workspace --size-mb 2048
heyctl artifact push --image acme-workspace --tag acme/workspace:v1
That builds the image on your machine and pushes the result. You can also have the app-lb build it instead, with no Docker or heyvm on your side: commit the Dockerfile and init.sh to a git repository (the remote plugin gives you one) and point a deployment's build block at it, then lb.startBuild(id), the same way Publishing an App, below, builds the app itself. Either way, a deployment names a pushed image with no store, and it comes from your namespace's corner of the artifact store: artifact: { ref: "acme/workspace:v1" }. A new version of the toolchain is a new tag; projects pick it up the next time they are pulled.
Connect Your Agent with MCP
The best agent is probably the one you already have, connect it to Heyo's MCP server: https://mcp.heyo.work/mcp. With your namespace token the agent gets about fifty tools to run commands in a project's VM, commit to its repository, deploy, and read logs.
Add the server to your agent. In Claude Code:
claude mcp add --transport http heyo https://mcp.heyo.work/mcp \
--header "Authorization: Bearer $HEYO_TOKEN"
Most other MCP clients take the same thing as JSON (a project's .mcp.json, or the client's settings):
{
"mcpServers": {
"heyo": {
"type": "http",
"url": "https://mcp.heyo.work/mcp",
"headers": { "Authorization": "Bearer applb_…" }
}
}
}
If your agent is your own loop, connect with the MCP SDK (@modelcontextprotocol/sdk), hand the tool list to your model, and pass its tool calls through:
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
const mcp = new Client({ name: "acme-builder", version: "1.0.0" });
await mcp.connect(new StreamableHTTPClientTransport(new URL("https://mcp.heyo.work/mcp"), {
requestInit: { headers: { authorization: `Bearer ${process.env.HEYO_TOKEN}` } },
}));
const { tools } = await mcp.listTools(); // name, description and inputSchema, ready for your model
// …and when the model asks for one:
const result = await mcp.callTool({
name: "applb_exec",
arguments: { id: dep(id), command: "cd /workspace && npm test" },
});
The tools a project's agent leans on:
| Tool | What it is for |
|---|---|
heyo_guide |
Start here: a step-by-step plan for a goal in plain words, or for an error the agent does not understand |
applb_exec |
Run a command in the project's VM: write files, npm install, run the tests. It starts in /, so begin with cd /workspace |
repo_create, repo_write_files |
The project's repository on the git remote, and committing files to it with no git in the VM |
repo_deploy, applb_deploy |
Ship it: build a repository into a deployment, or register one from a full spec |
diagnose_deployment, deployment_logs, diagnose_vm_boot |
What went wrong, from the app's own output and app-lb's view of the VM |
heyo_whoami |
What the token can reach, when a call answers 401 or 403 |
Live Previews
Each project's route, p-acme-<project>.us5.heyo.work, is its preview, with a certificate the load balancer issues on its own. app-lb wakes the VM on the first request and holds it until the dev server answers, so a preview link always works, even after the sandbox has scaled to zero.
A preview is unfinished work, so put it behind sign-in. Your namespace already has the sign-in provider heyo, so one call gates the deployment. A path listed in publicPaths stays open to anyone, for a health check or a webhook:
await lb.setAuth(dep(id), {
provider: "heyo",
publicPaths: [{ path: "/healthz" }],
});
Signed-in viewers reach the preview. Anyone else is sent to sign in, and a script without a session gets a 401. The app sees who is viewing in x-auth-request-email and x-auth-request-user, if it wants to.
Sharing a preview publicly is lb.clearAuth(dep(id)). To admit only your organisation instead of every Heyo account, install auth-heyo once more under its own name with a require, and gate with that provider instead:
await lb.installPlugin(NS, "auth-heyo", { name: "heyo-team", require: { accountId: ["acct_…"] } });
await lb.setAuth(dep(id), { provider: "heyo-team" });
Persisting Projects
/workspace survives a sandbox stopping, but not the deployment being deleted or rebuilt from a new image. History belongs in git. With the remote plugin, each project gets a repository on the region's git remote, git.us5.heyo.work, and the agent pushes to it like any other remote. You can browse them from the dashboard: Plugins, then Open dashboard on Git.
Create the repository and a write token scoped to it from your backend. The git remote accepts your namespace token:
const git = `https://git.${REGION}`;
const auth = { authorization: `Bearer ${process.env.HEYO_TOKEN}`, "content-type": "application/json" };
await fetch(`${git}/api/repos/${NS}`, { method: "POST", headers: auth, body: JSON.stringify({ name: id }) });
const { token } = await (await fetch(`${git}/api/tokens`, {
method: "POST", headers: auth,
body: JSON.stringify({ namespace: NS, repos: [id], access: "write", name: `agent-${id}` }),
})).json();
// Kept as a secret too: the build reads it from there when you publish.
await lb.putSecret({ id: `repo-${id}`, namespace: NS, data: { token } });
Your backend reaches the same VM with exec:
const sh = (project: string, command: string) =>
lb.exec(dep(project), `cd /workspace && ${command}`, { timeoutSecs: 300 });
exec does not pass a deployment's env_from secrets to the command, so give the token to git itself: write it to a credential store in the sandbox, outside /workspace so it never ends up in the repository, and configure the remote without it:
const creds = Buffer.from(`https://x-access-token:${token}@git.${REGION}\n`).toString("base64");
await sh(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 "${NS} agent" && git config --global user.email agent@${NS}.invalid`,
`git init -q -b main 2>/dev/null; git remote add origin https://git.${REGION}/${NS}/${id}.git 2>/dev/null; true`,
].join(" && "));
The store and the git configuration live on the VM's root filesystem, which comes from the image: run this again whenever the project's VM is rebuilt.
After each turn of the agent, commit what changed:
await sh(id, `git add -A && git commit -qm "${summary.replace(/"/g, "'")}" && git push -q origin main`);
Every turn is now a commit: the user can browse the history in the git remote's pages, and "undo the last change" is a git revert.
Publishing an App
Create a production version of the app, built from the project's repository. The preview / production split gives you a staging environment to test in before pushing to a live environment that others may be using.
You can give each instance of an app its own database using the postgres plugin:
await lb.postgres(NS).createDatabase({ name: id.replace(/-/g, "_") });
Then create the deployment from the repository, build it, and wait for it to come up:
const app = `app-${NS}-${id}`;
await lb.createDeployment({
id: app,
namespace: NS,
routes: [{ host: `${app}.${REGION}` }],
build: {
repo: `https://git.${REGION}/${NS}/${id}.git`,
ref: "main",
dockerfile: "Dockerfile",
auth: { secret: `repo-${id}`, key: "token", username: "x-access-token" },
},
vm: {
driver: "firecracker",
port: 8080,
env_from: [{ secret: `pg-${id.replace(/-/g, "_")}`, key: "url", as: "DATABASE_URL" }],
},
scaling: { min_replicas: 0, max_replicas: 4, scale_to_zero_after_secs: 600 },
});
const build = await lb.startBuild(app);
await lb.waitForJob(build.id);
await lb.waitForReady(app);
To serve it on your own domain as well, add a route for that host and point the name at the app's address with a CNAME; the load balancer issues the certificate once DNS resolves.
min_replicas: 0 lets an app nobody is using cost nothing; the app-lb scales to 1 on access. Publishing a new version is another startBuild: app-lb boots the new image beside the old one and switches traffic once it is healthy.
A public app is now live. An internal one, a dashboard for the user's team say, takes one more call: Heyo sign-in as for previews (heyo, or heyo-team for one account). When the Google Auth plugin is installed, you can implement a Google sign-in to gate access to your application (auth-google in heyctl plugins list -n acme), the team's own Google Workspace works too, installed once per namespace with its OAuth client:
await lb.installPlugin(NS, "auth-google", {
client_id: "1234.apps.googleusercontent.com",
client_secret: { secret: "google", key: "client_secret" },
allowed_domains: ["example.com"],
});
await lb.setAuth(app, { provider: "google" });