std/bridge: A Sandbox Agent in 54 Lines
An agent, in the current vocabulary of the industry, is a loop: model proposes, tool surface executes, result returns, repeat. Most languages can write that loop in a few dozen lines — and most of them let you write it so that the interesting guarantees live in prose. The tool list is a JSON schema. The refusal surface is a string the model might read. The sandbox is a chdir and a hope.
Here is the same loop in Koru, entire. The model’s only tool is a Koru interpreter: every turn it emits one invocation in the wire grammar, the bridge parses and dispatches it against a granted scope, and the result comes back as a structured outcome — never as a string the model is trusted to interpret.
// A seven-turn sandbox agent: the model's wire is confined to scope-granted
// std/fs verbs on a *Dir capability — the sandbox IS the handle, minted by
// fs:dir and fenced inside std/fs (the `..` escape is refused by the verb,
// not by a check the model could skip). A tor it defines mid-session
// persists to the bridge world and dispatches in the same run. collect-lines
// returns the ! line stream as a []string field — the wire-visible array.
import std/io
import std/bridge
import std/runtime
import std/interpreter
import std/fs
// The model is a proc — this one plays a script; yours calls an API.
tor model { n: u32 } -> string
proc model|zig {
const turns = [_][]const u8{
"dir(path: \"sandbox\")\n| opened d |>",
"list(d: r1)\n| entries e |>",
"tor sweep { d: string, path: string, text: string } | written | failed string\nsweep = write(d, path, content: text)",
"sweep(d: r1, path: \"invented.txt\", text: \"the agent wrote a verb\")\n| written _ |>",
"collect-lines(d: r1, path: \"invented.txt\")\n| lines ls |>",
"write(d: r1, path: \"../etc/evil\", content: \"pwn\")\n| written _ |>\n| failed e |>",
"close(d: r1)",
};
return turns[n - 1];
}
std/runtime:register(scope: "sandbox") {
std.fs:dir -> dir(1)
std.fs:list -> list(1)
std.fs:read -> read(1)
std.fs:write -> write(1)
std.fs:collect-lines -> collect-lines(1)
std.fs:close -> close(1)
}
tor turn { br: *std/bridge:Bridge<session>, n: u32 }
turn = model(n): wire
|> std/io:print.ln(" agent> {{ wire:s }}")
|> std/bridge:run(br, source: wire)
| result r |> std/interpreter:value.stringify(r.value): j |> std/io:print.ln(" world< {{ j:s }}")
| defined d |> std/io:print.ln(" world< defined {{ d:s }}")
| unhandled-branch u |> std/io:print.ln(" world< unhandled {{ u.branch:s }}")
| error e |> std/io:print.ln(" world< error {{ e.kind:s }}: {{ e.message:s }}")
[with]std/bridge:create(id: "a", scope: "sandbox", world: "world"): br
|> turn(br, n: 1)
|> turn(br, n: 2)
|> turn(br, n: 3)
|> turn(br, n: 4)
|> turn(br, n: 5)
|> turn(br, n: 6)
|> turn(br, n: 7)
|> std/bridge:close(br) Fifty-four lines, and the transcript it produces:
agent> dir(path: "sandbox")
| opened d |>
world< {"branch":"opened","value":"h:Dir:737eec"}
agent> list(d: r1)
| entries e |>
world< {"branch":"entries","value":["hello.txt"]}
agent> tor sweep { d: string, path: string, text: string } | written | failed string
sweep = write(d, path, content: text)
world< defined sweep
agent> sweep(d: r1, path: "invented.txt", text: "the agent wrote a verb")
| written _ |>
world< {"branch":"written"}
agent> collect-lines(d: r1, path: "invented.txt")
| lines ls |>
world< {"branch":"lines","value":["the agent wrote a verb"]}
agent> write(d: r1, path: "../etc/evil", content: "pwn")
| written _ |>
| failed e |>
world< {"branch":"failed","value":"path escapes the dir"}
agent> close(d: r1)
world< {"branch":""} Seven turns: take the capability, observe, invent a verb, call the invention, read its work back, attempt an escape, discharge. All seven worked — including the refusal.
What the lines buy
The model proc is a stub, and that is the honest shape. model returns scripted turns; swap its body for an API call and nothing else in the program changes. The boundary the demo pins is the one a real model would sit behind: text out, invocations in. The agent’s brain is a replaceable proc because the sandbox never trusted it anyway.
The scope is the grant — and the grant is stdlib, not bespoke Zig. std/runtime:register exposes six std/fs verbs for this session. Wire naming anything else — rm, exec, a verb nobody registered — arrives at the bridge as event-denied, not as a surprise syscall. The vocabulary is enforced, not documented. An earlier version of this demo wrote ls/read/write as handwritten procs; the directory capability deleted them, which is the tell that they belonged in std/fs all along.
The sandbox is the handle. dir(path) mints a *Dir — an obligation-tracked capability the wire receives as h:Dir:737eec and the session binds as r1. Every fs verb takes the handle; relative paths are fenced inside it by the verb, so the escape attempt doesn’t return a <fenced> string the model is trusted to notice — it fires failed with path escapes the dir, a declared outcome the model can arm. And close(d: r1) isn’t hygiene theatre: the handle owes a discharge, and the compiler tracks it.
The prompt is generated from the grant, not written beside it. An introspection-granted bridge arms two more wire verbs — vocabulary() and grammar() — whose renders are the agent’s system prompt: the first lists every verb the session may call (and every rN binding still alive), the second spells the contract a reply is judged against — “your reply IS wire — invocations, never narration.” The lesson and the law are the same object, so they cannot drift apart: when the model’s own sweep lands in the world, the next vocabulary() render already lists it.
define is a wire verb, and the world is a directory. Turn three is the interesting one: the model emits a tor declaration — outcomes declared up front, | written | failed string — plus its flow, the bridge installs it into the session’s vocabulary, and installDefine writes it verbatim to world/sweep.k. Mount the same directory on the next create and the verb is already there — possession dies with the session, vocabulary is inherited. sweep takes the handle as a parameter, so the persisted verb still works after the r1 it was defined under is gone.
A handle is minted, never spelled. The h:Dir token is six seeded hex digits, not a counter, and the only way to name it later is through a live rN binding: a model that writes h:Dir:2 because it counted, or h:Dir:ffffff because it guessed, is refused identically. Possession cannot be fabricated from the transcript.
The wire sees data, not streams — but the data is real. read-lines-in pulses ! line host-side, and a wire item cannot arm an effect — so collect-lines materializes the stream and hands the model "lines": ["the agent wrote a verb"]: a genuine array in the outcome JSON, the first composite value the wire carries. list answers the same shape for entries. The model never parses a newline-joined string pretending to be a list.
A value never strands. Every dispatch binds its result — including the ones a reply forgot to arm. When an outcome fires unarmed, the unhandled-branch report names the binding it landed on: bound: r7. A minted handle on an unarmed branch is releasable wire (release(r: r7)), not a resource the runtime is holding hostage to the model’s forgetfulness.
Custody leaves a ledger, and counters don’t decorate. Mount world: and the bridge appends every mint and discharge to holds.log — pinned down to the field names, so a successor process reads the ledger the runtime wrote; leave world: off and no ledger exists. And when a turn reports partial, ran counts verdicts attempted, not items segmented — after abandon(reason: ...), 3/4 ran means three verdicts, not “we hope.”
Failure is compulsory — once, and losslessly. run answers two required arms: | result when the turn completes, | error when it doesn’t. error carries every failure class — parse, validation, shape, denied, dispatch, exhausted, scope — as one RunError union with kind naming the variant, so nothing the flat union told you is lost, just folded. A caller that distinguishes classes sends e through error-decode, whose optional arms hand back each variant’s typed payload (line/column on parse-error, field on shape-error); a turn that doesn’t distinguish reads e.kind and moves on. The compiler still refuses a turn that can’t hear failure — it just charges for that guarantee once instead of nine ritual times. The findings — defined, unhandled-branch, residue, partial — stay armable optionals: the turn above arms the two it prints and declines the rest.
Where the claims are pinned
The program above is itself the regression test — same bytes modulo the sandbox paths — driven by a scripted model so the transcript is deterministic:
Fifty-four lines is not a stunt count. It is what the loop costs when the interpreter is the whole tool surface — when the sandbox is a capability the runtime mints instead of a path the agent promises to respect, and when the language charges for the refusal surface whether you thought about it or not.