XJSNa notation for models to write programs as data
XJSN is JSON with one additional value type: an inert function call.
A call can appear anywhere a JSON value can appear. It looks like code, but it is never evaluated as JavaScript. The parser turns it into data, and a validator checks the resulting tree against a registry of allowed functions.
{
"user": checkUser("alice"),
"actions": [
sendEmail("welcome"),
createProfile()
]
}The design came from a practical problem. I needed an intermediate form that a model could generate reliably, a program could parse, a person could inspect, and a validator could reject before anything executed.
JSON satisfied three of those requirements. It struggled with the first.
The solution was not to give the model a full programming language. It was to use the part of programming-language syntax that models are unusually good at producing, then remove everything that makes code dangerous or difficult to validate.
The problem is constrained generation
Suppose a user describes a workflow in ordinary language. A model translates that description into an intermediate representation, and a runtime eventually carries it out.
That intermediate form has four readers:
- The model has to generate it.
- The parser has to reconstruct it.
- A person may need to inspect or edit it.
- The validator has to decide whether it is safe and meaningful.
Most serialization formats are designed around only the second reader. They optimize for programs exchanging data. A model-generated program adds a different constraint: the notation must be easy to produce correctly under deep nesting.
The central problem is therefore not parsing arbitrary model output. A parser can be written for almost any notation. The problem is defining a small set of structures the model may generate, giving the model a reliable way to express them, and returning errors specific enough for the model to repair its own output.
That makes generation, validation, and error reporting parts of one protocol.
Why plain JSON becomes awkward
JSON is the obvious starting point. It is familiar, language-independent, easy to parse, and supported by schema tools.
It also has a deliberately small type system: objects, arrays, strings, numbers, booleans, and null. There is no native way to say that a value is a function call, a condition, or a reference.
Those constructs can be encoded with tagged objects:
{
"$type": "conditional",
"$condition": {
"$type": "function_call",
"$name": "user_has_permission",
"$args": [
{
"$type": "variable",
"$ref": "current_user"
}
]
}
}The representation is valid JSON, but it makes both generation and reading harder.
The model has to preserve several levels of brackets, repeat structural keys such as $type, and remember which fields belong to each tagged variant. A person has to read through the encoding before reaching the operation it represents.
Validation also becomes indirect. The shape of an object depends on the value of $type, so the schema becomes a union over every possible construct. That is workable in principle, but errors tend to point at a failed union rather than the actual mistake: a missing argument, an unknown function, or a value of the wrong type.
The notation had moved the complexity of the language into the least readable part of the document.
Models are better at code-shaped nesting
Deep nesting is not equally difficult in every notation. Models produce nested code much more reliably than equally nested tagged JSON.
That is not surprising. Source code is abundant in training data, and nesting is fundamental to it. Models have seen function calls inside function calls, arrays of expressions, object literals containing calls, and long chains of structured arguments.
This suggested a different strategy:
- Start with a shape the model already knows how to generate.
- Keep only the syntax needed to describe a structured program.
- Remove variable declarations, assignment, closures, branching, loops, and arbitrary execution.
The goal was to use the model's coding priors without accepting general-purpose code.
That put XJSN between two familiar languages:
- It is a subset of JavaScript syntax, which gives the model familiar function-call notation.
- It is a superset of JSON values, which keeps every parsed result representable as data.
The result looks like the declarative part of a JavaScript codebase, but its semantics are closer to a typed data format.
A function call is the only new primitive
JSON needs only one extension to represent most of the structures I cared about: a call that occupies a value position.
{
"workflow": checkPermission(currentUser),
"actions": [
sendNotification(),
returnResponse("success")
]
}The parser never executes checkPermission. It produces an AST node:
{
"$type": "call",
"$fn": "checkPermission",
"$args": [
{
"$type": "reference",
"$ref": "currentUser"
}
]
}The verbose representation still exists internally. The model no longer has to write it.
This is the main trade XJSN makes. Complexity moves from generated text into the parser, where it can be implemented once and tested.
Calls compose naturally because every argument is itself an XJSN value:
{
"simple": greet("World"),
"namespaced": user.create("Alice"),
"nested": processData(
getData("source"),
"transform"
)
}The notation does not need separate syntax for pipelines, action nodes, or tagged operations. A domain can express them through the functions it defines.
That makes the function call similar to an s-expression. f(a, b) and (f a b) represent the same tree with different punctuation. XJSN uses the first because models already associate it with ordinary code.
What XJSN deliberately removes
Looking like JavaScript creates a risk: readers may assume it behaves like JavaScript.
The safest response is a narrow grammar, not a warning in the documentation.
XJSN has no syntax for:
- Variable declarations
- Assignment
- Closures or function definitions
- Property mutation
- Loops
- General branching
- Imports
- Class or object construction
- Arbitrary operators
- Access to a JavaScript runtime
A call name must resolve to a function declared in the active schema. Its arguments must match the declared signature. Nothing in the document can introduce new behavior.
This boundary changes the security model. The runtime does not evaluate a source string or expose a global environment. It receives a parsed tree containing literals, collections, references, and calls selected from a registry.
The document describes behavior using an allowed vocabulary. It does not define behavior from first principles.
That distinction is what makes the notation useful as an AI-generated intermediate representation rather than another way to ask a model for code.
Why not XML or a richer configuration language?
Before settling on inert calls, I explored a richer external syntax with JSON underneath it.
Markup was attractive for three reasons:
- It handles nesting and mixed content well.
- Models have seen enormous amounts of it.
- XML has mature validation systems such as XSD and RELAX NG.
The validation ecosystem was especially interesting. A validator that returns only true or false can stop bad output. A validator that reports a precise path, expected type, and invalid value can drive a repair loop: return the errors to the model and ask it to correct the document.
But markup solved the parser and validation problem by adding a second representation. The model would write one form, the runtime would consume another, and the system would maintain a translation between them.
Richer configuration languages such as Dhall, EDN with tagged literals, or a Racket-like DSL offered more expressive syntax. That expressiveness mainly benefits a human author. Models do not need shorthand because they get tired of typing, and they do not need infix syntax to scan a formula quickly.
The question that simplified the design was: who is the primary author?
If the model writes the document and a person only occasionally reads or edits it, then the notation should optimize first for reliable generation and precise checking. Function calls and JSON values already met that need. More syntax would expand the parser and validator without adding much expressive power.
The parser converts familiarity into structure
Because XJSN is a superset of JSON, a standard JSON parser is not enough. The implementation uses a lexer and parser built with Chevrotain.
The parser recognizes ordinary JSON values plus three important additions:
- Function calls
- Namespaced function names such as
user.create - References to values supplied by the surrounding runtime
Its output is an AST made only of data. A simplified type model looks like this:
type XJSNValue =
| null
| boolean
| number
| string
| XJSNValue[]
| { [key: string]: XJSNValue }
| XJSNReference
| XJSNCall;
type XJSNCall = {
kind: "call";
functionName: string;
arguments: XJSNValue[];
};The parser's job is intentionally limited. It answers what the text says, not whether the program is valid for a particular domain.
Domain meaning belongs to the schema and validator.
This separation allows the syntax to remain fixed while different products define different vocabularies.
The schema is the actual language
XJSN supplies the grammar for calls. A schema supplies the operations those calls may invoke.
const schema = new XJSNSchemaBuilder()
.name("Todo DSL")
.addFunction("task.create", {
name: "create",
namespace: "task",
description: "Create a new task",
arguments: [
{ name: "title", type: struct.string() },
{ name: "priority", type: struct.string() }
]
})
.build();The schema defines:
- The functions available to the model
- Their namespaces and names
- Their descriptions
- Positional or named arguments
- The type of each argument
- Return types
- Any domain-specific constraints
Two domains can therefore use identical XJSN syntax while exposing completely different languages.
A workflow domain might define:
{
"user": checkUser("alice"),
"actions": [
sendEmail("welcome"),
createProfile()
]
}A game domain might define:
{
"spell": castSpell("fireball"),
"effects": [
dealDamage(50),
consumeMana(25)
]
}The parser sees the same structures in both documents. The schemas assign them different meanings.
This makes XJSN a substrate for small declarative languages rather than one large language with every possible operation built in.
Generate the prompt and validator from one source
The schema serves both sides of the model loop.
On the generation side, it produces instructions describing the functions the model may use, their purpose, and their argument types.
On the validation side, it checks the parsed document against exactly those same definitions.
const result = XJSN.validate(text, schema);
// {
// valid: false,
// errors: [...],
// warnings: [...]
// }This avoids a common failure mode in generated DSLs: the prompt and the checker slowly diverge. A new function should not require separate edits to prose instructions, TypeScript types, validation code, and repair logic.
With one schema, adding a function updates both what the model is told and what the validator accepts.
That closes the generation loop:
schema -> prompt -> model output -> parse -> validate
^ |
|-------------- repair errors <-------|If validation fails, the system can return a structured error to the model:
actions[1]: sendEmail expected 2 arguments but received 1
actions[1].argument[0]: expected EmailAddress, received string "welcome"The error identifies the call, location, and expected signature. The model can repair one part of the document without regenerating the whole structure blindly.
Error quality is therefore part of the language interface. A validation failure is an input to the next generation attempt.
Raise the vocabulary instead of enriching the syntax
Once calls are the primitive, most of the design work moves from syntax into function selection.
A low-level schema could expose functions such as:
setColor("blue")
setPadding(16)
setFontSize(24)A higher-level schema could expose:
applyHeroStyle("technical", "high-contrast")The second function contains more domain judgment. It reduces the number of decisions the model has to coordinate and gives the runtime a stable place to improve the implementation.
Tailwind demonstrates a related principle. A constrained vocabulary of reusable style decisions is easier for a model to handle than arbitrary CSS spread across a large program. The useful unit is not always the lowest-level property. It is often a composition the domain has already decided is valid.
Every invariant moved into a function definition is one less relationship the model must rediscover in generated text.
This does not mean every function should be large. A useful domain vocabulary needs layers:
- Small primitives for operations that compose safely
- Higher-order functions for common decisions
- Namespaces that make the available concepts easy to navigate
- Types that prevent invalid combinations
The notation stays small while the schema becomes more capable.
Validation should report domain errors, not syntax accidents
Tagged JSON tends to fail at the encoding layer. A missing $type, malformed wrapper object, or incorrect nesting prevents the validator from reaching the operation the author intended.
In XJSN, the parser owns the encoding. Once parsing succeeds, validation can speak in domain terms:
- Unknown function
task.complete - Missing required argument
taskId prioritymust be one oflow,normal, orhighsendEmailcannot be used in this workflow phase- Return type
Usercannot be placed in a field expectingBoolean
These errors are useful to both people and models because they describe the attempted program, not the representation used to encode it.
The validator can also distinguish errors from warnings. An invalid argument type blocks execution. A deprecated function name or an unusually expensive operation may produce a warning that the model can choose to address.
This is where the function registry becomes more than a list of callable names. It can carry effects, capabilities, cost information, deprecation status, and contextual rules.
The richer that metadata becomes, the less policy has to be embedded in prompts.
Execution happens after validation
XJSN separates parsing, validation, and interpretation.
text -> AST -> validated AST -> interpreter -> registered functionsThe parser never executes calls. The validator never needs access to the implementation of a function. The interpreter accepts only a tree that has already been checked against the active schema.
At runtime, a call node resolves through the registry rather than through JavaScript name lookup:
const fn = registry.resolve(call.functionName);
const args = call.arguments.map(argument => interpret(argument));
return fn.invoke(args);The actual implementation may be a local function, an API request, a workflow action, or a constructor for another internal representation. XJSN does not require all calls to execute immediately. A domain can interpret them as plans, UI nodes, game effects, or database queries.
This keeps syntax and execution decoupled. The same document can be validated, visualized, transformed, or simulated before a runtime performs any effect.
Where XJSN fits
XJSN belongs to the family of data-oriented language systems rather than general-purpose programming languages.
EDN extends data notation with additional literal forms. Clojure.spec describes the shape of valid data. Racket provides tools for building languages whose programs can be manipulated as data. Lisp demonstrates how little syntax is required when calls and lists share one representation.
XJSN takes a similar idea and chooses a surface that models already generate well: JSON values plus JavaScript-style calls.
It also overlaps with projectional editing. Systems such as JetBrains MPS let users edit program structure directly instead of editing text that is later parsed. XJSN keeps the text interface, but the parser immediately recovers a structural document that tools can inspect and manipulate.
The practical difference is adoption cost. A team can define a small schema and use ordinary text generation instead of committing to a specialized IDE or a full language workbench.
The parser is not the defensible part of such a system. The value accumulates in domain schemas, high-quality validation, repair loops, editors, visualizers, and runtimes that know how to use the resulting tree.
Design principles
XJSN ended up with a small surface because most of the important choices belong elsewhere.
The principles are:
- Optimize the notation for the system that writes it most often.
- Reuse syntax the model already generates reliably.
- Add the smallest primitive missing from JSON.
- Parse code-shaped text into data; never evaluate it as source code.
- Put domain expressiveness in a typed function registry.
- Generate the prompt and validator from the same schema.
- Return validation errors precise enough to drive repair.
- Prefer higher-level domain operations over a more expressive general language.
- Keep parsing, validation, and execution as separate stages.
- Make every executable operation resolve through an explicit registry.
The central idea is straightforward: if a model is best at producing code-shaped structures, give it a code-shaped notation. Then remove declarations, control flow, mutation, and arbitrary execution until what remains is data the system can completely understand.
XJSN looks like code because that makes it easier for the model to write. It refuses to behave like code because that makes it possible for the system to trust.