Skip to main content
Execution

Node.js

Execute JavaScript and TypeScript, install npm dependencies, and manage execution lifecycles in agentOS.

agentOS runs JavaScript and TypeScript on native V8 inside the VM, backed by a real Node.js surface: node:fs, node:child_process, sockets, and npm.

Letting an agent write code instead of chaining one tool call per step is called Code Mode. It has a few advantages over driving Bash:

  • Fewer tokens: Ten chained operations cost one round trip, not ten.
  • Type checking: Validate generated TypeScript before you run it.
  • Real data processing: map and filter instead of jq and awk.
  • Parallelism: Promise.all instead of shell job control.

Evaluate an expression

evaluate() returns a JSON-serializable value.

import { AgentOs } from "@rivet-dev/agentos";

const runtime = await AgentOs.create();

try {
	const sum = await runtime.javascript.evaluate<number>("1 + 2");
	console.log(sum.outcome === "succeeded" ? sum.value : sum.error); // 3

	// More than one statement goes in a function, so the code has somewhere to
	// return from.
	const report = await runtime.javascript.evaluate<{ total: number }>(`
		(() => {
			const values = [1, 2, 3];
			return { total: values.reduce((a, b) => a + b, 0) };
		})()
	`);
	console.log(report.outcome === "succeeded" ? report.value : report.error); // { total: 6 }
} finally {
	await runtime.dispose();
}

Returning undefined, a function, a symbol, or a circular value fails with evaluation_serialization_failed rather than silently losing the value.

Execute code

execute() runs source and captures its output instead of returning a value.

import { AgentOs } from "@rivet-dev/agentos";

const runtime = await AgentOs.create();

try {
	const result = await runtime.javascript.execute(
		`console.log("hello from agentOS")`,
		{ output: { capture: "all" } },
	);
	console.log(result.outcome === "succeeded" ? result.stdout : result.error); // "hello from agentOS\n"
} finally {
	await runtime.dispose();
}

Executions are ephemeral, so capture stdio only when you want it — "stderr" for diagnostics, "all" for both streams. onStdout/onStderr stream live and work independently of capture.

Pass data into code

inputs hands host values to the guest as real objects, so data never gets interpolated into source.

import { AgentOs } from "@rivet-dev/agentos";

const runtime = await AgentOs.create();

try {
	const result = await runtime.javascript.evaluate<number>(
		"inputs.items.reduce((total, item) => total + item.price, 0)",
		{ inputs: { items: [{ price: 10 }, { price: 32 }] } },
	);
	console.log(result.outcome === "succeeded" ? result.value : result.error); // 42
} finally {
	await runtime.dispose();
}

Keep state between calls

Pass a contextId to keep globals, imports, and modules alive across calls in one retained V8 isolate.

import { AgentOs } from "@rivet-dev/agentos";

const runtime = await AgentOs.create();

try {
	await runtime.createContext("analysis");

	await runtime.javascript.execute("const answer = 40", {
		contextId: "analysis",
	});

	const result = await runtime.javascript.evaluate<number>("answer + 2", {
		contextId: "analysis",
	});
	console.log(result.outcome === "succeeded" ? result.value : result.error); // 42

	// Delete an idle context when you are done with it. `contexts.reset()`
	// is the other option: it keeps the id and drops only the retained state.
	await runtime.contexts.delete("analysis");
} finally {
	await runtime.dispose();
}

A context runs one operation at a time — reusing a busy contextId fails immediately. Files, npm, Bash, and type checks may pass the same id, but they run in fresh processes and never touch retained memory.

Contexts live for the VM lifetime. They do not survive actor sleep/wake, because the VM is disposed; create them lazily on the first stateful use after wake.

Type check before running

Executing TypeScript transpiles it without a semantic check, so validate the agent’s generated code explicitly.

import { AgentOs } from "@rivet-dev/agentos";

const runtime = await AgentOs.create();

try {
	// Type checking validates an agent's generated code before it executes.
	// `filePath` labels the source for diagnostics; it is never read from disk.
	const checked = await runtime.typescript.check(
		`const total: number = "not a number";`,
		{ filePath: "example.ts" },
	);

	// Feed these diagnostics back to the agent so it can fix the code it
	// generated and try again, instead of running code you know is broken.
	for (const diagnostic of checked.diagnostics) {
		// error TS2322: Type 'string' is not assignable to type 'number'.
		console.log(
			`${diagnostic.category} TS${diagnostic.code}: ${diagnostic.message}`,
		);
	}

	// Executing TypeScript only transpiles it, so check first if it matters.
	if (checked.diagnostics.length === 0) {
		await runtime.typescript.execute(`const total: number = 42;`);
	}
} finally {
	await runtime.dispose();
}

Install npm packages

import { AgentOs } from "@rivet-dev/agentos";

const runtime = await AgentOs.create();

