Capabilities and Custom Atoms

An agent can only do what its host hands it. This chapter is for the host: how to give an agent network, storage and a model, what those functions may return, and how to add operations of your own without opening a hole in the sandbox.

Two words carry the whole chapter:

Three more terms recur:

An agent never sees a capability. A call to an IO atom goes like this:

  1. The agent calls the atom with named inputs: httpFetch({ url }).
  2. The VM checks the call's quota and charges its fuel.
  3. Outbound copy. The inputs are copied out of the guest and checked against the atom's input schema.
  4. The atom's body runs and calls the capability you passed for this run.
  5. Inbound copy. Whatever the capability returns is copied back in as plain data and checked against the atom's output schema.
  6. The agent gets the copy.

When a capability is absent, the atom decides what happens. With no fetch, httpFetch uses the built-in client, which fetches nothing unless the run names the domains it may reach. With no llm, llmPredict fails with Capability 'llm.predict' missing.

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

const source = `function lookup({ id = 'u1' }) {
  let user = storeGet({ key: 'user:' + id })
  let summary = llmPredict({ prompt: 'Describe this user in one line: ' + JSON.stringify(user) })
  return { user, summary }
}`

const data = new Map([['user:u1', { name: 'Ada', role: 'admin' }]])

const vm = new AgentVM()
// atoms: vm.atoms lets the transpiler check every atom call (see Registering atoms)
const { ast } = transpile(source, { atoms: vm.atoms })
const run = await vm.run(ast, { id: 'u1' }, {
  fuel: 1000,
  capabilities: {
    store: {
      get: async (key) => data.get(key),
      set: async (key, value) => void data.set(key, value),
    },
    llm: { predict: async (prompt) => `(model reply to ${prompt.length} chars)` },
  },
  // request-scoped data for the VM: here, the only domains the built-in client may fetch
  context: { allowedFetchDomains: ['api.example.com'] },
})
console.log(run.result)
// → { user: { name: 'Ada', role: 'admin' }, summary: '(model reply to 61 chars)' }

vm.run resolves to { result, error, fuelUsed, … } and does not throw for anything the agent does; What vm.run returns has every field.

Capabilities are passed per run, not per VM. One AgentVM can serve many agents, each with a different set. This chapter refers to several other run options (context, quotas, quotaUsed, timeoutOverrides, membraneMaxBytes, maxHeapBytes); each is described in Run options.


The Capabilities interface

These are the keys the built-in atoms look for. Any key you add is yours to read from your own atoms. In the table, data means plain data, as defined in What a capability may return.

Key Signature Used by When absent
fetch (url, init) => Promise<data> httpFetch The built-in client, limited to context.allowedFetchDomains
store { get, set, query?, queryPredicate?, vectorSearch?, … } storeGet, storeSet, storeQuery, storeQueryWhere, storeVectorSearch; battery atoms storeSearch, storeCreateCollection, storeVectorAdd A fresh in-memory store for each run
llm { predict(prompt, options?) => Promise<string>, embed? } llmPredict llmPredict fails: Capability 'llm.predict' missing
agent { run(agentId, input) => Promise<data> } agentRun, for an agentId that is neither an AST nor a stored procedure token agentRun with such an id fails: Capability 'agent.run' missing
code { transpile(source) => ast } runCode, transpileCode Both fail: Capability 'code.transpile' missing. …
xml { parse(xml) => Promise<data> } xmlParse xmlParse fails: Capability 'xml.parse' missing
llmBattery { predict(system, user, tools?, responseFormat?), embed } llmPredictBattery, llmVision (battery atoms) Those atoms fail
vector { embed(text) => Promise<number[]> } storeVectorize (battery atom) storeVectorize fails

Every failure in the last column is an ordinary error the agent can catch. What each atom sends and returns is in the Atom Reference. What follows is what your function receives.

fetch

httpFetch({ url, method, headers, body, responseType }) admits the request, then calls your function:

