kosa8 docs

Guides / JavaScript & TypeScript / Recipes

Recipes

Each recipe is a whole program, run against kosa8 v0.9.10 as shown, with its real output. Copy one, change the parts that are yours, and run it.

Run LLM-generated code safely

A model wrote some Python and you want to run it. The code might be wrong, slow, or hostile. Give it a machine with no network, a small memory, a fresh container for every run, a time limit enforced inside the sandbox, and a cap on how much output you keep.

import { Kosa8Client } from '@kosa8/sdk';

const kosa8 = new Kosa8Client();
const IMAGE = 'python:3.13-alpine';

// One sealed machine for untrusted code: no network, small, its own kernel.
await kosa8.createSandbox({ name: 'untrusted', egress: 'deny', memMb: 512 });
await kosa8.pullIntoSandbox('untrusted', IMAGE);

async function runUntrusted(code, { seconds = 10, maxOutput = 64 * 1024 } = {}) {
  // Each call is a fresh container: nothing one snippet writes is seen by the next.
  // `timeout` inside the sandbox ends the process; aborting the request would not.
  // The trailing `; exit $?` keeps sh as PID 1, so python is an ordinary child that
  // SIGKILL can reach (PID 1 in a container ignores signals it has no handler for).
  const r = await kosa8.run('untrusted', IMAGE,
    ['sh', '-c', 'timeout -s KILL "$0" python3 -c "$1"; exit $?', String(seconds), code]);
  return {
    exitCode: r.exitCode,
    timedOut: r.exitCode === 137,
    stdout: r.stdout.slice(0, maxOutput),
    stderr: r.stderr.slice(0, maxOutput),
  };
}

// Pretend each of these came back from a model.
const generated = {
  fine: 'print(sum(i * i for i in range(10)))',
  network: 'import urllib.request\nurllib.request.urlopen("https://example.com", timeout=5)',
  hostFiles: 'import os\nprint(os.path.exists("/Users"), os.path.exists("/etc/passwd"), os.uname().nodename)',
  forever: 'while True: pass',
  crash: 'raise SystemExit(3)',
};
for (const [name, code] of Object.entries(generated)) {
  const r = await runUntrusted(code, { seconds: 8 });
  console.log(name.padEnd(9), JSON.stringify({ ...r, stderr: r.stderr.trim().split('\n').pop() }));
}

await kosa8.deleteSandbox('untrusted');
$ node llmcode.mjs
fine      {"exitCode":0,"timedOut":false,"stdout":"285\n","stderr":""}
network   {"exitCode":1,"timedOut":false,"stdout":"","stderr":"urllib.error.URLError: <urlopen error [Errno -3] Try again>"}
hostFiles {"exitCode":0,"timedOut":false,"stdout":"False True kosa8-guest\n","stderr":""}
forever   {"exitCode":137,"timedOut":true,"stdout":"","stderr":"Killed"}
crash     {"exitCode":3,"timedOut":false,"stdout":"","stderr":""}

The network attempt failed to resolve. The code saw a Linux machine called kosa8-guest with no /Users: none of your files exist there. The infinite loop was killed at 8 seconds. Every outcome is an exit code your program can act on.

The time limit, and PID 1

Two things went wrong on the way to this version, and both will bite you elsewhere. Aborting the HTTP request does not stop the code, so the limit has to be inside the sandbox. And the first version, timeout 8 python3 -c … as the container's command, never fired: timeout replaces itself with Python, which becomes PID 1, and PID 1 in a container ignores signals it has no handler for, even SIGKILL sent from inside. A plain sh -c '… python3 …' was no better, because sh runs its last command in place of itself. The trailing ; exit $? keeps sh as PID 1 and Python as an ordinary child, and then the limit works.

A coding agent editing your repo through a mounted folder

The agent's machine sees one folder of yours, writable, and nothing else. It edits files there; you review the result with git diff on your side, where it cannot reach.

import { generateText, stepCountIs, tool } from 'ai';
import { MockLanguageModelV4 } from 'ai/test';
import { z } from 'zod';
import { createKosa8NetworkSandboxSession } from '@kosa8/ai-sdk-sandbox';

