Weather API

An agent that reaches out to the world: it calls a public API and returns the part of the answer that matters. This one asks Open-Meteo, which needs no API key, for the current weather in San Francisco.

Four words recur below. An agent is the AJS program. The host is the program that runs it in an AgentVM and decides what it may touch. An atom is a built-in operation the VM provides, such as httpFetch. A capability is a piece of IO the host hands the run (a fetch, a store, a model); with none, the agent cannot reach anything outside the VM. Fuel is the agent's budget of work. Embedding the AJS VM covers all of them in detail.

function getWeather({ lat = 37.7749, lon = -122.4194 }) {
  let url =
    'https://api.open-meteo.com/v1/forecast?latitude=' +
    lat +
    '&longitude=' +
    lon +
    '&current_weather=true'
  let response = httpFetch({ url })
  let weather = response.current_weather
  return { weather }
}

Running it returns something like this (your numbers will differ):

{ weather: { time: '2026-11-06T18:15', interval: 900, temperature: 14.3,
             windspeed: 2.4, winddirection: 101, is_day: 1, weathercode: 3 } }

weathercode is a WMO weather code (3 means overcast), and interval is how many seconds the reading covers. The agent wraps the reading in an object because an agent's entry function must return an object: return 14.3 ends the run with Agent must return an object, got number. Naming the field ({ weather }) also tells the caller what it got.

To get London instead, call the agent with { lat: 51.5, lon: -0.12 } as its arguments. In your own host they are the second argument of vm.run (see the host code at the end of this page).

What to notice

A variation

A three-day forecast. The URL is long, so it is easier to read as a template literal; AJS accepts both a template and + concatenation, as long as the pieces are strings or numbers.

function forecast({ lat = 37.7749, lon = -122.4194 }) {
  let url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&daily=temperature_2m_max,temperature_2m_min&timezone=auto&forecast_days=3`
  let response = httpFetch({ url })
  let daily = response.daily
  return {
    days: daily.time,
    highs: daily.temperature_2m_max,
    lows: daily.temperature_2m_min,
  }
}

Open-Meteo returns each daily field as a parallel array, so the agent passes the three arrays through as they are. The result looks like this:

{ days: ['2026-11-06', '2026-11-07', '2026-11-08'],
  highs: [20.5, 19.5, 20], lows: [10, 10.8, 13.3] }

The gotchas

An HTTP error status is not an error. The built-in client returns whatever body the server sent, 400 or not, and the agent never sees the status code. Open-Meteo answers a bad request with { error: true, reason: '…' } and no weather, so check the body before you use it. The default latitude here is deliberately out of range, to trigger that answer:

function getWeatherChecked({ lat = 999, lon = 0 }) {
  let url =
    'https://api.open-meteo.com/v1/forecast?latitude=' +
    lat +
    '&longitude=' +
    lon +
    '&current_weather=true'
  let response = httpFetch({ url })
  if (typeof response === 'string' || response.error) {
    return { error: response.reason ?? response }
  }
  return { weather: response.current_weather }
}

This returns { error: 'Latitude must be in range of -90 to 90°. Given: 999.0.' }. Without the check the agent would return { weather: undefined } and nothing would say why. The typeof response === 'string' test catches an error page that is not JSON (an HTML 502 from a proxy, say), which arrives as a string with no error field. A host that wants agents to see status codes supplies its own fetch capability and returns them as part of the data.

A domain the host did not allow is an error you can catch. Errors caused by the outside world (a blocked domain, a failed request, a timeout) can be caught with try/catch. Errors that mean the agent itself is wrong, such as calling an atom with input it does not accept, end the run and cannot be caught; so does running out of fuel or time. Catchable failures and run-ending refusals explains the split.

function tryElsewhere() {
  try {
    httpFetch({ url: 'https://example.com/' })
    return { ok: true }
  } catch (e) {
    return { ok: false, error: e }
  }
}

On this page that returns { ok: false, error: 'Fetch blocked: domain not in allowlist. Allowed: api.github.com, itunes.apple.com, api.open-meteo.com' }. Unlike JavaScript, e is the message string itself, not an Error object, so use e, not e.message (which is undefined).

Without the try, vm.run does not throw. It resolves with error set to an AgentError whose message is that same text (and op is httpFetch), and result holds the same error.

In your own host, nothing is allowed until you say so. Pass the allowlist in the context run option:

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

const vm = new AgentVM()
const { ast } = transpile(source, { atoms: vm.atoms }) // source: the getWeather agent above

const run = await vm.run(ast, { lat: 51.5, lon: -0.12 }, {
  context: { allowedFetchDomains: ['api.open-meteo.com'] },
})
if (run.error) console.log(run.error.message)
else console.log(run.result.weather.temperature)

With no allowlist and no fetch capability, every URL is refused (Fetch blocked: no allowedFetchDomains configured). The built-in client is meant for demonstrations; a real deployment supplies its own fetch capability, capabilities: { fetch: async (url, init) => body }, which receives the admitted request and returns the response body as plain data. The fetch policy section of the host chapter covers the allowlist's syntax, methods, headers and redirects; Writing a safe fetch capability and Safe Eval each have a complete, tested one.