fetch(url, {
  method, //       'GET', 'POST', … (upper case), or undefined when the agent gave none
  headers, //      the agent's headers, plus X-Agent-Depth (added by the VM)
  body, //         the agent's body AS DATA: an object stays an object; serialize it yourself
  signal, //       the run's AbortSignal: pass it on, so a timed-out run cancels the request
  responseType, // 'json' | 'text' | 'dataUrl' if the agent asked, else undefined
})

Whatever your function resolves to is what the agent's httpFetch returns. Return the body, or a small object such as { ok, status, body }; never the Response (see What a capability may return). A throw becomes an ordinary error the agent can catch, with your message.

The VM checks these whatever fetch you pass:

Your fetch owns everything else: which hosts it reaches when no allowlist is set, redirects, cookies and other ambient credentials, how much of the body it reads, rate limits and retries, and what responseType means. The boundary still refuses a return over membraneMaxBytes (4MB by default), but only after your function has read it into memory.

The built-in client makes those decisions like this: it follows no redirects and returns one as { redirect: true, status, location } with location made absolute; it sends no cookies; it reads at most membraneMaxBytes of the body; it returns parsed JSON for a JSON content type or responseType: 'json', a data: URL for responseType: 'dataUrl', and text otherwise. Writing a safe fetch capability builds one that makes the same decisions. Fetch policy has the run options in full.

store

const store = {
  get: async (key) => value, //       storeGet({ key })
  set: async (key, value) => {}, //   storeSet({ key, value })
  // optional:
  query: async (query) => [rows], //  storeQuery({ query }); what `query` means is yours
  queryPredicate: async ({ collection, predicate, limit }) => [rows], // storeQueryWhere
  vectorSearch: async (collection, vector, k, filter) => [docs], // storeVectorSearch passes no
  //                                     filter; only the battery atom storeSearch passes one
}

Only get and set are required. storeQuery returns [] when your store has no query, and storeVectorSearch returns [] without vectorSearch. storeQueryWhere is the exception: the agent calls storeQueryWhere, the VM calls your queryPredicate, and without one it fails (Capability 'store.queryPredicate' missing …) rather than return unfiltered rows, because a filter that silently does not filter is a data leak. Its predicate is a canonical verified predicate, built with canonicalizePredicate from tjs-lang/lang: a pure function in a normalized form, with a key that is the same for any two predicates that mean the same thing. The store can evaluate it next to the data, and cache results on that key.

When you pass no store, each run gets its own in-memory store, discarded when the run ends. So this agent, run without a store, always returns a count of 1, because every run starts empty:

function visits({ page = '/home' }) {
  let before = storeGet({ key: page })
  let count = (before ?? 0) + 1
  storeSet({ key: page, value: count })
  return { page, count }
}

It returns { page: '/home', count: 1 }. Pass a store and the count survives between runs.

llm

llmPredict({ prompt, options }) calls llm.predict(prompt, options) and must get a string back. Anything else, including null, fails output validation, which the agent can catch.

options arrives as the agent wrote it, after the VM has checked it. Only these keys are admitted: model, temperature, maxTokens, max_tokens, topP, top_p, stop, seed, responseFormat, tools. Any other key fails the call (an agent cannot pass apiKey or baseUrl to your client). responseFormat arrives in OpenAI's response_format shape, one of { type: 'text' }, { type: 'json_object' } or { type: 'json_schema', json_schema: { name, schema } }, and its schema and any tools schemas have already been checked against the restricted schema dialect agents may use. Map them to your provider's request yourself; nothing is forwarded for you.

predict receives no AbortSignal. When the run times out, or llmPredict's own 120-second timeout passes, the agent stops waiting, but your request keeps going unless you bound it yourself, as the example does with AbortSignal.timeout:

const llm = {
  async predict(prompt, options = {}) {
    const res = await fetch('https://llm.internal/v1/chat/completions', {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: `Bearer ${API_KEY}` },
      body: JSON.stringify({
        model: 'house-model', // the host picks the model; ignore options.model if you prefer
        messages: [{ role: 'user', content: prompt }],
        temperature: options.temperature,
        max_tokens: options.maxTokens ?? options.max_tokens,
        response_format: options.responseFormat,
      }),
      signal: AbortSignal.timeout(120_000),
    })
    if (!res.ok) throw new Error(`model server: HTTP ${res.status}`)
    const json = await res.json()
    // content is null when the model answers with tool calls: turn that into a string
    return json.choices[0].message.content ?? ''
  },
}