const repo = `${process.env.HOME}/projects/greeter`;   // a git checkout on your machine

// The agent's machine sees this one folder, writable, at /workspace/repo, and nothing else.
const session = await createKosa8NetworkSandboxSession({
  image: 'alpine:latest',
  networkPolicy: { mode: 'deny-all' },
  mounts: [{ host_path: repo, guest_path: '/workspace/repo', read_only: false }],
  workingDirectory: '/workspace/repo',
});

const tools = {
  readFile: tool({
    description: 'Read a file.',
    inputSchema: z.object({ path: z.string() }),
    execute: async ({ path }, { experimental_sandbox: sb }) => ({ content: await sb.readTextFile({ path }) }),
  }),
  writeFile: tool({
    description: 'Replace a file.',
    inputSchema: z.object({ path: z.string(), content: z.string() }),
    execute: async ({ path, content }, { experimental_sandbox: sb }) => {
      await sb.writeTextFile({ path, content });
      return { ok: true };
    },
  }),
  shell: tool({
    description: 'Run a shell command in the repository.',
    inputSchema: z.object({ command: z.string() }),
    execute: ({ command }, { experimental_sandbox: sb }) => sb.run({ command }),
  }),
};

// Scripted so it runs without a key; a real model makes the same tool calls.
const usage = { inputTokens: { total: 1, noCache: 1 }, outputTokens: { total: 1, text: 1 } };
const call = (id, toolName, input) => ({
  content: [{ type: 'tool-call', toolCallId: id, toolName, input: JSON.stringify(input) }],
  finishReason: { unified: 'tool-calls' }, usage, warnings: [],
});
const model = new MockLanguageModelV4({ doGenerate: [
  call('1', 'readFile', { path: 'greet.js' }),
  call('2', 'writeFile', { path: 'greet.js', content: "export function greet(name = 'world') {\n  return `Hello, ${name}!`;\n}\n" }),
  call('3', 'shell', { command: 'ls -la; cat /etc/hostname; ls /Users 2>&1 | head -1' }),
  { content: [{ type: 'text', text: 'greet() now has a default and an exclamation mark.' }], finishReason: { unified: 'stop' }, usage, warnings: [] },
] });

try {
  const r = await generateText({
    model, tools, stopWhen: stepCountIs(6),
    prompt: 'Give greet() a default name.', experimental_sandbox: session.restricted(),
  });
  console.log(r.steps.flatMap((s) => s.toolResults).at(-1).output.stdout);
  console.log(r.text);
} finally {
  await session.destroy();
}
process.exit(0);
$ node coding-agent.mjs
total 4
drwxr-xr-x    4 root     root           128 Oct 16 00:57 .
drwxr-xr-x   12 root     root           384 Oct 16 00:57 .git
-rw-r--r--    1 root     root            70 Oct 16 00:57 greet.js
localhost
ls: /Users: No such file or directory

