Embedding the AJS VM
This chapter is for the host: the application or service that runs AJS agents written by
someone else, a user or a language model. It covers where to parse, every option vm.run
takes, what comes back, which failures an agent can catch and which end the run, how quotas
hold across nested runs, and where to put the VM.
Two other chapters sit beside this one. The AJS Language is for the people and models who write agents. Capabilities and Custom Atoms covers giving agents new powers: writing a capability and defining an atom. The guarantees the sandbox makes, and the ones it does not, are listed once, in Safe Eval; the invariants behind fuel and the heap ceiling are in VM budgets.
Five words carry most of this chapter:
- An atom is a named operation the VM can run, such as
httpFetch,storeGetorllmPredict. An agent's calls are atom calls; the VM comes with a set of core atoms, and you can register your own. - A capability is a host function an atom calls to reach the outside world: your
fetch, your store, your model client. The VM has none of its own. - The membrane is the copy-and-check every value goes through when it crosses between the agent and the host, in either direction.
- The guest is the agent's side of the membrane: its variables and the values it holds.
seqis a block of steps. The root of every AST is one.
A complete host
This host lets an agent fetch from one API and ask a model one question, and nothing else. Everything below it in this chapter explains a piece of it.
import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'
const vm = new AgentVM()
// 1. Transpile on the side that owns the source. Here it is a function in the same file;
// in production it runs on the caller's machine, in a worker or in another process.
function compile(source) {
try {
const { ast } = transpile(source, {
atoms: vm.atoms, // check calls against the atoms this VM really has
maxSourceBytes: 16 * 1024, // refuse oversized source before parsing it
})
return { ast: JSON.stringify(ast) } // the wire and storage format
} catch (e) {
// e.g. "Unsupported statement type: ForStatement. AJS has no `for` loops. Use `while`
// with a counter: … at <source>:1:19"
return { problem: e.message }
}
}
// What a model might have written:
const { ast, problem } = compile(`
/**
* Fetch a city's forecast and summarize it in one sentence.
* @param city - the city to look up
*/
function forecast({ city: 'Paris' }): { city: '', summary: '' } {
let data = httpFetch({ url: \`https://api.weather.example/v1/\${city}\` })
let summary = llmPredict({ prompt: \`One sentence: \${JSON.stringify(data)}\` })
return { city, summary }
}
`)
if (problem) throw new Error(problem)
// 2. The capabilities: the only ways out of the sandbox for this run.
const capabilities = {
// A stand-in for your HTTP client. It returns the response BODY as plain data.
fetch: async (url, init) => ({ city: url.split('/').pop(), high: 21, sky: 'clear' }),
// A stand-in for your model client.
llm: { predict: async (prompt) => 'Clear skies and a high of 21.' },
}
// 3. Run the stored AST on the VM that has no parser.
const { result, error, fuelUsed, trace } = await vm.run(
JSON.parse(ast),
{ city: 'Oslo' },
{
fuel: 1000, // the work budget
timeoutMs: 10_000, // someone is waiting for this answer
capabilities,
context: { allowedFetchDomains: ['api.weather.example'] }, // where fetch may go
quotas: { llmPredict: 1, httpFetch: 2 }, // call caps, whatever the fuel allows
trace: true, // keep an audit trail
}
)
// 4. Branch on the outcome. vm.run returned; it did not throw.
if (!error) {
console.log(result, fuelUsed)
// { city: 'Oslo', summary: 'Clear skies and a high of 21.' } about 105.6
} else if (error.message === 'Out of Fuel') {
console.log('the agent ran out of budget at', error.op)
} else if (error.op === 'vm.run') {
// refused by the VM itself: bad arguments, a broken result contract, the timeout
console.log('refused:', error.message)
} else {
// a step failed and the agent did not catch it; error.op names the step
console.log(error.op, 'failed:', error.message)
}
console.log(trace.map((event) => event.op))
// [ 'varsImport', 'httpFetch', 'llmPredict', 'return', 'seq' ]
// varsImport binds the arguments; seq is the whole program, logged last because an
// event is recorded when its step finishes (see Tracing for audit).
city: 'Paris' is an example, not a default: it declares a required string parameter,
and 'Paris' is what one looks like. A parameter written with = (budget = 50.0) has a
default instead and may be left out. The : { city: '', summary: '' } after the parameters
declares the shape of the result, and the VM checks it. Both become part of the AST (see
Arguments and contracts); the language guide has the
full rules.
Change one thing and a different branch runs:
| Change | error.op |
error.message |
|---|---|---|
allowedFetchDomains: ['other.example'] |
httpFetch |
Fetch blocked: domain not in allowlist. Allowed: other.example |
arguments { city: 5 } |
vm.run |
Input validation failed: args do not match expected schema |
a model stand-in that returns 42 |
llmPredict |
Output validation failed for 'llmPredict' (it declares a string) |
timeoutMs: 20 and a model that never answers |
vm.run |
Execution timeout after 20ms. … |
fuel: 50 |
llmPredict |
Out of Fuel |
What to branch on. An AgentError has a message, an op and sometimes a cause, and
no machine-readable kind. So today a host tells fuel exhaustion from other failures by its
message, as step 4 does, and tells "refused by the VM" from "a step failed" by op. The
result does not say whether an uncaught failure was one the agent could have caught or one
that ended the run: a run-ending refusal at
storeGet arrives with op: 'storeGet', like any failure there. A host rarely needs to
know: either way the run failed, and op says where.
Transpile where parsing is safe
An AJS program crosses three places: it is transpiled where its source is handled, it travels as a JSON AST, and it runs on the VM. Keep the first and the last apart.
Parsing is the one step whose cost the author of the source controls and that runs before any
budget exists: fuel, the timeout and the heap ceiling all start with vm.run. A run is
metered, a parse is not. So:
- Run ASTs on
tjs-lang/vm-ast. It is the sameAgentVMwith no parser in its bundle (well under half the size oftjs-lang/vm). Passed a source string, it throws and says where to transpile instead. A VM that cannot parse has no parser defect for a payload to reach. - Transpile with
transpilefromtjs-lang/lang, outside the process you cannot afford to lose: on the caller's machine, in a worker, or in a separate process.transpilehas no size cap by default. If you must transpile untrusted source in-process, passmaxSourceBytes; over the cap,transpilethrows before it parses anything. This is an option totranspile, separate from thevm.runoption of the same name (see Run options). - Pass
atoms: vm.atoms. The transpiler then checks every atom call against the atoms the VM will really have, and refuses an input the atom does not declare ('scrape' has no input 'href'. It takes scrape({ url })). Core atoms are always checked; withoutatoms, a call to your own atom is not. - Store or send the AST as JSON. It is versioned (
"$ajs": 2at its root). A VM refuses an AST newer than it understands rather than guess at its meaning, and keeps running older versions, because stored ASTs must keep working.
transpile reports a bad program by throwing a TranspileError with a message, a line and a
column, and the message says what to write instead. That is where an agent author, or the
model you are prompting, gets help. The VM does not explain: it runs the AST as written, or
refuses it.
vm.run(source) on tjs-lang/vm still works and is deprecated. It parses inside the VM host,
capped at 8 KB (maxSourceBytes), and notes the deprecation once in the flight recorder (the
runtime's log of failures and near-misses, read with __tjs.records()). For agents you wrote
yourself, the ajs tagged template (from tjs-lang) returns the AST in one step.
Both VMs also accept a procedure token (proc_…) in place of an AST. The
storeProcedure atom returns one: it names an AST kept in a store inside the current
process, and it expires (after an hour by default). It is a handle within one process, not a
way to store an AST.
Run options
vm.run(ast, args, options) reads every option once, checks it, and refuses a bad value as an
error value (Invalid run option fuel: -1 — it must be a non-negative number). An option
defined as a getter is refused too: run options are data. The few call mistakes that throw
instead are listed under What vm.run returns.
| Option | Default | What it bounds |
|---|---|---|
fuel |
1000 |
Work. Every step costs fuel; the run stops at Out of Fuel. Infinity is no limit. |
timeoutMs |
twice the slowest atom's timeout, at least 60 s | Wall-clock time for the whole run, including time spent waiting on capabilities. Infinity is no limit. Unlike the two options below, 0 is not "off": it means already expired. |
maxHeapBytes |
64 MB | Bytes the agent holds live at once. Fuel bounds total work, not peak memory. |
membraneMaxBytes |
4 MB | The size of one value crossing to or from a capability (an atom's input going out, a capability's return coming in). |
argsMaxBytes |
4 MB | The run's arguments. The fuel bounds them too: one unit of fuel pays for 8,000 bytes, and a string counts about 2 bytes per character, so roughly 4,000 characters. |
maxSourceBytes |
8 KB | Source strings: given to vm.run on tjs-lang/vm, or built by an agent for runCode or transpileCode. 0 disables it for trusted source; for agent-built source it can only be lowered. |
quotas |
none | Calls per atom, { llmPredict: 3 }. An atom you do not list is unlimited; 0 forbids it. |
quotaUsed |
a fresh counter per run | The quota tally. Pass one object to several runs to share it (see Quotas across nested runs). |
costOverrides |
none | Fuel cost per atom: a number, or (input, ctx) => number. |
timeoutOverrides |
none | Per-atom timeout in ms: a number or a function; 0 disables that atom's timeout. |
signal |
none | An AbortSignal. Aborting ends the run with Execution aborted by the caller (options.signal). |
trace |
false |
Records every step (see Tracing for audit). |
capabilities |
{} |
The only IO the agent has: fetch, store, llm, agent and any your own atoms read (see Capabilities). |
context |
none | Request-scoped data for the VM and your atoms (see Fetch policy). |
Sizing fuel. Each atom has a cost, on vm.atoms[name].cost: a number, or a function of
the input. Data steps are fractions of a unit, httpFetch costs 5 and llmPredict 100,
which is why the host above spends about 105.6. To pick a budget, run the agent once with
trace: true on typical arguments, read fuelUsed, and allow headroom for the inputs that
make it loop longer.
The default timeout is generous, on purpose. Without timeoutMs, a run gets twice the
timeout of the slowest atom registered on the VM, and never less than a minute. Most core
atoms time out after one second (control atoms such as seq and while have no timeout of
their own). httpFetch and llmPredict are core atoms too, with 30 s and 120 s; the model
call is the slowest, so a VM with only core atoms gives a run 240 s, and registering a slower
atom raises it. That suits a background job. Anything a person waits on should pass its own
timeoutMs.
Each atom also has its own timeout. An atom that times out fails like any other step, and
the agent may catch it (Atom 'llmPredict' timed out); the run's own timeout ends the run.
When the run ends, for any reason, the VM aborts the run's AbortSignal, so nothing it
started is left running. A fetch capability receives that signal as init.signal; pass it on
to your HTTP client.
The heap ceiling is not a running total. The VM keeps a cheap estimate that only grows;
when the estimate crosses the ceiling, the VM measures the bytes the agent can still reach
and fails only if those are over. So a loop that keeps overwriting one variable does not trip
it. When it does fail, the message says how much the agent held:
Heap limit exceeded: guest state holds ~2MB, limit 1MB. How it stays true is in
VM budgets.
Capabilities
The VM has no IO of its own. Each run gets the capabilities you pass and nothing else: fetch
for httpFetch, store for the store atoms, llm for llmPredict, agent for agentRun
with a named agent, and whatever your own atoms read. A missing one is an ordinary failure
(Capability 'llm.predict' missing). With no store, the store atoms use an in-memory store
that lasts one run, and the result's warnings says so.
agent is { run(agentId, input) }, returning the sub-agent's RunResult (or its result
alone). An agent's agentRun names its sub-agent one of two ways:
// A name: your agent capability decides what runs, as a new vm.run.
let short = agentRun({ agentId: 'summarize', input: { text } })
// An AST value the agent holds (passed in as an argument, or built with transpileCode):
// it runs inline, inside the current run, with no capability involved.
let out = agentRun({ agentId: helper, input: { text } })
The difference matters for quotas.
Everything a capability returns crosses the membrane, which builds a fresh copy: JSON data
plus Date. A Response, a Map, a class instance, a function or a getter is refused, and
that refusal ends the run (see
Catchable failures and run-ending refusals).
The interface for each key, the rules for what a capability may return, and how to define
your own atoms are in Capabilities and Custom Atoms.
Fetch policy
context is yours: put request metadata in it (a user id, a tenant) and your own atoms read
it as ctx.context. The VM itself reads four keys, all for httpFetch:
| Key | Type | Effect |
|---|---|---|
allowedFetchDomains |
string[] |
The hosts httpFetch may reach. api.example.com admits the default port only; api.example.com:8443 names one; *.example.com admits the domain and its subdomains. |
allowedFetchMethods |
string[] |
Methods beyond the default GET and HEAD. CONNECT, TRACE and TRACK are never admitted. |
allowedRequestHeaders |
string[] |
Header names beyond the default list (below). |
requestDepth |
number | How many agent hops deep this request already is. A fetch at depth 10 is refused; 9 is the last allowed. |
A list must be an array of strings; a single string is refused rather than read one character at a time.
The request is admitted the same way whoever sends it. The scheme must be http: or
https:, the method must be admitted, and every header the agent sets must be on the list. A
header that is not on it is refused, not dropped. The default list:
| Kind | Headers |
|---|---|
| Content | Accept, Accept-Language, Content-Language, Content-Type, Range |
| Caching | Cache-Control, If-Match, If-Modified-Since, If-None-Match, If-Unmodified-Since |
| Credentials | Authorization, X-API-Key |
| Tracing | User-Agent, X-Request-Id, X-Correlation-Id, X-Requested-With |
The credential headers are there because calling an API needs them. An agent can only send a credential it was given, in its arguments or in what a capability returned; it has no secrets of its own. What stops a credential you gave it from going somewhere else is the domain allowlist, so set one whenever an agent holds a key.
When allowedFetchDomains is set it applies on every path, your own fetch capability
included.
Without a fetch capability, httpFetch uses a built-in client meant for demonstrations.
It fetches nothing unless allowedFetchDomains is set, follows no redirects (a redirect comes
back to the agent as { redirect: true, status, location }, and fetching location is a new
request, admitted again), sends no cookies or HTTP authentication, and reads the body under
membraneMaxBytes.
With your own fetch capability, the VM still admits the scheme, method and headers (and
the domain, when allowedFetchDomains is set), then calls your function with the admitted
request. Your function owns the rest: redirects, credentials and rate limits. What it
receives and what it must decide is in
Capabilities and Custom Atoms.
Warning. With your own
fetchand noallowedFetchDomains, the VM checks no destination at all. An agent can then ask your function for any URL, internal hosts and cloud metadata addresses included, and only your function stands in the way. Set an allowlist, or make yourfetchrefuse what it should not reach.
Agent depth. Every request carries an X-Agent-Depth header one higher than
context.requestDepth, and an agent cannot set it. An endpoint that runs agents should read
the header from the incoming request and pass it back in as requestDepth; then a loop of
agents calling agents stops after ten hops (Agent request depth exceeded (max 10)). It is
cooperative: it stops accidental loops, not an endpoint that drops the header. In a browser
the built-in client sends it only to the page's own origin, because a custom header makes
every cross-origin request a CORS preflight.
Eval and SafeFunction, the two-line wrappers over this VM, take the same
policy as fetchPolicy: { domains, methods, headers }, which becomes allowedFetchDomains,
allowedFetchMethods and allowedRequestHeaders. They cannot set requestDepth.
What vm.run returns
vm.run resolves to a RunResult and does not throw for anything an agent does:
| Field | What it holds |
|---|---|
result |
The agent's returned object. On failure, the same AgentError as error. |
error |
undefined on success, or an AgentError: message, op (the step that failed, or vm.run), and cause (the host Error behind it, when there was one). |
fuelUsed |
Fuel spent, including what admitting the arguments cost. |
trace |
The step log, when trace: true. |
warnings |
Non-fatal notes, such as the default in-memory store being used. |
result holds the error on failure so that code which keeps only result (the function
createAgent returns resolves to it) still receives the failure rather than undefined. It
also means a failed run's result is not a successful value: always test error, or
isAgentError(result), before using it.
An AgentError serializes as { "$error": true, "message": …, "op": … }, so a result sent
over the network still says it is an error. isAgentError(value) (from tjs-lang/vm-ast,
tjs-lang/vm or tjs-lang) recognises both the object and its JSON form. cause stays on
the host side and is not serialized.
A bad option value comes back as an error value. vm.run throws only when what it was
handed cannot be a program to run, before anything runs:
- a source string on
tjs-lang/vm-ast; - on the deprecated
tjs-lang/vmsource path, source that does not transpile (AJS transpilation failed: …; oversized source comes back as an error value instead); - an AST whose root is not a
seq; - an AST format newer than this build;
capabilitiesthat is not an object.
Catchable failures and run-ending refusals
Inside an agent, try/catch catches failures of the world: a fetch that fails or is
blocked, a store that throws, a model that times out, a quota spent, an answer too large to
copy in. A program might reasonably retry or work around any of them. The agent's catch
receives the error's message, a capped string, not an Error object.
Some failures end the run, even inside a try. No catch block runs, and no later step.
Three rules decide which:
- The budget ends the run. Out of fuel, the run's own timeout, and your
signal. - Every outbound refusal ends the run. The agent chose what to send, so an atom input that
does not match what the atom declares (
httpFetch({ url: 5 })), or a value too large or not plain data to copy out to a capability, is the program's bug. An agent that sendsstoreSeta 5,000-character value under a 1,024-bytemembraneMaxBytesends with'storeSet': the value exceeds the 1024-byte membrane budget, and itscatchnever runs. - An inbound refusal ends the run only if plain JSON could not have done it. A server can
send back four megabytes, so an oversized answer is the world's and is catchable. A server
cannot send back a
Map, so aMapis a bug in your capability, and letting the agent catch it would let it loop on your bug.
Here is the same agent run against three stores:
import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'
const vm = new AgentVM()
const { ast } = transpile(
`
function lookup({ id: 'a1' }) {
try {
let record = storeGet({ key: id })
return { record }
} catch (err) {
return { failed: err }
}
}`,
{ atoms: vm.atoms }
)
const run = (get, options = {}) =>
vm.run(ast, { id: 'a1' }, {
capabilities: { store: { get, set: async () => {} } },
...options,
})
// The world's failure: the store is down. The agent's catch runs.
const down = await run(async () => {
throw new Error('store unavailable')
})
console.log(down.result) // { failed: 'store unavailable' } err is the message string
// The world's failure: the answer is too large. Also catchable.
const big = await run(async () => 'x'.repeat(5000), { membraneMaxBytes: 1024 })
console.log(big.result.failed)
// "Capability boundary rejected the return of 'storeGet': the value exceeds the
// 1024-byte membrane budget"
// The host's bug: JSON cannot express a Map. The run ends; the catch never runs.
const map = await run(async () => new Map([['name', 'Ada']]))
console.log(map.error.op, map.error.message)
// storeGet "Capability boundary rejected the return of 'storeGet': capability return
// contains a Map; only plain data (and Date) crosses, … use an object, or an array of
// [key, value] pairs"
Every copy across the boundary is paid for in fuel, refused or not, so an agent that retries a
catchable refusal in a loop pays each time. A sub-agent served by your agent capability is a
separate run: a refusal ends that run, and the parent sees an ordinary, catchable failure.
Arguments and contracts
The arguments cross the same membrane as a capability's return, before the agent runs:
- They are copied. The agent gets JSON data plus
Date, never a live host object. A class instance is refused rather than thinned, because its getters and private fields would silently read asundefined:Capability boundary rejected the run arguments: run argument contains an instance of Rec; … convert it to a plain object first. - They are capped and charged, by
argsMaxBytesand by the run's fuel. Arguments the fuel cannot pay to admit end the run asOut of Fuelbefore the first step. - They are checked against the parameters. The agent's parameter examples travel in the
AST as
inputSchema, and arguments that do not match are refused withInput validation failed: args do not match expected schema, fromvm.run, before the agent runs. An example's kind is what it says:40declares an integer, so8.5is refused; write40.0for a number that may have a fraction. - A declared result is checked after.
function forecast(…): { city: '', summary: '' }puts anoutputSchemain the AST; a successful run whose result does not match ends withResult does not match the declared result type. The check is paid from the fuel left.
What the two contracts accept, member by member, is in the language guide. Both live in the AST, so any VM that runs it enforces them, wherever it was transpiled.
Quotas across nested runs
A quota counts calls within one run. A sub-agent started inline (agentRun with an AST)
shares its parent's run and its counter. A sub-agent served by your agent capability is a
new vm.run, and a new run starts a fresh counter, so an agent that can reach one could
multiply its allowance. To hold a cap across the whole call tree, pass the same quotaUsed
object to every run in it:
import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'
const vm = new AgentVM()
const compile = (source) => transpile(source, { atoms: vm.atoms }).ast
const agents = {
summarize: compile(`
function summarize({ text: '' }) {
let summary = llmPredict({ prompt: \`Summarize: \${text}\` })
return { summary }
}`),
triage: compile(`
function triage({ ticket: '' }) {
let label = llmPredict({ prompt: \`Label: \${ticket}\` })
let short = agentRun({ agentId: 'summarize', input: { text: ticket } })
return { label, summary: short.summary }
}`),
}
let modelCalls = 0
const llm = {
predict: async (prompt) => `answer ${++modelCalls}`,
}
function runAgent(name, input, quotaUsed) {
return vm.run(agents[name], input, {
quotas: { llmPredict: 1 },
quotaUsed,
capabilities: {
llm,
// A named sub-agent is a NEW run. Handing it the same quotaUsed makes it
// draw on the parent's allowance instead of starting its own.
agent: { run: (id, input) => runAgent(id, input, quotaUsed) },
},
})
}
const separate = await runAgent('triage', { ticket: 'login fails' })
console.log(modelCalls, separate.result)
// 2 { label: 'answer 1', summary: 'answer 2' } one call per run
modelCalls = 0
const quotaUsed = {}
const shared = await runAgent('triage', { ticket: 'login fails' }, quotaUsed)
console.log(modelCalls, shared.error.op, shared.error.message, quotaUsed)
// 1 agentRun "Quota exceeded for 'llmPredict': 1 call allowed" { llmPredict: 1 }
quotaUsed is trusted host state. The VM checks each count as it reads it (a finite,
non-negative number) and refuses a frozen or read-only object up front. A run also keeps its
own count of the calls it made: if the shared object is reset to 0 mid-run, that run goes
on counting from its own tally. A nested run, though, starts with no tally of its own, so
one started with a doctored object believes it. Do not build quotaUsed from anything an
agent or a remote party can write. Across a process or network boundary there is nothing to
share: budget does not travel, only data does, so each side enforces its own.
Agents as tools
transpile also returns a signature: the entry function's name, its JSDoc description, each
parameter's type, example or default and description, and the declared result. createAgent
and getToolDefinitions (both from tjs-lang/lang) turn it into something a model can be
offered:
import { createAgent, getToolDefinitions } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'
const shortlist = createAgent(
`
/**
* Pick the items that fit a budget.
* @param budget - the most to spend
*/
function shortlist({ items = [{ name: 'kettle', price: 40.0 }], budget = 50.0 }) {
let fits = items.filter((item) => item.price <= budget)
let names = fits.map((item) => item.name)
return { names }
}`,
new AgentVM(),
{ fuel: 1000 }
)
await shortlist({ items: [{ name: 'mug', price: 8.5 }], budget: 30 }) // { names: ['mug'] }
shortlist.signature.description // 'Pick the items that fit a budget.'
getToolDefinitions({ shortlist })
// [{ type: 'function', function: { name: 'shortlist',
// description: 'Pick the items that fit a budget.',
// parameters: { type: 'object', properties: {
// items: { type: 'array', items: { type: 'object', properties: {
// name: { type: 'string' }, price: { type: 'number' } } } },
// budget: { type: 'number', description: 'the most to spend' } },
// required: [] } } }]
required is empty because both parameters are written with = and have defaults.
One gap to know about: an integer example (price: 40, budget = 50) currently comes
out of getToolDefinitions with no type at all (price: {}), so the model is not told the
parameter is a number. Floats, strings, booleans, arrays and objects are typed.
The agent createAgent returns resolves to the run's result, which on failure is the
AgentError; check it with isAgentError. createAgent transpiles the source where it
runs, with no size cap, so use it for agents you wrote. For source you did not write, keep the
transpile-then-run split: getToolDefinitions takes any
object with a signature, so the one transpile returned is enough.
import { transpile, getToolDefinitions } from 'tjs-lang/lang'
import { AgentVM, isAgentError } from 'tjs-lang/vm-ast'
const vm = new AgentVM()
// On the transpiling side:
const { ast, signature } = transpile(source, {
atoms: vm.atoms,
maxSourceBytes: 16 * 1024,
})
const tools = getToolDefinitions({ shortlist: { signature } })
// On the VM side, when the model calls the tool with arguments:
const { result, error } = await vm.run(ast, args, { fuel: 1000 })
Tracing for audit
With trace: true, the result carries one event per step:
{
op: 'varsImport',
input: { keys: ['city'] },
stateDiff: { city: 'Oslo' }, // the variables the step changed
result: undefined,
error: undefined, // the message, when the step failed by throwing
fuelBefore: 999.878, // not 1000: admitting the arguments already cost a little
fuelAfter: 999.677,
timestamp: 1793989773225, // epoch milliseconds
}
An event is recorded when its step finishes, so a block (seq, if, try) appears after
the steps inside it, and the last event is the whole program. fuelBefore − fuelAfter is
what a step and everything inside it cost; in the host example above, the model call is about
100 of the 105.6. Store the trace beside the AST and the arguments and you can show what an
agent did, step by step, after the fact.
Where to run it
The VM runs in any JavaScript environment, browser or server, and the same AST behaves the same in each. Choose by what you can afford to lose:
- In-process. The simplest shape, and enough when the source comes from you or from
callers who transpile on their side. Fuel, the timeouts, the heap ceiling and the membrane
all hold. If you transpile untrusted source in the same process, set
maxSourceBytes. - A worker. Move the transpile step (and the run too, if you like) into a worker. A hostile payload that makes parsing slow then costs the worker, not the thread serving your requests.
- A separate process, or a container. Use this when your threat model includes what a JavaScript-in-JavaScript sandbox cannot address: timing side channels, JavaScript-engine JIT bugs and memory-level attacks. Process isolation underneath is the only answer to those.
Because the agent only ever sees atoms, the same program can run against a client-side atom that answers from data already loaded in the browser and a server-side one that queries the datastore, under one capability model. That design, and what it bought in production, is in The universal endpoint.
Further reading
- Safe Eval:
EvalandSafeFunction, the two-line entry points onto this VM, and the list of what the sandbox guarantees. - Capabilities and Custom Atoms: writing capabilities and atoms.
- AJS Patterns: retries, parallel calls and other work that belongs in the host.
- VM budgets: how fuel, the heap ceiling and the membrane stay true.