The API key lives in the capability, where the agent cannot read it. embed is part of the interface for your own atoms; no core atom calls it.

agent

agentRun({ agentId, input }) runs a sub-agent. When agentId is an AST, or a token returned by storeProcedure (see Stored procedures), the sub-agent runs inside the current run on the same fuel and needs no capability. Any other agentId (a name, a URL) goes to agent.run(agentId, input), which receives copies of both. Return the sub-agent's result. If you return the RunResult of your own vm.run (the { result, error, … } object), the atom unwraps it and turns its error into an error in the caller.

A new vm.run inside agent.run starts a fresh fuel budget and fresh quotas. To hold a quota (a cap on calls per atom, such as quotas: { llmPredict: 3 }) across the nested run, pass the same quotaUsed counter object to both runs; see Quotas across nested runs.

code

runCode({ code, args }) and transpileCode({ code }) let an agent compile AJS it wrote itself. Both need code.transpile(source), which returns the AST. Pass the VM's atoms so calls in the generated code are checked like any other:

const code = { transpile: (source) => transpile(source, { atoms: vm.atoms }).ast }

This puts a parser back on the agent's path. tjs-lang/vm-ast has none, so that nothing an agent sends can reach parser code, and this capability runs the parser on source the agent chose. Source over 8KB is refused before it reaches you, because parsing runs before fuel can stop it; a run's maxSourceBytes can lower that cap but not raise it. Where a slow parse would hurt, run transpile in a worker. Transpile where parsing is safe explains the trade-off.

runCode's program shares the run's fuel, its heap budget (maxHeapBytes, the bytes the agent may hold at once) and its capabilities, but sees only the args it is given.

xml

xmlParse({ str }) calls xml.parse(str) with the agent's string and returns what it resolves to, under the same rules as every other capability: plain data only, through the boundary.

llmBattery and vector (the batteries)

The batteries are the zero-config local setup: an OpenAI-compatible model server (LM Studio, MLX) for chat and embeddings, plus an in-memory key-value and vector store. The battery atoms are registered on the VM; the capabilities come from getBatteries():

import { AgentVM, batteryAtoms } from 'tjs-lang/vm-ast' // also exported by tjs-lang/vm
import { getBatteries } from 'tjs-lang/batteries'

const vm = new AgentVM(batteryAtoms)
const batteries = await getBatteries() // probes the local server once, then caches the audit

// batteries = {
//   llmBattery, // { predict(system, user, tools, responseFormat), embed }
//   vector, //     { embed }
//   store, //      key-value plus createCollection / vectorAdd / vectorSearch
//   models, //     what the audit found
// }
const run = await vm.run(ast, args, { fuel: 1000, capabilities: batteries })

llmBattery and models are null, and vector is undefined, only in a browser page served over HTTPS, which cannot call a local http: endpoint. The battery atoms then fail with a "missing" error the agent can catch. Anywhere else the capabilities are always present: when no server answers, getBatteries() logs that it could not connect and still returns them, and each call then fails at the connection, again as an error the agent can catch. TJS_LLM_BASE_URL picks the server; the setup guides are MLX and LM Studio.

llmPredictBattery returns the model's message object ({ role, content, tool_calls }), not a string; llmPredict returns a string. To use the battery atoms with your own backends, supply the same keys: llmBattery.predict(system, user, tools, responseFormat), vector.embed(text), and a store with createCollection(name, schema, dimension) (storeCreateCollection always passes undefined for schema), vectorAdd(collection, doc) and vectorSearch(collection, vector, k, filter).


What a capability may return

Everything a capability returns is copied into the run before the agent sees it. The copy is built from what the VM reads, one value at a time, and only these values cross:

Shared references and cycles are kept. Everything else is refused: functions, Map, Set, typed arrays, ArrayBuffer, RegExp, Error, class instances (including a Response), subclasses of Array or Date, and objects from another realm (an iframe, a node:vm context).

