The AJS Language

Code as data. Safe. Async. Sandboxed.

This chapter is the language guide and reference for people (and models) who write AJS agents. Running agents in your own application (budgets, timeouts, capabilities, tracing) is covered in Embedding the AJS VM; giving agents new powers is in Capabilities and Custom Atoms; every atom's inputs and outputs are in the Atom Reference.


What is AJS?

AJS (AsyncJS) is a JavaScript subset that compiles to a JSON AST. It is designed for code you do not trust: user scripts, LLM-generated agents, logic sent from somewhere else.

function repoSummary({ owner = 'tonioloewald', repo = 'tjs-lang' }) {
  let r = httpFetch({ url: `https://api.github.com/repos/${owner}/${repo}` })
  return { name: r.full_name, stars: r.stargazers_count, language: r.language }
}

The compiled form is plain JSON, so it can be:

Three words recur on every page of this book:

httpFetch above is an atom: a built-in operation the VM provides. Every effect an agent has on the world goes through an atom, and every atom that touches the world needs a capability the host chose to grant.

How the pieces fit

Source goes to the transpiler, which writes the JSON AST. The host gives the AST, the arguments and a budget to the VM, and gets back a value, never an exception:

import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'

const { ast } = transpile(`
  function add({ a: 0, b: 0 }) {
    return { sum: a + b }
  }
`)
const vm = new AgentVM()
const run = await vm.run(ast, { a: 2, b: 3 }, { fuel: 100 })
// run.result: { sum: 5 }, run.error: undefined, run.fuelUsed: about 0.5

Transpiling and running can happen in different places: the AST is data, so it can be compiled once, stored, and run later or elsewhere. Capabilities are passed as vm.run(ast, args, { capabilities }). Everything about the host side is in Embedding the AJS VM.

Use cases

AI agents

Fetch, think, answer. The model call needs an LLM capability. Without one, llmPredict fails with Capability 'llm.predict' missing (an ordinary error a try can catch), so run this with a model configured (see Embedding the AJS VM). On the doc site, where no model is available, the live example says so.

function researchAgent({ topic = 'sandboxing' }) {
  let search = httpFetch({
    url: `https://api.github.com/search/repositories?q=${encodeURIComponent(topic)}&per_page=5`,
  })
  let names = search.items.map((repo) => repo.full_name)
  let summary = llmPredict({
    prompt: `In two sentences, what do these projects about ${topic} have in common? ${names.join(', ')}`,
  })
  return { topic, names, summary }
}

Rule engines

Business rules that change more often than the application, stored as data and run safely.

function applyDiscounts({ cart = { total: 120 }, userTier = 'gold' }) {
  let discount = 0

  if (userTier === 'gold') {
    discount = 0.2
  } else if (userTier === 'silver') {
    discount = 0.1
  }

  if (cart.total > 100) {
    discount = discount + 0.05
  }

  return {
    originalTotal: cart.total,
    discount,
    finalTotal: cart.total * (1 - discount),
  }
}

Returns { originalTotal: 120, discount: 0.25, finalTotal: 90 }.

Smart configuration

Configuration that decides, rather than configuration that lists.

function routeRequest({
  request = { path: '/api/users/42' },
  config = {
    rules: [
      { prefix: '/api/', backend: 'api-pool', timeout: 5000 },
      { prefix: '/static/', backend: 'cdn', timeout: 1000 },
    ],
    defaultBackend: 'web-pool',
  },
}) {
  for (let rule of config.rules) {
    if (request.path.startsWith(rule.prefix)) {
      return { backend: rule.backend, timeout: rule.timeout }
    }
  }
  return { backend: config.defaultBackend, timeout: 30000 }
}

Returns { backend: 'api-pool', timeout: 5000 }.

Remote jobs

Send the code to the data instead of the data to the code. The client transpiles the agent and sends its AST to the service that holds the data; the service runs it there with its own capabilities and budget, and only the result travels back. The agent itself is ordinary AJS:

function applyTransform(item, transform) {
  if (transform == 'double') {
    return item * 2
  }
  return item
}

