I've been doing a fair bit of agentic OxCaml programming in the past year, and consistently finding that OCaml is a perfect fit across the spectrum of languages I've been developing in recently. If you're not familiar with OCaml, this post is a quick guide as to why I think this.
As codebases get larger, a coding agent is better at filling in a 'well-specified hole' than
cramming in a million-line codebase into its context window.
OCaml has a clean separation between .mli files (an interface definition for a module) and the .ml implementations.
I'll follow with with a toy project that has two modules, but the technique scales to million-line codebases. We'll start by writing only the interfaces,
then exercise them with a test client,
then fill in the implementations.
After that we get more exotic and refine with OxCaml modes
and finish by speculating where formal proofs could go.
1 First build up only the module interfaces
We have a module Money that tracks our cash (i.e it shoudl never be negative). If you need help with the syntax then the RWO guided tour may be helpful.
(* lib/money.mli *)
type t
val add : t -> t -> t
val sub : t -> t -> t option
(** [sub a b] is [None] if [b > a]. *)
val to_string : t -> string
(** [to_string m] is e.g. ["£3.05"]. *)
The Book module is a list of deposits and withdrawals.
(* lib/book.mli *)
type t
val empty : t
val deposit : Money.t -> t -> t
val withdraw : Money.t -> t -> (t, [ `Insufficient of Money.t ]) result
(** [`Insufficient bal] carries the balance that was too small. *)
val balance : t -> Money.t
Notice that we don't have any implementations yet, but that the types we have defined can reference each other's module despite this. This OCaml project can be made to compile via a dune directive to suppress the need for an implementation as well.
(library
(name ledger)
(modules_without_implementation money book))
Now the magic begins, as we can typecheck our project from just this descrption of how they should work together.
$ dune build @check
This interface compiles without warnings, so it's time to exercise our fledgling interface with a test binary!
2 Building a test client for this interface
By writing a binary next, we can test if the interface we just defined is sufficiently precise to actually use externally.
(* bin/main.ml *)
open Ledger
let () =
let ( let* ) = Option.bind in
let r =
let* ten = Money.of_pence 1000 in
let* three = Money.of_pence 305 in
let b = Book.deposit ten Book.empty in
match Book.withdraw three b with
| Ok b -> Some (Money.to_string (Book.balance b))
| Error _ -> None
in
print_endline (Option.value r ~default:"failed")
The first realisation when we compile it is that our interface was
too abstract, so we have no way to make a Money.t! This makes the build
fail:
$ dune build @check
File "bin/main.ml", line 5, characters 15-29:
5 | let* ten = Money.of_pence 1000 in
^^^^^^^^^^^^^^
Error: Unbound value "Money.of_pence"
We therefore edit our interface to add the constructor function:
(* lib/money.mli *)
type t
val of_pence : int -> t option
(** [of_pence n] is [None] if [n < 0]. *)
Now the binary typechecks, and fails at linking time complaining that there's no implementation. But because the type checker has passed it, we know that the interface is good enough to be worth implementing!
$ dune build ./bin/main.exe
Error: No implementations provided for the following modules:
"Ledger__Money" referenced from bin/.main.eobjs/native/dune__exe__Main.cmx
"Ledger__Book" referenced from bin/.main.eobjs/native/dune__exe__Main.cmx
3 Fill in the module implemenations one at a time
We can now hand Money to the coding agent with an instruction to read the
interface (.mli) files, and start implementing the module implementations in
dependency order with tests per module (such as expect tests, which keep the expected output next to the code).
(* lib/money.ml *)
type t = int
let of_pence n = if n < 0 then None else Some n
let add = ( + )
let sub a b = if b > a then None else Some (a - b)
let to_string m = Printf.sprintf "£%d.%02d" (m / 100) (m mod 100)
The dune link error now only complains about Ledger__Book. The agent
then moves onto writing the Book module, with similar instructions
to only read the interface files.
This then brings up another problem with the interface, as writing Book.empty shows needs
a starting balance. At this point the agent uses its context-driven discretion to either
use of_pence, or add a helper function to Money:
File "lib/book.ml", line 3, characters 12-22:
3 | let empty = Money.zero
^^^^^^^^^^
Error: Unbound value "Money.zero"
If the user (or agent goal) agrees to reassess the interface design, the Book interface gains a zero function.
3.1 Stopping agents from taking shortcuts
A tempting shortcut for an agent in Book is to treat money as a plain integer and do
the operations directly within that implementation:
(* lib/book.ml *)
type t = Money.t
let empty = Money.zero
let deposit m b = Money.add m b
let withdraw m b = if m > b then Error (`Insufficient b) else Ok (b - m)
let balance b = b
This results in a type error in OCaml though:
Error: The implementation "lib/book.ml"
does not match the interface "lib/.ledger.objs/byte/ledger__Book.cmi":
Values do not match:
val withdraw : int -> int -> (int, [> `Insufficient of int ]) result
is not included in
val withdraw : t -> t -> (t, [ `Insufficient of t ]) result
Type "int" is not compatible with type "t"
The agent can't subtract pence directly, since the OCaml interfaces
enforce that only the Money module can perform this operation over a value
of that type.
Conveniently, the compiler rejects it with a message that's helpful enough for the agent
to write the correct implementation from the Money interface.
(* lib/book.ml *)
type t = Money.t
let empty = Money.zero
let deposit m b = Money.add m b
let withdraw m b =
match Money.sub b m with
| Some b' -> Ok b'
| None -> Error (`Insufficient b)
let balance b = b
This now fully builds end-to-end, yay!
$ dune build ./bin/main.exe && ./_build/default/bin/main.exe
£6.95
The great thing about this technique is that it scales to enormous projects with hundreds of mli files, and this agentic workflows allows for a cheap definition of a complex set of interfaces before embarking on the expensive implementations. OCaml's separate compilation keeps build times very fast so we have a quick edit/compile loop.
4 Refining even more with OxCaml modes
We don't have to stop at just OCaml interfaces though! OxCaml is a language extension from Jane Street that provides mode annotations that can extend this workflow. (If you want to learn more, we ran an OxCaml tutorial at ICFP 2025.)
We can now run an agentic pass to refine our interfaces to have even more checks:
(* lib/money.mli *)
type t : immutable_data
val add : t @ local -> t @ local -> t
val to_string : t @ local -> string
(* lib/book.mli *)
type t : immutable_data
The immutable_data annotations promises that a Money.t or Book.t contains no mutable
state in its implementation, so it can (e.g.) be shared freely between parallel threads.
The @ local ensures that a function doesn't hold onto its argument, so the caller can pass in a stack-allocated
value and not have to have heap allocations.
An agent that decides to "optimise" Book with a mutable balance now fails:
type t = { mutable bal : Money.t }
Error: The implementation "lib/book.ml"
does not match the interface "lib/.ledger.objs/byte/ledger__Book.cmi":
Type declarations do not match:
type t = { mutable bal : Money.t; }
is not included in
type t : immutable_data
The kind of the first is
mutable_data with Money/2.t @@ forkable unyielding many
because of the definition of t at file "lib/book.ml", line 1, characters 0-34.
But the kind of the first must be a subkind of immutable_data.
The first mode-crosses less than the second along:
contention: mod uncontended ≰ mod contended
visibility: mod read_write ≰ mod immutable
This error is admittedly a little opaque to a human user (something that's being worked on in OxCaml), but it's fine for an agent with an OxCaml skill. (I run these agents in a sandboxed devcontainer.) Crucially, these mode annotations helped to stop an agent introducing a subtle error that may have corrupted data when used across multiple processsors.
5 Going deeper down the refinment rabbithole
All the OxCaml modes earliy are statically defined by the compiler, which is getting increasingly capable. The ICFP 2026 mode crossings paper this summer shows how the compiler automatically strengthens modes for values of certain types, which is how our Money.t can declare immutable_data succinctly. But wouldn't it be cool if we could also express arbitrary logical conditions in the interfaces?!
Here's a sketch of Money using a Gospel-style specification. I've not actually compiled this one, but you'll get the idea:
(* lib/money.mli *)
type t
(*@ model pence : integer
invariant pence >= 0 *)
val sub : t -> t -> t option
(*@ r = sub a b
ensures match r with
| None -> a.pence < b.pence
| Some c -> c.pence = a.pence - b.pence *)
The comment is now a machine-checked contract, so every implementation must guarantee it satisfies these pre- and post-conditions.
About two decades ago, Patrick Rondon and Ranjit Jhala worked on a liquid OCaml that had these features (PLDI 2008 paper). I'm really excited that it's heading back into modern OxCaml as I've been jealous of Liquid Haskell for a long time :-)
The beautiful thing about using OCaml's module system as a basis for these formal extensions is that separate compilation architecture I sketched above means that the edit/feedback loop is fast even on million-line codebases. The layering of annotations also lets us make code progressively more specified without piling on huge numbers of unit tests.
This is context efficient for agents and preserves human sanity as code gets more complex. We're also only beginning to investigate how to visualise such constraints in our user interfaces, like the work ongoing in Hazel and our own work on bidirectional type slicing to debug type errors (also this last paper just got conditionally accepted into POPL 2027, which I'm super excited about and will wrote more on later!)