Convert such a value yourself, field by field: a Map to an object or to an array of [key, value] pairs, an instance to the fields the agent needs. A JSON.parse(JSON.stringify(value)) round-trip is not a fix. It loses data silently: a Map or Set becomes {}, a Date becomes a string, and functions vanish.

A refused return is a bug in the host, so it ends the run, even inside the agent's try. A return that is merely too large (over membraneMaxBytes, 4MB by default) or too deeply nested is the world's doing, and the agent can catch it.

The common mistake is returning the Response:

// Wrong: a Response is a class instance. The run ends with
// "capability return contains an instance of Response; only plain data (and Date) crosses".
const fetchCap = async (url, init) => fetch(url, init)

Spreading it is worse, because it fails silently. A Response keeps ok, status and headers on its prototype, so the spread copies nothing:

// Wrong, silently: the agent receives {}
const fetchCap = async (url, init) => ({ ...(await fetch(url, init)) })

A getter is refused too. It is host code, and the copy never runs host code to find out what a value is:

// Wrong: "capability return has an accessor property 'status'"
const fetchCap = async (url, init) => {
  const res = await fetch(url, init)
  return { ok: res.ok, get status() { return res.status }, body: await res.json() }
}

Read each field once and name it:

// Right: plain data, built field by field
const fetchCap = async (url, init) => {
  const res = await fetch(url, init)
  return { ok: res.ok, status: res.status, body: await res.json() }
}

The same rule covers what a capability receives. An atom's inputs are copied out of the run before your function is called, so a capability never holds a live object the agent can still change.


Which failures an agent can catch

A failure is either catchable, so the agent's try/catch sees it as a string, or it ends the run: nothing after it runs, not even a catch block, and the host gets it as run.error. The rule is who caused it. The world's failures are catchable; bugs, in the agent or in the host, end the run, because a program that could catch a bug could loop on it.

Failure Whose fault Result Message, for an atom forecast
The capability throws the world catchable your message, such as HTTP 503
The atom's body throws (a missing capability, a bad value) the world catchable the body's message, such as Capability 'weather' missing
The return is too large or too deeply nested the world catchable … the value exceeds the 4194304-byte membrane budget
The return does not match the output schema the world catchable Output validation failed for 'forecast'
The atom's own timeout passes the world catchable Atom 'forecast' timed out
A quota is used up the world catchable Quota exceeded for 'forecast': 0 calls allowed
An IO atom's input does not match its input schema the agent ends the run 'forecast': its input does not have the shape the atom declares
The capability returns a value that is not plain data the host ends the run … capability return contains a Map; only plain data (and Date) crosses …
Fuel runs out, or the run's timeout passes the budget ends the run Out of Fuel, Execution timeout after …

In AJS, a caught error is a string, the error's message. This agent asks for a page its host does not allow and catches the refusal:

function tryFetch({ url = 'https://not-on-the-list.example/' }) {
  try {
    let page = httpFetch({ url })
    return { page }
  } catch (err) {
    return { failed: err }
  }
}

It returns { failed: 'Fetch blocked: …' }, with a message that says why: no allowlist was configured, or the domain is not on it. Catchable failures and run-ending refusals covers the host's side, and Catchable, and run-ending the agent's.


Writing an atom

defineAtom takes five arguments:

import { defineAtom } from 'tjs-lang/vm-ast'
import { s } from 'tjs-lang/schema'

const atom = defineAtom(
  'opCode', //          the name it is registered and called by
  inputSchema, //      a tosijs-schema builder: what the agent must pass
  outputSchema, //     what the atom returns, or undefined for nothing
  async (input, ctx) => result, // the implementation
  { cost, timeoutMs, effects, resolveInputs, docs } // all optional
)
Option Default Meaning
cost 1 Fuel charged per call. A number, or (input, ctx) => number computed from the resolved input.
timeoutMs 1000 Wall-clock limit per call, in milliseconds. 0 means none. Core atoms set their own (httpFetch 30 s, llmPredict 120 s), and a host can override any atom's per run with timeoutOverrides.
effects 'io' 'io' puts the atom's inputs and return through the boundary. 'pure' opts out.
resolveInputs true The VM evaluates the agent's inputs to values before calling you. false for atoms that take steps.
docs '' A description, shown by vm.getTools() and in tooling.

