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:
- Stored in a database
- Sent over the network
- Executed in a sandboxed VM
- Audited before running
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:
- Functions take a single destructured object parameter:
function process({ input }) - Functions must return a plain object:
return { result },return { summary, count } - Non-object returns produce an AgentError:
return 42orreturn 'hello'will fail - Bare
returnis allowed for void functions (no output)
// 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:
Top-level siblings, not closures. A helper sees only its own parameters — never the caller's locals. This keeps them predictable and reusable.
They may return any value (number, string, array, object). Only the entry function is held to the object-return contract.
Recursion is allowed. Helpers may call themselves or each other. Runaway recursion is bounded by fuel/timeout, with a hard call-depth cap (256) that surfaces as a normal monadic error — never a host crash.
Call them at statement level. Like template literals, a helper call cannot be nested inside a larger expression — lift it to a variable first:
// Fails at transpile time: return { v: double(n) + 1 } // Do this instead: const d = double(n) return { v: d + 1 }
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:
__proto__constructorprototype
SSRF Protection
The httpFetch atom can be configured with:
- Allowlisted hosts only
- Blocked private IP ranges
- Request signing requirements
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:
- Be non-blocking (no synchronous CPU-heavy work)
- Respect
ctx.signalfor cancellation - Access I/O only via
ctx.capabilities
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
- No closures - functions can't capture outer scope
- No classes - use plain objects
- No async/await syntax - the VM handles async internally
- No modules - logic is self-contained
- No direct DOM access - everything goes through capabilities
- No computed member access with variables -
items[i]is rejected; useitems[0](literal) orfor...ofloops
What AJS Intentionally Avoids
- Complex language features that enable escape from the sandbox
- Syntax that LLMs frequently hallucinate incorrectly
- Patterns that make code hard to audit
Performance
- 100 agents in ~6ms (torture test benchmark)
- ~0.01 fuel per expression
- Proportional memory charging prevents runaway allocations
AJS is interpreted (JSON AST), so it's slower than native JS. But:
- Execution is predictable and bounded
- I/O dominates most agent workloads
- Tracing is free (built into the VM)
For compute-heavy operations in your platform code, use TJS with wasm {} blocks.
Learn More
- TJS Documentation — The host language
- Builder's Manifesto — Why AJS is fun
- Enterprise Guide — Why AJS is safe
- Technical Context — Architecture deep dive