AJS LLM System Prompt
An AJS agent is a function that runs inside the AgentVM sandbox. It can reach the outside world only by calling atoms: functions the host (your application, the code that runs the VM) registers with the VM and backs with capabilities such as a model client or an HTTP client. The VM meters the work an agent does in fuel and stops it when the budget runs out. The AJS overview has the rest.
This page holds the system prompt we maintain for models that write AJS. Paste the block under System Prompt into your model's system message, add the list of atoms your host provides, then ask for an agent. The prompt is measured against a small model, so it is kept deliberately narrow: it teaches one way to do each thing.
How to use it
- Put the System Prompt block in the system message.
- Below it, list the atoms the model may call, each with its named inputs and what it returns.
The model cannot know your atoms otherwise, and a call to an atom it invents fails with
Unknown Atom: <name>when the agent runs. See The atom list. - Take the code out of the reply. Models usually wrap it in a
```javascriptfence and add prose around it; transpile only what is inside the fence. - Transpile it with your atoms, so calls to them are checked too, and then run it. Some mistakes pass the transpiler and fail only when the agent runs (see Self-correction loop), so a reply that only transpiles has not been checked.
- If either step fails, send the error message back to the model verbatim.
Steps 3 to 5 in code:
import { transpile } from 'tjs-lang/lang'
import { AgentVM, defineAtom } from 'tjs-lang/vm-ast'
import { s } from 'tjs-lang/schema'
// One host atom. Core atoms (template, llmPredict, …) are already on every VM.
const getWeather = defineAtom(
'getWeather',
s.object({ city: s.string }),
s.object({ temp: s.number, condition: s.string }),
async ({ city }) => ({ temp: 80, condition: 'humid' })
)
const vm = new AgentVM({ getWeather })
function extractCode(reply) {
const fenced = reply.match(/```(?:javascript|js|ajs)?\s*\n([\s\S]*?)```/)
return (fenced ? fenced[1] : reply).trim()
}
async function runReply(reply, args) {
let ast
try {
ast = transpile(extractCode(reply), { atoms: vm.atoms }).ast
} catch (e) {
return { problem: e.message } // send this back to the model
}
const { result, error } = await vm.run(ast, args, {
fuel: 1000,
capabilities: { llm: { predict: async (prompt) => 'It is humid.' } },
})
return error ? { problem: error.message } : { result }
}
For a reply that calls getWeather and then llmPredict, runReply(reply, { city: 'Tokyo' })
gives { result: { city: 'Tokyo', temp: 80, summary: 'It is humid.' } }; for one with a C-style
for loop it gives { problem: 'Unsupported statement type: ForStatement. …' }.
Hosting the VM covers the other vm.run options (timeouts, fetch domains, call
quotas) and why the transpile step belongs outside the VM host.
The atom list
Every VM has the core atoms, listed in the Atom Reference. template and the
other pure ones always work. The ones that reach outside need a capability from you:
llmPredict fails with Capability 'llm.predict' missing unless the run has an llm
capability, and httpFetch fetches nothing unless the run sets allowedFetchDomains. The
prompt's examples use llmPredict, httpFetch and storeGet and tell the model to use them
only if they are listed, so list every atom that will work in this run, core or your own, and
leave the rest out.
Put the list straight after the block, in the same plain style the prompt uses, with a realistic example value for each input:
### Atoms available to you
- getWeather({ city: 'Tokyo' }) returns { temp: 80, condition: 'humid' }
The current weather for a city, temperature in Fahrenheit.
- convertTemp({ fahrenheit: 72 }) returns a number
Fahrenheit to Celsius, rounded.
- llmPredict({ prompt: 'Summarize: ...' }) returns the model's reply as a string
- template({ tmpl: 'Hi {{name}}', vars: { name } }) returns a string
### Task
Write an agent called weatherReport that takes a city and returns { city, tempC, condition }.
System Prompt
You write AJS, a small subset of JavaScript for AI agents. AJS looks like JavaScript but has
strict rules. Follow every rule below.
### 1. INPUT AND OUTPUT
- An agent is one function with ONE destructured object parameter. Give each input an EXAMPLE
value after a colon:
- `function greet({ name: 'World' })` — `name` is a required string
- `function factorial({ n: 5 })` — `n` is a required number
- `function search({ query: 'cats', limit = 10 })` — `limit` is optional, default 10
- The example value sets the type. Use the real value, never a type written as a string:
- WRONG: `{ count: 'number' }` or `{ count: '5' }` — those are STRINGS
- RIGHT: `{ count: 5 }` — a bare number
- The agent MUST return a plain object:
- WRONG: `return 42`, `return 'hello'`, `return [1, 2]`
- RIGHT: `return { result: 42 }`, `return { message: 'hello' }`, `return { items: [1, 2] }`
- Do NOT write a return type after the parameters.
- WRONG: `function foo({ x: 0 }): number { ... }`
- RIGHT: `function foo({ x: 0 }) { ... }`
- No `async`, no `await`. Every atom call already waits for its result.
- WRONG: `let page = await fetch(url)`
- RIGHT: `let page = httpFetch({ url })`
### 2. STATEMENTS
- Variables: `let` and `const`. Destructuring a variable is not allowed:
- WRONG: `let { a } = obj`
- RIGHT: `let a = obj.a`
- Loops: `for (const x of list)` and `while`. Nothing else.
- WRONG: `for (let i = 0; i < n; i++)`, `for (const k in obj)`
- RIGHT: `for (const item of items) { ... }`
- RIGHT: `let i = 1` then `while (i <= n) { total = total + i; i = i + 1 }`
- Always change the counter inside a `while`, or the agent is stopped for running too long.
- For an object's keys: `for (const k of Object.keys(obj)) { ... }`
- NO `break`, NO `continue`, NO `switch`.
- Instead of `switch`, use `if` / `else if` / `else`.
- Instead of `break`, `return` from inside the loop, or use a flag in the `while` condition:
`while (i < items.length && found == null) { ... }`
- Instead of `continue`, wrap the rest of the loop body in an `if`.
- Assign only to plain variables. You cannot assign to a property or an index:
- WRONG: `obj.count = 5`
- RIGHT: `obj = { ...obj, count: 5 }`
- WRONG: `arr[0] = 'x'`
- RIGHT: build a new list with a loop and `push`, then assign it: `arr = changed`
- `push` changes a list in place and is allowed: `results.push(item)`
- Errors: no `throw`. `Error('message')` fails like `throw`: inside `try` it jumps to `catch`;
outside `try` it stops the agent with that error.
- WRONG: `throw new Error('bad input')`
- RIGHT: `if (n < 0) { Error('n must not be negative') }`
- `try { ... } catch (err) { ... }` recovers from a failed atom call or `Error()`. `err` is the
error MESSAGE, a string: use `err`, not `err.message`. Without `try`, a failure stops the agent.
- No `class`, `new`, `this`, `import`, `require`.
### 3. CALLS
- Atoms are the tools the host gives you. Call ONLY the atoms listed for you. `template` is
always available. The examples below also use `llmPredict`, `httpFetch` and `storeGet`: use
those only if they are listed. An atom takes ONE object of named inputs:
- RIGHT: `llmPredict({ prompt: question })`, `httpFetch({ url })`, `storeGet({ key })`
- WRONG: `llmPredict(question)` (refused): a positional call
- An atom call must be the WHOLE right-hand side of `let`, `const` or an assignment, or a
statement on its own. Store the result, then use it:
- WRONG: `return { reply: llmPredict({ prompt }) }`
- RIGHT: `let reply = llmPredict({ prompt })` then `return { reply }`
- `template` fills in text:
`let message = template({ tmpl: 'Hello, {{name}}!', vars: { name } })`
Template literals also work:
``let message = `Hello, ${name}!` ``
- You may write helper functions above the agent. The LAST function is the agent. A helper
cannot read the agent's variables: pass in what it needs as arguments. A helper takes
positional arguments, may call atoms and earlier helpers, and may return any value. Like an
atom, a helper call must be the whole right-hand side: `const d = double(n)`, not
`double(n) + 1`.
- Builtins use normal JavaScript syntax: `Math.max(a, b)`, `JSON.parse(text)`,
`JSON.stringify(obj)`, `Object.keys(obj)`, `text.split(',')`, `text.trim().toLowerCase()`.
### 4. ARRAYS
- Use `map`, `filter`, `find` and `reduce` with an arrow function: `items.map(x => x * 2)`
- ONE arrow-function method per statement. Do not chain them:
- WRONG: `let r = items.filter(x => x > 1).map(x => x * 2)`
- RIGHT: `let big = items.filter(x => x > 1)` then `let r = big.map(x => x * 2)`
- `some` and `every` are NOT available. Use `filter` and check the length:
`let bad = items.filter(x => x < 0)` then `if (bad.length > 0) { ... }`
- `sort()` takes no comparator:
- WRONG: `items.sort((a, b) => a - b)`
- RIGHT: `items.sort()`
### 5. VALUES AND OPERATORS
- Operators (`+`, `<`, ...) need strings, numbers or booleans, never arrays or objects:
- WRONG: `'items: ' + items`
- RIGHT: `'items: ' + items.join(', ')`
- Method arguments must have the right type: a count is a number, not a string.
`'ab'.repeat(3)`, not `'ab'.repeat('3')`.
- `==` never converts types: `'1' == 1` is false. `null == undefined` is true.
- Reading a property of null gives undefined, not an error: `user.address.city` is safe.
- A method is not a value. Call it (`s.trim()`), never store it (`const f = s.trim`).
- Regexes work in `replace`, `split`, `match` and `search`: `s.replace(/\s+/g, ' ')`.
- `re.test(s)` is NOT available. Use `s.search(re) !== -1`.
- No backreferences (`\1`), no lookahead or lookbehind (`(?=`, `(?<=`), no `\p{...}`.
### 6. DATES, SETS, SCHEMAS
- No `new`. `Date()` is now; `Date('2024-01-15')` parses an ISO string.
- A Date is immutable and UTC. Months are 1 to 12 (1 = January).
- Fields: `.year`, `.month`, `.day`, `.hours`, `.minutes`, `.seconds`, `.timestamp`,
and `.value` (the ISO string).
- `d.add({ days: 5 })` returns a NEW Date. `d.format('YYYY-MM-DD')` returns a string.
- `Set([1, 2, 2])` is a list of unique items: `.add(x)`, `.remove(x)`, `.has(x)`, `.size`,
`.toArray()`, and `.union(other)`, `.intersection(other)`, `.diff(other)`, which return NEW sets.
- `filter(data, { a: 0, b: '' })` with TWO arguments is a builtin, not the array method
`items.filter(x => ...)`. It returns a copy of `data` with only the keys `a` and `b`. The
example values are checked like an agent's inputs: here `a` must be a whole number and `b` a
string, and both must be present, or the step fails. Use it to clean up an API or model response.
- Schemas are plain data. `Schema.string`, `Schema.number`, `Schema.email` are ready-made
schemas (values, not functions). `Schema.object({ ... })` builds an object schema. Write
limits as keywords, not chained calls like `.min()`:
`let ok = Schema.isValid(x, Schema.object({ name: Schema.string, age: { type: 'integer', minimum: 0 } }))`
### 7. REPORTING
- Put everything the caller needs in the returned object. Do not use `console.log`: its output
goes only to a debug trace, so the caller never sees it.
### EXAMPLES
**Example 1: Calling atoms**
```javascript
function summarizeTopic({ topic: 'quantum computing', maxWords = 50 }) {
if (topic.trim() == '') {
return { error: 'No topic given' }
}
let prompt = template({
tmpl: 'Explain {{topic}} in at most {{maxWords}} words.',
vars: { topic, maxWords },
})
let summary = llmPredict({ prompt })
return { topic, summary }
}
```
**Example 2: Factorial with a while loop (number parameter)**
```javascript
function factorial({ n: 5 }) {
let result = 1
let i = n
while (i > 1) {
result = result * i
i = i - 1
}
return { result }
}
```
**Example 3: Several number parameters**
```javascript
function calculateVolume({ width: 2, height: 3, depth: 4 }) {
let volume = width * height * depth
return { volume }
}
```
Note: width, height and depth have BARE NUMBER examples (2, 3, 4), not strings like '2' or 'number'.
**Example 4: Greeting with the template atom (optional parameter)**
```javascript
function greet({ name: 'World', greeting = 'Hello' }) {
let message = template({ tmpl: '{{greeting}}, {{name}}!', vars: { greeting, name } })
return { message }
}
```
**Example 5: Looping, filtering, recovering from an error**
```javascript
function priceReport({ items: [{ name: 'pen', price: 2 }], limit = 10 }) {
let cheap = items.filter(item => item.price <= limit)
let names = cheap.map(item => item.name)
let total = 0
for (const item of cheap) {
total = total + item.price
}
let average = 0
try {
if (cheap.length == 0) {
Error('no items under the limit')
}
average = total / cheap.length
} catch (err) {
return { names, total, average, note: err }
}
return { names, total, average }
}
```
Why the prompt is shaped this way
One signature shape. The prompt teaches a single destructured parameter with example values,
and a returned object. AJS also accepts positional parameters (function f(n: 5)), and both run
the same way. A destructured signature matches how agents are called (with one arguments
object) and how they compose (one agent's result object is the next one's arguments). Teaching
two shapes gives a small model two chances to mix them up.
No declared result. AJS lets a signature declare the object an agent returns, after the parameters:
function double({ x = 3 }): { y: 0 } {
return { y: x * 2 }
}
The VM enforces it exactly: every declared member present with the declared type, and no extra
members (see
Both sides of a typed signature are enforced).
Returning { y: x * 2, z: 1 } from double fails the run with
Result does not match the declared result type. That strictness is the problem for a small
model: it is likely to write TypeScript's ): number, which is refused at transpile time, or to
declare a result and then return one extra key. So the prompt says not to write one.
If you want an output contract, keep it on your side: state the result shape in the task ("return
an object with city, tempC and condition"), and add the declared result to the signature
yourself before you transpile the reply.
Self-correction loop
The transpiler's error messages name the construct and, mostly, the fix, so the cheapest repair loop is to send the message straight back:
- Ask the model for an agent, using the prompt above.
- Take the code out of the fence, transpile it with your atoms
(
transpile(source, { atoms: vm.atoms })), and run it. - If either step fails, reply with the error message: "That failed with:
<message>. Fix it and send only the code." If the message is one that names the symptom rather than the fix (see the hints below), append the hint to that reply. - Cap the loop at a few attempts. A model that repeats the same mistake needs a better hint, not another try.
Hints worth adding, because the message does not say what to do:
- A chained
filter(…).map(…)or asortcomparator both giveUnsupported expression type in condition: ArrowFunctionExpression. Say "put each array method in its ownlet", or "sort()takes no comparator; callsort()alone or build the order in a loop". re.test(s): say "uses.search(re) !== -1".- An atom call inside an expression: say "store the atom's result in a variable first".
awaitin a plain function givesCannot use keyword 'await' outside an async function, which points the model at addingasync, the wrong fix. Say "removeawait; atom calls already wait".- A string example like
'number': say "use a bare example value, such as0".
The messages below are what the transpiler and VM give for the mistakes models make most often. AJS is called AsyncJS in some of them; it is the same language. A message may continue on further lines (often with a code sample) and ends with the source position, which is left out here.
Caught when transpiling:
| The model wrote | Start of the error |
|---|---|
for (let i = 0; i < n; i++) |
Unsupported statement type: ForStatement. AJS has no `for` loops. Use `while` with a counter: |
for (const k in obj) |
Unsupported statement type: ForInStatement. AJS has no `for...in`. Get the keys and walk them with `while`: |
switch (x) { … } |
Unsupported statement type: SwitchStatement. AJS has no `switch`. Use `if`/`else if`: |
break / continue |
Unsupported statement type: BreakStatement (or ContinueStatement) |
throw new Error('…') |
'throw' is not supported in AsyncJS. Use Error('message') to trigger error flow |
new Date() |
The 'new' keyword is not supported in AsyncJS. Use Date() or Date('2024-01-15') instead - no 'new' needed. |
await in a plain function |
Cannot use keyword 'await' outside an async function |
await in an async function |
Unsupported expression in AsyncJS: AwaitExpression |
obj.x = 5 or arr[0] = 5 |
Only simple variable assignment is supported |
let { a } = obj |
Only simple variable names are supported |
items.some(…) |
Method 'some' is not available in AsyncJS — use for...of, or map/filter/find/reduce with an arrow function |
a.filter(…).map(…) |
Unsupported expression type in condition: ArrowFunctionExpression |
a.sort((x, y) => x - y) |
Unsupported expression type in condition: ArrowFunctionExpression |
/^\d+$/.test(s) |
Method 'test' is not available in AsyncJS |
llmPredict(question) (refused) |
'llmPredict' takes named arguments: write llmPredict({ name: value, … }), not llmPredict(a, b). Positional arguments are for local functions. |
llmPredict({ system, user }) |
'llmPredict' has no input 'system'. It takes llmPredict({ prompt, options }). |
function f({ x: 0 }): number |
Declared result: an agent returns an object, so a declared number result can never be met. Declare the object it returns, e.g. ): { value: 0 } |
Caught only when the agent runs:
| The model wrote | Start of the error |
|---|---|
return { v: llmPredict({ … }) } |
Atom 'llmPredict' cannot be called inside an expression |
search({ query }) (not an atom) |
Unknown Atom: search |
return { total } (never declared) |
'total' is not defined |
return total |
Agent must return an object, got number |
function f({ n: 'number' }) |
Input validation failed: args do not match expected schema |
The last row needs care. { n: 'number' } declares n as a string, so the run fails only when
the caller passes a number. If the caller happens to pass a string, nothing fails, and the
mistake can go unnoticed. Check the signature yourself.
Compact version (for context-limited models)
Use this when the full prompt does not fit. It keeps the rules small models break most often,
and drops: helper functions, try/catch, Object.keys for walking an object's keys,
filter(data, shape), Schema, the Set methods, the console.log rule, and every example.
Expect more of the mistakes in the tables above. Only the full prompt is measured against a
model; this one is not.
You write AJS, a subset of JavaScript for agents. Rules:
1. One destructured parameter with EXAMPLE values; return a plain object:
`function add({ a: 0, b: 0 }) { return { sum: a + b } }`
- Numbers are bare: `{ n: 5 }`, never `{ n: '5' }` or `{ n: 'number' }`.
- Optional input: `{ limit = 10 }`. No return type after the parameters.
- WRONG: `return 42`. RIGHT: `return { result: 42 }`.
2. NO: async, await, new, class, this, import, throw, switch, break, continue,
C-style `for (;;)`, `for...in`. Loops are `for (const x of list)` and `while`.
3. Assign only to variables: not `obj.x = 1` but `obj = { ...obj, x: 1 }`. `list.push(x)` is fine.
4. Call ONLY the atoms listed for you. Atoms take one object of named inputs:
`llmPredict({ prompt })`, `httpFetch({ url })`.
An atom call is the whole right-hand side: `let r = llmPredict({ prompt })`, then use `r`.
5. One arrow-function array method per statement (no `.filter().map()` chains).
No `some`, `every`, or `sort` comparator.
6. Errors: `Error('message')` fails like `throw`; outside `try` it stops the agent.
In `catch (err)`, `err` is a string.
7. Create dates and sets without `new`: `Date()`, `Date('2024-01-15')`, `Set([1, 2])`.
Dates are immutable, UTC, months 1 to 12.
8. Regex: `s.search(re) !== -1`, never `re.test(s)`. No backreferences or lookaround.
9. Operators take primitives: `'n=' + list.join(',')`, never `'n=' + list`.
Maintaining this prompt
This prompt must change when DOCS-AJS.md changes (signatures, loops, forbidden
constructs, builtins, atoms). It is also a test input: src/use-cases/ajs-grokkability.test.ts
reads the System Prompt block from this exact file (the first four-backtick block after the
## System Prompt heading) and measures how often a small pinned model (gemma-4-e2b) writes AJS
that runs. Run bun run test:grok before and after any edit to the block, record both rates in
the commit message, and treat a drop as a regression in the prompt. The compact version is not
measured.