The VM checks the output schema on every call. It checks the input schema, through the outbound copy, only for IO atoms. For a pure atom the input schema is what the transpiler and vm.getTools() use, but at run time the body gets whatever the AST holds (a hand-built AST, or source transpiled without atoms, can pass anything), so a pure atom checks its own inputs.

What an atom can use

The implementation receives its input and ctx, the run's context:

Run steps only on the ctx you received or on a createChildScope(ctx) scope, never on a copy such as { ...ctx }. With resolveInputs: true a copy is refused by name. With resolveInputs: false, the case for step-taking atoms, nothing stops you, and a copy loses the steps' errors: a step that fails sets error on the copy, and the run carries on as if it had succeeded.

An atom must also be non-blocking: no long synchronous work, because no timeout can interrupt it. Do heavy computation in a worker, and await it.

A pure atom

A pure atom computes from its inputs and nothing else. It is a good fit for a calculation that would be slow or awkward in AJS:

const slugify = defineAtom(
  'slugify',
  s.object({ text: s.string }),
  s.string,
  async ({ text }) => {
    if (typeof text !== 'string') throw new Error('slugify: text must be a string')
    return text.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
  },
  { effects: 'pure', docs: 'Turn a title into a URL slug' }
)

An agent calls it like any atom, with named inputs: let slug = slugify({ text: title }) returns 'hello-world' for 'Hello, World!'.

The regexes here run on the host's engine over a string the agent chose, and that is fine: they are your code, and simple enough to run in linear time. Keep any regex in an atom's body that way, because a synchronous match cannot be interrupted by a timeout. (The pattern ban on IO atom schemas, below, is about regexes the VM itself would run during validation.)

Mark an atom 'pure' only when all three hold: it reads no capability, it gives the same output for the same input, and it has no side effects. Two things depend on the tag:

Verified predicates are small pure functions that tjs-lang/lang certifies and compiles to native JavaScript, for uses such as storeQueryWhere filters. The verifier reads atom tags through effectfulFromAtoms(vm.atoms).

An IO atom over a REST API

An IO atom is how host data comes in. Give it a narrow input, build the request in the atom so the agent chooses values rather than URLs, and reach the world through a capability so the host decides per run whether the atom works at all:

const forecast = defineAtom(
  'forecast',
  s.object({ latitude: s.number, longitude: s.number, days: s.number.optional }),
  s.array(s.object({ date: s.string, maxC: s.number })),
  async ({ latitude, longitude, days = 3 }, ctx) => {
    const weather = ctx.capabilities.weather
    if (!weather) throw new Error("Capability 'weather' missing")
    if (!Number.isInteger(days) || days < 1 || days > 16)
      throw new Error('forecast: days must be a whole number from 1 to 16')
    return weather.daily({ latitude, longitude, days }, ctx.signal)
  },
  {
    // charged before the call, from the resolved input: more days, more fuel
    cost: ({ days }) => 5 + (Number.isInteger(days) ? Math.min(Math.max(days, 1), 16) : 3),
    timeoutMs: 10_000,
    docs: 'Daily maximum temperature for a location',
  }
)

// The capability: the only code here that touches the network.
const weather = {
  async daily({ latitude, longitude, days }, signal) {
    const url = new URL('https://api.open-meteo.com/v1/forecast')
    url.search = new URLSearchParams({
      latitude: String(latitude),
      longitude: String(longitude),
      daily: 'temperature_2m_max',
      forecast_days: String(days),
      timezone: 'UTC',
    }).toString()
    const res = await fetch(url, { signal, redirect: 'error', credentials: 'omit' })
    if (!res.ok) throw new Error(`forecast: HTTP ${res.status}`)
    const { daily } = await res.json()
    return daily.time.map((date, i) => ({ date, maxC: daily.temperature_2m_max[i] }))
  },
}
const vm = new AgentVM({ forecast })
const { ast } = transpile(
  `function berlin({ days = 2 }) {
    let daily = forecast({ latitude: 52.52, longitude: 13.41, days })
    return { daily }
  }`,
  { atoms: vm.atoms }
)
const run = await vm.run(ast, {}, { fuel: 100, capabilities: { weather } })
// run.result → { daily: [{ date: '…', maxC: 6.8 }, { date: '…', maxC: 8.7 }] }  (live data)

