AJS: Code That Travels to Data

AJS is a subset of JavaScript for code you do not trust: agents written by a language model, scripts submitted by users, logic sent from another service. It is the familiar syntax with the dangerous parts left out, and it never runs as JavaScript.

An AJS program goes through three steps:

  1. Transpile. The source is parsed and checked on the side that owns it, and becomes a versioned JSON AST. Mistakes are refused here, with a message that says what to write instead.
  2. Ship. The AST is plain JSON, so it can be stored in a database, sent over the network, or inspected before anything runs it. This is what the title means: instead of pulling the data out to wherever the code lives, you send the program to the service that holds the data. The data stays put, the host decides what the program may touch, and the program can be read before it runs.
  3. Run. The AJS VM executes the AST. Every step costs fuel, so a runaway agent stops. The VM can reach nothing by default: no network, no storage, no model. The host passes each one in for this run as a capability, an object of functions given to vm.run (the quickstart below passes capabilities: { llm: { predict } }).

Fuel is counted in small units. An expression node costs 0.01, a variable write 0.1, an httpFetch 5 and a model call 100; a run's default budget is 1000.

The VM is the security boundary, and it only has to be correct and safe. Being helpful is the transpiler's job: it rejects bad programs before they reach the VM and explains the fix.

The built-in operations an agent calls, such as llmPredict, httpFetch or storeGet, are atoms. An atom takes one object of named inputs, storeSet({ key, value }), and the transpiler refuses a positional call, storeSet('k', v) (refused). Some JavaScript is left out, and each refusal names the replacement: there is no C-style for (use for...of, or while with a counter), and no throw, because an error is a value, not an exception that could escape into the host (call Error('message'), which a try/catch can take). The language guide lists every rule.

Who this book is for

A first agent

On the doc site this agent runs in the page, called with no arguments, and its result appears below the code:

function shortlist({ items = [
  { name: 'kettle', price: 40 },
  { name: 'toaster', price: 25 },
  { name: 'blender', price: 90 },
], maxPrice = 50 }) {
  let affordable = items.filter((item) => item.price <= maxPrice)
  let names = affordable.map((item) => item.name)
  let total = affordable.reduce((sum, item) => sum + item.price, 0)
  return { names, total }
}

It returns { names: ['kettle', 'toaster'], total: 65 } for about 5 fuel. The shape is the one every agent has: one destructured object in, one plain object out, so agents compose.

A parameter's value sets its type. maxPrice = 50 is a default, used when the caller leaves maxPrice out, and it makes maxPrice an integer: { maxPrice: 49.5 } is refused. Write 50.0 to accept any number. A colon (maxPrice: 50) would make the argument required, with 50 as an example of its type.

Quickstart for embedders

The host transpiles the agent, then runs the AST on tjs-lang/vm-ast, the VM build with no parser in it. This agent asks a model for a summary, so the host grants it a model, here a stand-in function, and allows exactly one call:

import { transpile } from 'tjs-lang/lang' // where the source is handled
import { AgentVM } from 'tjs-lang/vm-ast' // where the agent runs

const source = `
/**
 * Summarize a list of notes in one line.
 * @param notes - the notes to summarize
 */
function summarize({ notes = ['buy milk'], style = 'terse' }): { summary: '', count: 0 } {
  if (notes.length == 0) {
    return { summary: '', count: 0 }
  }
  let summary = llmPredict({
    prompt: \`Summarize, \${style}: \${notes.join('; ')}\`,
  })
  return { summary, count: notes.length }
}
`

// Model-written source is untrusted input to the parser too, so cap its size.
// A bad program throws here (see below); catch it where you accept submissions.
const { ast } = transpile(source, { maxSourceBytes: 64 * 1024 })

const vm = new AgentVM()
const { result, error, fuelUsed } = await vm.run(
  ast,
  { notes: ['ship the release', 'write the docs'] },
  {
    fuel: 1000, // the time budget: every step costs fuel
    capabilities: {
      // the only model this agent can reach; a real host calls its LLM here
      llm: { predict: async (prompt) => `summary of: ${prompt}` },
    },
    quotas: { llmPredict: 1 }, // at most one model call, whatever the fuel allows
  }
)

