JSON Processing

JSON is what agents mostly handle: APIs return it, models are asked to produce it, and an agent returns an object of plain data to its host (see Input/Output Contract in the AJS reference). JSON.parse and JSON.stringify work as in JavaScript.

An agent's inputs are its destructured parameters. The defaults are what the example runs with on this page; edit them and run it again to try other input.

function jsonRoundTrip({ data = { name: 'Alice', age: 30 } }) {
  let jsonStr = JSON.stringify(data)
  let parsed = JSON.parse(jsonStr)
  let name = parsed.name
  let city = parsed.address.city ?? 'unknown'
  return { jsonStr, parsed, name, city }
}

The result is { jsonStr: '{"name":"Alice","age":30}', parsed: { name: 'Alice', age: 30 }, name: 'Alice', city: 'unknown' }.

What to notice

When the text may not be JSON

Text from outside (a model's answer, a form field) may not be JSON at all. A failed JSON.parse is an error you can catch with try/catch.

In a catch, err is the error's message, a string, not an Error object, so err.message is undefined; use err itself. Errors reach an agent only as plain text, so no host object (and none of its properties) crosses into the program. The message is all there is to read.

function readConfig({ raw = '{"user":{"name":"Ann","roles":["admin","dev"]}}' }) {
  try {
    let config = JSON.parse(raw)
    let roles = config.user.roles ?? []
    return { name: config.user.name, isAdmin: roles.includes('admin') }
  } catch (err) {
    return { error: err }
  }
}

The result is { name: 'Ann', isAdmin: true }. The ?? [] line is the null-safe read at work: with raw set to '{"user":{"name":"Bo"}}' there are no roles, and the result is { name: 'Bo', isAdmin: false }.

Change raw to '{oops' and you get { error: ... } holding the parser's message. The wording comes from the JavaScript engine running the VM, so it differs between browsers and runtimes (Bun says JSON Parse error: Expected '}'). Show it, but do not match on its text.

Without the try, a failed parse ends the run: nothing after it runs, and vm.run hands the host a result whose error holds the message, with the fuel spent so far. Nothing is thrown into the host's code. Error Handling covers this in full.

When it is JSON but not the shape you expected

A model or a remote service can send you more than you asked for, such as a token, a debug field, or an instruction aimed at the next step. Check the shape of anything they give you before your agent acts on it or returns it. The shape you checked is then the shape you pass on, and fields you never asked for do not travel with it.

Parsing proves the text is JSON. It says nothing about what is in it: a field can be missing, have the wrong type, or carry something extra. The builtin filter(data, schema) (a global function, like parseInt; Schema builds the schema it takes) checks a value against a shape and returns a copy that holds only the fields the shape names, at every depth, including objects nested inside objects and arrays. If the value does not fit, it fails with a message naming the field.

function readUser({ raw = '{"name":"Ann","score":97.5,"token":"s3cret"}' }) {
  let shape = Schema.object({ name: Schema.string, score: Schema.number })
  try {
    let user = filter(JSON.parse(raw), shape)
    return { user }
  } catch (err) {
    return { error: err }
  }
}

The result is { user: { name: 'Ann', score: 97.5 } }: token is gone. Scalar types are values (Schema.string, Schema.number); containers are calls (Schema.object({...}), Schema.array(...)). Schema.string() ends the run with method 'string' is not callable.

Try these values of raw:

raw result
'{"name":"Ann"}' { error: 'root: Missing score' }
'{"name":"Ann","score":"high"}' { error: 'score: Expected number' }
'[1,2]' { error: 'root: Expected object' }
'nope' { error: ... }, the parser's message

The part before the colon is the path to the value that failed. A missing key is reported against the object that should hold it (root is the whole value), and a value of the wrong type is reported against itself. Deeper down the path grows: a missing city inside address reads address: Missing city, and a bad id in the first element of tags reads tags.0.id: Expected number.

One try catches both kinds of failure: a parse error and a shape error land in the same catch. A shape error starts with a path and a colon (score:, root:); a parse error is the engine's text. If you need to tell them apart, parse and filter in separate try blocks.

Optional fields

Every field Schema.object names is required. A field written as a plain JSON-Schema object with a nullable type is the exception: it may be absent, or null. Plain keyword objects mix freely with Schema values:

function readProfile({ raw = '{"name":"Ann"}' }) {
  let shape = Schema.object({
    name: Schema.string,
    nick: { type: ['string', 'null'] },
  })
  try {
    return { user: filter(JSON.parse(raw), shape) }
  } catch (err) {
    return { error: err }
  }
}
raw result
'{"name":"Ann"}' { user: { name: 'Ann' } }
'{"name":"Ann","nick":null}' { user: { name: 'Ann', nick: null } }
'{"name":"Ann","nick":"A"}' { user: { name: 'Ann', nick: 'A' } }
'{"name":"Ann","nick":3}' { error: 'nick: Expected string' }

The keywords a schema may use are listed under Schema in the AJS reference.

An example instead of a schema

filter also accepts an example in place of a schema: filter(data, { name: '', age: 0 }). It strips and requires fields exactly as a schema does, and it is the quick way to write a shape made of strings, booleans and integers.

The example's values set the types, so 0 means an integer, and { "age": 41.5 } is refused with age: Expected integer. In TJS signatures 0.0 means a float example, but that does not carry over here: filter receives the runtime value 0, which is an integer. When a field may hold any number, use a schema with Schema.number, as in readUser.