What the VM does around that call follows the steps at the top of this chapter:

  1. Quota and fuel. A quotas entry for forecast is checked first; then cost is charged. The cost function sees the resolved input before it is checked against the schema, which is why it guards against a days that is not a number.
  2. The outbound copy. The input is copied out of the run (the copy is charged too) and validated against the input schema. An input that does not match is the agent's bug, so the run ends: 'forecast': its input does not have the shape the atom declares.
  3. The call, under timeoutMs. An error the body throws, such as the days check, is catchable.
  4. The inbound copy. The return crosses the boundary under the rules above, then is checked against the output schema. A mismatch there is the world's doing (an API that changed), so the agent can catch it: Output validation failed for 'forecast'.

Two rules for IO atoms are enforced when you call defineAtom:

A step-taking atom

Control atoms (if, while, try) take steps, not values: lists of AST nodes to run. You write one to add a new kind of control flow, such as a retry loop, for hosts that build agents as ASTs. AJS source cannot pass steps to an atom, so a step-taking atom is called from the builder (vm.Agent, the fluent API that writes ASTs; see Builder API) or from a hand-built AST.

Such an atom must set resolveInputs: false, or the VM evaluates the steps as data before you see them. With it off, you resolve the value inputs yourself with resolveValue(value, ctx). Being pure, it also gets no input check from the VM, so it checks attempts itself.

This one runs its steps up to attempts times, until a pass ends without an error:

import { defineAtom, seq, resolveValue, createChildScope, releaseScope } from 'tjs-lang/vm-ast'

const retry = defineAtom(
  'retry',
  s.object({ attempts: s.any, steps: s.array(s.any) }),
  undefined,
  async (step, ctx) => {
    const attempts = resolveValue(step.attempts, ctx)
    if (typeof attempts !== 'number' || attempts < 1 || attempts > 5)
      throw new Error('retry: attempts must be a number from 1 to 5')
    for (let i = 1; i <= attempts; i++) {
      const scope = createChildScope(ctx) // variables set by the steps stay in here
      try {
        await seq.exec({ op: 'seq', steps: step.steps }, scope)
        if (!ctx.error) return // the scope shares ctx.error
        if (i < attempts) ctx.error = undefined // clear it and go again
      } finally {
        releaseScope(scope) // always: an unreleased scope stays counted against the heap
      }
    }
  },
  // pure: it touches no capability (the steps it runs are charged as they run)
  { resolveInputs: false, effects: 'pure', timeoutMs: 0, cost: 1 }
)

// A test atom: fails on its first two calls, then returns 3.
let calls = 0
const flaky = defineAtom('flaky', s.object({}), s.number, async () => {
  if (++calls <= 2) throw new Error(`flaky: failure ${calls}`)
  return 3
})
const vm = new AgentVM({ retry, flaky })
const ast = vm.Agent.retry({
  attempts: 3,
  steps: vm.Agent.flaky({}).as('n').storeSet({ key: 'answer', value: 'n' }).steps,
})
  .storeGet({ key: 'answer' })
  .as('result')
  .return(s.object({ result: s.number }))
  .toJSON()

const run = await vm.run(ast, {}, { fuel: 100 })
// run.result → { result: 3 }

flaky fails twice, the third pass succeeds, and the run's result is { result: 3 }. With attempts: 2 the run ends with flaky: failure 2.

Watch the strings. The builder writes a v1 AST, where a bare string is a variable reference when a variable by that name is in scope, and a literal otherwise. value: 'n' stores the value of n; key: 'answer' stays the literal 'answer' only because no variable named answer exists. (The transpiler writes v2 ASTs, where a bare string is always a literal.)

