AJS: The Agent Language

Code as Data. Safe. Async. Sandboxed.


What is AJS?

AJS (AsyncJS) is a JavaScript subset that compiles to a JSON AST. It's designed for untrusted code—user scripts, LLM-generated agents, remote logic.

function searchAndSummarize({ query }) {
  let results = httpFetch({ url: `https://api.example.com/search?q=${query}` })
  let summary = llmPredict({ prompt: `Summarize: ${JSON.stringify(results)}` })
  return { query, summary }
}

This compiles to JSON that can be:


The VM

AJS runs in a gas-limited, isolated VM with strict resource controls.

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

const agent = ajs`
  function process({ url }) {
    let data = httpFetch({ url })
    return { fetched: data }
  }
`

const vm = new AgentVM()
const result = await vm.run(
  agent,
  { url: 'https://api.example.com' },
  {
    fuel: 1000, // CPU budget
    timeoutMs: 5000, // Wall-clock limit
  }
)

Fuel Metering

Every operation costs fuel:

Operation Cost
Expression evaluation 0.01
Variable set/get 0.1
Control flow (if, while) 0.5
HTTP fetch 10
LLM predict 100

When fuel runs out, execution stops safely:

if (result.fuelExhausted) {
  // Agent tried to run forever - stopped safely
}

Timeout Enforcement

Fuel protects against CPU abuse. Timeouts protect against I/O abuse:

await vm.run(agent, args, {
  fuel: 1000,
  timeoutMs: 5000, // Hard 5-second limit
})

Slow network calls can't hang your servers.

Capability Injection

The VM starts with zero capabilities. You grant what each agent needs:

const capabilities = {
  fetch: createFetchCapability({
    allowedHosts: ['api.example.com'],
  }),
  store: createReadOnlyStore(),
  // No llm - this agent can't call AI
}

await vm.run(agent, args, { capabilities })

Capabilities must return structured-cloneable plain data. Every value a capability returns crosses a structuredClone membrane before it reaches guest state, so it must not carry functions or live host references — a fetch capability returning a live Response (with .json()/.text()) is rejected at the boundary; return the fields the guest reads as a plain object ({ ok, status, body }). Oversized returns are also rejected before the copy allocates; the cap is the membraneMaxBytes run option (default 4 MB), which you may need to raise for large-JSON or base64 dataUrl payloads.

Accessor properties are rejected too (0.13.0 — breaking). A getter is host code, and a membrane that ran one while inspecting a payload would be executing the very thing it exists to keep out. The pre-walk reads own property descriptors and never invokes an accessor, so it rejects rather than silently evaluating:

// Rejected: `status` is a getter — code wearing a data costume.
return { ok: res.ok, get status() { return res.status }, body }

// Fine: read it once, hand over the value.
return { ok: res.ok, status: res.status, body }

Spreading is not the fix and fails silently: a Response keeps ok/status/headers on its prototype, so { ...res } is {} — it crosses the boundary cleanly and delivers nothing. Build the object literally, naming each field.

Live-Heap Ceiling

Fuel meters work, so it is a time budget. It bounds how much a program allocates over its lifetime but says nothing about how much it holds at once: x = x + x charges honestly, yet a legitimate 100,000-fuel budget still buys roughly a gigabyte of live string. A run that exhausts host memory has taken the process down however honestly it paid.

await vm.run(agent, args, {
  fuel: 100_000,
  maxHeapBytes: 64 * 1024 * 1024, // default 64 MB — the SPACE budget
})

Accounting is per key, so overwriting a variable frees its budget and ordinary loops don't false-positive.

Per-Atom Call Quotas

Fuel is denominated in VM work, so it cannot express "at most 3 model calls" — an llmPredict costing 50 fuel might cost real money, and a httpFetch costing 10 might hammer someone else's service. Quotas cap calls, not work:

await vm.run(agent, args, {
  fuel: 10_000,
  quotas: { llmPredict: 3, httpFetch: 10 }, // an op you don't list is unlimited
})

Scope — read this before treating a quota as a spend cap. A quota counts calls within one run. A capability that starts a new vm.run gets a fresh counter, so an agent able to trigger re-entrancy can multiply its allowance. Inline sub-agents share the parent's context and therefore its counter; a capability calling back into the VM does not.

