Source: handbook/ in the repository. Every code block compiles: CI runs ray check on each one.
Getting started
The shortest path from zero to a useful program: install, set up the editor, create a project, the language in fifteen minutes, concurrency, working with an LLM assistant and the tools. At the end you will know which guide to follow for what you want to build.
1. Install
curl -sSfL https://raylang.dev/install.sh | sh # macOS / Linux → ~/.local/bin/ray
irm https://raylang.dev/install.ps1 | iex # Windows (PowerShell)
Check the installation and look for new versions:
ray version
ray upgrade --check # 0 = up to date, 1 = a new version exists
ray upgrade # installs the latest
Without installing anything, the playground runs the language in the browser, with diagnostics and completion.
ray build --native needs a Rust toolchain. If you have none, ray toolchain install installs a
private one under ~/.ray/toolchain without touching your system.
2. The editor
Every extension talks to the same language server, ray lsp: diagnostics as you type, completion,
hover with the signature, go to definition, rename and formatting.
| Editor | How |
|---|---|
| VS Code | the raylang extension from the marketplace |
| Sublime Text | Package Control: Install Package → raylang |
| Zed | the raylang extension |
| Neovim, Helix | point at ray lsp; the configuration snippets are in editors/README.md |
3. A project
ray new hello && cd hello
ray run # runs src/main.ray on the VM
ray new leaves three files: ray.toml, src/main.ray and a .gitignore.
[package]
name = "hello"
version = "0.1.0"
[dependencies]
fn main() -> int {
print("hello from hello");
0
}
main returns an int, the process exit code, or nothing. A single file works too:
ray run file.ray, and the arguments after the file arrive through args().
The working loop has four commands:
ray dev # rebuilds and restarts on save
ray test # runs the @test functions
ray fmt --write src/ # canonical formatting
ray build --native # native binary, with the same output as the VM
Two engines, one behavior. While you develop, the program runs on the VM: it starts instantly
and ray dev restarts it on every change. To deploy, ray build --native translates it to Rust
and compiles it to machine code. Both produce exactly the same output, byte for byte, and the
language's CI checks it on every change. The native binary is several times faster; the figures
are on the benchmarks page.
VM (ray run, ray dev, ray test) |
Native (ray build --native) |
|
|---|---|---|
| Starts | instantly, nothing to compile | after compiling: seconds, more with --release |
| Good for | developing, testing, scripts | deploying and distributing |
| Needs | just ray |
a Rust toolchain as well |
| Delivers | nothing: it runs the source | an executable that does not depend on ray |
Dependencies
The standard library ships inside the ray binary and is imported with import std/…. Everything
else is a package declared in ray.toml:
ray search http # searches the public index
ray add web # adds the dependency to ray.toml and downloads it
The official packages are net (HTTP/1.1 and 2, WebSocket, DNS, TLS, gRPC), web (the
Express-style application framework), rpc, db (Postgres, MySQL, SQLite, Redis, MongoDB), tz,
cron, and the three for agents: llm (talking to a model), mcp (tools over the Model Context
Protocol) and agent (the loop that joins them); and oidc (signing in with an OpenID Connect
provider and accepting its tokens). Versions are pinned in ray.lock with their hash. Packages are downloaded to
.ray-deps/, which stays out of version control: after cloning a project, ray fetch downloads
them again.
A dependency is always a string, in one of three forms:
[dependencies]
web = "^0.6" # a version from the index (what `ray add` writes)
oidc = "path:../oidc" # a local directory, relative to the project root
geo = "git+https://host/geo@v1.0" # a git repository at a tag or commit
A package of your own is a directory with its ray.toml and its modules; it is imported by
directory and file (import oidc/client;). If it has no program —no entry in [package] and no
src/main.ray— it is a library package: ray check and ray test walk it module by module
(the @tests may sit next to the code or under tests/), and ray run/ray build say there is
nothing to run.
4. The language in fifteen minutes
Values, variables and functions
fn square(x: int) -> int { x * x } // the last value of a block is its result
fn sign(x: int) -> int {
if (x > 0) { return 1; } // `return` only to leave early
if (x < 0) { return -1; }
0
}
fn main() -> int {
let x = 10; // immutable, inferred type
var total = 0; // mutable
total = total + square(x);
let ratio: float = 2.5; // explicit annotation whenever you want one
print("total ${total}, ratio ${ratio}, sign ${sign(-4)}"); // interpolation
0
}
Everything is an expression: if, match and blocks produce a value. Function signatures are
always annotated; locals are inferred. There is no null.
When something does not compile
The compiler checks the whole program before running anything. An error points at the line and the column, with the source line underneath:
type error at 2:22: 'total' is declared as int but initialized with string
2 | let total: int = "42";
| ^^^^
ray run then exits with code 65. Messages carry one of three headers, lex error,
syntax error or type error, and always the position. The editor shows the same as you type,
and ray check verifies without running.
Structs, enums and match
struct Point { x: int, y: int }
enum Shape {
Circle(float),
Rect(float, float),
Dot,
}
fn area(s: Shape) -> float {
match (s) {
Shape.Circle(r) => 3.14159 * r * r,
Shape.Rect(w, h) => w * h,
Shape.Dot => 0.0,
}
}
fn main() -> int {
let p = Point { x: 1, y: 2 };
p.x = 5; // structs have reference semantics
print(p.x + p.y);
print(area(Shape.Rect(2.0, 3.0)));
0
}
match is exhaustive: the compiler demands every variant be covered, so adding a variant to
the enum flags every match that needs updating. The scrutinee goes in parentheses.
Patterns also match literals, tuples and nested variants (Result.Ok(Option.Some(v))). On an
int or a string a _ arm is required, because the possible values cannot be listed:
fn label(code: int) -> string {
match (code) {
200 => "ok",
404 => "not found",
_ => "other",
}
}
fn main() -> int {
print(label(404));
0
}
Errors as values: Option, Result and ?
fn divide(a: int, b: int) -> Result<int, string> {
if (b == 0) { Result.Err("division by zero") } else { Result.Ok(a / b) }
}
fn average(xs: [int]) -> Option<int> {
if (xs.len() == 0) { return Option.None; }
var sum = 0;
for x in xs { sum = sum + x; }
Option.Some(sum / xs.len())
}
fn compute() -> Result<int, string> {
let q = divide(10, 2)?; // unwraps, or returns the Err to the caller
Result.Ok(q + 1)
}
fn main() -> int {
match (compute()) {
Result.Ok(v) => print("ok ${v}"),
Result.Err(e) => print("error: " + e),
}
match (average([3, 4, 5])) {
Option.Some(m) => print(m),
Option.None => print("empty"),
}
0
}
There are no exceptions: a function that can fail says so in its type, and ? propagates the
failure upwards with a single keystroke.
Arrays, maps and iterators
fn main() -> int {
var xs = [3, 1, 2];
xs.push(4);
print(xs.sort()); // [1, 2, 3, 4] (sorted copy)
print(xs.contains(2));
var ages: Map<string, int> = Map.new();
ages.insert("ada", 36);
ages.insert("grace", 45);
for (name, age) in ages { // iterates in key order, deterministic
print("${name}: ${age}");
}
let squares = xs.iter()
.filter(fn(x: int) -> bool { x % 2 == 0 })
.map(fn(x: int) -> int { x * x })
.collect(); // iterators are lazy until the terminal
print(squares);
print(range(1, 6).sum()); // 15
0
}
x.f(args) is sugar for f(x, args) (UFCS), so any free function can be chained; x |> f(a) is
the same thing in pipeline form.
Loops, break and continue
fn main() -> int {
var i = 0;
var odd_sum = 0;
while (true) {
i = i + 1;
if (i % 2 == 0) { continue; }
if (i > 9) { break; }
odd_sum = odd_sum + i;
}
print(odd_sum); // 1+3+5+7+9 = 25
for k in 0..3 { print(k); } // half-open range
0
}
Traits and generics
trait Describe { fn describe(self) -> string; }
struct User { name: string, age: int }
impl Describe for User {
fn describe(self) -> string { "${self.name} (${self.age})" }
}
fn show_all<T: Describe>(items: [T]) {
for it in items { print(it.describe()); }
}
@derive(Eq, Show)
struct Version { major: int, minor: int }
fn main() -> int {
show_all([User { name: "Ada", age: 36 }, User { name: "Grace", age: 45 }]);
let a = Version { major: 1, minor: 7 };
print(a == Version { major: 1, minor: 7 }); // derived Eq
print(a); // derived Show
0
}
Traits dispatch statically (and dynamically with dyn Trait). Eq, Show, Hash and ToJson
can be derived; Ord and the operators (Add, Sub, …) are implemented by hand.
Modules
A module is a file. pub exposes; you import by path and use it qualified by the last segment:
// src/geo/point.ray
pub struct Point { x: int, y: int }
pub fn origin() -> Point { Point { x: 0, y: 0 } }
// src/main.ray
import geo/point;
from geo/point import origin;
fn main() -> int {
let p = point.origin(); // qualified
let q = origin(); // brought into scope
print(p.x + q.y);
0
}
The standard library is embedded in the binary and imported the same way: import std/math; →
math.sqrt(2.0). The full catalog is in the
reference.
Tests
fn double(x: int) -> int { x * 2 }
@test
fn double_doubles() -> bool { double(21) == 42 }
@test
fn double_zero() { assert_eq(double(0), 0); }
fn main() -> int { 0 }
ray test runs the project's @test functions (also those in tests/*.ray); each one runs
isolated. A test that returns bool passes with true; one with no return value passes if no
assert fails. ray test --watch reruns them on save.
If you come from another language
raylang looks enough like Rust and TypeScript that habit makes you write things that do not compile. These are the differences you notice on the first day:
| If you write | In raylang it is |
|---|---|
if x > 0 { |
if (x > 0) {: the condition of if, while and match goes in parentheses |
let mut total = 0 |
var total = 0 |
Some(3), Ok(v), None |
Option.Some(3), Result.Ok(v), Option.None: variants are qualified |
null, nil, undefined |
does not exist: a value that may be missing is an Option<T> |
try / catch, throw |
do not exist: a function that can fail returns Result<T, E>, and ? propagates |
f"hello {x}" |
"hello ${x}", in any string |
| a multi-line string | backticks: `…`, which allow line breaks and double quotes |
| a mutable global variable | does not exist: the top level only has const; state lives in main or in a fiber |
| two functions with the same name | one name, one signature: there is no overloading |
5. Concurrency in two minutes
Fibers with an isolated heap that talk through typed channels, on a multicore scheduler. There
is no shared mutable state: whatever a closure passed to spawn captures is copied; only
channels and handles are shared between fibers.
fn main() -> int {
let ch: Channel<int> = Channel.bounded(4); // bounded: backpressure
let producer = spawn(fn() {
for i in 0..10 { send(ch, i * i); }
close(ch); // closing is the "end" signal
});
var total = 0;
while (true) {
match (recv(ch)) {
Option.Some(v) => { total = total + v; },
Option.None => { break; }, // channel closed and drained
}
}
join(producer);
print(total); // 285
0
}
scope(fn() { … }) joins on exit every task spawned inside it (and if one fails, cancels its
siblings); try_join and try_call turn a failure into a Result; try_send/try_recv never
block; select/select_timeout wait on several channels. With --deterministic the scheduling is
reproducible.
6. Working with an LLM assistant
raylang ships two pieces so that a coding assistant writes code that compiles:
llms.txtis the distilled context of the language: how it differs from Rust, the canonical forms and the exact error messages. Paste it into the project'sCLAUDE.mdor the prompt.ray mcpis an MCP server that gives the assistant theray_check,ray_run,ray_test,ray_fmtandray_doctools. The assistant writes, compiles, reads the exact diagnostic and fixes it, without you copying errors by hand.
With Claude Code you connect it like this:
claude mcp add raylang -- ray mcp
In a project, the assistant should pass path (the file or the project directory) to the
tools, not loose code: that way imports across files and the ray.toml dependencies resolve
exactly as with ray run. The server's own instructions tell it so. The details are in
docs/mcp.en.md.
7. The tools
| Command | What for |
|---|---|
ray run / ray dev |
run on the VM / development mode with restart and browser reload |
ray check |
verifies the program compiles, without running it |
ray test |
the @test functions; --watch re-runs on save |
ray fmt --write src/ |
canonical formatting (keeps your parentheses and comments) |
ray build --native --release |
optimized native binary |
ray bundle |
desktop app (.app, .desktop, .exe) or an iOS (--ios) and Android (--android) project |
ray dev --device |
hot reload of the program on the phone |
ray doc src/main.ray |
documentation from the /// comments |
ray profile |
where a program spends its time |
ray serve _site |
serves a static directory for previews |
ray lsp / ray mcp |
the editor / LLM assistants |
ray add, ray search, ray registry publish |
dependencies and publishing |
ray help lists everything, and ray <command> --help explains each one.
8. What to build
From here, each handbook guide is a complete project, with its example app:
- a mobile app for iOS and Android with a React frontend;
- the same app on the desktop (macOS, Linux, Windows), and windows in depth;
- a server-rendered site with templates;
- a web API with the
webframework and Postgres; - a site with a React frontend embedded in the binary;
- a command-line tool;
- an LLM agent with tools, and an MCP server of your own.
Once it works, performance shows how to measure it and make it fast.
And to get all of that to its users: shipping, with signing, the mobile stores and automatic updates.
For everything else: the reference has every function with its signature, and the manual (Spanish) explains the language in depth.
<!-- sync: sha256:b58e36ae4cf7 -->