if (error) console.log(error.op, error.message)
else console.log(result, fuelUsed)
// { summary: 'summary of: Summarize, terse: ship the release; write the docs',
//   count: 2 }  101.4  (the model call is 100 fuel; the rest is about 1.4)

Transpiling reports a bad program by throwing. Had the agent used throw, transpile would throw a TranspileError with 'throw' is not supported in AsyncJS. Use Error('message') to trigger error flow, plus the line and column. Source over maxSourceBytes is refused the same way, before it is parsed.

Running never throws for a failing agent. vm.run returns { result, error, fuelUsed }, and on failure error is an AgentError naming the step that failed (error.op) and why (error.message). result then holds the same error object, so check error first. Changing one thing above shows each guard:

Change error.op error.message
leave out capabilities llmPredict Capability 'llm.predict' missing
fuel: 1 varAssign Out of Fuel
call llmPredict twice in the agent llmPredict Quota exceeded for 'llmPredict': 1 call allowed
{ notes: 'x' } as the arguments vm.run Input validation failed: args do not match expected schema
return { summary, count, debug: 1 } vm.run Result does not match the declared result type

With fuel: 1, error.op is whichever step was running when the fuel ran out; here that is the first assignment. The fourth row is the parameters at work: they are the agent's input contract, checked before it runs. The declared result after the parameter list, : { summary: '', count: 0 }, is the output contract, checked when the agent returns.

The llmPredict atom calls capabilities.llm.predict, and quotas are keyed by atom name. The Atom Reference lists the capability every atom needs.

Two more things the quickstart relies on:

Agents as tools

transpile also returns a signature: the entry function's name, its JSDoc description and its parameters. createAgent wraps an agent as an async function, and getToolDefinitions turns such functions into OpenAI-style tool definitions. When the model answers with a tool call, dispatch it by name:

const tools = { shortlist: createAgent(shortlistSource, new AgentVM(), { fuel: 1000 }) }
const definitions = getToolDefinitions(tools) // offer these to the model

// the model replies with a tool call
const call = { function: { name: 'shortlist', arguments: '{"maxPrice":30}' } }
await tools[call.function.name](JSON.parse(call.function.arguments))
// { names: ['toaster'], total: 25 }

Here shortlistSource is the first agent's source as a string. The function createAgent returns resolves to the run's result, which on failure is the AgentError. It also parses where it runs, with no size cap, so use it only for agents you wrote. The full version is in Agents as tools.

Reading path

  1. The language. The AJS Language: what is allowed and forbidden, the input and output contract, builtins, errors, and the JSON AST format.
  2. Examples. AJS Examples: runnable agents, from string handling to API calls and model pipelines.
  3. Patterns. AJS Patterns: what to write instead of break, switch, retries and parallel calls, and which of those belong to the host.
  4. Safe Eval. Safe Eval: the same VM as Eval and SafeFunction, for running snippets in place of eval() and new Function().
  5. Embedding. Embedding the AJS VM: run options, fuel, timeouts, the heap ceiling, quotas, and what vm.run returns.
  6. Capabilities. Capabilities and Custom Atoms, then The universal endpoint for one capability model shared by browser and server.
  7. Atom reference. Atom Reference: every atom, including the batteries (optional atoms for a local model and vector search).
  8. The LLM prompt. AJS LLM System Prompt: a system prompt for models that write AJS.
  9. Internals. VM budgets (how fuel and the heap ceiling stay true) and Technical Context (the VM's architecture).

Design notes, not yet shipped: the native VM (Rust compiled to wasm), the Lean specification, and Pattern, the planned replacement for regex. AJS supports regex today, on the VM's own linear-time engine; see Regular expressions.

To run the model examples on your own machine, see Running a local model (LM Studio) or Local models with MLX.

In this section