A language for building systems together with agents: why I work on algebraic effects
okay-effects.github.io/language.html
What I am trying to solve
Agents now write a large share of the code in the systems I work on, and the share is growing. That changes what a programming language is for. It used to be enough that a language let a human tell a machine what to do. Now a language also has to be the shared medium in which humans and agents describe a system, reason about it, change it, and check each other's work.
That raises the bar a lot. Such a language has to be:
- readable by both — a person reviewing an agent's change, and an agent reading a person's intent, must see the same thing;
- able to state hard things precisely — not only "call this API", but "this part of the system may do these things and nothing else", "this action must be approved", "this must happen exactly once";
- checkable by a machine — whatever is stated formally should be verified automatically, at compile time where possible, because no one can review every line an agent writes;
- portable — the same ideas have to work in the browser, natively, on the JVM, in the cloud, and in the different languages one organisation already uses;
- built on real abstraction — without it, every large system eventually collapses under its own cognitive weight: people stop understanding how it is put together, agents make more and more mistakes, and nobody can show that it is correct.
The last point is the one that worries me most. Agents are very good at producing code locally. They are much worse at keeping a large structure coherent, and so are we. If the language does not carry the structure, the structure is lost.
So the question is: what language, and how would we know it is the right one?
The ideas are already known — they look scattered
Functional programming has been answering parts of this question for decades, and almost every mainstream language has borrowed from it. The list of ideas is long:
- the lambda calculus;
- referential transparency and the equivalence of expressions;
- algebraic data types and pattern matching;
- type theory and category theory [1];
- continuation-passing style and delimited continuations;
- coroutines and generators;
- tail calls without limit;
- non-blocking asynchrony;
- reactive streams.
Each language implements some of these, usually as separate features with separate rules: one mechanism for exceptions, another for async/await, another for generators, another for dependency injection, another for transactions, another for logging. They look unrelated.
They are not. One idea contains all of them as special cases, is simple enough to explain in a few paragraphs, and is mathematically exact. It is called algebraic effects and handlers [2, 3, 4].
Algebraic effects, as data types
Here is the idealised model. It is small on purpose.
An operation is a question, written as data. It names what it asks and the type of its answer:
```
Get : Op[State[S], S] -- "what is the state?" answered by an S
Put(s) : Op[State[S], Unit] -- "set the state to s" answered by nothing
Charge(c) : Op[Payments, Receipt] -- "charge this card" answered by a receipt
```
An effect is a family of operations — an algebraic data type. State[S] is Get | Put(s). Payments is Charge(c) | Refund(r). An effect is an interface, not an implementation. It says what may be asked, never how it is done.
A computation is data too. A program that may ask the operations of a set of effects R and comes to a value of type A is:
```
Comp R A = Done(a : A) -- finished, with a value
| Ask(op : Op[E, X], k : X -> Comp R A) -- asks op (E in R); k is the rest
```
That is all a program is: either a value, or a question together with the rest of the program, waiting for the answer. Sequencing is substitution: Done(a) >>= f = f(a), and Ask(op, k) >>= f = Ask(op, x -> k(x) >>= f). Mathematically this is the free monad over the signature of operations [5, 6]: the most general structure in which programs can be written using those operations, with no meaning attached yet.
A handler gives an effect its meaning. It is two functions:
```
ret : A -> Comp R' B -- what to do with the final value
on : (op : Op[E, X], k : X -> Comp R' B) -> Comp R' B -- how to answer each operation
```
and handling is a fold over the program's structure:
```
handle h (Done a) = h.ret a
handle h (Ask op k), op of E = h.on op (x -> handle h (k x))
handle h (Ask op k), otherwise = Ask op (x -> handle h (k x)) -- passed on outward
```
Algebraically, a handler is a model of the effect's signature, and handle is the unique structure-preserving map from the free model into it [3]. That is where the word algebraic comes from, and it is why the theory has laws you can rely on:
```
handle h (pure a) = h.ret a
handle h (perform op >>= f) = h.on op (x -> handle h (f x)) -- op belongs to h's effect
handle h (perform op >>= f) = perform op >>= (x -> handle h (f x)) -- it does not
```
The type records what a program may do. A program's type is A ! R: a value A, with the effects R [7, 8]. Handling an effect removes it from R. A program can be run only when R is empty — when every question it may ask has someone to answer it. So "this part of the system may charge cards and read configuration, and nothing else" is not a comment or a convention. It is the type, and the compiler checks it.
Why this one idea contains the others
Look at what a handler's clause receives: the operation and k, the rest of the program, as an ordinary value. Everything in the list above is a choice of what to do with k:
| Resume k… | …and you get |
|---|---|
| once, immediately | ordinary state, configuration, logging, dependency injection |
| never | exceptions, early exit, cancellation |
| many times | backtracking, search, probabilistic programming, every path of a choice |
| later, from elsewhere | async/await, non-blocking I/O, callbacks — with no "coloured" functions |
| once per value, on demand | generators, coroutines, lazy and infinite streams, reactivity |
| after asking something else | delegation: one effect implemented by others |
| after recording or checking | interception, audit, policy, approval |
And the other ideas follow from the representation itself:
- Referential transparency and equivalence of expressions: a program is a value, so equal programs can replace each other, and the laws above are equations you can use to reason and to refactor.
- Continuations and CPS:
kis a delimited continuation; the handler is its delimiter. - Unlimited tail recursion, stack safety: a program is data run by a loop, so its depth lives on the heap, not on the call stack.
- Algebraic data types and pattern matching: operations are an ADT, and a handler is a pattern match over them — exhaustive, so a forgotten case is a compile error.
- Category theory: the free monad, algebras and homomorphisms are the precise statement of all of the above [1, 5].
One concept, a handful of definitions, and the features that most languages add one by one fall out as special cases of a single rule.
Why this matters for code that agents write and read
This is the part I care about most.
1. A strict formal model, small enough to hold in mind. Humans and agents both do best with a model that has few rules and no exceptions. Effects give exactly that: operations are data, programs are data, handlers are folds, and three equations say what handling means. An agent that has learned the model once can apply it everywhere in the system, at every scale. So can a reviewer.
2. The business logic becomes pure and refined. A program written with effects reads as the business process and nothing else:
```scala
def checkout[R[_]: Has[Cart] : Has[Payments] : Has[Mail]](user: User): Receipt ! R =
for
items <- Cart.items(user)
receipt <- Payments.charge(user.card, items.total)
_ <- Mail.send(user.email, Confirmation(receipt))
yield receipt
```
No HTTP client, no retry policy, no connection pool, no transaction boilerplate, no logging framework. The type says what it may touch. That is the right level for a person to review and the right level for an agent to generate: there is far less room for the accidental complexity where agents make most of their mistakes.
3. Every technical detail lives in one understandable, predictable place: the handler. Where Payments.charge really goes — a test double, a sandbox, the production provider, a queue for asynchronous processing, another service in another language — is decided by a handler. Handlers are small, each concerns one effect, and each can be read and tested on its own. When something goes wrong, there is one place to look.
4. Handlers compose like functions. A whole application is one line:
```scala
run(Mail.smtp(config)(Payments.stripe(keys)(Cart.postgres(db)(checkout(user)))))
```
To run the same logic in a test, swap the handlers. To move a part to another process, swap a handler. To make a call asynchronous, swap a handler. The business code does not change, and the compiler confirms that every effect is still answered. Architecture becomes composition, and composition is checked.
5. Every action passes a point of control. Because every interaction with the world is an operation, every interaction can be intercepted: seen, changed, limited, recorded, sent for approval, or refused — without touching the program that asked for it. For AI systems this is decisive. An agent's next action was not written in advance by anyone. If its tools are effects, then what it may do is the type of the program it runs in, and what it actually did is a record kept by construction, not by discipline. An effect can even be marked sensitive, so that the compiler refuses any program that would perform it outside an audit.
6. Correctness can be checked, not hoped for. Rows are checked at compile time. Handlers can be tested against laws. The same program can be run against a recording and against the world. And because the model is mathematical, parts of it can be proved: I have the laws of interception and audit proved in Lean.
Why Scala — and why the idea is bigger than Scala
What I need from a language
I want one language in which I can build a whole product, and in which algebraic effects are an ordinary library rather than a special compiler feature. Scala is the only language I know that gives me all of this at once.
It is a full-stack language. Scala compiles to three targets: the JVM, JavaScript (Scala.js) and native code (Scala Native). The same code runs on a server, in a browser, in a serverless function and as a native binary. I use this in practice, not as a slogan:
- business websites with their order flows and back offices — static pages, the forms' code in the browser, the API on Cloudflare Workers, the data in Durable Objects — written entirely in Scala, with the same tests on the JVM and in JavaScript;
- serverless back ends that run unchanged on Cloudflare, on AWS Lambda or on a plain JVM host, the cloud's services being effects and each cloud a set of handlers;
- one application on every screen — a desktop window, a web page, native iPhone and Android apps, a terminal, a REST API, tools for an AI agent — written once (more on this below);
- machine learning from Scala: bindings to Apple's MLX on the JVM and natively, and a transformer engine of my own that runs a language model in the browser on WebGPU and WebGL2 — the neurosymbolic model described below runs on it.
It can express algebraic effects as a plain, purely functional library. Scala 3's type system — higher-kinded types, type lambdas, intersection and union types, match types, given instances found by the compiler, opaque types, inline and macros — is rich enough that effects need no special support. In my library okay-scala, the row of effects is an ordinary type (State % Int + Choose), membership of an effect in a row is evidence the compiler finds, and the machine that runs programs is a typed loop with no unchecked casts. Code written with it reads as if effects were built into the language, as the checkout example above shows.
It is fast. The JVM's JIT compiler is one of the best optimising compilers there is. In my reference kernel a state operation through a handler costs on the order of ten nanoseconds, and a bind about six, on an ordinary laptop. Abstraction does not have to cost what people expect it to cost.
Its syntax is simple. A program is a for over its steps; a handler is a pattern match over an effect's operations. Nothing else is needed to read it — which matters just as much for an agent as for a person.
Other languages have some of these qualities. Haskell has the type system and decades of effect research, but not the platforms. Rust has native speed and a strong type system, but effects there fight the ownership model and need macros or code generation. TypeScript and Kotlin have the platforms, but not types strong enough to carry rows of effects without compromise. No other language I know gives all of them together. That — and only that — is why I choose Scala.
An example of what the language and effects can do together: one application, every face
Desktop and mobile used to be the usual answer to "but can Scala do the front end?". With effects they are not a gap any more, and the way they stopped being one shows what the combination is capable of when it is used properly.
In okay-faces an application is one program over one effect, Face. The program shows views and offers actions: fill asks for a form made from a type, confirm for a yes or no, show tells something. Nothing in it says how. Each face is a handler of that effect:
- a desktop window (Swing on the JVM);
- a web page in the browser (Scala.js), or the same page driven by a program on a server;
- HTML from a server, with no script at all;
- a terminal dialogue;
- a REST API with links and affordances;
- an MCP server — the same dialogue as tools for an AI agent;
- a test face, which plays the program by a script and checks what was shown;
- and native phone apps: on an iPhone the program is compiled by Scala Native into a library and the screen is SwiftUI; on Android it runs on Android's own runtime and the screen is Jetpack Compose. On each phone it looks like that phone's own app — native lists and forms, pickers and switches, tables, pictures, the system's back gesture, dark mode and text size.
The program does not change between them; only the face does. A form comes from one typed description, which is at once the form a person fills, the JSON Schema an agent is given, and the decoder of what comes back. The phone's own capabilities — storage, the network, secrets in the Keychain or the Android Keystore, notifications, opening links — are effects too: the program says "keep this note" or "remind me in five seconds", and Swift does it on the iPhone, Kotlin on Android, and a test does it in memory. The Swift and Kotlin parts are small libraries shared by every app; they know nothing about any of them. The same UI scenarios run on the iPhone simulator and the Android emulator; the whole example app for the iPhone is 3.4 MB.
There is a point here beyond convenience. The MCP face gives an agent, at every step, exactly the actions a person would see. So an agent working through an application cannot do more than a person can — the boundary is the program, not a prompt.
That is the claim of this section in one example: a rich type system and effects as a library let one model, written once, reach the server, the browser, the desktop, the phone and the agent.
What remains genuinely outside Scala's reach today is embedded systems, where C++ and Rust rule — though Scala already compiles to native code. And the language keeps improving: capture checking, for example, adds safety that tracks who may use or hold a value.
The same model in every language
At the same time, nothing in the idea depends on Scala. Algebraic effects are a model of computation, not a feature of one compiler, and they make sense in any language. So I have built one compatible ecosystem of effect libraries for the languages in use today, and for some experimental ones:
- Scala 3 (and Scala 2.13 through a shared interface);
- Java 25 — both in the style of sealed interfaces and records, with the row of effects as a nested type checked by
javac, and in direct style on Loom's continuations; - Kotlin, Rust, Go, Zig, TypeScript, Python;
- Lean 4, where the laws are not tested but proved;
- Goose, an experimental language.
Each takes the form its language allows: a program as data run by a machine, direct style on the language's own continuations, coroutines or generators, a function compiled into a state machine, or a plain function over a typed context. Where the compiler can check the row of effects, it does — in Scala, Java, Rust, TypeScript and Lean. Where a language cannot copy its own continuation (a Python or TypeScript generator), a program that must be resumed many times is written as data, never by re-running it from the start.
What holds them to one meaning is not resemblance but tests:
- a shared test kit of a thousand randomly generated programs whose results fold into one number, which several of the implementations reproduce exactly;
- a reference model — the same small kernel in Scala, Java and Rust — whose properties are the same tests in the three languages.
Many languages, one system
This opens a real possibility: to join programs in different languages into one system through effects.
The family speaks one protocol, the okay wire. A program runs in one process, in one language; when it asks an operation, the operation — a name and its arguments — crosses to another process, possibly in another language, and is answered by a handler there. The rest of the program stays where it is, as a value, known on the other side only by a number, so it can even be resumed more than once. To the program, a remote handler is just a handler.
This is checked, not only designed:
- in the reference model every host is run against every worker — Scala, Java and Rust, nine pairs, one answer;
- a separate check drives far sides written in Zig, Lean and TypeScript, and the platform's reference Python worker, from the Scala host;
- the platform's own host drives the reference model's three workers in turn: calls, held objects, programs resumed twice, streams under back-pressure, compression, authentication.
So the boundary between languages becomes the same kind of boundary as between modules: a set of effects. A team can keep its language. The system keeps one model.
Large heterogeneous systems, and taking a monolith apart
The property that makes effects good for agents also makes them good for architecture at scale: the business logic is written once, against effects, and where each part runs is a handler.
A monolith, taken apart in steps
Take a shop's checkout: reserve the item, charge the card, send a receipt. In a monolith these are three calls into modules the function reaches directly. With effects:
- Boundaries inside the monolith. The calls become an effect, as plain data; the logic keeps its shape. The modules are given at the top by a ha






