The AJS VM: Technical Context
AJS lets a program run code it did not write, from a user, another service or a language model, without trusting it. The code runs inside a VM (a virtual machine: a program that executes other programs). It cannot touch anything you did not hand it, and it always stops. AJS is a subset of TJS: JavaScript with typed signatures. You do not need this chapter to write TJS or AJS programs.
This chapter is advanced material: the map of how the AJS VM is built, for people embedding the VM in their own program or reviewing it. If you only want to write agents, read AJS: The Agent Language first, and you can skip this page. The day-to-day host API (every run option, capabilities, quotas, tracing) is in Embedding the AJS VM, and writing atoms is in Capabilities and Custom Atoms.
Terms
- Host: your program. It creates the VM, decides what the agent may do, and calls
vm.run. - Agent: the untrusted program the VM runs. (VM source code and some error messages call it the guest.)
- Front end: anything that produces an agent's AST: the transpiler, the builder, or a compiler of your own.
- Atom: one operation an agent can call, such as
httpFetchorstoreGet. The VM's core atoms are built in; the host can register more. - Capability: a piece of the host's IO (a
fetchfunction, a key-value store, a model client) that the host passes to a run. Atoms reach the outside world only through capabilities. - Fuel: the work budget. Every step spends some, and the run stops when it reaches zero. An
expression node costs 0.01, setting a variable 0.1,
storeSet5 andllmPredict100; the default budget is 1000. The full table is in What a run costs. - Membrane: the copy-and-check layer every value passes through when it enters or leaves the
VM. A value that crosses is a fresh copy of plain JSON data (plus
Date), so the agent never holds a live host object, and the host never receives a live agent one. - Heap ceiling: the cap on how much memory the agent may hold at once (
maxHeapBytes).
1. The pieces
An agent travels as a JSON AST: its program written out as plain JSON data. A front end produces the AST, and the VM runs it:
| Piece | Where it lives | What it does |
|---|---|---|
| Transpiler | transpile from tjs-lang/lang |
AJS source to AST. Checks atom calls, parameters and syntax, and explains what to write instead. |
| Builder | Agent from tjs-lang, or vm.Agent |
Builds the same AST in code with a fluent API. It does not check atom calls or parameters. |
| AST | plain JSON, "$ajs": 2 from the transpiler |
The wire format. It can be stored, sent over a network and run elsewhere. |
| VM | AgentVM from tjs-lang/vm-ast |
Runs an AST: the atoms, fuel, timeouts, the heap ceiling and the membrane. |
| Atoms | core atoms, plus any the host registers | The operations an agent can call (httpFetch, storeGet, llmPredict, your own). |
| Capabilities | the capabilities run option |
The host's IO: fetch, store, llm, agent, and whatever your own atoms read. Empty by default. |
The split between the front ends and the VM is deliberate. The builder and the transpiler
contain no execution logic, and the VM's core contains no parser; tjs-lang/vm-ast ships it
without one. That is why vm-ast is the recommended VM.
Parsing is the risky step. How long it takes and how much memory it uses is decided by the source text, and it happens before fuel, timeouts or any other budget apply, so crafted source can make it slow or memory-hungry. A VM with no parser cannot be attacked that way. So transpile on the side that owns the source (your build, a worker, a separate process), and send the AST:
import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'
// Where the source is handled: source in, JSON AST out.
// Parameter values are examples, as in TJS: 0.0 means "a number".
const { ast } = transpile(`
function total({ price: 0.0, taxRate: 0.0 }) {
return { total: price * (1 + taxRate) }
}
`)
// Where the agent runs: AST in, result out.
const vm = new AgentVM()
const run = await vm.run(ast, { price: 100, taxRate: 0.2 }, { fuel: 100 })
console.log(run.result, run.error) // { total: 120 } undefined
console.log(run.fuelUsed < 1) // true: this agent spends under 1 of its 100 fuel
tjs-lang/vm is the same AgentVM with the transpiler wired in, so it also accepts source.
Passing source to vm.run is deprecated as of 0.14.0; tjs-lang/vm-ast refuses it and says
where to transpile instead. On tjs-lang/vm, vm.run(source) refuses source over
maxSourceBytes (8 KB by default). If you call transpile yourself on source you did not write,
cap it the same way: transpile(source, { maxSourceBytes: 64 * 1024 }).
The builder
The builder is for generating agents in code. Start with Agent.take(schema) (the input
schema), chain atom calls, and finish with .toJSON(). vm.Agent is the same builder with the
VM's custom atoms added.
import { Agent, AgentVM } from 'tjs-lang'
import { s } from 'tjs-lang/schema'
const ast = Agent.take(s.object({ n: s.number })) // the input schema
.varsImport(['n']) // copy the input field n into the agent's state
.if(
'n > 10', // the condition, as a string
{ n: 'n' }, // condition name -> state variable
(then) => then.varSet({ key: 'size', value: 'big' }),
(otherwise) => otherwise.varSet({ key: 'size', value: 'small' })
)
.return(s.object({ size: s.string })) // the output schema
.toJSON()
const vm = new AgentVM()
console.log((await vm.run(ast, { n: 42 })).result) // { size: 'big' }
console.log((await vm.run(ast, { n: 1 })).result) // { size: 'small' }
An if or while condition in the builder is a string, parsed when the AST is built:
- Supported: comparisons,
&&,||,!, arithmetic, parentheses, member access (obj.foo.bar) and literals. - Refused: a ternary, an index (
a[0]) or a call (f(x)). Building the AST throwsUnsupported condition syntax. - The name map: the second argument maps each name the condition uses to a variable (or a
dotted path) in the agent's state. A name the condition reads that the map leaves out gets a
console.warnfrom the builder, at build time.
To test something the condition grammar refuses, compute it first with a varSet whose value is
an expression node (see the next section), then test that variable:
import { Agent, AgentVM } from 'tjs-lang'
import { s } from 'tjs-lang/schema'
const ast = Agent.take(s.object({ items: s.array(s.number) }))
.varsImport(['items'])
.varSet({
key: 'first', // items[0], as an expression node
value: {
$expr: 'member',
object: { $expr: 'ident', name: 'items' },
property: { $expr: 'literal', value: 0 },
computed: true,
},
})
.if(
'first > 10',
{ first: 'first' },
(then) => then.varSet({ key: 'size', value: 'big' }),
(otherwise) => otherwise.varSet({ key: 'size', value: 'small' })
)
.return(s.object({ size: s.string }))
.toJSON()
const vm = new AgentVM()
console.log((await vm.run(ast, { items: [42] })).result) // { size: 'big' }
The builder writes a version-1 AST ("$ajs": 1), the older format, in which a bare string that
names a variable in scope is read as that variable. In the first builder example, 'big' stays
a string only because the agent has no variable called big; if it had one, size would get
that variable's value. To write a string that is always a string, use a literal node:
value: { $expr: 'literal', value: 'big' }. The transpiler writes version 2, in which a bare
string is always a string, so for anything written by hand the transpiler is the safer front
end. Both formats run; they are described under JSON AST Format.
Expressions are nodes
There is no eval anywhere in the VM. Arithmetic and logic are AST nodes, evaluated by the VM:
$expr |
Shape |
|---|---|
literal |
{ $expr: 'literal', value: 5 } |
ident |
{ $expr: 'ident', name: 'x' } |
member |
{ $expr: 'member', object, property, computed? }; computed is obj[key] |
binary |
+ - * / % **, comparisons, == != === !== |
unary |
!, -, +, typeof |
logical |
&&, ||, ?? |
conditional |
test ? consequent : alternate |
array, object |
literals |
call |
a builtin (Math.max) or one of the agent's own helper functions |
methodCall |
a method the VM's method table lists (name.trim()) |
regex |
a regex literal, run on the VM's own engine, whose running time cannot blow up on crafted input |
Each node costs 0.01 fuel. __proto__, constructor and prototype are refused as property
names, read or written. new and this do not exist. A method can be called only if the
method table (METHOD_TABLE in src/vm/runtime.ts) has an entry for it, and every entry bounds
what the call can allocate; see VM budgets.
2. What happens in a run
vm.run(ast, args, options) does these things in order. The agent's code runs only in step 7.
1. Admit the options read each option once, check it, freeze the result
2. Resolve the AST a stored-procedure token (proc_…) is looked up here (section 5)
3. Gate the format an AST version this build cannot read is refused
4. Check the root the AST's root must be a seq (its top-level list of steps)
5. Admit the arguments copied through the membrane, budgeted by fuel and argsMaxBytes
6. Check the input the arguments against the AST's inputSchema
7. Execute the root seq, step by step, charging fuel; the run timeout,
the heap ceiling and per-atom quotas apply throughout
8. Check the output the result against the AST's outputSchema, if it declares one
9. Return { result, error, fuelUsed, trace?, warnings? }
The schemas come from the front end. The transpiler derives inputSchema from the function's
parameter examples and outputSchema from a declared result; the builder takes them from
.take() and .return().
Some failures come back as the run's error, and some throw into the host:
Comes back as error |
Throws into the host |
|---|---|
Anything the agent does: a failed atom, Error(...), out of fuel, a timeout |
A token that is missing (Procedure not found) or expired (Procedure expired) |
A bad run option (fuel: -1) |
An AST format version this build cannot read |
| Arguments the membrane refuses | A root that is not a seq |
Arguments that do not match inputSchema |
Source passed to tjs-lang/vm-ast |
A result that does not match outputSchema |
Source that fails to transpile, on tjs-lang/vm |
The rule: what the agent does, and whether its options, arguments and result are admitted, comes back as a value. A host mistake about what is being run throws.
error is an AgentError with the step that failed (error.op) and why (error.message).
Test for one with isAgentError(value), exported from tjs-lang/vm-ast.
3. The security model
The sandbox protects the host from the agent: the AST is untrusted. It does not protect the
host from its own atoms and capabilities: you register those, and they are trusted to be
non-blocking, to stop when the run's abort signal (ctx.signal, an AbortSignal) fires, and to
return plain data.
No ambient authority. The VM has no IO of its own. Every network call, store read or model call goes through a capability the host passed. See Capabilities.
Bounded work. Every step costs fuel; at zero the run ends with Out of Fuel. Work that grows
with its input (copying, string building, a regex) is charged in proportion. See
VM budgets.
Bounded time. Each run has a timeout. The default is the larger of 60 seconds and twice the
longest timeout of any registered atom; timeoutMs sets it. Each atom call also has its own
timeout, and the host can cancel a run with the signal option. See
Run options.
Bounded memory. maxHeapBytes (64 MB by default) caps what the agent holds live;
membraneMaxBytes (4 MB) caps one value crossing to or from a capability. See
VM budgets.
Bounded external calls. quotas: { llmPredict: 3 } caps how many times an atom may be called
in a run, which fuel cannot express. See Quotas.
A closed boundary. Values crossing to or from a capability are copies of JSON data plus
Date, built by the VM. Getters never run; functions and class instances are refused. See
What a capability may return.
No host code from the agent. No eval, no function values, no prototype keys; only listed
methods; regexes on the VM's own engine. See AJS: The Agent Language.
Errors are values. Failures are returned as AgentError, never thrown into the host. Inside
the agent, AJS's try/catch can catch a failure of the outside world: a fetch that returns an
error or is blocked, a capability answer that is too large. A bug at the capability boundary
ends the run even inside a try: an atom called with input that does not match its declared
schema, or a capability that returns a class instance. See
Catchable, and run-ending.
Each run starts with fresh state, and the host's capabilities object is used exactly as passed:
the VM never copies it or writes to it. When there is no store capability, the run gets a
private in-memory store, and a warning in the run's warnings if the agent uses it.
4. Request context
The context option carries request-scoped data, such as who is asking, to your atoms. The
agent cannot read it: it is not in the agent's scope, and context inside an agent is an
ordinary undefined name. So an atom can make authorization decisions that the agent cannot
influence.
defineAtom takes the atom's name, its input schema, its output schema and its implementation.
The implementation receives the input and ctx, the per-call context the VM passes to every
atom; ctx.context is the run's context option. An atom may throw: the VM turns that into a
failed step, which the agent can catch, and which otherwise becomes the run's error.
import { transpile } from 'tjs-lang/lang'
import { AgentVM, defineAtom } from 'tjs-lang/vm-ast'
import { s } from 'tjs-lang/schema'
const readRecord = defineAtom(
'readRecord', // name
s.object({ id: s.string }), // input schema
s.object({ id: s.string, owner: s.string }), // output schema
async ({ id }, ctx) => {
const user = ctx.context?.user
if (!user?.permissions.includes('records:read'))
throw new Error('not allowed to read records')
return { id, owner: user.id }
}
)
const vm = new AgentVM({ readRecord })
const { ast } = transpile(
`function report({ id: 'r1' }) {
const row = readRecord({ id })
return { row }
}`,
{ atoms: vm.atoms }
)
const allowed = await vm.run(ast, { id: 'r1' }, {
context: { user: { id: 'u-123', permissions: ['records:read'] } },
})
console.log(allowed.result) // { row: { id: 'r1', owner: 'u-123' } }
const denied = await vm.run(ast, { id: 'r1' }, {
context: { user: { id: 'u-456', permissions: [] } },
})
console.log(denied.error.message) // not allowed to read records
Passing { atoms: vm.atoms } to transpile lets it check calls to readRecord the way it
checks core atoms. The VM itself reads a few context keys, all for httpFetch's policy; they
are listed in Fetch policy.
Streaming partial results
vm.run returns once, when the agent finishes. To send results while it runs, give it an atom
that calls a callback from context:
import { transpile } from 'tjs-lang/lang'
import { AgentVM, defineAtom } from 'tjs-lang/vm-ast'
import { s } from 'tjs-lang/schema'
const emit = defineAtom(
'emit',
s.object({ data: s.any }),
s.null,
async ({ data }, ctx) => {
await ctx.context?.onChunk?.(data)
return null
}
)
const vm = new AgentVM({ emit })
const { ast } = transpile(
`function countdown({ from: 3 }) {
let n = from
while (n > 0) {
emit({ data: { n } })
n -= 1
}
return { done: true }
}`,
{ atoms: vm.atoms }
)
const chunks = []
const run = await vm.run(ast, { from: 3 }, {
context: { onChunk: (data) => chunks.push(data) },
})
console.log(chunks, run.result) // [{ n: 3 }, { n: 2 }, { n: 1 }] { done: true }
The agent writes emit(...) without await, but the VM waits for every atom call to finish
before the next step, so chunks arrive in order. In a server, onChunk would write to the
response (res.write(JSON.stringify(data) + '\n')). The atom's input has crossed the membrane,
so the callback receives a plain copy, never a live object from inside the agent.
5. Stored procedures
The storeProcedure atom stores an AST and returns a token, a string like
proc_6f1c… (proc_ plus a random UUID). A token can be run anywhere an AST can: by the host
with vm.run(token, args), or by an agent with agentRun({ agentId: token, input }). That lets
behaviour be passed around as a string, the way a function pointer would be.
import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'
const vm = new AgentVM()
const double = transpile(`function double({ n: 0 }) { return { n: n * 2 } }`).ast
// An agent that registers a handler and hands back its token.
// handler has no example value: it accepts anything, here an AST passed as plain JSON.
const register = transpile(`
function register({ handler }) {
const token = storeProcedure({ ast: handler, ttl: 300000 })
return { token }
}`).ast
// An agent that runs whichever handler it is given.
// 'proc_' is only an example value: it says "a string".
const dispatch = transpile(`
function dispatch({ handler: 'proc_', value: 0 }) {
const out = agentRun({ agentId: handler, input: { n: value } })
return { answer: out.n }
}`).ast
const { token } = (await vm.run(register, { handler: double })).result
console.log((await vm.run(token, { n: 21 })).result) // { n: 42 }
console.log((await vm.run(dispatch, { handler: token, value: 5 })).result) // { answer: 10 }
An AST is plain JSON, so it is a legal run argument: it crosses the membrane like any other data.
How the store behaves:
- It is in memory, per process. Tokens do not survive a restart and are not shared between
processes or machines. The store is
procedureStore, aMapexported from the VM, so a host can inspect it or clear it. - Entries expire. The default
ttlis one hour (3,600,000 ms). Running an expired token fails withProcedure expired.releaseProcedure({ token })deletes one early and returns whether it existed;clearExpiredProcedures()sweeps expired entries and returns how many it removed. - Size is capped. An AST over
maxSizebytes of JSON is refused; the default is 100 KB, andstoreProcedure({ ast, maxSize })sets another. The AST's root must have anopfield (every AST from the transpiler or the builder has one); anything else is refused asInvalid AST. - The format is checked twice: when the AST is stored and again when the token is run, so an AST this build cannot read fails at the door, not later in someone else's run.
- A token is a capability to run code, not to gain privileges. The stored AST runs with the capabilities, fuel and quotas of whoever runs the token, never of whoever stored it. Tokens are random, so they cannot be enumerated, but anyone holding one can run it in the same process.
| Atom | Base fuel | Notes |
|---|---|---|
storeProcedure |
1 | plus copying the AST, charged by its size (VM budgets) |
releaseProcedure |
0.1 | |
clearExpiredProcedures |
0.5 |
6. Sub-agents and whose budget they spend
agentRun({ agentId, input }) runs another agent, which sees only input. What agentId is
decides where it runs:
- An AST, or a string starting with
proc_(a stored-procedure token) runs inside the caller's run. It spends the caller's fuel, counts against the caller's quotas, and uses the caller's capabilities. A sub-agent that loops forever runs the caller out of fuel; it cannot escape the budget. - Any other string (usually a name) goes to the host's
agentcapability, whose minimal shape is{ agent: { run: async (agentId, input) => result } }. That is typically a newvm.run, with a fresh fuel budget and fresh quotas that the host chooses. Without anagentcapability, the call fails withCapability 'agent.run' missing.
An inline sub-agent's declared parameters and result are not checked: steps 6 and 8 of a run
happen only in vm.run. A stored function double({ n: 0 }): { n: 0 } run through agentRun
with { n: 'x' } runs and returns whatever it computes. If a sub-agent's contract matters, check
its result in the caller, or serve it through the agent capability with its own vm.run.
So the host decides how much a named sub-agent may spend. The worked example of holding one quota across a whole call tree is in Quotas across nested runs; composition patterns (fan-out, pipelines, workers) are in Composing Agents.
7. Runs are not transactions
If an agent fails part way through, what it already did through a capability stays done. The VM has no rollback, because it cannot undo a write it does not own:
import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'
const vm = new AgentVM()
const { ast } = transpile(`
function transfer({ amount: 0 }) {
storeSet({ key: 'debited', value: amount })
if (amount > 100) {
Error('limit exceeded') // AJS has no throw: calling Error(...) fails the run
}
storeSet({ key: 'credited', value: amount })
return { ok: true }
}`)
// The smallest store capability: async get and set.
const data = new Map()
const store = {
get: async (key) => data.get(key),
set: async (key, value) => {
data.set(key, value)
},
}
const run = await vm.run(ast, { amount: 500 }, { capabilities: { store } })
console.log(run.error.message, Object.fromEntries(data))
// limit exceeded { debited: 500 }
When several writes must succeed or fail together, make that one capability call that the host
performs atomically (a transfer atom that runs a database transaction), rather than a sequence
the agent drives. How Error(...) and try/catch work is in Errors.
8. Dependencies and size
tjs-lang/vm-ast needs one package at runtime, tosijs-schema (^1.12.0), for schema
validation. 1.12 is the floor because it marks its schema builders, so tjs-lang can tell a
builder from a plain object that happens to have a schema key. The transpiler adds acorn,
the JavaScript parser. The package as a whole also ships acorn-walk (for the linter and the
predicate verifier) and acorn-loose (so editor tooling can read half-typed code). Between them
they pull in nothing else: acorn-walk and acorn-loose depend only on acorn, and acorn and
tosijs-schema have no dependencies.
Sizes, measured on the 2026-11-05 build with tosijs-schema left external: tjs-lang/vm-ast
is about 137 KB minified (43 KB gzipped); tjs-lang/vm, which adds the transpiler and acorn,
is about 324 KB (98 KB gzipped).
Further reading
- Embedding the AJS VM: run options, capabilities, quotas, tracing.
- VM budgets: how fuel and the heap ceiling are measured.
- The universal endpoint: the same agent and atoms running in a browser and on a server, under one security model.