Guides / JavaScript & TypeScript / Vercel AI SDK adapter
Vercel AI SDK 7 adapter
@kosa8/ai-sdk-sandbox gives an AI SDK agent a sealed machine to work
in. Its tools read and write files and run commands through the sandbox they are handed, and the
machine has its own kernel and a network closed to everything you did not allow. It implements the
AI SDK's own sandbox interfaces, the same shape as @ai-sdk/sandbox-vercel and
@e2b/ai-sdk-sandbox, and runs on your machine with no account.
$ npm install @kosa8/ai-sdk-sandbox ai
Node 22 or later, and kosa8 running; see Install. Tested with
[email protected].
Concepts
session.sandboxName is its name, so you can also
drive it with @kosa8/sdk or the CLI.image (default alpine:latest) runs every command and holds every file.
The image is pulled through the host, so this works under deny-all. Its working
directory is /workspace unless you say otherwise; relative paths resolve against
it.description,
the six read and write methods, run and spawn. Hand this to tools and to
the model's side of your code: it cannot reach the client, stop the sandbox or change its
network.Create a session, and use it
import { createKosa8NetworkSandboxSession } from '@kosa8/ai-sdk-sandbox';
const session = await createKosa8NetworkSandboxSession({
image: 'alpine:latest', // the work container's image (the default)
networkPolicy: { mode: 'deny-all' },
env: { APP_ENV: 'test' },
});
console.log('sandbox', session.sandboxName, 'container', session.containerId, 'cwd', session.defaultWorkingDirectory);
console.log(session.description);
// The tool-safe view: files, run and spawn. No stop, destroy or network controls.
const sandbox = session.restricted();
console.log('restricted keys:', Object.keys(sandbox), 'frozen:', Object.isFrozen(sandbox));
await sandbox.writeTextFile({ path: 'notes/todo.txt', content: 'one\ntwo\nthree\n' });
console.log(await sandbox.readTextFile({ path: 'notes/todo.txt', startLine: 2, endLine: 3 }));
console.log('missing ->', await sandbox.readTextFile({ path: 'nope.txt' }));
console.log(await sandbox.run({ command: 'pwd; echo $APP_ENV $EXTRA; ls notes', env: { EXTRA: 'per-call' } }));
console.log(await sandbox.run({ command: 'pwd', workingDirectory: '/tmp' }));
console.log(await sandbox.run({ command: 'echo to-stderr >&2; exit 2' }));
await session.destroy();
console.log('destroyed');
process.exit(0); // see Known issues: 0.1.0 can leave a socket open after run()
$ node ad1.mjs
sandbox sbx6 container k1-ad24a82f cwd /workspace
kosa8 sandbox "sbx6": a microVM with its own kernel. Commands run in a alpine:latest container inside it (container k1-ad24a82f).
Working directory: /workspace. Relative paths resolve against it.
Network: no network access (every outbound connection is refused).
Filesystem changes persist for the lifetime of the sandbox. No ports are exposed to the host.
restricted keys: [
'description',
'readFile',
'readBinaryFile',
'readTextFile',
'writeFile',
'writeBinaryFile',
'writeTextFile',
'spawn',
'run'
] frozen: true
two
three
missing -> null
{
exitCode: 0,
stdout: '/workspace\ntest per-call\ntodo.txt\n',
stderr: ''
}
{ exitCode: 0, stdout: '/tmp\n', stderr: '' }
{ exitCode: 2, stdout: '', stderr: 'to-stderr\n' }
destroyed
description is written for the model: put it in the prompt so the model knows what
machine it has. run applies the session's env and working directory to each
command; per-call env and workingDirectory win. A non-zero exit is in the
result, not thrown. File methods are covered on Files.
Settings
| Setting | Default | Meaning |
|---|---|---|
client | new Kosa8Client() | A Kosa8Client, or its options (socket, host, actor, agent token…). |
image | alpine:latest | The work container's image. It needs sh and base64. |
workingDirectory | /workspace | Created if missing. |
env | Environment for every command. | |
networkPolicy | daemon default | The AI SDK's policy. Mapped to kosa8 egress below. |
egress | kosa8's own egress instead ('deny', 'allow:…', { allow }…). Not with networkPolicy. | |
cpus, memMb, diskGb | daemon defaults | Machine size. |
mounts | Host folders, { host_path, guest_path, read_only }; they appear in the work container. | |
labels, broker | As for createSandbox. With broker, read the placeholders through @kosa8/sdk; the session does not return them. | |
setupCommands | Shell commands run once on a fresh sandbox. A failure deletes the sandbox and throws. | |
approval | A signed-approval id sent with every call (Approvals). | |
sandboxId | daemon picks | The sandbox's name. Creating never resumes: a taken name is an error. |
template | { identity, prepare }. Below. | |
abortSignal | Cancels creation. |
Network policy
import { createKosa8NetworkSandboxSession, toKosa8Egress } from '@kosa8/ai-sdk-sandbox';
// How AI SDK network policies map onto kosa8 egress.
for (const p of [
{ mode: 'allow-all' },
{ mode: 'deny-all' },
{ mode: 'custom', allowedHosts: ['registry.npmjs.org', '*.githubusercontent.com'], allowedCIDRs: ['10.20.0.0/16'] },
{ mode: 'custom', allowedHosts: ['example.com'], deniedCIDRs: ['10.0.0.0/8'] },
]) {
try { console.log(JSON.stringify(p), '->', toKosa8Egress(p)); }
catch (e) { console.log(JSON.stringify(p), '->', e.name + ':', e.message); }
}
const session = await createKosa8NetworkSandboxSession({
networkPolicy: { mode: 'custom', allowedHosts: ['example.com'] },
});
try {
await session.setNetworkPolicy({ mode: 'custom', allowedHosts: ['example.com'] });
console.log('same policy again: ok (no-op)');
for (const [what, fn] of [
['setNetworkPolicy(allow-all)', () => session.setNetworkPolicy({ mode: 'allow-all' })],
['getPortEndpoint(3000)', () => session.getPortEndpoint({ port: 3000 })],
['setPorts([3000])', () => session.setPorts([3000])],
]) {
try { await fn(); } catch (e) { console.log(`${what} -> ${e.name}: ${e.message}`); }
}
console.log('ports:', session.ports);
} finally {
await session.destroy();
}
$ node ad3.mjs
{"mode":"allow-all"} -> any
{"mode":"deny-all"} -> deny
{"mode":"custom","allowedHosts":["registry.npmjs.org","*.githubusercontent.com"],"allowedCIDRs":["10.20.0.0/16"]} -> allow:registry.npmjs.org,*.githubusercontent.com,10.20.0.0/16
{"mode":"custom","allowedHosts":["example.com"],"deniedCIDRs":["10.0.0.0/8"]} -> AI_HarnessCapabilityUnsupportedError: kosa8 egress is an allow-list with no deny-list: every host or network not in allowedHosts/allowedCIDRs is already refused on the host. Remove deniedCIDRs (and leave those networks out of allowedCIDRs).
same policy again: ok (no-op)
setNetworkPolicy(allow-all) -> AI_HarnessCapabilityUnsupportedError: kosa8 fixes a sandbox's egress policy when the sandbox is created, and cannot change it while it runs (sandbox "sbx6" has "allow:example.com"; asked for "any"). Create the session with this networkPolicy instead.
getPortEndpoint(3000) -> AI_HarnessCapabilityUnsupportedError: kosa8 does not expose sandbox ports to the host through its API, so there is no endpoint to connect to. Use a harness adapter that does not need a bridge port, or talk to the process through run/spawn.
setPorts([3000]) -> AI_HarnessCapabilityUnsupportedError: kosa8 cannot expose sandbox ports (3000) to the host through its API.
ports: []
| AI SDK policy | kosa8 egress |
|---|---|
{ mode: 'allow-all' } | any |
{ mode: 'deny-all' } | deny: every outbound connection refused |
{ mode: 'custom', allowedHosts, allowedCIDRs } | allow: those hosts and CIDRs, *.domain wildcards included; everything else refused on the host |
deniedCIDRs | refused: kosa8 has an allow-list, not a deny-list |
| neither set | the org policy's default_egress, else any |
An unsupported request throws the AI SDK's HarnessCapabilityUnsupportedError, never a
silent no-op, so a harness that can fall back will.
spawn, kill and abort
import { createKosa8NetworkSandboxSession } from '@kosa8/ai-sdk-sandbox';
const session = await createKosa8NetworkSandboxSession({ networkPolicy: { mode: 'deny-all' } });
const sandbox = session.restricted();
try {
// spawn: returns at once, with output as web streams.
const t0 = Date.now();
const proc = await sandbox.spawn({ command: 'for i in 1 2 3; do echo line $i; sleep 1; done' });
const dec = new TextDecoder();
for await (const chunk of proc.stdout) process.stdout.write(`[${Date.now() - t0}ms] ${dec.decode(chunk)}`);
console.log('exit', (await proc.wait()).exitCode);
// kill(): TERM to the command and everything it started, then KILL after 2s.
const long = await sandbox.spawn({ command: 'sleep 300 & sleep 300; echo never' });
setTimeout(() => long.kill(), 1000);
console.log('killed ->', await long.wait());
// abortSignal does the same, and wait() rejects with the signal's reason.
try {
await sandbox.run({ command: 'sleep 300', abortSignal: AbortSignal.timeout(1500) });
} catch (err) {
console.log('aborted ->', err.name);
}
console.log('left running:', (await sandbox.run({ command: 'ps -o pid,args | grep -c "[s]leep 300"' })).stdout.trim());
} finally {
await session.destroy();
}
process.exit(0); // see Known issues: 0.1.0 leaves a socket open after kill() or an abort
$ node ad2.mjs
[5ms] line 1
[1011ms] line 2
[2013ms] line 3
exit 0
killed -> { exitCode: 143 }
aborted -> TimeoutError
left running: 0
spawn returns at once with stdout and stderr as web
streams, wait() and kill(). Unlike an SDK-level abort, the adapter's
kill() and abortSignal end the command inside the sandbox and everything
it started: TERM, then KILL after two seconds. The last line confirms no sleep 300 was
left behind. There is no stdin and no pid.
Stop, resume, destroy
import {
createKosa8NetworkSandboxSession,
resumeKosa8NetworkSandboxSession,
} from '@kosa8/ai-sdk-sandbox';
// Session 1: do some work, then stop.
const s1 = await createKosa8NetworkSandboxSession({ sandboxId: 'agent-42', networkPolicy: { mode: 'deny-all' } });
await s1.restricted().writeTextFile({ path: 'progress.md', content: '- [x] step 1\n' });
// PID 1 of the work container is its long-lived main process; ps shows how long it has run.
const pid1 = 'ps -o pid,etime,args | sed -n 2p';
await new Promise((r) => setTimeout(r, 2000));
console.log('before stop: ', (await s1.run({ command: pid1 })).stdout.trim());
await s1.stop(); // snapshot of the running machine, then the sandbox stops
console.log('stopped; sandbox state:', (await s1.restricted().run({ command: 'true' }).catch((e) => e.message)));
// Later, maybe from another process: resume by name. Files and processes come back.
const s2 = await resumeKosa8NetworkSandboxSession({ sandboxId: 'agent-42' });
console.log('resumed', s2.sandboxName, 'same container:', s2.containerId === s1.containerId);
console.log(await s2.restricted().readTextFile({ path: 'progress.md' }));
console.log('after resume:', (await s2.run({ command: pid1 })).stdout.trim());
await s2.destroy();
console.log('destroyed');
process.exit(0); // see "Known issue" below: 0.1.0 can leave a socket open after run()
$ node ad4.mjs
before stop: 1 0:03 sh -c touch /.kosa8-ai-sdk 2>/dev/null; mkdir -p '/workspace' 2>/dev/null; while :; do sleep 3600; done
stopped; sandbox state: sandbox is not running: agent-42 (start it with `kosa8 sandbox up agent-42`)
resumed agent-42 same container: true
- [x] step 1
after resume: 1 0:03 sh -c touch /.kosa8-ai-sdk 2>/dev/null; mkdir -p '/workspace' 2>/dev/null; while :; do sleep 3600; done
destroyed
stop()snapshots the running machine as<name>-aisdk-resume, then stops it.resumeKosa8NetworkSandboxSession({ sandboxId })restores that snapshot under the same name and deletes it. The work container is the same container, its PID 1 never restarted: it reads the same elapsed time because it was paused, not stopped. If the sandbox was stopped some other way, resume starts it from its disk and a new work container. If it is running, resume reattaches. It never creates a sandbox: a name that does not exist rejects withKosa8NotFoundError.destroy()deletes the sandbox and any resume snapshot.- Background processes do not survive the command that started them. In testing, a
process started with
&(orsetsid) insiderunwas gone once that command returned. Long-lived processes belong in the image's entry point, or in a sandbox you drive with@kosa8/sdk'sstartContainer.
Templates: set up once
import { createKosa8NetworkSandboxSession, templateSnapshotId } from '@kosa8/ai-sdk-sandbox';
import { Kosa8Client } from '@kosa8/sdk';
const template = {
identity: 'py-tools-v1', // change it when prepare() changes
prepare: async ({ session }) => {
// Expensive setup, done once. Here: a fake 3-second install that leaves a file behind.
await session.run({ command: 'sleep 3; mkdir -p /opt/tools && echo "tools v1" > /opt/tools/VERSION' });
},
};
for (const n of [1, 2]) {
const t0 = Date.now();
const s = await createKosa8NetworkSandboxSession({ networkPolicy: { mode: 'deny-all' }, template });
const v = (await s.run({ command: 'cat /opt/tools/VERSION' })).stdout.trim();
console.log(`session ${n}: ${s.sandboxName} ready in ${Date.now() - t0}ms, ${v}`);
await s.destroy();
}
// The template lives on as a kosa8 snapshot until you delete it.
const id = templateSnapshotId('py-tools-v1', 'alpine:latest', 'deny', '/workspace');
const kosa8 = new Kosa8Client();
console.log('template snapshot:', (await kosa8.listSnapshots()).map((s) => s.id), 'expected', id);
console.log(await kosa8.deleteSnapshot(id));
process.exit(0);
$ node ad5.mjs
session 1: sbx1 ready in 7462ms, tools v1
session 2: aisdk-fb74ec19d4 ready in 475ms, tools v1
template snapshot: [ 'aisdk-tpl-8b0b0400732fb898644fb54b' ] expected aisdk-tpl-8b0b0400732fb898644fb54b
{
encrypted: false,
ok: true,
removed: 'aisdk-tpl-8b0b0400732fb898644fb54b'
}
The first session ran prepare and saved the result; the second restored it in under
half a second. The snapshot id is a hash of the identity, image, egress and working directory
(templateSnapshotId), so changing any of them gives a new template. Change
identity when prepare changes. The snapshot stays until you delete it.
A full agent with generateText
import { generateText, stepCountIs, tool } from 'ai';
import { MockLanguageModelV4 } from 'ai/test';
import { z } from 'zod';
import { createKosa8NetworkSandboxSession } from '@kosa8/ai-sdk-sandbox';
// Tools act only through the sandbox the AI SDK hands them (experimental_sandbox).
const tools = {
writeFile: tool({
description: 'Write a text file in the sandbox.',
inputSchema: z.object({ path: z.string(), content: z.string() }),
execute: async ({ path, content }, { experimental_sandbox: sandbox, abortSignal }) => {
await sandbox.writeTextFile({ path, content, abortSignal });
return { written: path };
},
}),
shell: tool({
description: 'Run a shell command in the sandbox.',
inputSchema: z.object({ command: z.string() }),
execute: ({ command }, { experimental_sandbox: sandbox, abortSignal }) =>
sandbox.run({ command, abortSignal }),
}),
};
// A scripted model so this runs without an API key. Swap in any AI SDK model:
// import { anthropic } from '@ai-sdk/anthropic'; const model = anthropic('claude-sonnet-4-5');
const usage = {
inputTokens: { total: 1, noCache: 1, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 1, text: 1, reasoning: undefined },
};
const call = (id, toolName, input) => ({
content: [{ type: 'tool-call', toolCallId: id, toolName, input: JSON.stringify(input) }],
finishReason: { unified: 'tool-calls', raw: undefined }, usage, warnings: [],
});
const model = new MockLanguageModelV4({
doGenerate: [
call('c1', 'writeFile', { path: 'fib.sh', content: 'a=0; b=1; for i in $(seq 10); do echo $a; t=$((a+b)); a=$b; b=$t; done\n' }),
call('c2', 'shell', { command: 'sh fib.sh | tr "\\n" " "; echo; wget -q -T 3 -O- https://example.com >/dev/null || echo "network: refused"' }),
{ content: [{ type: 'text', text: 'Wrote fib.sh and ran it; the network is closed.' }],
finishReason: { unified: 'stop', raw: undefined }, usage, warnings: [] },
],
});
const session = await createKosa8NetworkSandboxSession({ networkPolicy: { mode: 'deny-all' } });
try {
const sandbox = session.restricted();
const result = await generateText({
model,
tools,
stopWhen: stepCountIs(5),
prompt: `Write a script that prints the first ten Fibonacci numbers and run it.\n\nSandbox:\n${sandbox.description}`,
experimental_sandbox: sandbox,
});
for (const step of result.steps) {
for (const r of step.toolResults) console.log(`${r.toolName} ->`, JSON.stringify(r.output));
}
console.log(result.text);
} finally {
await session.destroy();
}
// 0.1.0 can leave the exec socket open after run(); see Known issues. Exit explicitly.
process.exit(0);
$ node agent.mjs
writeFile -> {"written":"fib.sh"}
shell -> {"exitCode":0,"stdout":"0 1 1 2 3 5 8 13 21 34 \nnetwork: refused\n","stderr":"wget: bad address 'example.com'\n"}
Wrote fib.sh and ran it; the network is closed.
The model here is the AI SDK's MockLanguageModelV4, scripted to make the tool calls a
real model would, so the example runs without an API key. Everything else is real: the tool calls
ran in a kosa8 microVM, and the network refusal came from the host. To use a real model, replace
model with any AI SDK provider's model. untested No
hosted model was used for this page.
The same tools work anywhere the AI SDK accepts tools, such as
streamText.
HarnessAgent
The adapter also implements the harness interfaces, HarnessV1NetworkSandboxSession
and HarnessV1SandboxProvider:
// a session
const session = await createKosa8NetworkSandboxSession({ image: 'node:22-alpine' });
const agentSession = await agent.createSession({ sandboxSession: session });
// or the older provider shape
new HarnessAgent({ sandbox: createKosa8Sandbox({ networkPolicy: { mode: 'deny-all' } }) });
untested HarnessAgent itself was not run for this page.
The provider object it would call was:
import { createKosa8Sandbox, sandboxNameForSession } from '@kosa8/ai-sdk-sandbox';
// The provider shape (HarnessAgent({ sandbox })): a sessionId maps to a fixed sandbox name.
const provider = createKosa8Sandbox({ networkPolicy: { mode: 'deny-all' } });
console.log(provider.providerId, provider.specificationVersion, '| name for "chat-7":', sandboxNameForSession('chat-7'));
const a = await provider.createSession({ sessionId: 'chat-7' });
await a.restricted().writeTextFile({ path: 'memory.txt', content: 'user prefers tabs' });
await a.stop();
const b = await provider.resumeSession({ sessionId: 'chat-7' });
console.log(b.id, '->', await b.restricted().readTextFile({ path: 'memory.txt' }));
await b.destroy();
process.exit(0);
$ node ad6.mjs
kosa8-sandbox harness-sandbox-v1 | name for "chat-7": aisdk-chat-7-b4362fbc75
aisdk-chat-7-b4362fbc75 -> user prefers tabs
A sessionId maps to a fixed sandbox name (sandboxNameForSession), so
resumeSession finds the same machine. identity plus
onFirstCreate in createSession work like a template.
Wrapping a sandbox you manage
import { Kosa8Client } from '@kosa8/sdk';
import {
createKosa8NetworkSandboxSessionFromSandbox,
createKosa8SandboxSessionFromContainer,
} from '@kosa8/ai-sdk-sandbox';
// A sandbox your own code manages, with whatever options the adapter does not expose.
const kosa8 = new Kosa8Client();
await kosa8.createSandbox({ name: 'mine', egress: 'deny', memMb: 512, labels: { owner: 'ci' } });
// Wrap it. With no container given, the adapter starts its work container in it.
const session = await createKosa8NetworkSandboxSessionFromSandbox({ client: kosa8, sandbox: 'mine' });
console.log((await session.run({ command: 'echo wrapped; pwd' })).stdout.trim());
await session.destroy(); // does nothing: the caller owns this sandbox
console.log('still there:', (await kosa8.getSandbox('mine'))?.state);
// Only the tool-safe view, over a container you started yourself.
const cid = await kosa8.startContainer('mine', 'alpine:latest', ['sh', '-c', 'mkdir -p /srv; while :; do sleep 3600; done']);
const tools = createKosa8SandboxSessionFromContainer({ client: kosa8, sandbox: 'mine', container: cid, workingDirectory: '/srv' });
await tools.writeTextFile({ path: 'x.txt', content: 'ok' });
console.log((await tools.run({ command: 'ls /srv; cat x.txt' })).stdout.trim().replace(/\n/g, ' | '));
console.log('has stop?', 'stop' in tools, '| keys:', Object.keys(tools).length);
await kosa8.deleteSandbox('mine');
process.exit(0);
$ node ad7.mjs
wrapped
/workspace
still there: running
x.txt | ok
has stop? false | keys: 9
createKosa8NetworkSandboxSessionFromSandbox wraps a sandbox you created, and its
stop() and destroy() do nothing. createKosa8SandboxSessionFromContainer
returns only the restricted view, over a container you started.
Limits
What the adapter cannot do
setNetworkPolicy()on a running session: only a no-op for the policy already in force. Anything else throws.deniedCIDRs: throws. List what is allowed instead.- Ports:
getPortEndpointandsetPorts([…])throw;portsis[]. - Request transformations (
setRequestTransformations,addRequestTransformations): not provided. Use thebrokersetting to keep credentials out, configured at creation. spawn: no stdin, nopid.- Files move through exec, so the image needs
shandbase64. - Checkpoints and fork are not part of the AI SDK interface. Use
@kosa8/sdkonsession.sandboxName.
After run, spawn, kill() or an abort, version 0.1.0 can
leave the exec request's socket open, so Node keeps running after your last line. In our tests the
agent example and the spawn example hung after printing everything, and the first example on
this page exited on its first run but hung on the next two. That is why several examples here end
with process.exit(0). The cause
is that the adapter stops reading the exec stream at its final frame without closing it; one
added await iter.return() in spawn fixed it in a local test. Until a
fixed release, end scripts with process.exit(). A long-running server is not blocked
by it, but would hold one open socket per affected command.
Fixed in @kosa8/ai-sdk-sandbox 0.1.1, which closes the stream after its final frame.
A coding agent editing a repository through a
mounted folder, with git diff to review its work.