We made the AI write in a language it can't break out of
Our assistants build apps you can run in the browser. Getting there meant giving the model a language of its own.
Traid, the platform we’re building, turns a prompt into small interactive mini-apps that run in the browser and share with a link.
A mini-app is a lasting artifact rather than a one-time render. It can be saved and run again later. And since it has to run in anyone’s browser, the format has to be sandboxed.
In this post we analyze what format is best for a model to emit.
Four ways to emit an app
There are four reasonable formats, each one with its own trade-offs in control flow, composability, and how the platform interacts with it.
Tool calling is the standard answer for getting structure out of a model. You describe a set of functions, the model replies with a name and arguments as JSON, and your code runs the function. The output is data, so it cannot do anything on its own.
That works when the model is just asking the harness to run pre-shipped tools. It stops working when you need an actual program, because there is nowhere to execute logic you didn't write in advance. Nothing accumulates either. Without a persistent state, the harness has to re-derive every request's chain of calls from scratch.
A JSON UI tree is the usual next step, and it gets the shape right. It holds until behavior arrives. Then a condition has to go somewhere, and the answers are a string to evaluate or a node type that means “compare these two things.” Both are a language. In this case, you’ve built a language. The question is only whether you designed it deliberately or accumulated it by accident.
JavaScript can express all of it, and isolating it is a solved problem. A null-origin iframe, a Worker, a hardened runtime like SES, an engine compiled to WASM. Any of them will hold.
But containment isn’t the real challenge. An app that can’t utilize host capabilities can’t do meaningful work, forcing you to build a bridge back into your system.
The real shift is how execution happens. Tool calling turns the model into the live runtime, driving conditions and chaining calls turn-by-turn through the harness. Generating a JSON UI, a DSL (domain specific language), or JavaScript flips the model back into an author that writes a static script upfront for an external engine to run.
This choice shapes how you constrain the model. A custom DSL or JSON schema enforces a strictly limited grammar that keeps the model on the rails. Raw JavaScript gives you complete expressive power, but leaves the model free to invent nonexistent APIs and write unnecessary code.
That fundamental tension is what decides the architecture. Whatever pattern you pick, you are ultimately defining the exact set of capabilities an app can access. The difference is simply where you start. Isolating JavaScript means starting from a language that can do everything and working backward to block every escape route. Building a DSL means starting from nothing and adding one capability at a time.
Designing our language
We built a small Lisp based on Clojure because s-expressions fit UI trees natively. Both are trees, so the code is the layout. A JSON tree needs an interpreter anyway, so the choice isn't about having a runtime. It's ergonomics: JSON forces logic into awkward string templates, whereas s-expressions unify layout and control flow natively.
On the left, JSON forces conditions and value extraction into strings. Parsing those strings creates an accidental language. On the right, control flow and UI elements are identical: loops and conditions are just ordinary forms, sitting alongside the list items they produce.
It is also far shorter. The DSL consumes a fraction of the tokens, reducing latency and context overhead. Because control flow isn’t stuffed into string templates, there is no second syntax to parse, eliminating entire categories of runtime errors.
A short grammar with few special cases is quick to parse and easy to reason about mechanically. When a model writes something the language does not support, we can name the problem exactly instead of guessing at intent.
What matters most is that the vocabulary is completely ours. Beyond a small set of control-flow primitives, the language is whatever we define it to be. We decide what a program can reference, extending it as our platform grows. Because the interpreter exposes no host runtime, there is zero interop with the underlying JavaScript.
Every generated app goes through the same path.
Containment comes built into the design. There is no eval to guard and no fetch to patch, because the evaluator simply has no execution path that reaches them. An app communicates with the platform strictly through registered functions, leaving no secondary route out.
State is separate from the app
This is where isolating JavaScript stops being equivalent. A model writing JS couples state management directly with its business logic. Reaching for localStorage, mutating closure variables, or firing a fetch bakes implementation details straight into event handlers, meaning any change to state strategy requires regenerating the program.
In our DSL, an app reads and writes state purely through abstract host functions. These operations specify an intent and a key, leaving the app code completely agnostic about whether it is backed by local memory or a remote database.
That decision belongs to a plug and play state provider. One provider scope keeps state local to the device. Another syncs it to the user's account across sessions, while a third connects every active viewer to a single shared instance.
The app remains identical across all three modes. Under a shared provider, an operation that appends a message to a list becomes a multiplayer write, bringing every viewer into sync. Real-time updates flow automatically because the provider manages synchronization without the app managing subscriptions.
The state model is decoupled from the app entirely. It operates strictly on keys and operations, completely unaware of which app generated them. That abstraction is why a single provider implementation powers every app on the platform instead of reinventing the wheel for each app.
Turning a private counter into a live shared one is a just setting on the saved app. Nothing in the app code itself is rewritten. The equivalent in a sandboxed JavaScript app is a regeneration, because storage calls, sync mechanisms, and state reconciliation are all baked directly into the program.
Teaching the model
Basing the DSL on Clojure means models already write most of it correctly out of the box. The real work lives in the margins where our subset differs from standard Clojure, and almost all of that comes down to context management.
The first lesson was that rejection alone does nothing. The model reaches for features we do not implement, like exception handling and host interop, and telling it a form is unsupported sends it back to write the same thing again. Because our parser maps intent directly rather than returning generic syntax errors, our messages name the valid form to use instead. The error itself carries the exact fix back to the model.
The second lesson was that examples outrun instructions. A paragraph describing layout is worth less than one working example. To scale this, context packs are authored offline by a stronger model, then shipped dynamically based on the app being built. A drawing app and a dashboard need different vocabulary; delivering targeted, model-authored examples keeps context lean and precise.
The third lesson shaped the platform. Context is scarce, so what occupies it has to earn the room. Everything an agent needs to build an app is organized on request, and nothing else is. A conversational agent never receives the language reference, because it only ever answers in text. A single shared context means every agent pays for every other agent’s material and gets worse at its own job.
When this is worth it
Making your own DSL is the wrong choice for self hosted apps, where you need a full library ecosystem. It is the right choice for us, a platform for building instantly available apps: small, self-contained programs with known capabilities.
The result is a program the platform can still reason about long after generation. Where an app stores state, who shares it, and what APIs it can access are all settled at runtime, which is why the exact same app can run privately on one device or go live for anyone holding the link.
Lisp is how we got there, but the specific syntax is the part that generalizes least. When an AI generates code, it is producing an intermediate representation. The only properties that matter are that it executes, serializes, composes, and reaches nothing you did not grant. Pick a shape that satisfies those constraints, and the rest follows.
I'm building Traid, where assistants generate interactive mini-apps instead of text.





