userland.rundocs70Try the demo
SDK

Agent tools & WebMCP

@userland-run/nano-sdk/agent turns a VM into capability-scoped tools an LLM agent can call: files, shell, Node.js, catalog installs and sandboxed scripts. It is model-agnostic and can register the tools with the browser's built-in agent over WebMCP.

The tools ship as a separate subpath (about 11 KB, no VM runtime inside), new in SDK 0.2.10, so apps that don't use them pay nothing. They work over a main-thread Nano or a worker client. In the samples, importAgent() stands in for import("@userland-run/nano-sdk/agent").

javascript
import { createNano } from "@userland-run/nano-sdk";
import { createVmTools, exposeWebMcp, isWebMcpAvailable, truncateOutput } from "@userland-run/nano-sdk/agent";

const tools = createVmTools(nano, {
  fs: "readwrite",   // "none" | "readonly" (default) | "readwrite"
  exec: true,        // run_shell
  approve: async (call) => confirm(`${call.tool}: ${call.summary}`),
});

createVmTools options

OptionTypeDefaultDescription
fs"none" | "readonly" | "readwrite""readonly"readonly: list_dir, read_file, file_exists. readwrite adds write_file, make_dir, move_path, delete_path.
execbooleanfalserun_shell: an sh line via shExec, starting in /.
nodebooleanfalserun_node: a .js file with optional args and cwd; exit 134 counts as success.
installbooleanfalseinstall_app: a signed catalog ref (when the host has installApp).
scriptbooleanfalserun_script: JavaScript in the Boa sandbox with nano.fs scoped to the fs grant, 10 s timeout (when the host has script).
approve(req) => boolean | Promise<boolean>noneCalled before every non-read tool with { tool, kind, args, summary }; false returns "rejected by user" to the agent. Without it, granted tools run.
prefixstring""Prefix for every tool name, to expose several VMs.
maxOutputCharsnumber32000Cap on output text; the middle is elided (truncateOutput).
maxStepsnumber2,000,000Instruction budget for run_shell.

list_ports (read) is always added when the host has listeners(), i.e. a main-thread Nano. Each tool is { name, description, inputSchema, kind, readOnly, execute(args) }; kind is read, edit or exec. execute resolves to { ok, output, data? } and never throws: guest errors and "VM is busy serving" come back as text the agent can read.

SDK · JavaScript

The default, read-only tool set

With no options the agent can only look. There is no write_file to call.

javascript
const { createVmTools } = await importAgent();
const tools = createVmTools(nano);
print(tools.map((t) => `${t.name}:${t.kind}`).join(" "));
nano.fs.writeFile("/work/notes.txt", "hello agent\n");
const call = (name, args) => tools.find((t) => t.name === name).execute(args);
print(JSON.stringify(await call("read_file", { path: "/work/notes.txt" })));
print(JSON.stringify(await call("read_file", { path: "/work/missing.txt" })));
print(JSON.stringify((await call("list_dir", { path: "/work" })).output));
print("write_file available:", tools.some((t) => t.name === "write_file"));
loading…
Expected output
list_dir:read read_file:read file_exists:read list_ports:read
{"ok":true,"output":"hello agent\n"}
{"ok":false,"output":"read_file: not found: /work/missing.txt"}
".keep  (0b)\nnotes.txt  (12b)"
write_file available: false
SDK · JavaScript

Grants, approval, prefix and output caps

Everything granted, with an approve hook that refuses deletes. Non-zero exits come back as ok: false with an [exit N] tail; long output is elided in the middle.

javascript
const { createVmTools } = await importAgent();
const asked = [];
const tools = createVmTools(nano, {
  fs: "readwrite", exec: true, node: true, prefix: "vm1_", maxOutputChars: 120,
  approve: ({ tool, summary }) => (asked.push(`${tool} ${summary}`), tool !== "vm1_delete_path"),
});
print(tools.map((t) => t.name).join(" "));
const call = (name, args) => tools.find((t) => t.name === "vm1_" + name).execute(args);
print(JSON.stringify(await call("write_file", { path: "/work/a.js", content: "console.log('hi from', process.version)" })));
print(JSON.stringify(await call("run_node", { file: "/work/a.js" })));
print(JSON.stringify(await call("run_shell", { command: "cd /work && ls a.js && false" })));
print(JSON.stringify(await call("delete_path", { path: "/work/a.js" })));
print(JSON.stringify((await call("run_shell", { command: "for i in 1 2 3 4 5 6 7 8 9; do echo line-$i-0123456789; done" })).output));
print("approve saw:", asked.length, "calls; read tools never ask");
loading…
Expected output
vm1_list_dir vm1_read_file vm1_file_exists vm1_write_file vm1_make_dir vm1_move_path vm1_delete_path vm1_run_shell vm1_run_node vm1_list_ports
{"ok":true,"output":"wrote /work/a.js (39 chars)"}
{"ok":true,"output":"hi from v25.4.0\n"}
{"ok":false,"output":"a.js\n\n[exit 1]"}
{"ok":false,"output":"vm1_delete_path: rejected by user"}
SDK · JavaScript

