Yoru v0.1 · Phase 0

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.

VariableWhen to set it
OPENROUTER_API_KEYMulti-provider gateway. Takes priority when set.
ANTHROPIC_API_KEYUse 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))
  }
}

The yoru CLI

CommandWhat it does
yoru run file.yr [args...]Lex, parse, type-check, evaluate. Trailing args reach the script via args().
yoru check file.yrType-check only (fast feedback in editors).
yoru fmt file.yrCanonical formatter.
yoru replInteractive REPL.
yoru run ./app/Multi-file project; auto-starts any declared service.
yoru build --target mcp --output bin file.yrStandalone MCP server binary (JSON-RPC over stdio).
yoru build --target http --output bin file.yrStandalone HTTP service binary.
yoru build --target cli --output bin file.yrStandalone 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

TypeExamples
Int / Float1, -7, 3.14
String"hello" (UTF-8)
Booltrue, false
Bytesraw 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.

CapabilityNameSemantics
isoIsolatedUnique ownership; may cross actor boundaries.
trnTransitionMutable globally unique; converts to val or ref.
refMutableMutable alias; never shared between actors.
valImmutableDeeply immutable; freely shareable.
boxRead-onlyRead-only alias from ref or val.
tagIdentityNo 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

EffectMeaningProvided 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)
No function coloring
Yoru has no 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

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

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
Back-pressure is a type
A 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")
Auto-retry with corrective messages
When the LLM produces output that fails the 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]
}
Safety property worth keeping
The model can declare requirements (effects, capabilities) on a minted tool but cannot grant them. 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.

FunctionPurpose
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 ...

FunctionPurpose
Path.join(parts)Compose a list of segments.
Path.resolve(path)Absolute, symlinks resolved when target exists.
Path.dirname/basename/extnameComponent 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

FunctionPurpose
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

KeywordPurpose
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 / implBehavioural contract and implementation
effect / handleDeclare or intercept an effect
stream Lazy, async sequence type
match Exhaustive pattern matching
spawn / receiveStart an actor / declare a message handler
fn / let / mutFunction · immutable · mutable
iso · trn · ref · val · box · tag Reference capability annotations

Operator reference

OperatorMeaning
<-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

PhaseWhatTarget 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.