Abilities

Partly built

Algebraic effects: ability declarations, handlers, and the one worked example that runs end to end.

Abilities are Clay’s algebraic-effects system, carried over from Unison’s own ability model. An ability declares a set of operations; a definition that calls one of them must say so, in a <...> list on its own name; and a handle block supplies an implementation, the same way an exception handler supplies a catch.

Abilities landed 2026-09-25 (grammar, compiler wiring, checker) and have grown since: resume, parameterised handlers and ability polymorphism are built, and the built-in IO ability is what a program’s main ultimately needs (see Printing and the clock). Everything shown here is oracle-verified.

The worked example

This is real Clay source. It declares a Console ability with one operation, defines hello, which requires Console, and greet, which provides a handler for it — and it has actually been added, verified and evaluated through Clay’s own tools, checked against UCM at every step:

ability Console {
print: string => unit
}

let hello<Console> = (name: string): unit =>
Console.print("Hi " ++ name)

let greet: string =
handle hello("World") {
  done(x) => "done"
  Console.print(msg, resume) => msg
}
ability declarations, a <...> requirement, and handle — all real, all UCM-verified.

A few things worth calling out about the syntax:

  • The <Console> list goes on the binder, right after the name and before the parameter list — never a bare prefix, never leading an expression (an expression-leading < is reserved for a future JSX-like syntax).
  • Console.print(...) is a narrow, specific call form — a known ability name, a known operation name, then arguments — not a general “dot access” the language doesn’t otherwise have.
  • A handler’s value clause is spelled done(x), deliberately not return(x), so it never collides with a future return keyword.
  • Every ability Clay emits is declared structural — its hash comes purely from its written shape, never a process-generated identifier — because that’s the only form of ability declaration that’s actually a pure function of its text. That’s a deliberate, permanent choice, not a temporary gap: it’s what keeps an ability’s identity as trustworthy as everything else that’s hashed.

An operation that never returns a value

An operation can be generic in its own result. fail below is declared <'x> string => 'x: whatever the caller needs at that point, fail can stand in for it, because a handler that does not call resume simply never produces one. That makes it an exception in the old sense: the handler decides what the whole handle expression is.

ability Fail {
fail: <'x> string => 'x
}

let safeDiv<Fail> = (a: int, b: int): int =>
if b == 0 { Fail.fail("divide by zero") } else { a / b }

let report: string =
handle safeDiv(1, 0) {
  done(x) => "ok"
  Fail.fail(msg, resume) => msg
}

let fallback: int =
handle safeDiv(8, 2) {
  done(x) => x
  Fail.fail(msg, resume) => 0
}
Verified through UCM: report is the message, fallback is 4.

The <'x> in front of the operation’s type is a binder-position list, like the one on a definition, and it is local to that one operation (an ability’s own type parameters are on the ability). In the first handle, fail is used as an int; in another place it could be a string or a list. Both handlers are complete: a handle needs a clause for every operation, plus done. The standard library’s Exception ability is this same idea, with a failure value as the message (Exception.raise(f)), and exceptionToResult turns a raise back into a result; see Results.

A handler you can name

An inline handle { ... } is fine for one use. When the same handling belongs in several places, or when it carries state, write the handler as an ordinary function. Its argument has the type request<Counter, int>: “what the ability asks for, or the final answer (an int)”. You take it apart with a switch whose arms are done(x) and one arm per operation, and you install it on an expression with handle expr with handler.

ability Counter {
next: () => int
}

let counting = (n: int, req: request<Counter, int>): int => switch req {
| done(x) => x
| Counter.next(resume) => handle resume(n) with counting(n + 1)
}

let twoNexts<Counter> = (): int => Counter.next() + Counter.next()

let total = (): int => handle twoNexts() with counting(10)
Verified through UCM: total is 21 (10, then 11).

counting hands the current number to whoever asked and then installs itself again on what is left of the computation, one number higher. That re-installation is the handle resume(n) with counting(n + 1) line: resume is an ordinary function, and calling it runs the rest of the body, so it has to be handled again. The state is nothing more than the first parameter.

What’s still missing

  • Ability-polymorphic functions (<'e>, “whatever effects the caller’s callback happens to need”), resume inside a handler clause and parameterised handlers (a State handler that threads a value) are built (2026-10-02) and agree with UCM; see the standard library’s runClock and fakeClock for worked uses. The standard library has an Exception ability (see Results); handlers can also be written as stand-alone functions (see above).
  • A handle must have a clause for every operation of the ability it handles (the checker refuses a missing one).
  • A declared <...> list must match exactly what the body performs — Unison itself is looser here (a declared-but-unused ability typechecks); Clay’s checker is intentionally stricter, so this can produce a false refusal, never a false accept.
  • Calling the handler’s resume continuation is accepted without being fully modeled the way Unison’s own type system models it (calling resume can, in real Unison, push the ability requirement onto the handler’s own type). Not a soundness risk — a real disagreement is still caught at write time — just a narrower checker than Unison’s in this one spot.
  • ▶ Run executes handlers in the page first, on the Rust engine’s explicit-stack machine (delimited, multi-shot continuations), so ability code runs offline; when a server is reachable the same program also runs through UCM and the two answers are compared.