To enforce a cap across nested runs, pass the same quotaUsed object to each:

const quotaUsed = {} // one shared ledger
const opts = { quotas: { llmPredict: 3 }, quotaUsed }
await vm.run(outerAgent, args, { ...opts, capabilities })
await vm.run(innerAgent, args, { ...opts, capabilities }) // draws from the same 3

Across a process or network boundary no such enforcement is possible: budget does not travel, only tokens and data do. The honest guarantee is over what we control — a time box on every run, abort on every exit path, and quotas on what we summon.


Input/Output Contract

AJS agents are composable — one agent's output feeds into another's input. To ensure this works reliably:

// CORRECT — object in, object out
function add({ a, b }) {
  return { sum: a + b }
}

// WRONG — non-object returns are errors
function add({ a, b }) {
  return a + b // AgentError: must return an object
}

Syntax

AJS is a JavaScript subset. Familiar syntax, restricted features.

What's Allowed

// Functions
function process({ input }) {
  return { output: input * 2 }
}

// Variables
let x = 10
const y = 'hello'

// Conditionals
if (x > 5) {
  return { size: 'big' }
} else {
  return { size: 'small' }
}

// Loops — `for...of` and `while`. There is NO C-style `for (;;)`.
for (let item of items) {
  results.push(item.name)
}

while (count > 0) {
  count = count - 1
}

// Try/catch
try {
  riskyOperation()
} catch (e) {
  return { error: e.message }
}

// Template literals
let message = `Hello, ${name}!`

// Object/array literals
let obj = { a: 1, b: 2 }
let arr = [1, 2, 3]

// Spread
let merged = { ...defaults, ...overrides }
let combined = [...arr1, ...arr2]

// Ternary
let result = x > 0 ? 'positive' : 'non-positive'

// Logical operators
let value = a && b
let fallback = a || defaultValue
let nullish = a ?? defaultValue

Local helper functions

An agent source file may declare multiple top-level functions. The last declaration is the entry point; the ones before it are helpers the entry (or other helpers) can 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) {
  const a = double(n)
  const b = addOne(n)
  return { a, b } // only the entry must return an object
}

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) doesn't bloat the agent's AST.

What's Forbidden

Feature Why Forbidden
C-style for (;;) Use for...of, or while with a counter
Destructuring declarations let { a } = o / let [a] = xs. Read members instead (const a = o.a). Destructured parameters — function agent({ apiKey }) — are fine and are the documented entry shape
class Too complex for LLMs, enables prototype pollution
new Arbitrary object construction
this Implicit context, hard to sandbox
Closures State escapes the sandbox
async/await VM handles async internally
eval, Function Code injection
__proto__, constructor Prototype pollution
import/export Module system handled by host

AJS is intentionally simple—simple enough for 4B parameter LLMs to generate correctly.

Differences from JavaScript

AJS expressions differ from standard JavaScript in a few important ways:

Null-safe member access. All member access uses optional chaining internally. Accessing a property on null or undefined returns undefined instead of throwing TypeError:

let x = null
let y = x.foo.bar // undefined (no error)

This is a deliberate safety choice — agents shouldn't crash on missing data.

No computed member access with variables. You can use literal indices (items[0], obj["key"]) but not variable indices (items[i]). This is rejected at transpile time:

// Works
let first = items[0]
let name = user['name']

// Fails: "Computed member access with variables not yet supported"
let item = items[i]

Workaround: use array atoms like map, reduce, or for...of loops instead of index-based access.

Footgun-free equality. == and != are footgun-free === (matching TJS) — no type coercion, but NOT structural. They unwrap boxed primitives and treat null/undefined (and NaN) as equal; distinct objects/arrays are distinct. Use ===/!== for strict identity:

'1' == 1                 // false (no coercion, unlike JS)
null == undefined        // true (nullish equality)
[1, 2] == [1, 2]        // false (distinct objects — NOT structural)
{ a: 1 } == { a: 1 }    // false (distinct objects)
[1, 2] === [1, 2]       // false (strict identity)