function processDataBatch({ items = [1, 2, 3], transform = 'double' }) {
  let results = []
  for (let item of items) {
    let processed = applyTransform(item, transform)
    results.push(processed)
  }
  return { processed: results.length, results }
}

Returns { processed: 3, results: [2, 4, 6] }. applyTransform is a helper, and helpers take ordinary positional arguments; only the last function, the entry point, takes one object of named arguments (see Local helper functions).


Syntax

AJS is a JavaScript subset: familiar syntax, fewer features. It is deliberately small, small enough that small local models write it well; the project measures this with a pinned small model (see Maintaining this prompt).

What's Allowed

One agent that uses most of the language:

function tour({ name = 'Ada', items = [{ name: 'a', qty: 2 }, { name: 'b', qty: 0 }], limit = 3 }) {
  // Variables
  let count = 0
  const greeting = `Hello, ${name}!`

  // Conditionals, ternary, logical operators
  const size = items.length > limit ? 'big' : 'small'
  const label = name || 'anonymous'
  const note = items[0].note ?? 'none' // member reads are already null-safe; no ?. needed

  // for...of and while (there is no C-style for)
  let names = []
  for (const item of items) {
    if (item.qty > 0) {
      names.push(item.name)
    }
  }
  while (count < limit) {
    count += 1
  }

  // Object and array literals, spread. To change a member, build a new object.
  const defaults = { color: 'red', size }
  const merged = { ...defaults, size: 'medium' }
  const all = [...names, 'z']

  // Arrow functions, passed straight to map / filter / find / reduce
  const total = items.reduce((sum, item) => sum + item.qty, 0)

  // try / catch (see Errors)
  let parsed = null
  try {
    parsed = JSON.parse('{ not json')
  } catch (err) {
    parsed = { failed: true }
  }

  return { greeting, size, label, note, names, count, merged, all, total, parsed }
}

It returns:

{
  greeting: 'Hello, Ada!',
  size: 'small',
  label: 'Ada',
  note: 'none',
  names: ['a'],
  count: 3,
  merged: { color: 'red', size: 'medium' },
  all: ['a', 'z'],
  total: 2,
  parsed: { failed: true },
}

Also allowed: every compound assignment (+=, -=, *=, …), ++ and -- as statements, assignment to an outer variable from inside a loop body, typeof, % and **.

console.log, console.warn and console.error are for debugging: each call, with its arguments, shows up in the run's trace when the host turns tracing on. They print nothing by themselves (see Tracing for audit).

Loop idioms

There is no C-style for, no break and no continue. These three shapes cover what they did:

function loops({ xs = [1, 5, 9, 2] }) {
  // Walk indices: while with a counter
  let i = 0
  let labelled = []
  while (i < xs.length) {
    labelled.push(`${i}:${xs[i]}`)
    i += 1
  }

  // Stop early: put the stop condition in the while
  let j = 0
  let found = null
  while (found == null && j < xs.length) {
    if (xs[j] > 4) {
      found = xs[j]
    }
    j += 1
  }

  // Skip an item: an if around the body
  let small = []
  for (const x of xs) {
    if (x < 5) {
      small.push(x)
    }
  }

  return { labelled, found, small }
}

Returns { labelled: ['0:1', '1:5', '2:9', '3:2'], found: 5, small: [1, 2] }. A return inside a loop also ends it, and the whole function. More shapes are in Patterns.

Calling atoms

Atoms take named arguments. Call an atom with one object literal, storeSet({ key, value }), or with nothing, random(). The transpiler refuses a positional call, a spread, an input the atom does not declare, and a missing required input:

The transpiler checks input names. It does not check value types (httpFetch({ url: 5 }) transpiles, and fails when it runs), and it does not know atoms the host adds unless the host passes them in; a call to a name that is not an atom at all fails at run time with Unknown Atom: <name>. These checks help the author. They are not the safety boundary: the VM is, and it is safe whatever AST it is given.