Clearing ctx.error cannot revive a run that ended: a run-ending refusal is recorded on the run itself, and the next step sees it and stops.


Registering atoms

Pass atoms to the VM's constructor. They are added to the core atoms:

const vm = new AgentVM({ slugify, forecast })

The key is the name agents call. new AgentVM({ slug: slugify }) makes slug({ text }) work and slugify({ text }) fail with Unknown Atom: slugify. Use the op code as the key, which object shorthand ({ slugify }) does for you. A key that matches a core atom replaces it, which is how a test swaps in a fake httpFetch.

Then give the same atoms to the transpiler, so calls are checked where the source is compiled:

const { ast } = transpile(source, { atoms: vm.atoms })

With atoms, a wrong call fails at transpile time with a message and its location:

'slugify' has no input 'txt'. It takes slugify({ text }). at <source>:1:31
'slugify' takes named arguments: write slugify({ name: value, … }), not slugify(a, b). Positional arguments are for local functions. at <source>:1:31

Without it, core atoms are still checked, but a call to your atom is compiled as written and fails only when it runs. vm.getTools() lists every registered atom with its input schema in the OpenAI tool format, which is a quick way to tell a model what it may call.


Writing a safe fetch capability

A fetch capability is where network policy lives, because it is the one part of the system the agent cannot see or change. An agent that was asked to check an allowlist, count its requests or back off could simply not. Put each rule here:

function createFetchCapability({
  allowedHosts, // host names, with the port when it is not the default: ['api.example.com']
  perSecond = 5, // spacing between requests, across every run that shares this capability
  retries = 2, // retries on 429 and 5xx, with exponential backoff
  maxBodyBytes = 1024 * 1024,
}) {
  const gap = 1000 / perSecond
  let next = 0

  // Wait `ms`, or reject as soon as the run is aborted.
  const pause = (ms, signal) =>
    new Promise((resolve, reject) => {
      if (signal?.aborted) return reject(new Error('aborted'))
      const onAbort = () => {
        clearTimeout(timer)
        reject(new Error('aborted'))
      }
      const timer = setTimeout(() => {
        signal?.removeEventListener('abort', onAbort)
        resolve()
      }, ms)
      signal?.addEventListener('abort', onAbort, { once: true })
    })

  // Read at most maxBodyBytes, and stop reading the moment the body passes it.
  async function readBody(res) {
    const reader = res.body?.getReader()
    if (!reader) return ''
    const chunks = []
    let size = 0
    for (;;) {
      const { done, value } = await reader.read()
      if (done) break
      size += value.byteLength
      if (size > maxBodyBytes) {
        await reader.cancel()
        throw new Error('Response too large')
      }
      chunks.push(value)
    }
    const bytes = new Uint8Array(size)
    let at = 0
    for (const chunk of chunks) {
      bytes.set(chunk, at)
      at += chunk.byteLength
    }
    return new TextDecoder().decode(bytes)
  }

  return async (url, init = {}) => {
    const target = new URL(url) // a relative URL throws here, so it is refused
    if (target.protocol !== 'https:' && target.protocol !== 'http:')
      throw new Error(`Scheme not allowed: ${target.protocol}`)
    if (!allowedHosts.includes(target.host)) throw new Error(`Host not allowed: ${target.host}`)
    if (init.responseType === 'dataUrl') throw new Error('responseType dataUrl is not supported')

    const request = {
      method: init.method ?? 'GET',
      headers: init.headers, // includes X-Agent-Depth
      body: init.body === undefined || typeof init.body === 'string' ? init.body : JSON.stringify(init.body),
      redirect: 'manual',
      credentials: 'omit',
      signal: init.signal,
    }

    for (let attempt = 0; ; attempt++) {
      const now = Date.now()
      const start = Math.max(now, next)
      next = start + gap
      if (start > now) await pause(start - now, init.signal)

      const res = await fetch(target, request)
      if ((res.status === 429 || res.status >= 500) && attempt < retries) {
        await res.body?.cancel()
        await pause(250 * 2 ** attempt, init.signal)
        continue
      }
      // A redirect, in the built-in client's shape. A browser hides it: type 'opaqueredirect',
      // status 0, no location.
      const location = res.headers.get('location')
      if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400 && location)) {
        await res.body?.cancel()
        return { redirect: true, status: res.status, location: location && new URL(location, target).href }
      }
      if (Number(res.headers.get('content-length') ?? 0) > maxBodyBytes) {
        await res.body?.cancel()
        throw new Error('Response too large')
      }
      const text = await readBody(res)
      const json =
        init.responseType === 'json' ||
        (init.responseType !== 'text' && (res.headers.get('content-type') ?? '').includes('application/json'))
      if (!json) return { ok: res.ok, status: res.status, body: text }
      try {
        return { ok: res.ok, status: res.status, body: JSON.parse(text) }
      } catch {
        throw new Error(`${target.host} sent a body that is not valid JSON`)
      }
    }
  }
}