Structural (deep) comparison is an explicit operation, never ==. In TJS that operation is the Is/IsNot function; AJS does not have it — it is one of the TJS constructs the AJS parser rejects. Compare the fields you care about, or do the comparison in the host.

a Is b used to parse on the AJS path, because AJS and TJS shared one parser and the Is transform never checked which language it was compiling. It transformed to a call to a function AJS has no atom for, so it never worked; it merely failed later and less clearly. AJS now has its own parser and says so up front. See src/lang/parser-agent.ts.


Atoms

Atoms are the built-in operations. Each atom has a defined cost, input schema, and output schema.

Flow Control

Atom Description
seq Execute operations in sequence
if Conditional branching
while Loop with condition
return Return a value
try Error handling

State Management

Atom Description
varSet Set a variable
varGet Get a variable
varsLet Batch variable declaration
varsImport Import from arguments
varsExport Export as result
scope Create a local scope

I/O

Atom Description
httpFetch HTTP requests (requires fetch capability)

Storage (Core)

Atom Description
storeGet Get from key-value store
storeSet Set in key-value store
storeSearch Vector similarity search

Storage (Battery)

Atom Description
storeVectorize Generate embeddings from text
storeCreateCollection Create a vector store collection
storeVectorAdd Add a document to a vector collection

AI (Core)

Atom Description
llmPredict Simple LLM inference (prompt → string)
agentRun Run a sub-agent

AI (Battery)

Atom Description
llmPredictBattery Chat completion (system/user → message object)
llmVision Analyze images using a vision-capable model

Procedures

Atom Description
storeProcedure Store an AST as callable token
releaseProcedure Delete a stored procedure
clearExpiredProcedures Clean up expired tokens

Utilities

Atom Description
random Random number generation
uuid Generate UUIDs
hash Compute hashes
memoize In-memory memoization
cache Persistent caching

Battery Atoms Reference

Battery atoms provide LLM, embedding, and vector store capabilities. They require a separate import and capability setup.

Setup

import { AgentVM } from 'tjs-lang'
import { batteryAtoms, getBatteries } from 'tjs-lang'

const vm = new AgentVM(batteryAtoms)
const batteries = await getBatteries() // auto-detects LM Studio models

const { result } = await vm.run(agent, args, {
  fuel: 1000,
  capabilities: batteries,
})

The getBatteries() function auto-detects LM Studio and returns:

{
  vector: { embed },       // embedding function (undefined if no LM Studio)
  store: { ... },          // key-value + vector store (always present)
  llmBattery: { predict, embed },  // LLM chat + embeddings (null if no LM Studio)
  models: { ... },         // detected model info (null if no LM Studio)
}

Important: vector and llmBattery will be undefined/null if LM Studio isn't running or the connection is made over HTTPS (local LLM calls are blocked from HTTPS contexts for security). Always check for availability or handle the atom's "missing capability" error.

Capability Keys

Battery atoms look up capabilities by specific keys that differ from the base Capabilities interface:

Capability key Used by atoms Contains
llmBattery llmPredictBattery, llmVision { predict, embed } (full LLM)
vector storeVectorize { embed } only
store storeSearch, storeCreateCollection, storeVectorAdd KV + vector store operations
llm llmPredict (core atom) { predict } (simple)
fetch httpFetch (core atom) fetch function

The split exists because storeVectorize only needs the embedding function, while llmPredictBattery needs the full chat API. If you're providing your own capabilities (not using getBatteries()), wire the keys accordingly.

llmPredict vs llmPredictBattery

There are two LLM atoms with different interfaces:

Atom Input Output Capability
llmPredict { prompt } string capabilities.llm
llmPredictBattery { system, user, ... } message object capabilities.llmBattery

Use llmPredict for simple prompts. Use llmPredictBattery when you need system prompts, tool calling, or structured output.

llmPredictBattery

Chat completion with system prompt, tool calling, and structured output support.

Input:

Field Type Required Description
system string No System prompt (defaults to helpful assistant)
user string Yes User message
tools any[] No Tool definitions (OpenAI format)
responseFormat any No Structured output format

