Effects and handlers
Requests declare capabilities, effect rows make them visible in signatures, and handlers decide what they mean.
A request is an effect definition: an operation an agent can perform without saying how
it is implemented. The agent's signature tracks it in the effect row after with, and a
handler somewhere up the call chain supplies the implementation. The same agent can log
to a counter in one caller, to a real sink in another, and escalate to a human in a third —
without changing a line of its body.
Declaring a request
@"Record one line of activity. A request is declared, never implemented here."
request log(line: string) -> null
@"`with log` — this agent may perform `log`; whoever runs it decides what that means."
agent greet(name: string) -> string with log {
log(line = f"greeting ${name}")
f"hello, ${name}"
}Performing log(...) looks like a call, and with a handler in scope it behaves like one.
The row with log is part of greet's type: a caller either handles log, carries it in
its own row, or — if it reaches the run root unhandled — the request
escalates to a human.
Handlers
@"A stateful handler: `next value with { ... }` answers the request, resumes the
performer, and advances the handler's state."
agent counted() -> string {
use handler (var lines = 0) {
request log(line: string) { next null with { lines = lines + 1 } }
}
greet(name = "katari")
}use handler installs handler clauses for the rest of the block and discharges the handled
requests from that block's row — counted has a pure signature. A handler clause has two
ways out:
next valueanswers the request withvalueand resumes the performer where it left off.next value with { ... }also updates the handler'svarstate, which lives across requests (the(var lines = 0)head declares it).break valuedoes not resume the performer: it aborts the whole handled block and makesvalueits result.
Katari's typed errors are exactly this machinery, not a separate feature. prelude.throw is
a request whose answer type is never, so the only useful handler clause is a break:
@"The typed error `parse_minutes` raises on non-numeric text."
data not_a_number(text: string)
@"Parse a minutes count; non-numeric text raises the typed error."
agent parse_minutes(text: string) -> integer with prelude.throw[not_a_number] {
match (string.to_integer(value = text)) {
case null -> prelude.throw(error = not_a_number(text = text))
case value -> value
}
}
@"`break value` aborts the handled block with that value — this is how a throw is caught."
agent minutes_or_zero(text: string) -> integer {
use handler {
request prelude.throw(error: not_a_number) -> never { break 0 }
}
parse_minutes(text = text)
}The clause's error: not_a_number selects by payload type: a throw carrying anything else
passes through to an outer handler.
Providers
A handler is the inline form. A provider packages the same idea as an agent you use —
it receives the rest of the block as a continuation and runs it with a capability served:
@"A capability a provider serves for the extent of a block."
request emit(line: string) -> null
@"A provider: it receives the rest of the block as `continuation` and runs it with
`emit` handled. `{...E, emit}` overwrites the row: the continuation performs the
caller's effects E plus `emit`, and this agent discharges `emit` — its own row is E."
agent quietly[R, effect E](
continuation: agent (value: null) -> R with {...E, emit},
) -> R with E {
use handler {
request emit(line: string) { next null }
}
continuation(value = null)
}
@"`use` a provider: everything after the `use` becomes its continuation."
agent report() -> string {
use quietly()
emit(line = "starting")
emit(line = "still going")
"done"
}use quietly() rewrites the rest of report into the continuation argument. This is the
shape every stdlib and package provider uses: use replay.exponential(...) serves the replay
signal, use mcp.provide(url = ...) serves an MCP server's tools for the extent of the block
(and let tools = use mcp.provide(...) binds the value the provider passes to its
continuation). The scoping is the point — the capability exists exactly for the block, and
the provider's own row proves it discharged what it served.
Two row spellings appear in provider signatures, and they differ:
with E | emitis a union row:emitmerges with whateverEalready carries.with {...E, emit}is an overwrite row: the entry foremitis pinned exactly, on top ofE. A provider uses it for the request it intends to discharge, so the subtraction in its result row (-> R with E) is honest.
Effect polymorphism
@"Effect-polymorphic: whatever `action` performs, `timed` performs — E flows through,
and `timed` adds `io` of its own for the clock reads."
agent timed[R, effect E](
action: agent (value: null) -> R with E,
) -> { result: R, elapsed: number } with E | io {
let before = time.now()
let result = action(value = null)
{ result = result, elapsed = time.now() - before }
}[effect E] declares an effect generic: timed works over an action with any row and
propagates it unchanged, adding io — the built-in, un-dischargeable effect every external
call (HTTP, the clock, an FFI sidecar) performs. Higher-order agents in the stdlib
(time.watch, replay.*, reflection.call_agent) all have this shape; see the
reference for their rows.
Marker effects
@"A marker effect: a pure capability. It rides effect rows and gates calls, but can
never be performed or handled — it vanishes at lowering."
effect reviewed
@"Only callable from a row that carries `reviewed`."
agent delete_project(name: string) -> null with reviewed {
null
}
@"The caller's own row covers the marker, so the call typechecks."
agent cleanup() -> null with reviewed {
delete_project(name = "old-prototype")
}An effect declaration with no request is a pure capability: it gates who may call what,
entirely at compile time. Markers can take arguments — effect scoped[resource] with a
string-literal resource is how a scoped provider (like mcp.provide) tags the tools it
mints, so a tool cannot escape the block that provided it.
Environment vs operation
Two kinds of thing hide behind an effect, and they differ by lifetime. Environment is what
is given to a run — env, OAuth tokens, the store.
These are requests: unhandled, they escalate to the run's outermost environment, the runtime,
which machine-answers them against durable project state — and any handler in between can
intercept first (a test stub, a sandboxed subtree). Operations with a lifetime — http.fetch,
a timer, a watch — are not requests waiting for an answer; they go straight to a dedicated
reactor that owns the in-flight work and wakes the run when it completes, and they ride the
un-dischargeable io effect rather than a catchable request. An external operation is,
conceptually, a direct line to that same outermost handler: the runtime, reached without stopping
at any handler on the way.
Where to go next
- Escalation — what happens when no handler is in scope.
- Durable execution —
replayproviders and converters, the mechanism / policy split. - Effects and escalation — the tutorial pass over this material.