Skip to main content
Custom Software

Software Definition

The software-package definition for custom commands and agents in an agentOS VM: a package is a directory, declared with defineSoftware({ packageDir }).

Software is anything you install into a VM — commands (executables in a package’s bin/) or an agent (a package that also exposes an ACP session).

A package is a self-contained directory: package it first, then point defineSoftware() at it with { packageDir }. The package’s name, optional agent block, and any files/env it provides all live in an agentos-package.json at the root of that directory — the sidecar reads it when it mounts the package, so the client only forwards the directory. Pick the quickstart that matches what you’re packaging.

Quickstart

WebAssembly

  1. You have C or Rust source for a command. (Most common commands already ship as @agentos-software/* packages you can use directly — compile only new or custom ones.)

  2. Compile it to WebAssembly — see Building Binaries. There’s no pack step: WASM binaries are self-contained, so the compile output is already the package — a bin/ of \0asm files plus a package.json for the name/version:

    my-cmds/
    ├── package.json
    └── bin/
        ├── tool-a       # \0asm WebAssembly
        └── tool-b
    
  3. Define it — point defineSoftware() at that directory:

    import { defineSoftware } from "@rivet-dev/agentos";
    import { dirname, resolve } from "node:path";
    import { fileURLToPath } from "node:url";
    
    // WASM output is already a package - no `pack` step.
    const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), "my-cmds");
    
    export default defineSoftware({ packageDir });
    
  4. Use it — pass it to a VM; the commands are on $PATH:

    import { agentOS, setup } from "@rivet-dev/agentos";
    import myCmds from "./my-cmds.ts";
    
    // The compiled commands are now on $PATH inside the VM.
    const vm = agentOS({ software: [myCmds] });
    
    export const registry = setup({ use: { vm } });
    registry.start();
    

Node.js

  1. You have a local project whose package.json bin names its commands:

    my-tool/
    ├── package.json     # "bin": { "my-tool": "cli.js" }
    └── cli.js           # #!/usr/bin/env node
    
  2. Package itpack installs the full dependency closure into a self-contained package directory (a flat node_modules plus a bin map of real files):

    npx @rivet-dev/agentos-toolchain pack ./my-tool
    # writes ./my-tool-package/   (override the location with --out <dir>)
    #   my-tool-package/
    #   ├── package.json     # "bin": { "my-tool": "node_modules/my-tool/cli.js" }
    #   └── node_modules/    # flat, self-contained closure
    

    Commands come from the package’s package.json bin map — real files, no symlinks — so the result ships cleanly as an npm dependency. (The runtime makes the /opt/agentos/bin symlinks itself when it mounts the package.) A native .node addon is an error (it can’t run in V8); re-run with --prune-native to drop unreachable ones.

  3. Define it — point defineSoftware() at the packaged directory:

    import { defineSoftware } from "@rivet-dev/agentos";
    import { dirname, resolve } from "node:path";
    import { fileURLToPath } from "node:url";
    
    // Point at the self-contained directory produced by `agentos-toolchain pack`.
    const packageDir = resolve(
    	dirname(fileURLToPath(import.meta.url)),
    	"my-tool-package",
    );
    
    export default defineSoftware({ packageDir });
    
  4. Use it — pass it to a VM; my-tool is now on $PATH:

    import { agentOS, setup } from "@rivet-dev/agentos";
    import myTool from "./my-tool.ts";
    
    // `my-tool` is now on $PATH inside the VM.
    const vm = agentOS({ software: [myTool] });
    
    export const registry = setup({ use: { vm } });
    registry.start();
    

Agent

An agent is a Node.js or WASM package (packaged exactly as above) whose agentos-package.json carries an agent block naming a bin/ command that speaks ACP over stdio.

  1. You have an npm package with a bin/ command that speaks ACP over stdio.

  2. Package it — same pack as Node.js, with --agent naming the ACP entrypoint. That writes the agent block into the package’s agentos-package.json:

    npx @rivet-dev/agentos-toolchain pack @scope/my-agent --out ./packages --agent my-agent-acp
    # → ./packages/my-agent/current   (its agentos-package.json now has the agent block)
    
  3. Define it — point defineSoftware() at the packaged directory; the agent block is already in its agentos-package.json:

    import { defineSoftware } from "@rivet-dev/agentos";
    import { dirname, resolve } from "node:path";
    import { fileURLToPath } from "node:url";
    
    const packageDir = resolve(
    	dirname(fileURLToPath(import.meta.url)),
    	"packages/my-agent/current",
    );
    
    // The agent block lives in the package's agentos-package.json, generated by `agentos-toolchain pack --agent`.
    export default defineSoftware({ packageDir });
    
  4. Use itcreateSession() launches the agent by spawning its acpEntrypoint:

    import { agentOS, setup } from "@rivet-dev/agentos";
    import myAgent from "./my-agent.ts";
    
    const vm = agentOS({ software: [myAgent] });
    // createSession() launches the agent by spawning its acpEntrypoint:
    //   const session = await vm.createSession("my-agent");
    
    export const registry = setup({ use: { vm } });
    registry.start();
    

Reference

The descriptor

A software entry is just a pointer to the packaged directory:

defineSoftware({
  packageDir: string,    // absolute host path to the self-contained package directory
})

packageDir must contain only the package — a package.json with a bin map, the runtime files (bin/, a flat node_modules), and an agentos-package.json. It is mounted read-only, so don’t point it at a source root: that drags src/, dev node_modules/, tsconfig, and build caches into the VM. Point it at a clean build output — pack and the WASM assemble step both emit one.

pack already emits a clean directory. For a package you build by hand (e.g. compiled WASM), assemble a dist/package/ holding just package.json + bin/ and point packageDir there — never at the workspace root:

// dist/package/  ←  { package.json (name, version, bin), bin/<cmd>…, agentos-package.json }
const packageDir = resolve(import.meta.dirname, "dist/package");
export default defineSoftware({ packageDir });

agentos-package.json

The package’s name, optional agent block, and any files/env it provides live in an agentos-package.json at the root of packageDir. The sidecar reads it when it mounts the package, so this metadata never travels on the wire. For command/WASM packages it is generated for you (name from package.json); for agents you author the agent block (or agentos-toolchain pack --agent <cmd> writes it).

{
  "name": "my-agent",            // → /opt/agentos/<name>
  "agent": {                     // optional — also exposes an agent session
    "acpEntrypoint": "my-agent-acp",  // bin/ command that speaks ACP over stdio
    "env":          { },               // static env for the adapter
    "launchArgs":   [],
    "snapshot":     false              // SDK snapshot optimization
  },
  "provides": {                  // optional — files + env the package contributes
    "env":   { "EXAMPLE_HOME": "/opt/agentos/my-agent" },
    "files": [{ "source": "etc/example.conf", "target": "/etc/example.conf" }]
  }
}
  • name — the package name; commands and the package mount under /opt/agentos/<name>.
  • agent.acpEntrypoint — the bin/ command spawned to start a session; speaks ACP over stdio.
  • agent.env — static env vars for the adapter, merged under the user env. Every command is on $PATH, so point at one directly, e.g. { "PI_ACP_PI_COMMAND": "/opt/agentos/bin/pi" } so pi-acp can spawn the pi CLI.
  • agent.launchArgs — extra CLI args prepended when launching the adapter.
  • agent.snapshot (default false) — load the SDK once per sidecar via a shared V8 heap snapshot instead of per session. Falls back to per-session loading if the SDK isn’t snapshot-safe, so it only affects startup latency.
  • provides.env — env vars merged into the VM’s base environment (existing values win — a package never clobbers the user env).
  • provides.files — read-only files overlaid into the VM filesystem. Each { source, target } maps a path inside the package to an absolute VM path; the sidecar mounts them as zero-copy read-only lower layers (a guest write copies-up, never touching the host). A missing source is a fatal packaging error.

Advanced

Meta-packages

A software entry may be an array of descriptors, so one package can bundle several. Pass arrays directly to software:

const vm = agentOS({
  software: [pi, buildEssential /* = [coreutils, make, git, curl] */],
});

SDK snapshotting & snapshot-safety

A V8 heap snapshot freezes the heap after the SDK’s modules are evaluated, then seeds each new session’s isolate from it. This works only if the SDK’s module-init code (everything that runs at import/require time) doesn’t:

  • Create native handles — load a .node addon, instantiate WebAssembly, or produce a V8 External/Foreign at top level.
  • Open a file descriptor, socket, timer, or worker, or leave a pending promise.
  • Bake in non-deterministic or per-session stateprocess.env, cwd, Date.now(), Math.random(), a UUID.

Defer all of the above behind functions or lazy import() that run per session. Leave agent.snapshot: false for any SDK that can’t — the agent still runs, just without the speedup.

Next steps