Output: OpenAI chat message object:

{
  role: 'assistant',
  content: 'The answer is 42.',    // null when using tool calls
  tool_calls: [...]                // present when tools are invoked
}

Example:

let response = llmPredictBattery({
  system: 'You are a helpful assistant.',
  user: 'What is the capital of France?',
})
// response.content === 'Paris is the capital of France.'

Cost: 100 fuel

llmVision

Analyze images using a vision-capable model.

Input:

Field Type Required Description
system string No System prompt
prompt string Yes Text prompt describing what to analyze
images string[] Yes URLs or data URIs (data:image/...;base64,...)
responseFormat any No Structured output format

Output: Same as llmPredictBattery (message object with role, content, tool_calls).

Example:

let analysis = llmVision({
  prompt: 'Describe what you see in this image.',
  images: ['https://example.com/photo.jpg'],
})
// analysis.content === 'The image shows a sunset over the ocean...'

Cost: 150 fuel | Timeout: 120 seconds

storeVectorize

Generate embeddings from text using the vector battery.

Input:

Field Type Required Description
text string Yes Text to embed
model string No Embedding model to use

Output: number[] — the embedding vector.

Example:

let embedding = storeVectorize({ text: 'TJS is a typed JavaScript' })
// embedding === [0.023, -0.412, 0.891, ...]

Cost: 20 fuel | Capability: vector

storeCreateCollection

Create a vector store collection for similarity search.

Input:

Field Type Required Description
collection string Yes Collection name
dimension number No Vector dimension (auto-detected)

Output: None.

Cost: 5 fuel | Capability: store

storeVectorAdd

Add a document to a vector store collection. The document is automatically embedded and indexed.

Input:

Field Type Required Description
collection string Yes Collection name
doc any Yes Document to store

Output: None.

Example:

storeVectorAdd({
  collection: 'articles',
  doc: { title: 'Intro to TJS', content: 'TJS is...', embedding: [...] }
})

Cost: 5 fuel | Capability: store

storeSearch

Search a vector store collection by similarity.

Input:

Field Type Required Description
collection string Yes Collection name
queryVector number[] Yes Query embedding vector
k number No Number of results (default: 5)
filter object No Metadata filter

Output: any[] — array of matching documents, sorted by similarity.

Example:

let query = storeVectorize({ text: 'How does type checking work?' })
let results = storeSearch({
  collection: 'articles',
  queryVector: query,
  k: 3,
})
// results === [{ title: 'Type System', content: '...' }, ...]

Cost: 5 + k fuel (dynamic) | Capability: store


Expression Builtins

AJS expressions have access to safe built-in objects:

Math

All standard math functions:

Math.abs(-5) // 5
Math.floor(3.7) // 3
Math.sqrt(16) // 4
Math.sin(Math.PI) // ~0
Math.random() // 0-1
Math.max(1, 2, 3) // 3
Math.min(1, 2, 3) // 1

JSON

Parse and stringify:

JSON.parse('{"a": 1}') // { a: 1 }
JSON.stringify({ a: 1 }) // '{"a": 1}'

Array

Static methods:

Array.isArray([1, 2]) // true
Array.from('abc') // ['a', 'b', 'c']
Array.of(1, 2, 3) // [1, 2, 3]

Object

Static methods:

Object.keys({ a: 1 }) // ['a']
Object.values({ a: 1 }) // [1]
Object.entries({ a: 1 }) // [['a', 1]]
Object.fromEntries([['a', 1]]) // { a: 1 }
Object.assign({}, a, b) // merged object

String

Static methods:

String.fromCharCode(65) // 'A'
String.fromCodePoint(128512) // emoji

Number

Constants and checks:

Number.MAX_VALUE
Number.isNaN(NaN) // true
Number.isFinite(100) // true
Number.parseInt('42') // 42
Number.parseFloat('3.14') // 3.14

Set Operations

Set-like operations:

Set.add([1, 2], 3) // [1, 2, 3]
Set.remove([1, 2, 3], 2) // [1, 3]
Set.union([1, 2], [2, 3]) // [1, 2, 3]
Set.intersection([1, 2], [2, 3]) // [2]
Set.diff([1, 2, 3], [2]) // [1, 3]

