Guides / JavaScript & TypeScript / Sandboxes
Sandboxes
A sandbox is a microVM with its own kernel, its own container engine, its own disk
and its own network. This page covers every option createSandbox takes and every
other call in a sandbox's life, each with the output it produced.
Create one
import { Kosa8Client } from '@kosa8/sdk';
const kosa8 = new Kosa8Client();
const { sandbox, boot_ms } = await kosa8.createSandbox({
name: 'web',
cpus: 2,
memMb: 1024, // MiB of guest memory
diskGb: 20, // the data disk: images, layers, everything written inside
egress: 'deny',
labels: { team: 'payments', ticket: 'PAY-142' },
});
console.log(`booted ${sandbox.id} in ${boot_ms}ms`);
console.log(sandbox);
console.log('list:', (await kosa8.listSandboxes()).map((s) => `${s.id}=${s.state}`));
console.log('get web:', (await kosa8.getSandbox('web'))?.state);
console.log('get nope:', await kosa8.getSandbox('nope'));
$ node sandboxes1.mjs
booted web in 1164ms
{
id: 'web',
name: 'web',
created: '2026-10-15T19:57:29.810605-04:00',
state: 'running',
cpus: 2,
mem_mb: 1024,
disk_gb: 20,
egress: 'deny',
ip: '192.168.127.2',
labels: { team: 'payments', ticket: 'PAY-142' }
}
list: [ 'web=running' ]
get web: running
get nope: undefined
createSandbox resolves when the machine has booted. The result is
{ sandbox, boot_ms, broker? }. The sandbox record uses the daemon's own field names
(mem_mb, disk_gb), so it matches what kosa8 sandbox ls --json
prints. There is no single-sandbox endpoint: getSandbox reads the list and returns
undefined for a name it does not find.
Every create option
| Option | Type | What it does |
|---|---|---|
name | string | The sandbox's name, which is also its id. The daemon picks one (sbx1, sbx2, …) when you leave it out. A name already in use is refused. |
cpus | number | Virtual CPUs. The daemon default is 2. |
memMb | number | Guest memory in MiB. The daemon default is 1024. A checkpoint saves this memory to disk, so smaller is cheaper to save. |
diskGb | number | Size of the data disk, which holds images, layers and everything written inside. The daemon default is 32. The file is sparse: it takes real space only as it fills. Your licence sets the ceiling. |
mounts | Mount[] | Host folders to share: { host_path, guest_path, read_only }. Every container started in the sandbox sees them at guest_path. Below. |
egress | EgressPolicy | The network rule: 'deny', 'any', 'fakenet', 'allow:a.com,*.b.com,10.0.0.0/8', or { allow: [...] }. Fixed for the sandbox's life. Left out, the org policy's default_egress applies, or any when there is none. See Network and secrets. |
egressAllow | string[] | Shorthand for egress: { allow: [...] }. Setting it together with an egress other than 'deny' throws before anything is sent. |
capture | CaptureSpec | Record the sandbox's traffic to a pcapng file on the host from the first frame: { path, max_bytes?, max_files? }. The path must be absolute. |
labels | Record<string,string> | Free-form key–value labels, kept on the sandbox record and shown by kosa8 sandbox ls --json. |
broker | BrokerSpec | Put the sandbox behind an egress broker that adds credentials the sandbox never holds. See the broker. |
approval, signal | string, AbortSignal | Accepted by every call. approval is a signed-approval id (Approvals); signal cancels the HTTP request. |
Share a host folder
import { Kosa8Client } from '@kosa8/sdk';
import { readFileSync } from 'node:fs';
const kosa8 = new Kosa8Client();
const host = '/tmp/k8d/projects/api';
await kosa8.createSandbox({
name: 'review',
egress: 'deny',
mounts: [
{ host_path: host, guest_path: '/work', read_only: false },
],
});
await kosa8.pullIntoSandbox('review', 'alpine:latest');
// Every container started in the sandbox sees the mount at its guest path.
const r = await kosa8.run('review', 'alpine:latest', ['sh', '-c',
'ls -l /work; echo "// reviewed" >> /work/index.js']);
console.log(r.stdout);
console.log('on the host now:\n' + readFileSync(`${host}/index.js`, 'utf8'));
console.log('sandbox record:', (await kosa8.getSandbox('review'))?.mounts);
$ node mounts.mjs
total 4
-rw-r--r-- 1 root root 36 Oct 15 23:58 index.js
on the host now:
console.log("hello from the host");
// reviewed
sandbox record: [
{
host_path: '/tmp/k8d/projects/api',
guest_path: '/work',
read_only: false
}
]
The sandbox sees that one folder and nothing else of your computer. A write inside lands on your disk at once. Make it read-only when the work inside should only look:
import { Kosa8Client, Kosa8ConflictError, Kosa8NotFoundError } from '@kosa8/sdk';
const kosa8 = new Kosa8Client();
// A read-only mount: writes fail inside, with an ordinary exit code.
await kosa8.createSandbox({
name: 'ro', egress: 'deny',
mounts: [{ host_path: '/tmp/k8d/projects/api', guest_path: '/src', read_only: true }],
});
await kosa8.pullIntoSandbox('ro', 'alpine:latest');
const w = await kosa8.run('ro', 'alpine:latest', ['sh', '-c', 'touch /src/new.txt']);
console.log('write to a read-only mount:', w.exitCode, w.stderr.trim());
write to a read-only mount: 1 touch: /src/new.txt: Read-only file system
--allow-mount elsewhere
Through the native API, the mount you pass is the mount you get: the caller owns the
daemon's socket, so it is trusted to choose. Two other ways in are limited by an allow-list.
kosa8 mcp serve --allow-mount DIR[:rw] limits the folders an AI agent may share into
sandboxes it creates, and kosa8 daemon start --allow-mount DIR[:rw] limits Docker
API bind mounts. Under a signed org policy whose label lists mount_paths, the policy
narrows native mounts too.
Stop, start, and what survives
import { Kosa8Client } from '@kosa8/sdk';
const kosa8 = new Kosa8Client();
const web = kosa8.sandbox('web'); // a handle: the same methods, bound to one sandbox
await web.pull('alpine:latest');
console.log('images:', await web.images());
const cid = await web.startContainer('alpine:latest', ['sh', '-c', 'echo kept > /data.txt; sleep 3600']);
console.log('containers:', await web.containers());
console.log('stop:', await web.stop());
console.log('state now:', (await web.info())?.state);
const up = await web.start();
console.log('start:', up.sandbox.state, `in ${up.boot_ms}ms`, up.warning ?? '');
console.log('images after restart:', await web.images());
console.log('containers after restart:', await web.containers());
$ node sandboxes2.mjs
images: [ 'docker.io/library/alpine:latest' ]
containers: [
{
id: 'k1-9f6ad3d7',
image: 'docker.io/library/alpine:latest',
status: 'running',
ip: '10.88.0.2'
}
]
stop: {
ok: true,
sandbox: {
id: 'web',
name: 'web',
created: '2026-10-15T19:57:29.810605-04:00',
state: 'stopped',
cpus: 2,
mem_mb: 1024,
disk_gb: 20,
egress: 'deny',
labels: { team: 'payments', ticket: 'PAY-142' }
},
warning: ''
}
state now: stopped
start: running in 581ms
images after restart: [ 'docker.io/library/alpine:latest' ]
containers after restart: []
stopSandbox shuts the machine down and keeps its disk. startSandbox
boots it again from that disk: pulled images and written files are still there, but no process
is, so the container list is empty. To bring back running processes, take a
snapshot or a checkpoint
before you stop. A daemon restart also leaves every sandbox stopped, because a sandbox's VM cannot
outlive kosa8d.
kosa8.sandbox('web') returns a SandboxHandle: the same methods with the
sandbox id filled in (info, start, stop, delete,
pull, run, startContainer, exec,
containers, images, egress, snapshot,
fork, explore, checkpoint, checkpoints,
rewind).
Delete, and wipe
// A name that is taken.
try {
await kosa8.createSandbox({ name: 'ro' });
} catch (err) {
console.log(err.constructor.name, err.status, '-', err.message);
}
// Delete, overwriting the disks first.
console.log('delete with wipe:', await kosa8.deleteSandbox('ro', { wipe: true }));
// Deleting what is not there.
try {
await kosa8.deleteSandbox('ro');
} catch (err) {
console.log(err instanceof Kosa8NotFoundError, err.status, '-', err.message);
}
Kosa8ApiError 500 - sandbox ro already exists (run `kosa8 doctor`; check ~/.kosa8/log/)
delete with wipe: {
files: [
{
path: '/tmp/k8d/.kosa8/sandboxes/ro.img',
size: 1610612736,
overwritten: 1610612736,
verified: true,
removed: true,
filesystem: [Object],
caveats: [Array]
},
{
path: '/tmp/k8d/.kosa8/sandboxes/ro-data.img',
size: 34359738368,
overwritten: 11395072,
verified: true,
removed: true,
filesystem: [Object],
caveats: [Array]
}
],
ok: true,
wipe: {
method: 'NIST SP 800-88 Rev. 1 Clear: one pass of a pseudorandom pattern over every allocated extent, flushed, read back and compared, then truncated and unlinked',
files: 2,
overwritten_bytes: 1622007808,
verified: true,
caveats: [
'flash media (SSD, NVMe) may keep old copies in cells the overwrite cannot address; cryptographic erase (FileVault/LUKS, crypto-shred) is the sanitization that reaches them',
'copy-on-write filesystem: the overwrite was written to new blocks, and blocks shared with a clone or filesystem snapshot keep the old data until nothing references them'
]
},
checkpoints_dropped: 0
}
true 404 - no such sandbox: ro (list sandboxes with `kosa8 sandbox ls`)
- A taken name comes back as HTTP 500 (
Kosa8ApiError) in v0.9.10, not as a 409. Check withgetSandboxfirst if you reuse names. Fixed in kosa8 0.9.36: it is aKosa8ConflictError(409), "sandbox ro already exists (choose another name, …)". wipe: trueoverwrites the sandbox's disks before removing them and reports exactly what it did, with the honest caveats for SSDs and copy-on-write filesystems. It writes as many bytes as the disks hold, so it takes longer and needs free space on APFS.- Checkpoints go too.
deleteSandboxalso removes the sandbox's checkpoints and their snapshots, askosa8 sandbox rmdoes, and reports how many incheckpoints_dropped. PasskeepCheckpoints: trueto keep them. - Deleting a name that does not exist rejects with
Kosa8NotFoundError.
Images and containers
Each sandbox has its own private image store and container engine. Nothing is shared with another sandbox or with your host.
| Call | Returns | Notes |
|---|---|---|
pullIntoSandbox(id, ref) | { ok, image } | Fetched on the host and checked against the org's registry allow-list, then loaded into the sandbox. Works under egress: 'deny'. |
sandboxImages(id) | string[] | Full references, such as docker.io/library/alpine:latest. |
sandboxContainers(id) | Container[] | { id, image, status, exit?, ip? }, including exited ones. |
sandboxEgress(id) | EgressStatus | The rule enforced on the host and its counters. See Network. |
pull(ref) | { ok, image } | Pulls into the shared engine, the one the Docker API and kosa8 run use, not into a sandbox. |
import { Kosa8Client } from '@kosa8/sdk';
// The shared engine (the one the Docker API and `kosa8 run` use), not a sandbox.
const kosa8 = new Kosa8Client();
console.log(await kosa8.pull('alpine:3.20'));
$ node hostpull.mjs
{ image: 'docker.io/library/alpine:3.20', ok: true }
Packet capture
Pass capture at creation to record every frame from the first one. The file is
written on the host, where the sandbox cannot reach it, and sandboxEgress reports
where it is. The Network page creates a sandbox with
capture: { path: '/tmp/k8d/allow1.pcapng' }; after one request the file held
18 KB of pcapng. Open it with Wireshark or tshark -r.
Network and secrets: allow-lists, refusals, and the broker that keeps API keys out.