Like a helper call, an atom call stands on its own: let id = uuid(), then use id. An atom call inside a larger expression (return { id: uuid() }) is refused when the agent is transpiled. See Where calls may appear.

Your own functions (helpers, below) and builtins (Math.max(a, b), encodeURIComponent(s)) take positional arguments as usual. Error('message') looks like a builtin but raises an error; see Errors.

Every atom call is awaited for you. There is no await (it is a transpile error), and an async marker on a function is ignored.

The atoms an author reaches for most:

Atom Returns
httpFetch({ url, method?, headers?, body?, responseType? }) the response body: parsed JSON for a JSON response, else text; a data: URL for responseType: 'dataUrl'
llmPredict({ prompt, options? }) the model's completion, as a string
storeGet({ key }) the stored value, or undefined
storeSet({ key, value }) nothing
agentRun({ agentId, input }) the other agent's result object
random(), uuid() a number from 0 up to 1; a UUID string
hash({ value, algorithm? }) a hex digest string, SHA-256 unless algorithm says otherwise

httpFetch goes through the host's fetch capability, llmPredict through its model, and the store atoms through its store. An agentId is an AST (for example one passed in as an argument), a token from storeProcedure, or a name the host's agent capability knows. The full list, with inputs, outputs, costs and the capability each needs, is the Atom Reference.

Local helper functions

A source file contains only function declarations. The last one is the entry point; the ones before it are helpers that the entry (or other helpers) call by name:

function double(x) {
  return x * 2
}

function addOne(x) {
  const d = double(x) // helpers can call earlier helpers
  return d + 1
}

function main({ n = 3 }) {
  const a = double(n)
  const b = addOne(n)
  return { a, b } // only the entry must return an object
}

Returns { a: 6, b: 7 }. Helpers behave like ordinary functions, with a few deliberate rules:

Helper bodies are compiled once and dispatched by name, so calling a helper many times (or in a loop) does not grow the agent's AST.

What's Forbidden

Each of these is refused, most when the agent is transpiled. The rows marked run time transpile and fail when they run.

Feature Instead
C-style for (;;), for...in, do...while for...of, or while; see Loop idioms
break, continue see Loop idioms
switch if / else if
throw Error('message'); see Errors
a computed object key, { [k]: v } Object.fromEntries([[k, v]]) (a literal key, ['a b'], works)
Writing a member or index (o.a = 1, xs[0] = v) build a new value, o = { ...o, a: 1 }, or xs.push(v)
Destructuring declarations (let { a } = o) const a = o.a; destructured parameters are fine
class, new, this plain objects; Date() and Set() without new
A builtin or method as a value (run time) call it; see below
await nothing: the VM awaits every atom call
eval, Function nothing: no string runs as code
import / export an agent is self-contained; the host chooses the atoms
__proto__, constructor, prototype (run time for reads) other property names
++ / -- used as a value x++ as its own statement, or x = x + 1

Some messages name the fix ("use for...of, or map/filter/find/reduce"); others are terse, such as Unsupported statement type: BreakStatement or Only simple variable assignment is supported. This table is the translation.

A name must be declared before it is read: a parameter, a let/const/var, a loop or callback variable, or the catch binding. Reading anything else (a typo, a helper or atom used as a value, let g = double) is refused when the agent is transpiled, with the name.

Differences from JavaScript

Null-safe member access. Agents work on data from outside (API responses, model output) that is often missing a field, and a crash on the first absent field would make every agent a pile of checks. So every member access behaves like optional chaining: reading a property of null or undefined gives undefined instead of throwing.

function f({ x = null }) {
  let y = x.foo.bar // undefined, not a TypeError
  return { missing: y === undefined }
}

Returns { missing: true }. The cost: a typo such as user.nmae also reads as undefined instead of failing. Declared parameter types and a declared result (see Input/Output Contract) catch some of these at the edges.

Computed member access works with literals and variables: items[0], user['name'], items[i] and user[key] are all member reads, with the same null-safety. The key must be a string, number, boolean or null.