Date

Date factory with arithmetic:

Date.now() // timestamp
Date.create('2024-01-15') // Date object
Date.add(date, 1, 'day') // new Date
Date.format(date, 'YYYY-MM-DD')

Schema

Build JSON schemas for structured LLM outputs:

// From example
let schema = Schema.response('person', { name: '', age: 0 })

// With constraints
let schema = Schema.response(
  'user',
  Schema.object({
    email: Schema.string.email,
    age: Schema.number.int.min(0).max(150).optional,
    role: Schema.enum(['admin', 'user', 'guest']),
  })
)

JSON AST Format

AJS compiles to a JSON AST. Here's what it looks like:

Sequence

{
  "$seq": [
    { "$op": "varSet", "key": "x", "value": 10 },
    { "$op": "varSet", "key": "y", "value": 20 },
    {
      "$op": "return",
      "value": { "$expr": "binary", "op": "+", "left": "x", "right": "y" }
    }
  ]
}

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
{ "$expr": "template", "tmpl": "Hello, ${name}!" }

Conditionals

{
  "$op": "if",
  "cond": { "$expr": "binary", "op": ">", "left": "x", "right": 0 },
  "then": { "$seq": [...] },
  "else": { "$seq": [...] }
}

Loops

{
  "$op": "while",
  "cond": { "$expr": "binary", "op": ">", "left": "count", "right": 0 },
  "body": { "$seq": [...] }
}

Security Model

Zero Capabilities by Default

The VM can't do anything unless you allow it:

// This agent can only compute - no I/O
await vm.run(agent, args, { capabilities: {} })

// This agent can fetch from one domain
await vm.run(agent, args, {
  capabilities: {
    fetch: createFetchCapability({ allowedHosts: ['api.example.com'] }),
  },
})

Forbidden Properties

These property names are blocked to prevent prototype pollution:

SSRF Protection

The httpFetch atom can be configured with:

ReDoS Protection

Suspicious regex patterns are rejected before execution.

Execution Tracing

Every agent run can produce an audit trail:

const { result, trace } = await vm.run(agent, args, { trace: true })

// trace: [
//   { op: 'varSet', key: 'x', fuelBefore: 1000, fuelAfter: 999.9 },
//   { op: 'httpFetch', url: '...', fuelBefore: 999.9, fuelAfter: 989.9 },
//   ...
// ]

Use Cases

AI Agents

function researchAgent({ topic }) {
  let searchResults = httpFetch({
    url: `https://api.search.com?q=${topic}`,
  })

  let summary = llmPredict({
    system: 'You are a research assistant.',
    user: `Summarize these results about ${topic}: ${searchResults}`,
  })

  return { topic, summary }
}

Rule Engines

function applyDiscounts({ cart, userTier }) {
  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: discount,
    finalTotal: cart.total * (1 - discount),
  }
}

Smart Configuration

function routeRequest({ request, config }) {
  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 }
}

Remote Jobs

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

Custom Atoms

Extend the runtime with your own operations:

import { defineAtom, AgentVM, s } from 'tjs-lang'

const myScraper = defineAtom(
  'scrape', // OpCode
  s.object({ url: s.string }), // Input Schema
  s.string, // Output Schema
  async ({ url }, ctx) => {
    const res = await ctx.capabilities.fetch(url)
    return await res.text()
  },
  { cost: 5 } // Fuel cost
)

const myVM = new AgentVM({ scrape: myScraper })

Atoms must:


Builder API

For programmatic AST construction:

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

const agent = Agent.take(s.object({ price: s.number, taxRate: s.number }))
  .varSet({ key: 'total', value: Agent.expr('price * (1 + taxRate)') })
  .return(s.object({ total: s.number }))

const ast = agent.toJSON() // JSON-serializable AST

The builder is lower-level but gives full control over AST construction.


Limitations

What AJS Doesn't Do

What AJS Intentionally Avoids


Performance

AJS is interpreted (JSON AST), so it's slower than native JS. But:

For compute-heavy operations in your platform code, use TJS with wasm {} blocks.


Learn More