How templates define runtime policy, sessions own live runtimes, and executions capture results.
Everything in vm-system revolves around three concepts. Templates describe what a runtime is allowed to do. Sessions are live runtimes built from templates. Executions are individual code runs inside a session, with every event captured and queryable.
These three concepts form a natural hierarchy: you define templates once, create sessions from them as needed, and run as many executions as you want inside each session. Understanding how they relate is the key to using vm-system effectively.
A template is a saved configuration that controls every aspect of a JavaScript runtime. Think of it as "a VM image you can stamp out sessions from." You create a template once, configure it to your needs, and then create sessions from it whenever you need a fresh runtime.
Templates capture these aspects of a runtime:
goja is the only
supported engine, with QuickJS, Node, and custom engines planned for the
future.import statements are resolved (module roots,
file extensions, whether absolute paths are allowed).The typical workflow is: create a template, then layer on configuration one piece at a time:
# Start with a basic template — you get goja, console enabled, and sensible limits
vm-system template create --name my-runtime --engine goja
# Add startup files — they'll run in order_index order during session creation
vm-system template add-startup <id> --path runtime/init.js --order 10 --mode eval
vm-system template add-startup <id> --path runtime/globals.js --order 20 --mode eval
# Add a native module for database access
vm-system template add-module <id> --name database
# Add a library — download to the local cache first
vm-system libs download
vm-system template add-library <id> --name lodash-4.17.21
# See the complete template with all its configuration
vm-system template get <id>
Every new template starts with sensible defaults so you can start using it immediately:
.js and .mjs extensions, absolute imports
disabledconsole.log captures events), ESM
and strict mode offThese settings are stored as JSON blobs in the database, which means adding new settings fields is a one-line code change — no schema migrations needed.
vm-system distinguishes between native modules and JavaScript libraries, and it's worth understanding why.
Native modules are Go code that's exposed to the JavaScript runtime. They provide capabilities that pure JavaScript can't offer — like talking to SQLite, running shell commands, or reading files from disk. Because these modules give the runtime access to host resources, they must be explicitly enabled per template. Three are available today:
database — SQLite access with configure, query, exec, and closeexec — run external shell commands and get the outputfs — read and write files on the host filesystemLibraries are plain JavaScript files. They're downloaded from CDN to a
local cache directory (.vm-cache/libraries/) and loaded into the runtime's
global scope at session startup. The built-in catalog includes popular libraries
like lodash, moment, axios, ramda, dayjs, and zustand.
One important gotcha: JavaScript built-ins like JSON, Math, and Date are
always available. They're part of the language, not something you configure
per template. If you try to add them as modules, you'll get
MODULE_NOT_ALLOWED — the system is telling you they're already there.
When you delete a template, the deletion cascades to its settings, capabilities, and startup files through database foreign keys. But sessions that were already created from that template keep running — their goja runtime is already allocated in memory and doesn't depend on the template anymore.
A session is a running goja instance. The daemon holds it in memory, and it persists across execution requests until you explicitly close it. This is what makes vm-system different from a one-shot script runner — you can set up state in one execution and use it in the next.
When you create a session, a lot happens behind the scenes:
Creating session
│
▼
starting ─── allocate goja runtime, apply settings,
│ load libraries, execute startup files in order
│
├──► ready (success — waiting for executions)
│
└──► crashed (something failed — check last_error)
ready ───────► closed (you called session close)
The daemon follows these steps:
console.log, console.warn, etc.
are captured as events instead of being lost.order_index order. Each file runs
in the same runtime, so earlier files can set up state that later files use.ready and is available
for execution requests.crashed and the error
message is saved in last_error.vm-system session create \
--template-id <id> \
--workspace-id ws-prod \
--base-commit abc123 \
--worktree-path /absolute/path/to/code
All four parameters are required, and each serves a specific purpose:
exec run-file resolves
paths. Must exist and must be absolute. This is the sandbox boundary for
file execution.State carries across executions. This is the key feature that makes
sessions useful. If you run var x = 42 in one REPL call, x is still 42
in the next call. Startup file mutations to globalThis are visible to every
later execution. This makes it possible to set up a rich environment once and
then run many operations against it.
One execution at a time. Each session has a mutex. If you fire two
execution requests concurrently, one succeeds and the other gets
SESSION_BUSY (409) immediately. There's no wait queue — the client is
responsible for retrying. This is a deliberate design choice: goja runtimes
are single-threaded, and serializing concurrent executions would add
complexity without real concurrency benefits.
Daemon restart loses runtimes. Session rows survive in the database, but the in-memory goja runtime is gone. You'll need to create new sessions after a restart. The database rows remain as historical records, but there's nothing to reconnect them to. This is a known limitation — daemon restart recovery is planned but not yet implemented.
An execution is a single REPL snippet or file run inside a session. It produces a persisted record and a stream of typed events that capture everything that happened — console output, return values, exceptions, and timing.
running ──► ok (expression returned a value)
──► error (JavaScript exception was thrown)
──► timeout (resource limit exceeded)
Most executions end in ok or error. The timeout state happens when the
code exceeds the CPU or wall time limits configured on the template.
vm-system exec repl <session-id> 'Math.random()'
The snippet runs in the session runtime. The return value becomes a value
event with a type, a human-readable preview, and an optional JSON
representation. Any console.log calls become console events. If the code
throws, you get an exception event with the message and stack trace.
One subtlety: if your REPL input is a statement like var x = 1, the return
value is undefined because statements don't produce values in JavaScript.
To see a value, end with an expression: var x = 1; x.
vm-system exec run-file <session-id> scripts/transform.js
The path is validated before the file is even read:
/../ is rejected with INVALID_PATHFILE_NOT_FOUNDThese checks happen before any JavaScript runs, so path traversal attacks can't reach the executor.
Every execution produces a stream of events, each with a sequential seq
number. Events are the primary way to see what happened during an execution:
vm-system exec events <execution-id> --after-seq 0
A typical REPL execution that calls console.log and returns a value looks
like this:
seq 1 input_echo "console.log('hi'); 42"
seq 2 console {"level":"log", "text":"hi"}
seq 3 value {"type":"number", "preview":"42", "json":42}
The --after-seq parameter enables cursor-based pagination. Pass the seq of
the last event you've seen, and you'll get only newer events. This is how you
poll for output in automation scripts without re-fetching everything.
Templates define limits that protect the host from runaway scripts:
console.log in a tight loop will hit this limit.Exceeding any limit produces OUTPUT_LIMIT_EXCEEDED (422). If your scripts
are chatty, you have two options: reduce the output volume, or create a
template with higher limits for that use case.
A typical workflow chains all three concepts. Here's a concrete example that sets up a database-backed runtime, runs some pipeline steps, inspects the results, and cleans up:
# 1. Define the runtime — what capabilities does it need?
vm-system template create --name etl-runner --engine goja
vm-system template add-module <id> --name database
vm-system template add-startup <id> --path runtime/setup-db.js --order 10 --mode eval
# 2. Create a session bound to a specific workspace
vm-system session create --template-id <id> --workspace-id etl-ws \
--base-commit abc123 --worktree-path /data/etl
# 3. Run code — each execution builds on the state left by the previous one
vm-system exec repl <session-id> 'db.query("SELECT count(*) FROM users")'
vm-system exec run-file <session-id> transforms/normalize.js
vm-system exec run-file <session-id> transforms/export.js
# 4. Look at what happened
vm-system exec list <session-id>
vm-system exec events <last-execution-id> --after-seq 0
# 5. Done — close the session to free the runtime
vm-system session close <session-id>
vm-system help getting-started — hands-on walkthrough from build to closevm-system help api-reference — endpoint contracts for all of the abovevm-system help cli-command-reference — every flag and argument