@sergiy.shcherbyna1
Sergiy ShcherbynaDeveloper▲5
Scala developer, Independent
2h ago · edited · Introductions · Wroclaw

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: k is 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:

  1. 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
Introductions

Comments2

  • @sergiy.shcherbyna
    Sergiy Shcherbyna
    Algebraic effects with audit for information systems management
    2h ago

    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:

    1. 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 handler. It is still a monolith, but its boundaries are now explicit, and the compiler knows them.
    2. Services. A handler sends each operation over the wire, and the answer goes to the rest of the program. The business code does not change.
    3. Asynchronous. Each operation becomes an asynchronous wait; the handler resumes the program when the answer arrives. The business code still does not change.

    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

    • CQRS: commands and queries are separate effects.
    • Event sourcing: state is a fold over a log of events; commands decide on it and append to it.
    • Sharding: the handler chooses a shard by the key the invariant is about.
    • Eventual consistency: shards project their events into a CRDT view whose merge converges.

    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:

    • business logic — what the process does;
    • policy — who is allowed, what must be checked, what must be logged;
    • implementation — which database, which vendor, which cloud.

    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:

    1. Audit by construction. Every action goes through a handler, so a complete audit trail is a property of the architecture, not an add-on someone forgot. It serves record-keeping, traceability of decisions and access control obligations — as evidence, not as a compliance claim.
    2. Controls as interceptors. Segregation of duties, limits, approvals: each is a handler wrapped around the process. Change the policy, not the process. An action can even be marked sensitive, so that a process which would perform it outside its control does not compile.
    3. Vendor independence. Same process, different handler. Moving from one payment provider or cloud to another does not touch business logic. What stays tied to a vendor is only what that vendor alone offers.
    4. One process, many interpretations. The same expense-approval process can be executed for real, simulated ("what if the limit were lower?"), costed, tested, or explained to an auditor — by running it under different handlers. Today these are often five separate artifacts that drift apart.
    5. Human-in-the-loop. "Waiting for manager approval" is just an effect. The process pauses there and continues when the answer arrives, even days later, because the paused rest of the process is an ordinary value that can be kept.

    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.

    • The cost of change. Moving from a monolith to services, from on-premises to the cloud, from synchronous to asynchronous, becomes a change of composition, not a rewrite. The argument: the logic is not rewritten, so the cost of the move is the cost of writing and testing the new handlers. What sti
  • @sergiy.shcherbyna
    Sergiy Shcherbyna
    Algebraic effects with audit for information systems management
    2h ago

    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

    1. The language model does not compute. It formalizes. The model's job is to read a question written in natural language and turn it into a formal statement: a query over the data, or the facts and constraints of a logic puzzle. It also chooses the solver for that statement. Everything after that is exact: a query runs on a Datalog engine, a puzzle on a constraint solver.
    1. A number never comes from the model. Every value in an answer is a cell of the data or a result of the exact engine. If the model writes a summary, the summary is kept only if every number in it is in the computed result.
    1. Read the model where it decides. I do not ask the model to generate a program token by token and hope it is well formed; small models fail at that, and on my benchmarks a free-form program from a small model was almost never fully correct. Instead, small learned readers — linear probes [15] — read the model's internal states at the exact point where it makes each decision: which table, which column, which relation, which direction. Each decision is read separately and checked against the data, and the formal statement is assembled from those decisions exactly.
    1. Abstain rather than guess — and show the reading. Each decision is either closed by the data, closed by the model with a margin above a threshold measured on held-out data, or closed by the exact side. A decision that is still open is not guessed: it is asked, in the words of the question. The user always sees how the system read the question, so a wrong reading is visible, not hidden behind a confident answer.
    1. The model is a handler. In the architecture, "ask the model" is an effect like any other. Where a decision can be filled from — the data, the model's tokens, a reader, the user, a stronger model — is a choice of handler. Models are therefore interchangeable: a stronger one can be plugged in where needed. But the goal is that even a very weak one copes with almost any task, because it only has to read, not to know or to compute.
    1. Small enough for a phone. The base is a 0.6-billion-parameter open model, cut to the layers the readers actually need and distilled back. It runs in the browser on WebGPU, on WebGL2 where WebGPU is missing, and partly on the CPU on a phone, through the transformer engine I wrote in Scala that compiles to the browser.
    1. Knowledge as loadable, exact packs. What the model does not need to know — family relations, units, the rules of a domain — is not trained into weights. It comes as an exact definition that can be loaded, checked and replaced.

    Where it stands

    These results are modest, and real:

    • Data questions. Over sample tables, the untouched 0.6B model with readers and exact facts answered all twelve sample questions — the score of an untouched 4B model, and of a fine-tuned 1.7B. A question takes about half a second to a second on WebGPU.
    • Logic problems. A collection of 233 tasks in twelve classes: Einstein-style grids, who is where, who is who, orderings, deductions, family relations, routes, knights and liars, and more. On tasks phrased the way the training corpus phrases them, the reader is exact on 99.7–100% of decisions.
    • What the gate shows. On the collection, the system shows answers to about two thirds of the tasks it can state and asks about the rest. In the browser it gave 59 right answers, asked about 20 and was wrong once out of 80; after the latest training, none of the answers it showed was wrong.
    • What is not solved. On families of wordings it has never seen, it is right on roughly half or less, a
Log in to join the thread.