Comments2
Large heterogeneous systems, and taking a monolith apart
The property that makes effects good for agents also makes them good for architecture at scale: the business logic is written once, against effects, and where each part runs is a handler.
A monolith, taken apart in steps
Take a shop's checkout: reserve the item, charge the card, send a receipt. In a monolith these are three calls into modules the function reaches directly. With effects:
The same test scenarios — themselves just handlers — check every step of the migration. Parts can be moved out one at a time, when each is ready; the rest stays local. Failures are handlers too: retries, timeouts, idempotency keys and circuit breakers wrap the remote handler, not the business code. This is the strangler-fig migration with a compiler checking each step.
Patterns that usually come as frameworks, as handlers
The program only performs effects. Storage, history, scale and consistency are all handlers.
What this means for those who manage information systems
Management Information Systems is a whole discipline: how information systems serve people, processes and decisions inside organizations [9]. Much of it is about control — who may do what, how every action is audited, how IT stays aligned with the business, how you change a vendor without breaking the company.
In practice, three things get mixed together in most enterprise systems:
Because they are tangled, every audit is a project, every migration is a risk, and every policy change touches code that it shouldn't.
Algebraic effects separate exactly these layers. A program only describes what it wants: "read the invoice", "ask a manager for approval", "pay the supplier". These requests are effects. Separate handlers decide how each request is carried out. What this gives an organization:
To be fair, BPM engines [10], hexagonal architecture [11] and middleware aim at similar goals. What effects add is precision: typed requests, composable handlers with a clear scope, and paused processes as real values. And effects won't fix adoption, culture or resistance to change: information systems are still mostly about people. But for the control layer, I think they are the right foundation.
A few more consequences, seen from management rather than code. Where a benefit is an argument rather than a measurement, I say so.
What I am building
I maintain the family of open-source libraries described above under the GitHub organisation okay-effects, with applications built on it: business sites and shops, serverless back ends, user interfaces as data, a guard that puts every tool call of a coding agent through a policy.
My current focus is applying all this to the architecture of AI itself.
What I am working on right now: a neurosymbolic micro language model
The project is a small neurosymbolic language model that can solve logic problems and work with data in external sources: tables, files, open data on the web. It runs in the browser, offline, on a laptop, and is meant to work on a phone. It is live at okay-effects.github.io/okay-desk.
The question behind it is simple. Large language models are impressive and unreliable. They compute, remember and reason in one opaque process, and when they are wrong, they are wrong fluently. Making them bigger makes them wrong less often, not never. For many tasks — "which customers have no orders", "who lives in the green house", "how did GDP change between these years" — an answer that is right 95% of the time is not an answer. We need a system that is exactly right, or says that it does not know. This is the old programme of neurosymbolic AI [12], and the more recent practice of letting a model write programs or call tools instead of computing [13, 14].
The principles
Where it stands
These results are modest, and real:
2 comments
Large heterogeneous systems, and taking a monolith apart
The property that makes effects good for agents also makes them good for architecture at scale: the business logic is written once, against effects, and where each part runs is a handler.
A monolith, taken apart in steps
Take a shop's checkout: reserve the item, charge the card, send a receipt. In a monolith these are three calls into modules the function reaches directly. With effects:
The same test scenarios — themselves just handlers — check every step of the migration. Parts can be moved out one at a time, when each is ready; the rest stays local. Failures are handlers too: retries, timeouts, idempotency keys and circuit breakers wrap the remote handler, not the business code. This is the strangler-fig migration with a compiler checking each step.
Patterns that usually come as frameworks, as handlers
The program only performs effects. Storage, history, scale and consistency are all handlers.
What this means for those who manage information systems
Management Information Systems is a whole discipline: how information systems serve people, processes and decisions inside organizations [9]. Much of it is about control — who may do what, how every action is audited, how IT stays aligned with the business, how you change a vendor without breaking the company.
In practice, three things get mixed together in most enterprise systems:
Because they are tangled, every audit is a project, every migration is a risk, and every policy change touches code that it shouldn't.
Algebraic effects separate exactly these layers. A program only describes what it wants: "read the invoice", "ask a manager for approval", "pay the supplier". These requests are effects. Separate handlers decide how each request is carried out. What this gives an organization:
To be fair, BPM engines [10], hexagonal architecture [11] and middleware aim at similar goals. What effects add is precision: typed requests, composable handlers with a clear scope, and paused processes as real values. And effects won't fix adoption, culture or resistance to change: information systems are still mostly about people. But for the control layer, I think they are the right foundation.
A few more consequences, seen from management rather than code. Where a benefit is an argument rather than a measurement, I say so.
What I am building
I maintain the family of open-source libraries described above under the GitHub organisation okay-effects, with applications built on it: business sites and shops, serverless back ends, user interfaces as data, a guard that puts every tool call of a coding agent through a policy.
My current focus is applying all this to the architecture of AI itself.
What I am working on right now: a neurosymbolic micro language model
The project is a small neurosymbolic language model that can solve logic problems and work with data in external sources: tables, files, open data on the web. It runs in the browser, offline, on a laptop, and is meant to work on a phone. It is live at okay-effects.github.io/okay-desk.
The question behind it is simple. Large language models are impressive and unreliable. They compute, remember and reason in one opaque process, and when they are wrong, they are wrong fluently. Making them bigger makes them wrong less often, not never. For many tasks — "which customers have no orders", "who lives in the green house", "how did GDP change between these years" — an answer that is right 95% of the time is not an answer. We need a system that is exactly right, or says that it does not know. This is the old programme of neurosymbolic AI [12], and the more recent practice of letting a model write programs or call tools instead of computing [13, 14].
The principles
Where it stands
These results are modest, and real: