The Yoru Documentation
A statically typed, multi-paradigm language built for the workloads that broke the previous generation: high-throughput services, heterogeneous ETL pipelines, supervised actor systems, and autonomous LLM agents talking to tools over MCP. Read this page top to bottom and you'll have the working surface of the language.
Introduction
Yoru (夜, "night") treats tools, agents, MCP servers, supervised actors, ETL pipelines, and HTTP services as first-class language constructs rather than libraries. The compiler enforces effect tracking, capability scoping, and reference uniqueness at compile time: the same invariants every production team ends up policing by hand.
Yoru is in Phase 0: a tree-walking interpreter hosted on a Go 1.22 runtime. The language surface is stable; the bytecode VM and LLVM backend are scheduled for Phase 1 and Phase 2.
Quick start
Three commands take you from clone to a running program:
# 1. clone & build
git clone https://github.com/adriangitvitz/yoru
cd yoru && go build ./cmd/yoru
# 2. write a file
echo 'fn main() { println("hello, yoru") }' > hello.yr
# 3. run it
./yoru run hello.yr
#=> hello, yoru
Installation
Yoru ships as a single Go binary. Build from source:
git clone https://github.com/adriangitvitz/yoru
cd yoru
go build ./cmd/yoru
mv ./yoru /usr/local/bin/
yoru version
#=> yoru 0.1.0 (Phase 0)
Optional environment variables
Only needed when running agents against a real LLM. Without them,
agents return Result.Err{kind: "llm_not_configured"};
everything else still works.
| Variable | When to set it |
|---|---|
OPENROUTER_API_KEY | Multi-provider gateway. Takes priority when set. |
ANTHROPIC_API_KEY | Use Anthropic directly. Consulted only if the above is unset. |
Hello, Yoru
fn greet(name: String) -> String {
"hello, " + name
}
fn main() {
let names = ["yoru", "claude", "world"]
for n in names {
println(greet(n))
}
}
- Functions use
fn. The last expression of a block is its value, so no explicitreturnis needed. - Bindings are immutable by default (
let); usemutto reassign. - Lists use
[…]; iteration usesfor … in ….
The yoru CLI
| Command | What it does |
|---|---|
yoru run file.yr [args...] | Lex, parse, type-check, evaluate. Trailing args reach the script via args(). |
yoru check file.yr | Type-check only (fast feedback in editors). |
yoru fmt file.yr | Canonical formatter. |
yoru repl | Interactive REPL. |
yoru run ./app/ | Multi-file project; auto-starts any declared service. |
yoru build --target mcp --output bin file.yr | Standalone MCP server binary (JSON-RPC over stdio). |
yoru build --target http --output bin file.yr | Standalone HTTP service binary. |
yoru build --target cli --output bin file.yr | Standalone CLI binary. Forwards its own os.Args[1:] into args() and mirrors the LLM-client env-var selection. |
Trailing CLI arguments reach a script through the args()
builtin. The cli build target produces a binary that
forwards its own arguments the same way.
yoru run scripts/agent.yr "Read /tmp/notes.md and fix the typo on line 3"
yoru build --target cli --output ./agent scripts/agent.yr
./agent "Read /tmp/notes.md and ..."
Bindings, functions, control flow
// immutable by default
let answer = 42
// mutable
mut counter = 0
counter += 1
// functions are expressions; lambdas use =>
let double = (x) => x * 2
// while & for
while counter < 5 { counter += 1 }
for i in [1, 2, 3] { println(i) }
Primitive types & collections
| Type | Examples |
|---|---|
Int / Float | 1, -7, 3.14 |
String | "hello" (UTF-8) |
Bool | true, false |
Bytes | raw byte buffer |
List[T] | [1, 2, 3] |
Map[K, V] | {"a": 1, "b": 2} |
Option[T] / Result[T, E] | core types, not stdlib |
Objects & protocols
Classical OOP conflates identity, state, and behavior. Yoru splits them:
// an Object is a named, capability-qualified record
object User {
id: String,
email: String,
role: Role,
}
// a Protocol describes behaviour (effect-aware)
protocol Serializable {
fn serialize(self: ref) -> Bytes effect [IO]
fn deserialize(data: Bytes) -> Result[Self, ParseError]
}
// impl wires them together, separate from declaration
impl User : Serializable {
fn serialize(self: ref) -> Bytes effect [IO] {
JSON.encode(self)
}
}
There is no inheritance. The delegate keyword embeds one
object inside another and auto-forwards protocol implementations:
object AdminUser {
delegate base: User, // auto-implements all of User's protocols
permissions: [Permission],
}
Enums & pattern matching
enum PipelineEvent {
Record(data: val Record)
Error(cause: PipelineError, record: Option[val Record])
Checkpoint(offset: Int)
EndOfStream
}
fn handle_event(ev: PipelineEvent) effect [Log, DB] {
match ev {
Record(data) => DB.upsert(data)
Error(cause, Some(rec)) => DB.insert_dead_letter(rec, cause)
Error(cause, None) => Log.error("unattributed", { cause })
Checkpoint(offset) => DB.save_checkpoint(offset)
EndOfStream => Log.info("complete", {})
}
}
Match is exhaustive: the compiler rejects a missing arm.
Errors & Result
Result[T, E] is core, not stdlib. The ?
operator unwraps Ok or early-returns Err:
fn process(record: val Record) -> Result[Row, ProcessError] effect [DB, HTTP] {
let validated = Validator.check(record)? // chains
let enriched = Enricher.enrich(validated)?
Ok(Mapper.to_row(enriched))
}
The ?? operator provides a default or an alternative Err:
let user = DB.find(User, id) ?? Err(NotFound)
Reference capabilities
Borrowed from Pony. Capabilities are annotations the compiler checks statically; they prevent data races without a borrow checker.
| Capability | Name | Semantics |
|---|---|---|
iso | Isolated | Unique ownership; may cross actor boundaries. |
trn | Transition | Mutable globally unique; converts to val or ref. |
ref | Mutable | Mutable alias; never shared between actors. |
val | Immutable | Deeply immutable; freely shareable. |
box | Read-only | Read-only alias from ref or val. |
tag | Identity | No read/write; actor identity / capability checks. |
// iso can be sent across actors; compiler rejects further use of msg
let msg: iso Message = Message.new(payload: "hello")
actor_ref <- msg
// val is deeply immutable, safe to broadcast
let config: val Config = Config.load("config.toml")
spawn worker_a(config)
spawn worker_b(config)
Why algebraic effects?
Exceptions only propagate up the call stack. Algebraic effects can suspend computation and let a handler decide what to do next: resume, abort, retry, run multiple times. Strictly more powerful than async/await, checked exceptions, or monadic error handling, and they don't colour the call graph.
Every function declares which effects it uses:
fn enrich_record(r: ref Record) -> Result[Row, Error]
effect [DB, HTTP, LLM]
// the compiler enforces that DB, HTTP, LLM are all handled before main()
Built-in effects
| Effect | Meaning | Provided by |
|---|---|---|
IO | Arbitrary I/O | Any unstructured handler |
HTTP | Outbound HTTP / gRPC | REST client, gRPC stub |
DB | Database read/write | Connection pool, transaction |
LLM | Language-model inference | Provider config, token budget |
Agent | Multi-step LLM loop | Agent runtime, tool registry |
Stream | Unbounded data stream | Pipeline scheduler, back-pressure |
Spawn | Spawning actors/tasks | Scheduler, resource limits |
Log | Structured logging | Logger sink |
Clock | Reading time | System clock or test clock |
Handling effects
A handle block intercepts an effect, providing an
implementation. The same business logic runs against a real provider
in production and a deterministic stub in tests:
effect LLMCall {
complete(prompt: String, opts: LLMOptions) -> String
}
fn summarize(text: String) -> String effect [LLMCall] {
LLMCall.complete("Summarize:\n" + text, LLMOptions.default())
}
// in production
let summary = handle(LLMCall) {
complete(p, opts) => Anthropic.claude(p, opts).await
} in summarize(article)
// in tests
let stub = handle(LLMCall) {
complete(_, _) => "stub summary for testing"
} in summarize(article)
async/await syntax. Suspension
happens transparently inside effect handlers. A function that calls a
database or makes an HTTP request looks exactly like one that doesn't.
Its effects are in the signature, not its colour.
Actors
Any object can become an actor. Actors run on lightweight green threads (M:N over OS threads) and communicate exclusively through typed messages. The Isolated Turn Principle guarantees one message at a time, with no locks.
actor ETLWorker {
state processed: Int = 0
state errors: Int = 0
receive Process(record: iso Record) {
match self.transform(record) {
Ok(out) => { self.sink <- Emit(out); self.processed += 1 }
Err(e) => { self.logger <- Log(e); self.errors += 1 }
}
}
receive Shutdown { self.sink <- Flush }
}
spawn · <- · ask
spawnstarts a task that belongs to the current scope's task group, so no dangling goroutines.actor <- Messagesends asynchronously (fire-and-forget).actor.ask(Message)awaits a typed reply.
let results = task_group {
let a = spawn fetch_user(id: 1)
let b = spawn fetch_orders(user_id: 1)
let c = spawn fetch_inventory()
{ user: await a, orders: await b, inventory: await c }
}
// If any task fails, the others are cancelled automatically.
Supervision trees
OTP-inspired: a crashed child is an event, not an exception. The supervisor decides.
let sup = Supervisor.new(
strategy: .one_for_one,
children: [
Child.spec(ETLWorker, restart: .permanent, max_restarts: 3, window: 60s),
Child.spec(HTTPService, restart: .transient),
]
)
sup.start()
Pipelines
Vassiliadis (2007) identifies three ETL parallelism axes: data, pipeline, and component. Yoru encodes all three in syntax.
pipeline DicomIngestion {
source: DICOMFetcher.stream(worklist)
|> transform: DicomParser.parse
|> transform: MetadataEnricher.enrich partition: 8
|> transform: HL7Mapper.to_fhir
|> sink: FHIRStore.upsert
on_error: .dead_letter_queue(dlq: my_dlq, max_retries: 3)
back_pressure: .bounded(capacity: 1024)
checkpoint: .every(1000.records)
}
DicomIngestion.run() effect [DB, HTTP, IO]
Partitioning, windowing, back-pressure
- Data parallelism via
partition: Non any stage. - Pipeline parallelism from the streaming model itself: stages run concurrently.
- Component parallelism with
Stream.merge([...]).
let hourly_summary = EventStream.from(kafka_topic)
|> window(.tumbling(size: 5.minutes))
|> aggregate { |batch| { count: batch.len(), total: batch.map(.amount).sum() } }
|> sink: Dashboard.push
Sink[T] that can't keep up creates a compile-time
requirement that its upstream Source[T] declares a
back_pressure policy. There is no way to silently lose
records.
Tools
In current frameworks tools are JSON Schemas described in docstrings
and parsed at runtime, the source of an entire class of LLM bugs. In
Yoru, tool is a keyword. The compiler generates the
schema, validates arguments, and tracks the tool's effects. User
types in the input flow through to the schema: an object
becomes a nested {type:"object",properties:...,required:...};
a tagged enum becomes anyOf with a
kind discriminator.
tool SearchOrders {
description: "Search orders by customer email or order ID"
input {
email: Option[String] @doc("Customer email")
order_id: Option[String] @doc("Exact order ID")
limit: Int = 10 @doc("Max results, 1-100")
}
output: [OrderSummary]
effect: [DB]
capability: .read_only
fn run(self, i: SearchOrders.Input) -> [OrderSummary] effect [DB] {
DB.query("SELECT * FROM orders WHERE email = $1 OR id = $2 LIMIT $3",
[i.email, i.order_id, i.limit])
}
}
Agents
An agent is an actor backed by an LLM reasoning loop with an explicit tool registry, retry policy, and typed output schema.
agent OrderAgent {
model: "claude-sonnet-4-6"
system: "You are a helpful order management assistant."
tools: [SearchOrders, CancelOrder, RefundOrder]
output {
action: String
summary: String
}
config {
max_turns: 10
budget_tokens: 8000
temperature: 0.2
}
}
let agent_ref = spawn OrderAgent()
let reply: Result[OrderAgent.Output, AgentError] =
agent_ref.chat("Refund order #ORD-4821 for customer@example.com")
output
schema, Yoru retries with a corrective system message describing the
validation error, up to max_turns. After that, the call
returns Err{kind: "agent_output_invalid"}.
Tagged-union tool inputs
Declaring an enum with payload variants and using it as
a tool input produces an anyOf schema with a
kind discriminator. The runtime reconstructs each
element back into the right variant before fn run
observes it.
enum PatchOp {
Fuzzy(old_text: String, new_text: String, count: Int),
Insert(line: Int, content: String),
Delete(start: Int, end: Int),
}
tool ApplyPatches {
description: "Apply ordered patches to a file"
input {
path: String,
ops: [PatchOp],
}
output: String
fn run(self, i: ApplyPatches.Input) -> String { ... }
}
The model sends {"path":"...","ops":[{"kind":"Fuzzy","old_text":"...","new_text":"...","count":0},{"kind":"Delete","start":5,"end":7}]}.
Wrong or missing kind, unknown variant, or missing
payload fields surface as Err{kind:"tool_invocation_failed"}
with a precise message the agent can recover from.
Self-minting agents
An agent can extend its own toolkit at runtime by emitting a Yoru
tool { ... } source string through a meta-tool that
wraps the define_tool builtin. The runtime parses,
registers, and exposes the new tool on the next LLM request (or the
next sibling tool_use block in the same response).
tool DefineTool {
description: "Register a new Yoru tool for the rest of this session."
input { source: String @doc("A Yoru `tool { ... }` declaration.") }
output: [String]
fn run(self, i: DefineTool.Input) -> [String] {
define_tool(i.source)
}
}
agent CuriousAgent {
model: "anthropic/claude-sonnet-4.5"
system: "When you need a tool you do not have, mint it via DefineTool."
tools: [DefineTool]
}
with_capability(...)
lives in host code; the runtime checks the capability stack at call
time regardless of how the tool was defined.
MCP servers
An MCP server is declared at the module level. The compiler emits the full JSON-RPC handler, capability negotiation, and tool registration.
mcp OrderServer {
name: "order-service"
version: "1.0.0"
tools: [SearchOrders, CancelOrder, RefundOrder]
resources: [
Resource.file("schema", path: "./schema.json"),
Resource.dynamic("recent_orders",
fn() => DB.query("SELECT ... LIMIT 20"))
]
transport: .stdio // or .http(port: 8080)
auth: .api_key // or .none for local use
}
Build a standalone MCP server with yoru build --target mcp src/order_server.yr.
HTTP services
service declares routes, middleware, and handlers. The
compiler emits the HTTP router and OpenAPI spec. No separate
framework, no proto files.
service UserAPI {
prefix: "/v1/users"
middleware: [AuthMiddleware, RateLimiter(rps: 1000)]
GET "/" -> [UserSummary] effect [DB]
fn list(req: Request) -> [UserSummary] {
DB.query("SELECT id, email FROM users LIMIT 100")
}
POST "/" -> User
body: CreateUserRequest
effect [DB, Log]
fn create(req: Request, body: CreateUserRequest) -> User {
let user = DB.insert(body.into_user())?
Log.info("user_created", { id: user.id })
user
}
GET "/{id}" -> User effect [DB]
fn get(req: Request, id: String) -> Result[User, NotFound] {
DB.find(User, id) ?? Err(NotFound)
}
}
Middleware, auth, rate limits
Middleware composes by declaration order. AuthMiddleware
verifies a JWT and provides the verified claims as an effect; the
rate limiter buckets requests by the route + auth subject.
let auth = JWT.middleware(
secret: env("JWT_SECRET"),
audience: "api.example.com",
leeway: 30.seconds,
)
For gRPC, declare a service with the grpc tag; protobufs are emitted from object declarations:
service UserRPC : grpc {
package: "user.v1"
rpc GetUser(GetUserRequest) -> User effect [DB]
rpc StreamUsers(Empty) -> Stream[User] effect [DB]
}
Standard library
Built-in providers installed on the interpreter by default. All errors
surface as Result.Err{kind:"...prefix..."}; successes are
unwrapped values.
FS and Path
Native filesystem operations, no shell-out. FS.write is
always atomic (tempfile + rename) and auto-creates parent dirs.
FS.read rejects binary content with
fs_binary instead of producing mangled UTF-8; use
FS.read_bytes for raw bytes.
| Function | Purpose |
|---|---|
FS.read(path) | Text read. Errors on binary content. |
FS.read_bytes(path) | Raw bytes. |
FS.read_lines(path, offset, limit) | Paginated read; limit=0 reads to EOF. |
FS.write(path, content) | Atomic write. |
FS.write_bytes(path, bytes) | Same with Bytes. |
FS.write_with(path, content, opts) | {backup:Bool, no_overwrite:Bool}. |
FS.exists(path) / FS.is_binary(path) | Bool. |
FS.stat(path) | {name, size, is_dir, is_file, modified_unix}. |
FS.list(path) / FS.list_recursive(path, max_depth) | Directory entries. |
FS.delete(path) / FS.mkdir(path) / FS.copy(src, dst) | Mutations. |
Path
Separate provider so non-IO code can compose paths without taking
the FS dependency. Path.is_within is the sandbox
primitive: it resolves both arguments through the longest existing
ancestor (handles cases where the child does not exist yet, e.g.
macOS /tmp as a symlink to /private/tmp),
then checks the relative path does not escape with ...
| Function | Purpose |
|---|---|
Path.join(parts) | Compose a list of segments. |
Path.resolve(path) | Absolute, symlinks resolved when target exists. |
Path.dirname/basename/extname | Component extraction. |
Path.is_within(child, parent) | Sandbox check returning Bool. |
Read-before-edit tracker
FS.with_session(fn) opens a per-block tracker. Every
FS.read inside records the file's SHA-256 and mtime;
FS.write_tracked refuses to overwrite a path the
session has not observed or whose on-disk hash has drifted.
FS.with_session(fn() => {
let original = FS.read("/tmp/notes.txt")?
let edited = replace(original, "TODO", "DONE")
FS.write_tracked("/tmp/notes.txt", edited)
})
Failure kinds, all recoverable: fs_no_session (write
outside the block), fs_not_read (path was never read),
fs_stale_read (file changed on disk since reading).
Fuzzy and Diff
Two narrow providers shipping the algorithms most LLM agents need for editing.
Fuzzy.find_replace
Four-level progressive matcher: exact,
trim_trailing, trim_all,
unicode_normalized. At trim_all and
above, the replacement is re-indented to match the haystack's
existing leading whitespace. Returns
{result, match_level, replacements} on hit, or
Result.Err{kind:"fuzzy_no_match"} on miss.
let file = "fn main() {\n println(\"old\")\n}\n"
let r = Fuzzy.find_replace(file, "println(\"old\")", "println(\"NEW\")", 0)
r.match_level is "trim_all" and the
replacement lands at the original four-space indent.
Diff.unified
Unified-diff renderer (git-compatible output). Diff.unified(a, b)
uses a default header path; Diff.unified_named(a, b, path)
sets the a/<path> / b/<path>
header explicitly. Empty string when a == b.
Selected builtins added with the agent surface
| Function | Purpose |
|---|---|
args() | List of CLI arguments after the script filename. |
env(name) | Environment variable as String, or nil if unset. |
replace_regex(s, pattern, replacement) | Regex find/replace. Invalid pattern returns Err{kind:"regex_invalid"}. |
define_tool(source) | Parse a Yoru source string and register any tool / object / enum declarations. Returns registered names. |
The specification, in one example
The following program is small enough to read top-to-bottom and
exercises every major feature: tool, agent,
mcp, capability scoping, the effect system, and
Result error handling.
// Clinical Assistant: full agentic program
import yoru.agent.*
import yoru.mcp.*
tool GetPatientRecord {
description: "Fetches a patient's clinical record by MRN"
input { mrn: String @doc("Medical Record Number") }
output: Result[PatientRecord, NotFound]
effect: [DB]
capability: .phi_read // HIPAA scope required to invoke
fn run(self, i: GetPatientRecord.Input) -> Result[PatientRecord, NotFound]
effect [DB] {
DB.find(Patient, i.mrn)
}
}
agent ClinicalAssistant {
model: "claude-sonnet-4-6"
system: "You are a clinical decision support assistant. Cite record IDs."
tools: [GetPatientRecord, SummarizeRecord]
config { max_turns: 8, budget_tokens: 4000, temperature: 0.1 }
}
mcp ClinicalMCPServer {
name: "clinical-assistant"
version: "1.0.0"
tools: [GetPatientRecord, SummarizeRecord]
transport: .http(port: 3000)
auth: .jwt(audience: "clinical-tools")
}
fn main() effect [IO, DB, LLM, Log] {
ClinicalMCPServer.start()
Log.info("MCP server started", { port: 3000 })
}
Keyword reference
| Keyword | Purpose |
|---|---|
object | Named record type |
blueprint | Generic / parameterized object template |
actor | Object with message-receive loop and isolated-turn semantics |
agent | Actor backed by an LLM reasoning loop |
tool | Typed function exposed to agents/MCP with auto-generated schema |
mcp | MCP server declaration |
service | REST or gRPC service |
pipeline | Typed ETL pipeline with declarative parallelism |
protocol / impl | Behavioural contract and implementation |
effect / handle | Declare or intercept an effect |
stream | Lazy, async sequence type |
match | Exhaustive pattern matching |
spawn / receive | Start an actor / declare a message handler |
fn / let / mut | Function · immutable · mutable |
iso · trn · ref · val · box · tag |
Reference capability annotations |
Operator reference
| Operator | Meaning |
|---|---|
<- | Send message to actor (async, fire-and-forget) |
? | Propagate Err from Result |
?? | Unwrap Ok or provide default / Err alternative |
|> | Pipeline stage composition |
=> | Match arm body / lambda shorthand |
... | Spread / rest pattern in destructuring |
Roadmap
| Phase | What | Target use |
|---|---|---|
| Phase 0 now |
Tree-walking interpreter on Go runtime; full type-checking, effect inference, actor scheduling. | Production agents, MCP servers, small services. |
| Phase 1 | Bytecode compiler + stack VM; effect handler inlining. | Higher-throughput services and ETL. |
| Phase 2 | LLVM IR emission; ahead-of-time compilation; arena allocator. | Latency-sensitive services, large ETL. |
| Phase 3 | Profile-guided optimization, WASM target, GPU offload for LLM pre/post processing. | Full production. |
Design decisions, in brief
Why no inheritance?
The research (Flageol et al., 2023; the original GoF book) and every serious modern OOP system (Rust traits, Go interfaces, Swift protocols) has moved away from classical inheritance because of fragile-base-class coupling. Yoru provides explicit delegation for the cases where "is-a" genuinely models the domain.
Why algebraic effects instead of async/await?
async/await colours the call graph: once a function is
async, every caller must be. Algebraic effects don't.
Leijen's Structured Asynchrony paper (Microsoft Research,
2018) shows that full async/await (including cancellation and
timeouts) can be implemented as a library on top of algebraic
effects, but not the other way around.
Why reference capabilities?
Go's "share memory by communicating" is aspirational advice, not enforcement. Rust's borrow checker enforces uniqueness but imposes a steep mental model and has no actor story. Pony's reference capabilities are the only production feature that proves data-race freedom at compile time without forcing a borrow-checker tax. Yoru adopts a simplified six-capability subset.
Why interpreted first?
Crystal demonstrates that a language with strong ergonomics can start interpreted and later compile to native. Building the type checker, effect inference engine, and actor scheduler before committing to an IR reduces iteration cost. Phase 0 hosts on Go because its scheduler and GC are well-suited to a green-thread interpreter.