Equality. == is ===, with two exceptions: null == undefined is true, and NaN == NaN is true. Nothing is coerced ('1' == 1 is false), and objects and arrays compare by identity, not contents. === and !== are strict identity.

function equality() {
  return {
    noCoercion: '1' == 1, // false
    nullish: null == undefined, // true
    notStructural: [1, 2] == [1, 2], // false: distinct arrays
    nan: NaN == NaN, // true
  }
}

Comparing contents. Since == on objects compares identity, compare the fields you care about, or compare JSON.stringify(a) == JSON.stringify(b) when key order is the same on both sides. (TJS's structural Is does not exist in AJS.)

Object.assign returns a new object. It never changes its first argument: Object.assign(o, extra) leaves o as it was. Write o = Object.assign(o, extra), or o = { ...o, ...extra }.

Functions and namespaces are not values. Call them; don't hold them. Math.max(a, b) and s.trim() work; const m = Math, const f = parseInt, items.map(parseInt) and Object.values(Math) are refused. A value in AJS is data: numbers, strings, booleans, null, arrays and objects. Sets and dates are data too (an array, and a plain object of fields; see Set and Date), so reading one of their methods as a value (const f = tags.add) is refused as well.

Operators

Arithmetic and ordering operators (+, -, *, /, <, >, <=, >=, …), template interpolation and computed keys take primitives: strings, numbers, booleans or null. Mixing primitives works as in JavaScript ('a' + 1 is 'a1', 'a' < 'b' is true). An object or array operand is an error, not a silent conversion to its string form. It is an ordinary error, which a try can catch:

function operators({ tags = ['a', 'b'] }) {
  // 'tags: ' + tags would be an error: '+' needs a string, number, boolean or null, not an array.
  return { joined: 'tags: ' + tags.join(','), json: JSON.stringify({ tags }) }
}

Say what you mean: arr.join(','), JSON.stringify(obj). JavaScript's implicit conversion of an array to a string is an allocation the VM cannot budget, so AJS refuses it rather than guessing.

==, !=, === and !== accept any values; on objects they compare identity.

Methods

These are the methods a guest may call. Anything else is refused, and the error names it.

Receiver Methods
string at charAt charCodeAt codePointAt concat endsWith startsWith includes indexOf lastIndexOf localeCompare normalize padStart padEnd repeat slice substring substr split replace replaceAll match search toLowerCase toUpperCase trim trimStart trimEnd toString
number toFixed toExponential toPrecision toString
array map filter find reduce (with an arrow), at concat copyWithin fill includes indexOf lastIndexOf join pop shift reverse push unshift slice sort toSorted toReversed splice toSpliced with flat toString
object hasOwnProperty propertyIsEnumerable toString
set, date see Set and Date

The namespaces (Math, JSON, Array, Object, String, Number, Date, Schema) are under Expression Builtins. Three rules apply to every call:

Why the list is closed (every method has a declared bound on what it may allocate): VM budgets.

Regular expressions

Regex is deprecated in AJS 0.14. Its planned replacement, Pattern (a grammar built as data; see Pattern), is not in the VM yet, so regex is what you use today. It works within the limits below, and is planned to leave the core VM no earlier than 0.15.

Regex literals work as search patterns in replace, replaceAll, split, match and search, and as the pattern of the regexMatch atom:

function regexes({ text = 'order 10-20, ref 7' }) {
  let dashed = text.replace(/\d+/g, '#')
  let parts = text.split(/,\s*/)
  let found = text.match(/(?<lo>\d+)-(?<hi>\d+)/)
  let hasRef = regexMatch({ pattern: /ref \d/, value: text })
  return { dashed, parts, lo: found.groups.lo, hasRef }
}

Returns dashed: 'order #-#, ref #', parts: ['order 10-20', 'ref 7'], lo: '10' and hasRef: true. Regex objects have no methods of their own: re.test(s) is refused, so use regexMatch, or s.search(re) != -1.

Guest regexes run on the VM's own linear-time engine, never the host's, so no pattern can hang a run; the work is charged as fuel. The engine supports classes, ., anchors, \b, groups (capturing, non-capturing, named), alternation and every quantifier, greedy and lazy, with flags g i m s u y.

It does not support backreferences (\1, \k<n>), lookahead or lookbehind, \p{…}, or the d and v flags; a regex literal using one is a transpile error at its location. Very large patterns are refused too. As a rule of thumb, keep repeat counts in the low thousands: {10000} is the hard limit for a fixed count, and an optional range is refused well before it (\w{0,5000} is too big).


Input/Output Contract

An agent is called with one object of named arguments, and it returns one object. That is what makes agents composable: one agent's output is the next one's input.

function add({ a = 2, b = 3 }) {
  return { sum: a + b }
}

A colon in a parameter is not JavaScript renaming. In JavaScript, { a: x } in a parameter renames a to x, and { a: 0 } is a syntax error. In AJS, { a: 0 } means "required argument a, and 0 is an example of it": an integer.

Parameters are the input contract

The VM checks the arguments against the parameters before the agent runs. A mismatch never reaches the agent: the host gets back a run whose error is Input validation failed: args do not match expected schema (the message does not name the field), and no fuel is spent.

What an example accepts:

Example Accepts
0 an integer
0.0 any number
+0 an integer, 0 or more
'' a string
true a boolean
[''] an array of strings
{ k: 0 } an object with exactly a required integer k
'low' | 'high' one of these exact strings
null any value

So function f({ a: 0, b = 'x' }) accepts { a: 1 } and { a: 1, b: 'y' }, and refuses {}, { a: 'one' } and { a: 1, z: 2 }.

A declared result is the output contract

A declared result is held to the same standard: the run ends in an error if the agent returns something else.

function score({ n = 12 }): { score: 0, label: 'low' | 'high' } {
  return { score: n, label: n > 10 ? 'high' : 'low' }
}

A lone example widens to its type (score: 0 means any integer). A union of string literals, 'low' | 'high', is an exact list of allowed values, like an enum.

Both contracts travel in the AST, as inputSchema and outputSchema, so any VM that runs the AST enforces them. Only the entry function's result is checked; a helper's declared return is not yet enforced.


Expression Builtins

Expressions have these builtin namespaces and constructors. All are called, never held as values.

Math

All of JavaScript's Math, including Math.PI:

function maths() {
  return {
    abs: Math.abs(-5), // 5
    floor: Math.floor(3.7), // 3
    sqrt: Math.sqrt(16), // 4
    max: Math.max(1, 2, 3), // 3
    round: Math.round(2.5), // 3
    roll: Math.floor(Math.random() * 6) + 1, // 1 to 6
  }
}

JSON, Array, Object, String, Number

function statics() {
  return {
    parsed: JSON.parse('{"a": 1}'), // { a: 1 }
    text: JSON.stringify({ a: 1 }), // '{"a":1}'
    isArray: Array.isArray([1, 2]), // true
    chars: Array.from('abc'), // ['a', 'b', 'c']
    of: Array.of(1, 2, 3), // [1, 2, 3]
    keys: Object.keys({ a: 1 }), // ['a']
    values: Object.values({ a: 1 }), // [1]
    entries: Object.entries({ a: 1 }), // [['a', 1]]
    fromEntries: Object.fromEntries([['a', 1]]), // { a: 1 }
    assigned: Object.assign({}, { a: 1 }, { b: 2 }), // a new object: { a: 1, b: 2 }
    letter: String.fromCharCode(65), // 'A'
    isNaN: Number.isNaN(NaN), // true
    finite: Number.isFinite(100), // true
    int: Number.parseInt('42'), // 42
    float: Number.parseFloat('3.14'), // 3.14
  }
}

parseInt, parseFloat and encodeURIComponent are available as plain functions too. To turn a number into a string, use a template: `${n}`. String(n) and Number(s) are not functions in AJS. Because of how the transpiler reads calls, the error says something unhelpful, 'String' takes named arguments; it means String is not available.

Set

Set(items) makes a set of unique items. A set is an array of its items: JSON.stringify, a store or a capability sees a plain array. The VM remembers which arrays are sets for the length of the run and supplies their methods to those, and only those.

function sets() {
  const tags = Set(['a', 'b', 'b']) // ['a', 'b']
  tags.add('c') // in place
  tags.remove('a')
  return {
    tags, // ['b', 'c']
    has: tags.has('b'), // true
    size: tags.size, // 2
    union: tags.union(['d']), // a new set: ['b', 'c', 'd']
    both: tags.intersection(['b', 'x']), // ['b']
    diff: tags.diff(['b']), // ['c']
  }
}

Sets also have clear() and toArray().

Date

Date(x) (or Date() for now) makes a date. x is an ISO string, a number of milliseconds, another date, or a stored date: an object with a numeric timestamp, which is what a date becomes after JSON, a store or a capability. Date(stored) turns it back into a date; until you do, it is a plain object without date methods. Date.parse(text) is the same as Date(text).

A date is a data object with the fields value (ISO string), timestamp (ms), year, month, day, hours, minutes, seconds and dayOfWeek. Every field is in UTC, so a program gives the same result on every host. You cannot change a field (AJS has no member assignment); d.add(…) makes a new date. A date has one form everywhere: JSON.stringify(d), a capability's input and the run's result all see this object. For the ISO string, write d.value.

month runs from 1 to 12, unlike JavaScript's 0 to 11. January is 1.

function dates() {
  const d = Date('2024-01-15T10:00:00Z')
  const next = d.add({ days: 1 }) // a new date; also years, months, hours, minutes, seconds, ms
  return {
    year: d.year, // 2024
    month: d.month, // 1
    days: next.diff(d, 'days'), // 1 (also 'seconds', 'minutes', 'hours'; default ms)
    before: d.isBefore(next), // true (also isAfter)
    day: d.format('YYYY-MM-DD'), // '2024-01-15', in UTC
    iso: d.value, // '2024-01-15T10:00:00.000Z'
    now: Date.now(), // a timestamp
  }
}

format takes either a keyword, 'ISO', 'date' (2024-01-15) or 'time' (10:00:00), or a template using the tokens YYYY, MM, DD, HH, mm and ss, each replaced once. Other letters are left as they are.

Schema

Build JSON schemas for structured model output and for validation. In AJS a schema is plain data: Schema.string, Schema.email, … are JSON schemas, and Schema.object(…), array, record, union, tuple, enum, const, fromExample, response and isValid build or check plain JSON. There is no builder chaining (.min(), .optional): write the keyword.

Wherever a schema is expected, you may pass an example instead, and it is read the way a parameter example is: { name: '', age: 0 } means an object with a string name and an integer age. An object whose type is a JSON type name ('string', 'object', …) or a list of them is read as a schema, not an example, so an example cannot have a field called type.

function validate({ input = { email: 'a@b.co', age: 30, role: 'user' } }) {
  let user = Schema.object({
    email: Schema.email,
    age: { type: 'integer', minimum: 0, maximum: 150 }, // constraints are plain keywords
    nickname: { type: ['string', 'null'] },
    role: Schema.enum(['admin', 'user', 'guest']),
  })
  let ok = Schema.isValid(input, user)

  // For a model's structured output, from an example
  let format = Schema.response('person', { name: '', age: 0 })
  return { ok, format }
}

ok is true for the default input. In Schema.object, every field is required except one whose type includes 'null', as nickname does here.

Schema.response(name, schema) wraps a schema in the shape a model's responseFormat takes, { type: 'json_schema', json_schema: { name, strict, schema } }. Pass it as llmPredict({ prompt, options: { responseFormat: format } }).

A guest schema is a closed dialect. It may use only these keywords, each with exactly its JSON value type:

pattern is refused. To check a field's shape, validate first and match the field afterwards:

function checkCode({ input = { code: 'AB-12' } }) {
  let ok = Schema.isValid(input, { code: '' })
  let shaped = regexMatch({ pattern: /^[A-Z]{2}-\d{2}$/, value: input.code })
  return { ok: ok && shaped }
}

Returns { ok: true }. Validation costs fuel in proportion to schema size times data size.


Errors

Raising and catching

There is no throw. Calling Error('message'), without new, puts the program into its error state: the steps after it do not run until a catch takes the error.

function safeDivide({ a = 10, b = 0 }) {
  try {
    if (b == 0) {
      Error('Division by zero')
    }
    return { result: a / b }
  } catch (err) {
    return { error: err }
  }
}

Returns { error: 'Division by zero' }.

Collect failures and carry on, the pattern for batch work where one bad item should not sink the rest:

function parseAll({ items = ['1', 'x', '3', '{"a":4}'] }) {
  let results = []
  let errors = []
  for (const item of items) {
    try {
      let value = JSON.parse(item)
      results.push(value)
    } catch (err) {
      errors.push({ item, error: err })
    }
  }
  return { results, errors, ok: errors.length == 0 }
}

Returns results: [1, 3, { a: 4 }], ok: false, and one entry in errors whose item is 'x' and whose error is the parser's message.

Catchable, and run-ending

The rule: if the world did it, you can catch it; if your program or the host broke a contract, the run ends.

try/catch catches failures of the world: a fetch that fails or is not allowed, a model that is down, bad JSON, a store that throws, a capability's answer that is too large or does not have the shape the atom declares. It also catches ordinary mistakes inside the program, such as an operator given an array. Every one of these is something a program might reasonably retry or work around.

function blocked() {
  try {
    httpFetch({ url: 'https://example.com/' })
  } catch (err) {
    return { caught: err } // 'Fetch blocked: …'
  }
  return { fetched: true }
}

httpFetch reaches only the domains the host allows (or whatever its own fetch capability permits), and the message says which rule refused it; see Fetch policy.

Some errors end the run, even inside a try. Nothing after them runs: no catch block, no later step, no step of a parent agent.

function badInput({ port = 8080 }) {
  try {
    httpFetch({ url: port }) // url must be a string; port is a number
  } catch (err) {
    return { caught: err } // never reached
  }
  return { after: true } // never reached either
}

This run ends with 'httpFetch': its input does not have the shape the atom declares. A bug is not something to retry, and a program that could catch these could loop on them. The transpiler checks atom input names, not their types, so this is found only when the call runs.

One exception: when agentRun hands a sub-agent to the host, the host runs it as a separate run. A refusal ends that run, and the parent sees an ordinary failure it can catch.


What a run costs

Every step costs fuel. The host sets the budget per run; if it sets none, the budget is 1000. The base costs:

Operation Fuel
Expression node 0.01
Variable set/get 0.1
Control flow (if, while, a block of steps) 0.1
httpFetch 5
llmPredict 100
storeVectorize (battery, if the host adds it) 20
llmVision (battery, if the host adds it) 150

Most agents are cheap except for their model calls: the applyDiscounts rule engine above costs about 2 fuel, and repoSummary about 7, most of it the fetch. A budget of 1000 buys fewer than ten llmPredict calls.

Work that grows with its input (copying, string building, a regex, a validation) is charged on top, in proportion to the work. A while (true) loop simply runs out. Hosts also set a wall-clock timeout, a live-memory ceiling and per-atom call quotas; see Embedding the AJS VM.


JSON AST Format

AJS compiles to a JSON AST. You read it to audit what an agent will do before it runs (every atom call is a named step), to debug the transpiler, or to build agents in code. This is the version-2 format the transpiler writes ("$ajs": 2).

An agent

function greet({ name: 'Ada' }) { let r = httpFetch({ url: 'https://example.com/' + name }); return { r } } compiles to:

{
  "$ajs": 2,
  "op": "seq",
  "steps": [
    { "op": "varsImport", "keys": ["name"] },
    {
      "op": "httpFetch",
      "url": {
        "$expr": "binary",
        "op": "+",
        "left": { "$expr": "literal", "value": "https://example.com/" },
        "right": { "$expr": "ident", "name": "name" }
      },
      "result": "r"
    },
    {
      "op": "return",
      "value": { "r": { "$expr": "ident", "name": "r" } }
    }
  ],
  "inputSchema": {
    "type": "object",
    "properties": { "name": { "type": "string" } },
    "required": ["name"],
    "additionalProperties": false
  }
}

Expressions

// Literal
{ "$expr": "literal", "value": 42 }

// Identifier
{ "$expr": "ident", "name": "varName" }

// Binary operation
{ "$expr": "binary", "op": "+", "left": {...}, "right": {...} }

// Member access
{ "$expr": "member", "object": {...}, "property": "foo" }

// Template literal: `Hello, ${name}!` compiles to string concatenation
{ "$expr": "binary", "op": "+",
  "left": { "$expr": "binary", "op": "+",
            "left": { "$expr": "literal", "value": "Hello, " },
            "right": { "$expr": "ident", "name": "name" } },
  "right": { "$expr": "literal", "value": "!" } }

// Method call (only the methods listed under Methods)
{ "$expr": "methodCall", "object": {...}, "method": "split", "arguments": [...] }

Conditionals

{
  "op": "if",
  "condition": {
    "$expr": "binary", "op": ">",
    "left": { "$expr": "ident", "name": "x" },
    "right": { "$expr": "literal", "value": 0 }
  },
  "then": [ ... ],
  "else": [ ... ]
}

Loops, assignment and try

{
  "op": "while",
  "condition": {
    "$expr": "binary",
    "op": ">",
    "left": { "$expr": "ident", "name": "x" },
    "right": { "$expr": "literal", "value": 0 }
  },
  "body": [
    {
      "op": "varAssign",
      "key": "x",
      "value": {
        "$expr": "binary",
        "op": "-",
        "left": { "$expr": "ident", "name": "x" },
        "right": { "$expr": "literal", "value": 1 }
      }
    }
  ]
}

varSet declares a variable in the current scope; varAssign writes an existing one in the scope that owns it, which is how a loop body updates a variable declared outside it.

for (const x of xs) { … } becomes a map step with "loop": true, and try/catch a try step:

{ "op": "map", "items": { "$expr": "ident", "name": "xs" }, "as": "x",
  "steps": [ ... ], "loop": true }

{ "op": "try", "try": [ ... ], "catch": [ ... ], "catchParam": "err" }

ASTs without "$ajs" (or with "$ajs": 1), which the builder and older transpilers write, still run, under the version-1 rule: a bare string that names a variable in scope is read as that variable.

Builder API

The AST can also be built in code, without the transpiler:

import { Agent, AgentVM } from 'tjs-lang'

const agent = Agent.take()
  .varsImport(['price', 'taxRate'])
  .varSet({
    key: 'total',
    // price * (1 + taxRate), as expression nodes
    value: {
      $expr: 'binary',
      op: '*',
      left: { $expr: 'ident', name: 'price' },
      right: {
        $expr: 'binary',
        op: '+',
        left: { $expr: 'literal', value: 1 },
        right: { $expr: 'ident', name: 'taxRate' },
      },
    },
  })
  .return({ properties: { total: {} } })

const ast = agent.toJSON() // JSON-serializable AST
const { result } = await new AgentVM().run(ast, { price: 100, taxRate: 0.2 })
// result: { total: 120 }

.return takes the shape of the result: a schema whose properties name the variables to return (total, of any type, here). The builder writes a version-1 AST, the format ASTs were first persisted in, which the VM keeps running so stored agents keep working. It is lower-level than the transpiler: nothing checks atom calls or parameters for you, and the VM runs the AST as written. That costs convenience, not safety: the VM's guarantees do not depend on the transpiler's checks.


Limitations

What AJS leaves out is listed under What's Forbidden. The reasons, in short:

AJS is interpreted from its JSON AST, so it is much slower than native JavaScript for computation. Execution is predictable and bounded, and I/O dominates most agent workloads. For compute-heavy work, do it in the host, in TJS (with wasm {} blocks where it matters), and expose it as an atom (Capabilities and Custom Atoms).


Learn more