try {
	await runtime.javascript.npm.install({ frozen: true }); // lockfile-exact

	const build = await runtime.javascript.npm.runScript("build");
	console.log(build.outcome); // "succeeded"

	await runtime.javascript.npm.runPackage("prettier", {
		args: ["--check", "."],
	});
} finally {
	await runtime.dispose();
}

Installs modify the VM-wide filesystem, so a package installed once is importable by every later execution in that VM, in any language. Only one npm/Python mutation runs at a time per VM; a concurrent install fails with execution_busy.

Background processes and web servers

spawn starts a long-lived process and returns a pid. From there you get stdin, output, signals, and waiting.

import { AgentOs } from "@rivet-dev/agentos";

const serverSource = `
import http from "node:http";
const app = http.createServer((req, res) => {
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ ok: true, path: req.url }));
});
app.listen(3000, "127.0.0.1", () => console.log("ready"));
await new Promise(() => {});
`;

const runtime = await AgentOs.create({ permissions: { network: "allow" } });

try {
	const server = await runtime.javascript.spawn(serverSource, {
		onStdout: (chunk) => process.stdout.write(new TextDecoder().decode(chunk)),
	});

	// The listener stays inside the VM — no host port is exposed.
	const response = await runtime.network.httpRequest({
		port: 3000,
		path: "/health",
	});
	console.log(response.status); // 200

	await runtime.process.signal(server.pid, "SIGTERM");
	await runtime.process.wait(server.pid);
} finally {
	await runtime.dispose();
}

Spawned processes always start with fresh state, so they take no contextId.

A full Linux environment underneath

There is a real Linux environment behind all of this, shared by every language. Files and installed packages are immediately visible to Bash, Python, agents, and other executions.

import { AgentOs } from "@rivet-dev/agentos";

const runtime = await AgentOs.create({ permissions: { network: "allow" } });

try {
	// Filesystem: node:fs is the VM's persistent filesystem.
	const files = await runtime.javascript.execute(
		`
		import fs from "node:fs/promises";

		await fs.writeFile("/workspace/data.txt", "hello");
		console.log(await fs.readFile("/workspace/data.txt", "utf8"));
		`,
		{ output: { capture: "all" } },
	);
	console.log(files.stdout); // "hello\n"

	// Process trees: node:child_process spawns real guest processes.
	const processes = await runtime.javascript.execute(
		`
		import { execFileSync } from "node:child_process";

		console.log(execFileSync("ls", ["-la", "/workspace"], { encoding: "utf8" }));
		`,
		{ output: { capture: "all" } },
	);
	console.log(processes.stdout); // Directory listing for /workspace

	// Networking: sockets and fetch go through the VM network policy.
	const status = await runtime.javascript.evaluate<number>(
		`(async () => (await fetch("https://example.com")).status)()`,
	);
	console.log(status.outcome === "succeeded" ? status.value : status.error); // 200
} finally {
	await runtime.dispose();
}

See Filesystem, Processes & Shells, and Networking & Previews.

Bindings

Guest code invokes bindings as ordinary typed commands, so host credentials stay outside the VM.

import { AgentOs, type Bindings } from "@rivet-dev/agentos";
import { z } from "zod";

// The handler runs on the host, so the API key never enters the VM.
const weather: Bindings = {
	name: "weather",
	description: "Weather data bindings",
	bindings: {
		forecast: {
			description: "Get the weather forecast for a city",
			inputSchema: z.object({ city: z.string() }),
			execute: async ({ city }: { city: string }) => {
				const res = await fetch(
					`https://api.weather.example/forecast?city=${city}&key=${process.env.WEATHER_API_KEY}`,
				);
				return res.json();
			},
		},
	},
};

// The collection is projected into the VM as an `agentos-weather` command.
const runtime = await AgentOs.create({ bindings: [weather] });

try {
	const result = await runtime.javascript.execute(
		`
		import { execFile } from "node:child_process";
		import { promisify } from "node:util";

		const run = promisify(execFile);
		const { stdout } = await run("agentos-weather", ["forecast", "--city", "Paris"]);
		console.log(JSON.parse(stdout).result);
		`,
		{ output: { capture: "all" } },
	);
	console.log(result.outcome === "succeeded" ? result.stdout : result.error);
} finally {
	await runtime.dispose();
}

Permissions, limits, and timeouts

Every operation inherits the VM permission policy and resource limits.

import { AgentOs } from "@rivet-dev/agentos";

const runtime = await AgentOs.create();

try {
	// timeoutMs is a wall-clock deadline for the whole operation. It expires
	// into a result rather than throwing, and never replaces the VM watchdogs.
	const result = await runtime.javascript.execute("while (true) {}", {
		timeoutMs: 1_000,
	});
	console.log(result.outcome); // "timed_out"
} finally {
	await runtime.dispose();
}