greet() now has a default and an exclamation mark.
$ git -C ~/projects/greeter diff
diff --git a/greet.js b/greet.js
index aeaa36f..b950a75 100644
--- a/greet.js
+++ b/greet.js
@@ -1,3 +1,3 @@
-export function greet(name) {
-  return 'Hello, ' + name;
+export function greet(name = 'world') {
+  return `Hello, ${name}!`;
 }

The model is scripted so the recipe runs without a key; replace it with a real one and the tools are unchanged. Add a checkpoint before each step, or give the agent a broker route to its model API instead of deny-all.

When the agent chooses the folder: --allow-mount

If the agent creates its own sandboxes through MCP, you decide which folders it may share, on the command line of kosa8 mcp serve. Anything outside is refused:

import { createMCPClient } from '@ai-sdk/mcp';
import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio';

const HOME = process.env.HOME;
// The agent may share host folders into the sandboxes it creates only below --allow-mount.
const mcp = await createMCPClient({
  transport: new Experimental_StdioMCPTransport({
    command: 'kosa8',
    args: ['mcp', 'serve', '--allow-mount', `${HOME}/projects:rw`],
    env: { HOME, PATH: process.env.PATH },
  }),
});
const opts = { toolCallId: 't', messages: [] };
const text = (r) => r.content.map((c) => c.text).join('').trim();
try {
  const tools = await mcp.tools();
  const p = tools.sandbox_create.inputSchema.jsonSchema.properties;
  console.log('mount:', p.mount.description, '\nread_write:', p.read_write.description);
  for (const mount of [`${HOME}/projects/greeter:/work`, `${HOME}/app:/work`]) {
    const r = await tools.sandbox_create.execute({ name: 'mcp-mount', egress: 'deny', mount, read_write: true }, opts);
    console.log(mount.replace(HOME, '~'), '->', r.isError ? 'refused: ' + text(r) : 'created');
    if (!r.isError) await tools.sandbox_delete.execute({ sandbox: 'mcp-mount' }, opts);
  }
} finally {
  await mcp.close();
}
$ node mcp-mount.mjs
mount: host directory to share, as HOST:GUEST (read-only), or HOST:GUEST:rw; HOST must be absolute and inside a directory the user allowed with --allow-mount
read_write: share the mount writable; refused unless the user allowed its directory with :rw
~/projects/greeter:/work -> created
~/app:/work -> refused: mount host path "/tmp/k8d/app" is outside the directories this MCP server allows (--allow-mount)

Parallel test variants with explore

Run one test suite under several configurations at once, each in its own fork, and read every result. This project has a timezone bug:

// Formats a timestamp as the local calendar date.
export const day = (ms) => new Date(ms).toDateString();
import { test } from 'node:test';
import assert from 'node:assert';
import { day } from '../index.js';

test('a late-evening UTC timestamp', () => {
  assert.strictEqual(day(Date.UTC(2026, 9, 15, 22, 30)), 'Thu Oct 15 2026');
});
import { Kosa8Client } from '@kosa8/sdk';

const kosa8 = new Kosa8Client();
const IMAGE = 'node:22-alpine';

// The project, read-only: every fork sees the same files and none can change them.
await kosa8.createSandbox({
  name: 'ci',
  egress: 'deny',
  memMb: 512,                                // every fork gets the same size: keep it small
  mounts: [{ host_path: `${process.env.HOME}/projects/dates`, guest_path: '/src', read_only: true }],
});
await kosa8.pullIntoSandbox('ci', IMAGE);

// Does the suite pass in every timezone we ship to? One fork per timezone, in parallel.
const zones = ['UTC', 'America/Los_Angeles', 'Asia/Kolkata'];
const res = await kosa8.explore('ci', {
  image: IMAGE,
  pull: false,                               // already pulled above
  variants: zones.map((tz) => `cd /src && TZ=${tz} node --test 2>&1 | grep -E "^# (pass|fail)"`),
  keepAll: true,                             // keep every branch: we want all the results, not a winner
  prefix: 'zone',
});
res.branches.forEach((b, i) => {
  const [pass, fail] = b.output.split('\n').map((l) => l.split(' ').pop());
  console.log(zones[i].padEnd(17), b.name, `pass ${pass} fail ${fail}`, `${b.duration_ms}ms`);
});
for (const b of res.branches) await kosa8.deleteSandbox(b.name);
await kosa8.deleteSandbox('ci');
$ node variants.mjs
UTC               zone1 pass 1 fail 0 1415ms
America/Los_Angeles zone2 pass 1 fail 0 1450ms
Asia/Kolkata      zone3 pass 0 fail 1 1433ms

Asia/Kolkata is five and a half hours ahead of UTC, so 22:30 UTC on 15 October is already 16 October there, and the test fails. Three forks ran at once, each about 1.4 seconds. The project is on a read-only mount, so every fork sees it and none can change it. Keep the parent small (memMb: 512 here): every fork is a whole machine of the same size. See Fork and explore.

Checkpoint before a migration, then rewind

import { Kosa8Client } from '@kosa8/sdk';

const kosa8 = new Kosa8Client();
const IMAGE = 'python:3.13-alpine';
const app = kosa8.sandbox('app');

await kosa8.createSandbox({ name: 'app', egress: 'deny' });
await app.pull(IMAGE);
const cid = await app.startContainer(IMAGE, ['sh', '-c', 'while :; do sleep 3600; done']);
const py = async (code) => {
  const r = await app.exec(cid, ['python3', '-c', code]);
  if (r.exitCode !== 0) throw new Error(r.stderr.trim());
  return r.stdout.trim();
};
const count = () => py('import sqlite3; print(sqlite3.connect("/data/app.db").execute("select count(*) from users").fetchone()[0])');

// Some data worth protecting.
await py(`import os, sqlite3
os.makedirs("/data", exist_ok=True)
db = sqlite3.connect("/data/app.db")
db.execute("create table users (id integer primary key, email text, plan text)")
db.executemany("insert into users (email, plan) values (?, ?)", [(f"u{i}@example.com", "free" if i % 3 else "pro") for i in range(1000)])
db.commit()`);
console.log('users before:', await count());

// 1. Checkpoint right before the risky step.
const cp = await app.checkpoint({ label: 'before 0042_drop_free_users' });
console.log(`checkpoint ${cp.checkpoint.id} in ${cp.snapshot.snapshot_ms}ms`);

// 2. The migration has a bug: the WHERE clause is wrong.
await py(`import sqlite3
db = sqlite3.connect("/data/app.db")
db.execute("delete from users where plan != 'enterprise'")
db.commit()`);
const after = Number(await count());
console.log('users after the migration:', after);

// 3. A check fails, so rewind. Same sandbox name, same container, data back.
if (after < 900) {
  const rw = await app.rewind();
  console.log(`rewound to ${rw.checkpoint.id} in ${rw.restore_ms}ms`);
  console.log('users now:', await count());
}
await app.delete();
$ node migrate.mjs
users before: 1000
checkpoint app-ckpt-1 in 647ms
users after the migration: 0
rewound to app-ckpt-1 in 532ms
users now: 1000

The migration meant to delete free users and deleted everyone. The check after it failed, so the program rewound: half a second later the same sandbox, with the same running container, had all 1,000 rows. No backup was restored and nothing was rebuilt. Because a checkpoint holds the whole machine, a database server running in the container would come back with its processes as well; this recipe used SQLite, and a server database was not tested here.

An API route that runs user code

Express

import express from 'express';
import { Kosa8Client, Kosa8Error } from '@kosa8/sdk';

const kosa8 = new Kosa8Client();
const SANDBOX = 'api-runner';
const IMAGE = 'python:3.13-alpine';

// One sealed machine for the server's lifetime. Each request gets a fresh container in it.
if (!(await kosa8.getSandbox(SANDBOX))) {
  await kosa8.createSandbox({ name: SANDBOX, egress: 'deny', memMb: 1024 });
}
await kosa8.pullIntoSandbox(SANDBOX, IMAGE);

const app = express();
app.use(express.json({ limit: '64kb' }));

app.post('/run', async (req, res) => {
  const code = req.body?.code;
  if (typeof code !== 'string' || code.length === 0) return res.status(400).json({ error: 'code is required' });
  try {
    const r = await kosa8.run(SANDBOX, IMAGE,
      ['sh', '-c', 'timeout -s KILL 10 python3 -c "$0"; exit $?', code]);
    res.json({ exitCode: r.exitCode, stdout: r.stdout.slice(0, 65536), stderr: r.stderr.slice(0, 65536) });
  } catch (err) {
    // A daemon refusal or an unreachable daemon: say so, and keep the daemon's own words.
    const status = err instanceof Kosa8Error && err.status === 403 ? 403 : 502;
    res.status(status).json({ error: err.message });
  }
});

const server = app.listen(3000, () => console.log('listening on http://localhost:3000'));
for (const sig of ['SIGINT', 'SIGTERM']) {
  process.on(sig, async () => {
    server.close();
    await kosa8.deleteSandbox(SANDBOX).catch(() => {});
    process.exit(0);
  });
}
$ node server.mjs
listening on http://localhost:3000
$ curl -s localhost:3000/run -H 'content-type: application/json' -d '{"code":"import sys; print(sys.version.split()[0]); print(2**100)"}'
{"exitCode":0,"stdout":"3.13.16\n1267650600228229401496703205376\n","stderr":""}
$ curl -s localhost:3000/run -H 'content-type: application/json' -d '{"code":"open(\"/tmp/x\",\"w\").write(\"hi\"); import socket; socket.create_connection((\"1.1.1.1\", 443), timeout=3)"}'
{"exitCode":1,"stdout":"","stderr":"Traceback (most recent call last):\n  File \"<string>\", line 1, in <module>\n    open(\"/tmp/x\",\"w\").write(\"hi\"); import socket; socket.create_connection((\"1.1.1.1\", 443), timeout=3)\n                                                   ~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n  File \"/usr/local/lib/python3.13/socket.py\", line 868, in create_connection\n    raise exceptions[0]\n  File \"/usr/local/lib/python3.13/socket.py\", line 853, in create_connection\n    sock.connect(sa)\n    ~~~~~~~~~~~~^^^^\nConnectionRefusedError: [Errno 111] Connection refused\n"}
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3000/run -H 'content-type: application/json' -d '{}'
400

Ctrl-C (SIGINT) closed the server and deleted the sandbox: afterwards only an unrelated sandbox was listed. One sandbox serves every request here and each request gets a fresh container, so one request's files are never seen by the next. For users who must not share a kernel, create a sandbox per user or per session and delete it when they leave.

Next.js (App Router)

import { Kosa8Client } from '@kosa8/sdk';

// The SDK speaks to a unix socket with node:http, so this route must run on Node, not the Edge runtime.
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

const kosa8 = new Kosa8Client();
const SANDBOX = 'next-runner';
const IMAGE = 'python:3.13-alpine';

// Created on first use and reused by later requests in this server process.
let ready;
function ensureSandbox() {
  ready ??= (async () => {
    if (!(await kosa8.getSandbox(SANDBOX))) {
      await kosa8.createSandbox({ name: SANDBOX, egress: 'deny', memMb: 1024 });
    }
    await kosa8.pullIntoSandbox(SANDBOX, IMAGE);
  })().catch((err) => { ready = undefined; throw err; });
  return ready;
}

export async function POST(request) {
  const { code } = await request.json().catch(() => ({}));
  if (typeof code !== 'string' || !code) return Response.json({ error: 'code is required' }, { status: 400 });
  try {
    await ensureSandbox();
    const r = await kosa8.run(SANDBOX, IMAGE, ['sh', '-c', 'timeout -s KILL 10 python3 -c "$0"; exit $?', code]);
    return Response.json({ exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr });
  } catch (err) {
    return Response.json({ error: err.message }, { status: err.status === 403 ? 403 : 502 });
  }
}
$ npx next build
Route (app)
┌ ○ /_not-found
└ ƒ /api/run
$ npx next start -p 3001
✓ Ready in 134ms
$ curl -s localhost:3001/api/run -H 'content-type: application/json' -d '{"code":"import platform; print(platform.machine(), platform.release())"}'
{"exitCode":0,"stdout":"aarch64 6.18.35\n","stderr":""}
$ curl -s localhost:3001/api/run -H 'content-type: application/json' -d '{"code":"print(1/0)"}'
{"exitCode":1,"stdout":"","stderr":"Traceback (most recent call last):\n  File \"<string>\", line 1, in <module>\n    print(1/0)\n          ~^~\nZeroDivisionError: division by zero\n"}

Tested with Next.js 16.4.2 and React 19.3.1, with "type": "module" in package.json. The first request took 10.6 seconds, because it created the sandbox and pulled the image; later requests reuse both. Two requirements: runtime = 'nodejs', because the SDK opens a unix socket with node:http, which the Edge runtime does not have; and a server that runs on the same machine as kosa8 (your own server or VM, not a serverless platform).

CI on GitHub Actions (Linux)

untested Not run for this release

This workflow was not run. It is assembled from what the installer checks on Linux (KVM access, cloud-hypervisor, virtiofsd) and from the steps tested on a Mac. It ends in kosa8 doctor before anything else, so a runner that cannot host sandboxes fails with the reason rather than halfway through your tests. Check your runner exposes /dev/kvm.

name: sandbox-tests
on: [push]
jobs:
  test:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - name: Allow this user to use KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm.rules
          sudo udevadm control --reload-rules && sudo udevadm trigger --name-match=kvm
      - name: Install kosa8 and what it needs on Linux
        run: |
          sudo apt-get update && sudo apt-get install -y cloud-hypervisor virtiofsd
          curl -fsSL https://kosa8.com/install.sh | sh
          kosa8 doctor
          kosa8 daemon start
      - run: npm ci
      - run: node test/sandbox.test.mjs     # your tests, using @kosa8/sdk

On Linux, images must be built for linux/amd64 on an x86-64 runner. The first sandbox downloads the guest image, about 1.5 GB unpacked; cache ~/.kosa8/guest between runs to skip it.

MCP from TypeScript frameworks

kosa8 mcp serve speaks MCP over stdio. Each framework below starts it as a child process and turns its 30 tools into that framework's tools. Each snippet was run, except the Claude Agent SDK one. The line kosa8 MCP gateway: AI session … that mcp serve prints to stderr is left out of the outputs below.

Vercel AI SDK MCP client

$ npm install @ai-sdk/mcp
import { createMCPClient } from '@ai-sdk/mcp';
import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio';

// kosa8 mcp serve speaks MCP over stdio; the client starts it as a child process.
const mcp = await createMCPClient({
  transport: new Experimental_StdioMCPTransport({
    command: 'kosa8',
    args: ['mcp', 'serve', '--allow-mount', `${process.env.HOME}/projects:rw`],
    env: { HOME: process.env.HOME, PATH: process.env.PATH },
  }),
});
try {
  const tools = await mcp.tools();       // AI SDK tools, ready for generateText({ tools })
  console.log(Object.keys(tools).length, 'tools:', Object.keys(tools).slice(0, 6).join(', '), '…');

  // Call a few directly, the way a model's tool calls would.
  const opts = { toolCallId: 't', messages: [] };
  const show = (r) => r.content.map((c) => c.text).join('').trim();
  console.log(show(await tools.kosa8_status.execute({}, opts)));
  console.log(show(await tools.sandbox_create.execute({ name: 'mcp-demo', egress: 'deny' }, opts)));
  console.log(show(await tools.sandbox_pull.execute({ sandbox: 'mcp-demo', image: 'alpine:latest' }, opts)));
  console.log(show(await tools.sandbox_run.execute({ sandbox: 'mcp-demo', image: 'alpine:latest', command: ['uname', '-a'] }, opts)));
  console.log(show(await tools.sandbox_delete.execute({ sandbox: 'mcp-demo' }, opts)));
} finally {
  await mcp.close();
}
$ node mcp-aisdk.mjs
30 tools: container_exec, container_list, container_logs, container_run, container_stats, container_stop …
{
  "commit": "ffaa677",
  "go": "go1.26.9",
  "phase": "0-foundations",
  "status": "ok",
  "uptime_seconds": 2296,
  "version": "0.9.10"
}
{
  "boot_ms": 1168,
  "sandbox": {
    "cpus": 2,
    "created": "2026-10-15T20:34:22.598582-04:00",
    "disk_gb": 32,
    "egress": "deny",
    "id": "mcp-demo",
    "ip": "192.168.127.2",
    "mem_mb": 1024,
    "name": "mcp-demo",
    "state": "running"
  }
}
pulled docker.io/library/alpine:latest into mcp-demo
Linux kosa8-guest 6.18.35 #1 SMP Sat Jul 18 15:25:41 UTC 2026 aarch64 Linux
deleted mcp-demo

Tested with @ai-sdk/mcp 2.0.77. Pass tools to generateText and the model can create, use and delete sandboxes itself.

LangChain.js MCP adapters

$ npm install @langchain/mcp-adapters @langchain/core
import { MultiServerMCPClient } from '@langchain/mcp-adapters';

const client = new MultiServerMCPClient({
  mcpServers: {
    kosa8: {
      transport: 'stdio',
      command: 'kosa8',
      args: ['mcp', 'serve'],
      env: { HOME: process.env.HOME, PATH: process.env.PATH },
    },
  },
});
try {
  const tools = await client.getTools();   // LangChain tools, named <server>_<tool>
  console.log(tools.length, 'tools, e.g.', tools.slice(0, 4).map((t) => t.name).join(', '));
  const list = tools.find((t) => t.name === 'kosa8_sandbox_list');
  console.log('sandbox_list ->', await list.invoke({}));
} finally {
  await client.close();
}
$ node mcp-langchain.mjs
30 tools, e.g. kosa8_container_exec, kosa8_container_list, kosa8_container_logs, kosa8_container_run
sandbox_list -> [
  {
    "cpus": 2,
    "created": "2026-10-15T20:01:13.262607-04:00",
    "disk_gb": 32,
    "egress": "deny",
    "id": "runner",
    "ip": "192.168.127.2",
    "mem_mb": 1024,
    "name": "runner",
    "state": "running"
  }
]

Tested with @langchain/mcp-adapters 2.0.1. Tool names are prefixed with the server name.

Mastra

$ npm install @mastra/mcp
import { MCPClient } from '@mastra/mcp';

const mcp = new MCPClient({
  servers: {
    kosa8: { command: 'kosa8', args: ['mcp', 'serve'], env: { HOME: process.env.HOME, PATH: process.env.PATH } },
  },
});
try {
  const tools = await mcp.listTools();     // pass to new Agent({ tools }) in Mastra
  const names = Object.keys(tools);
  console.log(names.length, 'tools, e.g.', names.slice(0, 4).join(', '));
  const status = tools['kosa8_kosa8_status'];
  console.log('kosa8_status ->', JSON.stringify(await status.execute({})).slice(0, 160));
} finally {
  await mcp.disconnect();
}
$ node mcp-mastra.mjs
30 tools, e.g. kosa8_container_exec, kosa8_container_list, kosa8_container_logs, kosa8_container_run
kosa8_status -> {"content":[{"type":"text","text":"{\n  \"commit\": \"ffaa677\",\n  \"go\": \"go1.26.9\",\n  \"phase\": \"0-foundations\",\n  \"status\": \"ok\",\n  \"uptime_se

Tested with @mastra/mcp 2.4.1. Keys are <server>_<tool>, so kosa8's own kosa8_status becomes kosa8_kosa8_status.

Claude Agent SDK

import { query } from '@anthropic-ai/claude-agent-sdk';

// Claude Agent SDK: give the agent kosa8's tools over MCP. (Typechecked against the SDK's types; not run.)
for await (const message of query({
  prompt: 'Create a sandbox with no network, run `uname -a` in alpine inside it, then delete it.',
  options: {
    mcpServers: {
      kosa8: { type: 'stdio', command: 'kosa8', args: ['mcp', 'serve'] },
    },
    allowedTools: ['mcp__kosa8__sandbox_create', 'mcp__kosa8__sandbox_pull', 'mcp__kosa8__sandbox_run', 'mcp__kosa8__sandbox_delete'],
  },
})) {
  if (message.type === 'result') console.log(message);
}

typechecked not run This compiles against @anthropic-ai/claude-agent-sdk 0.3.301 with strict TypeScript. It was not run, because a run needs a Claude API key. MCP tools appear to Claude as mcp__<server>__<tool>.

dockerode and Testcontainers via DOCKER_HOST

Existing Docker code runs unchanged. Containers run in kosa8's shared engine VM. Bind mounts work below folders you allow when starting the daemon:

$ kosa8 daemon start --allow-mount ~/projects:rw
docker bind mounts allowed under /private/tmp/…/home/projects:rw
$ export DOCKER_HOST=unix://$HOME/.kosa8/run/kosa8.sock

dockerode

// DOCKER_HOST=unix://$HOME/.kosa8/run/kosa8.sock node dockerode.mjs
import Docker from 'dockerode';

const docker = new Docker();   // reads DOCKER_HOST
const v = await docker.version();
console.log('engine', v.Version, 'api', v.ApiVersion, '|', v.Components.map((c) => `${c.Name} ${c.Version}`).join(', '));

await new Promise((resolve, reject) =>
  docker.pull('alpine:3.20', (err, stream) =>
    err ? reject(err) : docker.modem.followProgress(stream, (e) => (e ? reject(e) : resolve()))));

// A bind mount below a folder the daemon was started with: --allow-mount /tmp/k8d/projects:rw
const c = await docker.createContainer({
  Image: 'alpine:3.20',
  Cmd: ['sh', '-c', 'cat /site/index.html; echo "built" > /site/out.txt'],
  HostConfig: { Binds: [`${process.env.HOME}/projects/site:/site:rw`] },
});
await c.start();
console.log('exit', (await c.wait()).StatusCode);
console.log((await c.logs({ stdout: true })).toString('utf8').replace(/[\x00-\x08]/g, '').trim());
await c.remove({ force: true });

// A bind outside the allowed folders is refused before anything runs.
try {
  await docker.createContainer({ Image: 'alpine:3.20', HostConfig: { Binds: [`${process.env.HOME}/app:/app`] } });
} catch (err) {
  console.log('refused:', err.statusCode, err.json?.message ?? err.message);
}
$ node dockerode.mjs
engine 28.3.0 api 1.51 | Engine 28.3.0, kosa8d 0.9.10
exit 0
<h1>fixture</h1>
refused: 400 bind mount of /tmp/k8d/app refused: it is outside the directories allowed for Docker bind mounts (/private/tmp/…/home/projects:rw); allow it with `kosa8 daemon stop && kosa8 daemon start --allow-mount DIR` (DIR:rw for write access; repeat for more directories)
$ cat ~/projects/site/out.txt
built

Tested with dockerode 5.0.1. kosa8 reports Docker Engine 28.3.0, API 1.51, and itself as the kosa8d component. (The /private/tmp/… in the output is the test's home folder after symlinks are resolved, shortened here.)

testcontainers-node

// DOCKER_HOST=unix://$HOME/.kosa8/run/kosa8.sock TESTCONTAINERS_RYUK_DISABLED=true node tc.mjs
import { GenericContainer, Wait } from 'testcontainers';
import { createClient } from 'redis';

const container = await new GenericContainer('redis:7-alpine')
  .withExposedPorts(6379)
  .withWaitStrategy(Wait.forLogMessage('Ready to accept connections'))
  .start();
try {
  const url = `redis://${container.getHost()}:${container.getMappedPort(6379)}`;
  const client = createClient({ url });
  await client.connect();
  await client.set('k', 'v1');
  console.log('get:', await client.get('k'), 'via', url);
  await client.quit();
  const ex = await container.exec(['redis-cli', 'get', 'k']);
  console.log('exec:', ex.exitCode, ex.output.trim());
} finally {
  await container.stop();
}
$ DOCKER_HOST=unix://$HOME/.kosa8/run/kosa8.sock TESTCONTAINERS_RYUK_DISABLED=true node tc.mjs
get: v1 via redis://localhost:59521
exec: 0 v1

Tested with testcontainers 12.2.1 and redis 6.3.0. Keep TESTCONTAINERS_RYUK_DISABLED=true: Ryuk mounts the Docker socket, which kosa8 never shares into a container. Docker API compatibility in v0.9.10 also covers docker cp, build, commit and exec -i/-w/-e/-u; the full list, and Python and Java, are on kosa8.com/docs/integrations.

These containers are isolated from your Mac by the engine VM, but share that VM with each other. For a container that should get a microVM of its own, use @kosa8/sdk instead.