run_script and install_app

A VM booted with scripting gets run_script; install_app installs from the signed catalog.

javascript
const { createVmTools } = await importAgent();
const vm = await sdk.createNano({ image: { wasm: "/nano/console.wasm" }, ramMB: 256, scripting: { wasm: "/nano/boa.wasm" } });
const tools = createVmTools(vm, { script: true, install: true });
const call = (name, args) => tools.find((t) => t.name === name).execute(args);
print(tools.map((t) => t.name).join(" "));
print(JSON.stringify(await call("run_script", { source: "console.log('in boa'); nano.fs.list('/').length > 3" })));
const r = await call("install_app", { ref: "hexyl" });
print(r.ok, r.output);
vm.destroy();
loading…
Expected output
list_dir read_file file_exists install_app run_script list_ports
{"ok":true,"output":"in boa\ntrue","data":true}
true installed hexyl@0.14.0
SDK · JavaScript

Tools over a worker client

The same registry over a VM in a Web Worker; there is no list_ports because the worker client has no listeners().

javascript
const { createVmTools } = await importAgent();
const client = await sdk.createNanoWorker({ image: { wasm: "/nano/console.wasm" }, ramMB: 256 });
const tools = createVmTools(client, { exec: true });
print(tools.map((t) => t.name).join(" "));
print(JSON.stringify(await tools.find((t) => t.name === "run_shell").execute({ command: "uname -m" })));
client.destroy();
loading…
Expected output
list_dir read_file file_exists run_shell
{"ok":true,"output":"riscv64\n"}

Use with any model

Map each tool to your model's tool format and call execute when the model asks for it. With the Claude Messages API:

javascript
const defs = tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.inputSchema }));
// … after a response with stop_reason "tool_use":
const tool = tools.find((t) => t.name === block.name);
const result = await tool.execute(block.input);
messages.push({ role: "user", content: [
  { type: "tool_result", tool_use_id: block.id, content: result.output, is_error: !result.ok },
] });

The same objects plug into an MCP server, the Vercel AI SDK or your own loop.

WebMCP

exposeWebMcp(tools, opts?) registers every tool with document.modelContext.registerTool (navigator.modelContext, the older spelling, also works) and returns an unregister function. Read tools get readOnlyHint; all tools get untrustedContentHint, because their output is whatever the sandboxed program printed. WebMCP is behind a flag or origin trial in Chrome; where it is missing exposeWebMcp does nothing and isWebMcpAvailable() returns false.

OptionTypeDefaultDescription
approveAgentApprove—Gate non-read calls coming from the browser agent (for tools built without their own approve).
exposedTostring[]—Origins allowed to see the tools, passed through to registerTool.
SDK · JavaScript

What exposeWebMcp registers

This browser has no WebMCP, so the sample installs a stand-in document.modelContext that records registrations, then calls a registered tool the way a browser agent would.

javascript
const { createVmTools, exposeWebMcp, isWebMcpAvailable } = await importAgent();
print("WebMCP available:", isWebMcpAvailable());
const registered = [];
document.modelContext = { registerTool: (tool, o) => registered.push({ tool, o }) };
const unregister = exposeWebMcp(createVmTools(nano, { exec: true }), { approve: ({ tool }) => tool !== "run_shell" });
for (const { tool } of registered) print(tool.name, JSON.stringify(tool.annotations));
const shell = registered.find((r) => r.tool.name === "run_shell").tool;
print(await shell.execute({ command: "echo hi" }));
unregister();
print("signal aborted after unregister:", registered[0].o.signal.aborted);
delete document.modelContext;
loading…
Expected output
WebMCP available: false
list_dir {"readOnlyHint":true,"untrustedContentHint":true}
read_file {"readOnlyHint":true,"untrustedContentHint":true}
file_exists {"readOnlyHint":true,"untrustedContentHint":true}
run_shell {"readOnlyHint":false,"untrustedContentHint":true}
list_ports {"readOnlyHint":true,"untrustedContentHint":true}
run_shell: rejected by user
signal aborted after unregister: true

truncateOutput

SDK · JavaScript

Keep the head and the tail

javascript
const { truncateOutput } = await importAgent();
print(truncateOutput("a".repeat(50) + "b".repeat(50), 60));
loading…
Expected output
aaaaaaaaaa
…[truncated 80 chars]…
bbbbbbbbbb

Safety

  • Start read-only and grant only what the task needs. The VM is a sandbox, but an agent with exec can change anything inside it.
  • Pass approve for any agent you don't fully control.
  • Treat tool output as untrusted: programs in the VM, or files the agent reads, can carry prompt-injection text.
  • Give the agent its own VM instance rather than the one your app depends on.

Behavior

  • VM operations run one at a time; while a startServer() launch holds the VM, exec tools return the "VM is busy serving" error as text.
  • There is no serve tool: start servers from your code and let the agent use list_ports.
  • run_shell starts in /; the agent should use cd dir && ….
  • delete_path refuses / and /*.