const fetchCap = createFetchCapability({ allowedHosts: ['api.example.com'] })
await vm.run(ast, args, { fuel: 1000, capabilities: { fetch: fetchCap } })

Against a test server on Bun, an agent calling httpFetch({ url }) gets { ok: true, status: 200, body: { … } } after two 503s are retried, gets { redirect: true, status: 302, location: 'http://…/flaky' } for a redirect, and gets errors it can catch for a host off the list (Host not allowed: evil.test), a body that streams past the limit with no content-length (Response too large) and malformed JSON. In a browser the redirect arrives as { redirect: true, status: 0, location: null }, because the browser hides it.

Every agent reading this capability's results reads ok, status and body, so the shape is part of your API: tell agent authors (or the model's system prompt) what it is. The wait for a rate limit counts against httpFetch's 30-second timeout. If your limiter queues for longer, raise it for the run:

await vm.run(ast, args, {
  fuel: 1000,
  capabilities: { fetch: fetchCap },
  timeoutOverrides: { httpFetch: 60_000 },
})

Safe Eval has a shorter fetch capability built the same way, and the same policy stated as data with fetchPolicy.


Testing with fake capabilities

An agent is data plus a host, so test it like any function with injected dependencies: pass fake capabilities and assert on the result. Nothing here needs a network or a model.

import { expect, test } from 'bun:test'
import { transpile } from 'tjs-lang/lang'
import { AgentVM } from 'tjs-lang/vm-ast'

const vm = new AgentVM({ forecast })
const { ast } = transpile(
  `function packFor({ days = 3 }) {
    let daily = forecast({ latitude: 52.52, longitude: 13.41, days })
    let cold = daily.filter((d) => d.maxC < 10)
    return { coat: cold.length > 0, coldDays: cold.length }
  }`,
  { atoms: vm.atoms }
)

const fakeWeather = (temps) => ({
  daily: async ({ days }) =>
    temps.slice(0, days).map((maxC, i) => ({ date: `2026-01-0${i + 1}`, maxC })),
})

test('a cold day means a coat', async () => {
  const run = await vm.run(ast, {}, { fuel: 100, capabilities: { weather: fakeWeather([12, 8, 15]) } })
  expect(run.result).toEqual({ coat: true, coldDays: 1 })
})

test('an upstream failure is an error the host can see', async () => {
  const broken = { daily: async () => { throw new Error('HTTP 503') } }
  const run = await vm.run(ast, {}, { fuel: 100, capabilities: { weather: broken } })
  expect(run.error?.message).toBe('HTTP 503')
})

test('a capability that breaks the contract ends the run', async () => {
  const leaky = { daily: async () => new Map() }
  const run = await vm.run(ast, {}, { fuel: 100, capabilities: { weather: leaky } })
  expect(run.error?.message).toContain('Map')
})

A fake store is a Map behind get and set, as in the first example on this page. A fake llm is one function, { predict: async (prompt) => 'canned answer' }, which makes the logic around a model call testable without a model. To test the atom rather than the agent, register a fake under the same name: new AgentVM({ httpFetch: fakeFetchAtom }) replaces the core atom for that VM.

Patterns shows trace inspection (trace: true, to assert which atoms ran) and snapshot tests of the AST.