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").
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
| Option | Type | Default | Description |
|---|---|---|---|
fs | "none" | "readonly" | "readwrite" | "readonly" | readonly: list_dir, read_file, file_exists. readwrite adds write_file, make_dir, move_path, delete_path. |
exec | boolean | false | run_shell: an sh line via shExec, starting in /. |
node | boolean | false | run_node: a .js file with optional args and cwd; exit 134 counts as success. |
install | boolean | false | install_app: a signed catalog ref (when the host has installApp). |
script | boolean | false | run_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> | none | Called before every non-read tool with { tool, kind, args, summary }; false returns "rejected by user" to the agent. Without it, granted tools run. |
prefix | string | "" | Prefix for every tool name, to expose several VMs. |
maxOutputChars | number | 32000 | Cap on output text; the middle is elided (truncateOutput). |
maxSteps | number | 2,000,000 | Instruction 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.
The default, read-only tool set
With no options the agent can only look. There is no write_file to call.
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"));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: falseGrants, 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.
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");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"}run_script and install_app
A VM booted with scripting gets run_script; install_app installs from the signed catalog.
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();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.0Tools 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().
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();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:
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.
| Option | Type | Default | Description |
|---|---|---|---|
approve | AgentApprove | — | Gate non-read calls coming from the browser agent (for tools built without their own approve). |
exposedTo | string[] | — | Origins allowed to see the tools, passed through to registerTool. |
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.
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;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: truetruncateOutput
Keep the head and the tail
const { truncateOutput } = await importAgent();
print(truncateOutput("a".repeat(50) + "b".repeat(50), 60));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
execcan change anything inside it. - Pass
approvefor 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_shellstarts in/; the agent should usecd dir && ….delete_pathrefuses/and/*.