Introduction
The Keel Language
A small, statically-typed language where AI agents are first-class citizens.
Agents are primitives
The actor model is the one concurrency primitive. Per-agent serial mailboxes, isolated mutable state via self — no shared-memory races to reason about.
Small core, deep stdlib
AI calls, scheduling, HTTP, email, memory — all live in a standard library you import one line at a time with use std/<name>. The core language stays tiny.
Capabilities, deny-by-default
An agent can only touch what its @tools list declares. Undeclared effects are compile-time errors — every program is auditable at a glance.
Statically typed
Full inference, exhaustive pattern matching, nullable safety, no implicit any. Misconfiguration fails loud at startup — never with plausible nonsense at runtime.
A first agent
Everything you need to triage and reply to email — model calls, human-in-the-loop, scheduling — in one file:
use std/ai
use std/email
use std/io
use std/schedule
type Urgency = low | medium | high | critical
agent EmailAssistant {
@role "Professional email assistant"
@tools [ai, io, email]
on message(msg: Message) {
urgency = ai.classify(msg.body, as: Urgency) ?? Urgency.medium
when urgency {
low, medium => {
reply = ai.draft("response to {msg.body}", tone: "friendly")
if io.confirm(reply) { email.send(reply, to: msg.from) }
}
high, critical => {
io.notify("{urgency}: {msg.subject}")
guidance = io.ask("How should I respond?")
reply = ai.draft("response to {msg.body}", guidance: guidance)
if io.confirm(reply) { email.send(reply, to: msg.from) }
}
}
}
@on_start {
schedule.every(5.minutes, () => {
for email in email.fetch(unread: true) {
send(self, email.as_message())
}
})
}
}
run(EmailAssistant)
Four imports, one capability list, and the whole program is auditable at a glance: this agent can call models, talk to a human, and send email — and nothing else.
Design Principles
- Small core, deep stdlib. If a feature can be a library, it is one. The core language has a small reserved keyword set; everything else is a
std/module imported with the sameusesyntax as your own files. - Agents are primitives.
agentis the only concurrency model. Per-agent serial mailboxes with isolated mutable state viaself. - Capabilities are deny-by-default. Effectful modules must be declared in
@toolsbefore an agent can use them. Undeclared calls are compile-time errors, not surprises in production. - Interfaces everywhere. LLM providers, memory stores, HTTP clients, loggers — all behind interfaces. Users swap implementations without leaving the language.
- Statically typed. Full inference. Exhaustive pattern matching. Nullable safety. No implicit
any. - No silent fallbacks. Misconfiguration fails loud at startup, not with plausible-looking nonsense at runtime.
Try It
Install and run your first agent:
curl https://keel-lang.dev/install.sh | sh
keel run examples/showcase.keel
Versioning and Breaking Changes
Alpha. Keel is pre-1.0. Semver is not respected between 0.x minor versions, and breaking changes can land in patch releases.
- 0.2.x — current alpha. One module system (
use std/<name>and local file imports), deny-by-default@tools, built-intestblocks. - 0.x — further pre-1.0 releases will keep breaking things where the design demands it. The changelog flags every break.
- 1.0.x — first API-stable release. Semver begins.
See SPEC.md for the authoritative design and ROADMAP.md for the plan.
Release Notes
Alpha. Keel is pre-1.0. Breaking changes are expected between 0.x releases.
Unreleased
v0.2.1 — 2026-06-13
Struct pattern matching in when
Bind named fields from a struct value directly in when arms:
when signal {
{ price, volume } where price > 1000.0 and volume > 0.0 => "active"
{ price } where price > 1000.0 => "thin"
_ => "quiet"
}
An unguarded struct arm is total against a non-nullable struct — it matches any value of that struct type, so no separate _ arm is required. The subject must be a struct and every named field must exist on it; a struct arm against an enum or a mistyped field name is a compile-time error rather than a silent none binding. See Control Flow.
v0.2.0 — 2026-06-12
Deny-by-default @tools
Agent capabilities are now declared, never implied. A capability guards
effects: ai, io, http, email, file, shell, db, search,
and env require a declared @tools capability, and an agent without
@tools may call none of them. Pure-compute modules (json, math,
time, …) are never gated. @tools all is the explicit unrestricted
form. Uncovered direct calls fail at compile time with the
fix spelled out; calls reached through helpers raise CapabilityError at
runtime with the same guidance. See
Agents & Attributes.
Module system: use std/<name> and local file imports
Keel programs can now span multiple files, and the standard library is imported explicitly — the ambient PascalCase prelude is gone:
use std/io
use std/file
use "./validation.keel"
use Urgency from "./models.keel"
task load_config() -> str {
file.read("config.json")
}
io.show("valid: {validation.email("ada@example.com")}")
use std/filebindsfile;use "./validation.keel"bindsvalidation(the file stem);asrenames either;use A, B as C from ...pulls symbols unqualified.- Top-level statements are the implicit main: they run only when the file is executed directly, never on import.
keel test file.keelruns only that file’s tests; imported modules contribute declarations (including test helpers), never their tests.- Agent verbs are built into the language:
run,stop,send,delegate,broadcast(theAgent.*namespace is gone). - Breaking:
File.read(...)and friends now fail with a migration hint — adduse std/fileand writefile.read(...). The bareuuid()free function is removed; usestd/uuidanduuid.v4().
See the new Modules & Imports guide.
Built-in test blocks
Keel now has a first language-level test runner:
use std/ai
use std/testing
type Severity = low | medium | critical
task classify(text: str) -> Severity {
ai.classify(text, as: Severity) ?? Severity.low
}
test "mocked classify returns critical" {
testing.mock(ai.classify).returns(Severity.critical)
assert classify("payment outage") == Severity.critical, "expected critical"
}
Run tests with:
keel test triage.keel
Use keel test triage.keel --filter classify to run only tests whose names contain classify, keel test triage.keel --list to print matching test names without running them, --fail-fast to stop after the first failure, or --quiet to print only failures and the final summary. Passing a directory, such as keel test examples/, recursively runs .keel files with test blocks.
use std/testing brings the testing namespace into scope. testing.mock(Namespace.method).returns(value) overrides one prelude namespace method for the current test only. Repeating a mock target returns values in order, then repeats the final value. Mocked methods expose Namespace.method.called, Namespace.method.call_count, and Namespace.method.called_with(...) metadata inside the same test. setup { ... } prepares bindings for the current test body. test "name" for case in cases { ... } runs one indexed case per item in the cases list. assert expr requires a boolean expression and fails the test when it evaluates to false; assert expr, "message" customizes the failure message. Files with no test blocks print 0 tests found. Result lines include per-test elapsed time, failed tests include source locations when available, and the final summary includes total suite time. keel run ignores test blocks, so production execution remains unchanged.
test, setup, and assert are contextual syntax words rather than new reserved keywords.
v0.1.33 — 2026-06-07
Typed runtime errors for all stdlib namespaces
Every stdlib namespace that can fail now raises a named, catchable error type. Previously, namespace errors embedded type names in message strings ("CsvError: ...") without a consistent policy — try/catch could only match Error as a fallback and couldn’t distinguish causes.
Now each failure domain has its own type: FileError, CsvError, DbError, MathError, MemoryError, EmailError, HttpError, ShellError, JsonError, EnvError, AiError, AiSchemaError, CapabilityError, TimeoutError, DeadlineError, UserRaised, and RuntimeBusy. The raise statement now produces UserRaised instead of a plain error.
catch e: Error continues to work as a catch-all for all types.
use std/control
use std/csv
use std/file
use std/io
agent A {
@tools [file, io]
@on_start {
try {
data = file.read("config.json")
} catch e: FileError {
io.show("file error: {e.message}")
}
try {
rows = csv.parse_records("{}")
} catch e: CsvError {
io.show("CSV error: {e.message}")
}
try {
control.with_timeout(5.seconds, () => { "ok" })
} catch e: TimeoutError {
io.show("timeout error: {e.message}")
}
try {
raise "quota exceeded"
} catch e: UserRaised {
io.show("raised: {e.message}")
}
stop(self)
}
}
run(A)
See the Error Handling guide for the full type registry.
v0.1.32 — 2026-06-06
All syntax errors now reported at once
The parser previously stopped at the first syntax error. If a file contained errors in two separate tasks, only the first was shown — requiring multiple edit-compile cycles to clear a freshly-written file.
The parser now uses declaration-level error recovery: when a declaration fails to parse, it skips forward to the next declaration keyword and continues. All accumulated errors are reported together — both in the LSP (as individual diagnostics on the affected lines) and in the CLI (as labeled source spans in a single miette report).
task greet(name: str) {
result = # ← error 1
}
task farewell(name: str) {
reply = # ← error 2 — now visible without fixing error 1 first
}
v0.1.31 — 2026-06-04
Bounded event queue and backpressure
The interpreter event queue is now bounded (default 1024; set KEEL_EVENT_QUEUE_CAPACITY=<n> to tune).
Each producer uses non-blocking delivery with explicit overflow policy:
| Producer | Overflow behaviour |
|---|---|
schedule.every / schedule.cron (recurring ticks) | Drop (coalesce) — next tick fires on time |
schedule.after / schedule.at / schedule.every first fire (one-shot) | Wait for queue space — delivery is guaranteed |
http.serve | 503 Service Unavailable returned to the HTTP client |
Agent.send / Agent.delegate / Agent.broadcast | RuntimeBusy error raised |
RuntimeBusy is a typed, catchable error:
try {
send(Worker, payload)
} catch e: RuntimeBusy {
io.show("queue full — dropped: {e.message}")
}
See Agent Communication for backpressure strategies.
v0.1.30 — 2026-06-03
Bounded event queue and backpressure
The interpreter event queue is now bounded (default 1024; set KEEL_EVENT_QUEUE_CAPACITY=<n> to tune).
Each producer uses non-blocking delivery with explicit overflow policy:
| Producer | Overflow behaviour |
|---|---|
schedule.every / schedule.cron (recurring ticks) | Drop (coalesce) — next tick fires on time |
schedule.after / schedule.at / schedule.every first fire (one-shot) | Wait for queue space — delivery is guaranteed |
http.serve | 503 Service Unavailable returned to the HTTP client |
Agent.send / Agent.delegate / Agent.broadcast | RuntimeBusy error raised |
RuntimeBusy is a typed, catchable error:
try {
send(Worker, payload)
} catch e: RuntimeBusy {
io.show("queue full — dropped: {e.message}")
}
See Agent Communication for backpressure strategies.
Nominal struct type identity
Two declared struct types with identical fields are no longer interchangeable.
type Point { x: int, y: int } and type Offset { x: int, y: int } are now
distinct types — passing an Offset where a Point is expected is a compile-time
error.
Anonymous struct literals remain structurally compatible with any named type that
has the required fields. p: Point = { x: 1, y: 2 } continues to work.
impl dispatch is now based entirely on the value’s declared type tag. To enable
impl methods on a list of struct values, declare the list type so elements are
tagged at assignment time:
scores: list[Score] = [{ val: 30 }, { val: 10 }, { val: 20 }]
sorted = scores.sort() # Comparable.compare is used
Error messages for named struct mismatches now include the declared type name
(expected Score, got Point) rather than the generic struct.
Read-only HIR semantic index
The checker and LSP now consume a read-only high-level intermediate representation
lowered from the parser AST. HIR assigns stable symbol IDs to declarations and
bindings, records resolved references for editor navigation, and classifies brace
literals as structs or maps once when an expected type is available. Agent-local
self.task(...) references and self.field reads/writes are also linked to their
symbols during lowering.
This is an internal compiler change. Keel syntax and runtime behavior are unchanged, and the tree-walking interpreter intentionally remains AST-backed in this first phase.
Structured checker diagnostics
Type-checker diagnostics are now structured internally. keel check and the LSP still show the same user-facing messages, but undefined names, type mismatches, wrong arity, and non-exhaustive when checks now carry typed data and precise source spans for editor tooling.
Accurate interpolation diagnostics
Type-checker diagnostics inside string interpolation slots now underline the correct source range. This also applies to nested and triple-quoted strings.
Strict runtime arguments for data APIs
Runtime APIs now enforce their declared inputs. Data APIs such as
file.read(path: str), cache.get(key: str), json.parse(text: str), and
shell.run(cmd: str) reject non-string values with a clear error instead of silently
formatting them as strings. Required sleep and value-method arguments now also raise
an error when omitted instead of silently acting as no-ops or empty strings.
Display-oriented APIs such as interpolation, io.*, and log.* continue to format
arbitrary values.
Stable diagnostic codes for typed runtime errors
AiError, AiSchemaError, and FileError now expose a stable machine-readable
code via miette’s diagnostic protocol. When an error propagates uncaught to the
CLI, the code appears in the output — for example keel::runtime::FileError. The
code follows the pattern keel::runtime::<TypeName> and can be inspected by tooling
without parsing the error message.
FileError is now a typed runtime error: file.* failures can be caught by type
name (catch e: FileError), matching the behavior of AiError and AiSchemaError.
catch e: Error continues to catch all failures as before.
v0.1.30 — 2026-05-29
AST Node<T> migration
Every (T, Span) tuple in the public AST has been replaced by Node<T> — a named struct
with .kind (the wrapped value) and .span (the source range). This is a purely internal
refactor: no Keel source syntax or runtime behaviour is affected.
For contributors: AST traversal code now uses uniform field access (node.kind,
node.span) instead of tuple indexing (.0, .1). Synthetic nodes produced by the prelude
and test helpers use Node::synthetic(kind), which stores a 0..0 sentinel span.
Expression spans + annotation-precise type errors
Every expression in the AST now carries its exact source byte range. The most
visible benefit: when a let binding has an explicit type annotation and the
inferred type doesn’t match, the error caret points at the annotation — not the
whole statement.
error: `n`: expected int, got str
--> example.keel:2:5
|
2 | n: int = "hello"
| ^^^ ← caret lands here, not at the start of the line
See the Types guide for details.
Richer type diagnostics — Ty::Unknown split
The single overloaded Unknown fallback type has been replaced with four semantically distinct variants that give the checker, strict mode, and contributors a precise vocabulary for why a type is not known:
| Variant | When it appears | Fires in --strict? |
|---|---|---|
dynamic | Explicit dynamic annotation written by the programmer | Never |
Unknown(ExternalDynamic) | Namespace method whose return depends on runtime data (json.parse, LLM outputs) | Yes |
Unknown(InferenceLimitation) | Checker cannot cheaply infer the type (agent refs, unannotated lambdas, dispatch fallthrough) | Yes |
Unknown(UnsupportedFeature) | Construct the checker does not yet implement | Yes |
User-visible change — --strict and json.parse:
keel check --strict no longer warns on dynamic-annotated bindings; it only warns on
Unknown(_) results. let x: dynamic = json.parse("{}") is clean in strict mode because
the programmer explicitly opted into the dynamic type. let x = json.parse("{}") (unannotated)
still fires cannot infer type of 'x' because the return type is now Unknown(ExternalDynamic)
rather than dynamic.
# always clean — explicit dynamic annotation
data: dynamic = json.parse(raw)
# clean in normal mode, flagged in --strict — unannotated binding
data = json.parse(raw) # error in strict: cannot infer type of 'data'
Fix unannotated json.parse bindings with an explicit annotation or cast:
# annotate as dynamic (accept at call site)
data: dynamic = json.parse(raw)
# or narrow immediately to a concrete type
data = json.parse(raw) as Payload
Type-safe Agent.delegate symbol form
Handler references are now first-class compile-time expressions.
Symbol form (new, preferred):
delegate(Worker.process, payload)
Worker.process is resolved at compile time. The checker validates that process is a
declared on handler on Worker and that payload matches the handler’s parameter type.
A typo like Worker.typo is a compile-time error.
String form (legacy, now also validated for plain literals):
delegate(Worker, "process", payload)
Both forms are accepted. Prefer the symbol form for new code — handler renames update the reference automatically.
v0.1.29 — 2026-05-27
Csv namespace — CSV serialization
Three functions cover the full round-trip. No @tools annotation required.
csv.parse(text) -> list[list[str]]— every row as a list of strings; the caller decides whether to treat the first row as headers.csv.parse_records(text) -> list[map[str, str]]— first row becomes header keys; remaining rows become maps.csv.stringify(rows) -> str— convertslist[list[str]]to RFC 4180 CSV; cells containing commas, quotes, or newlines are automatically quoted.
raw = "symbol,price,volume\nBTC,67000,1234.5\nETH,3500,5678.9"
trades = csv.parse_records(raw)
for trade in trades {
log.info("{trade["symbol"]} @ {trade["price"] as float:.2f}")
}
rows = [["symbol", "price"], ["BTC", "67000"], ["ETH", "3500"]]
text = csv.stringify(rows)
Db namespace — SQLite database access
Query and modify SQLite databases. db.connect(url) opens a database and returns a connection with .query() and .exec() methods. URLs use the sqlite:// scheme.
db.connect(url: str) -> DbConnection— opens a SQLite databaseDbConnection.query(sql, params?) -> list[map[str, dynamic]]— SELECT queries return rows as mapsDbConnection.exec(sql, params?) -> int— INSERT/UPDATE/DELETE return affected row count
db = db.connect("sqlite://trades.db")
db.exec("CREATE TABLE IF NOT EXISTS trades (id TEXT, symbol TEXT, price REAL)")
db.exec("INSERT INTO trades VALUES (?, ?, ?)", ["t1", "BTCUSDT", 67000.0])
rows = db.query("SELECT symbol, price FROM trades WHERE symbol = ?", ["BTCUSDT"])
for row in rows {
log.info("{row["symbol"]} @ {row["price"] as float:.2f}")
}
SQLite is bundled into the binary. Other backends (Postgres, MySQL) are planned for v0.2.
v0.1.28 — 2026-05-26
Typed map keys — map[K, V] key types enforced at compile time
Map keys must now be one of the three hashable primitive types: str, int, or bool. Using any other type as K is a compile-time error with an actionable message:
scores: map[str, int] = {alice: 100} # valid
lookup: map[int, str] = {1: "one"} # valid
flags: map[bool, str] = {true: "on"} # valid
# Type errors caught at compile time:
# m: map[float, str] — float is not a valid map key type (NaN violates hash equality; use int)
# m: map[str?, int] — nullable types cannot be used as map keys
# m: map[Point, str] — struct/enum keys require interface Hashable (coming in v0.2)
The runtime map representation now stores keys as a typed union (str | int | bool), so map[int, str] and map[bool, str] maps work correctly at runtime — .keys() returns values of the declared key type.
Struct spread-update { ...base, field: new }
Creating a modified copy of a struct no longer requires re-stating every field. Use the spread-update syntax to copy all fields from a base and override only the ones that changed:
type Order { id: str, status: str, amount: float }
o: Order = { id: "ord-1", status: "pending", amount: 9.99 }
filled = { ...o, status: "filled" } # id and amount unchanged
copy = { ...o } # full copy
type Config { host: str, port: int, debug: bool }
base: Config = { host: "localhost", port: 8080, debug: false }
prod = { ...base, host: "api.example.com" }
The ...base spread must appear first and can appear only once. For struct bases, override field names must exist in the base struct (unknown fields are a compile-time error, and a runtime error on dynamic paths). For map bases (map[K, V]), any key may be added or overridden freely — values must match the map’s value type. The result preserves the base’s type tag, so impl dispatch works on the returned value. Spreading a none value raises at runtime.
Shell namespace — subprocess bridge
Agents can now invoke external commands and capture their output via shell.run. The command is passed to /bin/sh -c, so pipes, redirects, and shell builtins work as expected. Requires @tools [shell].
use std/io
use std/shell
agent Builder {
@tools [shell]
@on_start {
r = shell.run("cargo test --quiet 2>&1")
if r.exit_code != 0 {
raise "tests failed:\n{r.stdout}"
}
io.show("all tests passed")
}
}
run(Builder)
shell.run returns { stdout: str, stderr: str, exit_code: int }. A non-zero exit code is not automatically an error — check it and raise yourself if needed. Spawn failures (e.g. /bin/sh not in PATH) do raise. Optional named args: stdin: str (piped to the process) and cwd: str (working directory).
Math namespace
Transcendental and power functions are now available under the Math namespace. All functions accept int or float and return float. Domain errors (math.sqrt(-1), math.log(0)) raise at runtime and can be caught with try/catch.
h = math.sqrt(math.pow(3, 2) + math.pow(4, 2)) # 5.0
ln2 = math.log(2) # ≈ 0.693
s = math.sin(math.PI() / 6.0) # 0.5
| Function | Returns | Notes |
|---|---|---|
math.PI() | float | π |
math.E() | float | e |
math.sqrt(x) | float | Raises if x < 0 |
math.pow(x, y) | float | |
math.exp(x) | float | e^x |
math.log(x) | float | Natural log; raises if x ≤ 0 |
math.log2(x) | float | Raises if x ≤ 0 |
math.log10(x) | float | Raises if x ≤ 0 |
math.sin(x) | float | Radians |
math.cos(x) | float | Radians |
math.tan(x) | float | Radians |
math.asin(x) | float | Raises if x ∉ [-1, 1] |
math.acos(x) | float | Raises if x ∉ [-1, 1] |
math.atan(x) | float | Radians |
math.atan2(y, x) | float | Two positional args |
time.epoch_ms()
Returns the current Unix timestamp as an int in milliseconds — the Keel equivalent of JS Date.now(). Useful for database BIGINT columns, signed payloads, and any context where a raw numeric timestamp is more ergonomic than an RFC 3339 string.
ms = time.epoch_ms() # e.g. 1705314600500
as T coercions and typeof()
expr as T now performs real runtime coercions. typeof(x) is a new prelude function returning the runtime type name.
1 as float # 1.0
1.7 as int # 1 (truncated)
"3.14" as float # 3.14
"abc" as int # raises
type Point { x: int, y: int }
p: Point = { x: 1, y: 2 }
typeof(p) # "Point"
typeof(42) # "int"
See the Types guide for the full coercion table.
list.sort(by: key_fn)
.sort() now accepts an optional by: key function, consistent with min(by:) / max(by:). No impl Comparable needed.
by_price = products.sort(by: p => p.price) # cheapest first
by_name = products.sort(by: p => p.name) # alphabetical
by_rating = products.sort(by: p => 0.0 - p.rating) # highest rated first
The key function must return int, float, or str. Ascending only; negate numeric keys for descending.
String interpolation format specifiers
Slots now accept an optional format spec after a colon: {expr:spec}.
pi = 3.14159
io.show("{pi:.2f}") # → "3.14"
io.show("{pi:>10.2f}") # → " 3.14"
io.show("{"hi":<8}!") # → "hi !"
io.show("{"hi":^8}") # → " hi "
n = 42
io.show("{n:.2f}") # → "42.00" (int auto-promoted to float)
io.show("{n:8}") # → " 42" (bare width = right-align)
See the String Interpolation guide for the full spec.
v0.1.27 — 2026-05-21
while loops
Unbounded iteration is now supported. The condition must be bool; break and continue work identically to their for-loop counterparts.
n = 5
while n > 0 {
io.show("tick: {n}")
n -= 1
}
total = 0
i = 1
while true {
total += i
i += 1
if total > 10 { break }
}
Subscript access (list[i], str[i])
Lists and strings now support integer subscript syntax. Result type is T for list[T] and str for strings — no nullable wrapper. Out-of-bounds and negative indices raise a runtime error, so there is never ambiguity between a none value and a missing index:
items = ["alpha", "beta", "gamma"]
first = items[0] # "alpha"
mid = items[1] # "beta"
# items[99] # runtime error: index 99 out of bounds (length 3)
word = "keel"
ch = word[0] # "k"
# word[10] # runtime error: string index 10 out of bounds (length 4)
Use len() to guard dynamic indices; .first() / .last() remain available when you want a nullable fallback.
User-defined interfaces
Any user can now declare a named interface and implement it for a struct type. The compiler validates conformance — missing methods, wrong arity, and wrong return types are all compile-time errors:
use std/io
interface Printable {
task print(self) -> str
}
type Point { x: float, y: float }
impl Printable for Point {
task print(self) -> str { "({self.x}, {self.y})" }
}
p: Point = { x: 1.5, y: 2.0 }
io.show(p.print()) # → "(1.5, 2.0)"
- Interface declarations and their
implblocks can appear in any order in the same file. - Impl methods take priority over built-in map methods — a user-defined
size()method on a struct wins over the generic map.size()length accessor. - Interface-as-type annotations (
task f(x: Printable)) are not yet supported.
See the Interfaces guide for full documentation.
Built-in interfaces: Comparable, Equatable, Serializable, Iterable
Four new built-in interfaces extend the standard library with user-driven behaviour:
Comparable — sort and compare user-defined types.
type Score { val: int }
impl Comparable for Score {
task compare(self, other: Score) -> int { self.val - other.val }
}
items = [{ val: 30 }, { val: 10 }, { val: 20 }]
sorted = items.sort() # ascending by val
lo = items.min() # { val: 10 }
hi = items.max() # { val: 30 }
Equatable — typed equality check alongside structural ==.
use std/io
type Point { x: int, y: int }
impl Equatable for Point {
task equals(self, other: Point) -> bool {
self.x == other.x and self.y == other.y
}
}
a: Point = { x: 1, y: 2 }
b: Point = { x: 1, y: 2 }
io.show("{a.equals(b)}") # → true
Serializable — override json.stringify for a type.
use std/io
use std/json
type Event { name: str, score: int }
impl Serializable for Event {
task to_json(self) -> str { "name={self.name};score={self.score}" }
}
e: Event = { name: "goal", score: 3 }
io.show(json.stringify(e)) # → "name=goal;score=3"
Iterable — use a struct in a for loop.
use std/io
type Range { lo: int, hi: int }
impl Iterable for Range {
task items(self) -> list[int] {
result: list[int] = []
i = self.lo
while i <= self.hi {
result += [i]
i += 1
}
result
}
}
for n in Range { lo: 1, hi: 3 } { io.show("{n}") } # → 1, 2, 3
Iterablematerialises the full list before iteration — it is not a generator protocol.
See the Interfaces guide for the complete reference.
impl Stringable for Type — custom string interpolation
User-defined struct types can now participate in "{...}" interpolation by implementing the Stringable interface.
The impl keyword introduces the block; self inside the block is the receiver value:
use std/io
type Point {
x: float
y: float
}
impl Stringable for Point {
task to_str(self) -> str {
"({self.x}, {self.y})"
}
}
p: Point = { x: 3.0, y: 4.0 }
io.show("Origin to {p}") # → "Origin to (3.0, 4.0)"
s = p.to_str() # explicit call
implis a new reserved keyword.- Any struct type can implement
Stringable— each type gets its ownimplblock. - Values that do not implement
Stringablestill render via their built-in display representation, so existing programs are unaffected.
See the String Interpolation guide for the full reference.
Fixes
- Impl dispatch is now deterministic. Calling an impl method on a struct value previously used field-set matching — if two types declared the same fields (e.g.
PointandVec2both{x, y}), the wrong impl could be selected. Struct values are now type-tagged at their binding site and impl dispatch is a direct O(1) lookup.ai.extract(... as: T)results are tagged withTautomatically.
v0.1.26 — 2026-05-20
Numeric value methods
.abs(), .floor(), .ceil(), and .round() are now available on int and float values.
The return type always matches the receiver — float methods return float, int methods return int.
floor, ceil, and round are identity no-ops on int.
price = -3.75
price.abs() # 3.75
price.abs().ceil() # 4.0
count = -5
count.abs() # 5 — int stays int
Random namespace
Random is now available in the prelude for non-cryptographic pseudo-random values:
roll = random.int(min: 1, max: 6)
sample = random.float()
enabled = random.bool()
Use Random for simulation, sampling, games, and similar non-security work. random.int
uses an inclusive min: / max: range and raises a runtime error if min > max.
Uuid type and namespace
Uuid is now a distinct runtime/type-checker value with factories, parsing, formatting, and the
top-level uuid() alias:
id: Uuid = uuid.v4()
trace = uuid.v7()
site = uuid.v5(ns: uuid.DNS, name: "keel-lang.dev")
parsed = uuid.parse("f47ac10b-58cc-4372-a567-0e02b2c3d479")
simple = id.format(as: "simple")
version = id.version()
uuid.DNS, uuid.URL, uuid.OID, and uuid.X500 are built-in namespace constants for deterministic
version-5 UUIDs. Interpolation displays UUIDs in lowercase hyphenated form.
Crypto namespace
Crypto is now available for security-sensitive hashes, HMACs, tokens, and random bytes:
digest = crypto.sha256("hello")
sig = crypto.hmac_sha256("message", key: secret)
token = crypto.token(bytes: 32)
bytes = crypto.random_bytes(16)
Crypto exposes fixed safe SHA-2 methods: sha224, sha256, sha384, sha512,
sha512_224, and sha512_256, with matching hmac_ methods. Legacy MD5 and SHA-1 are not exposed.
crypto.token returns hex, so bytes: 16 produces a 32-character token. Use Random only for
non-security work.
v0.1.25 — 2026-05-19
Variadic parameters (...param: T) and spread (...expr)
Tasks can now accept any number of positional arguments through a rest-parameter:
task greet(...names: str) -> str {
result = ""
for n in names { result += n + " " }
result
}
greet("Alice", "Bob") # names = ["Alice", "Bob"]
greet() # names = []
Spread a list[T] or set[T] at any call site with ...:
xs = ["Dave", "Eve"]
greet("Alice", ...xs) # ["Alice", "Dave", "Eve"]
greet(...xs, ...xs) # merge two lists
Passing list[T] without ... to a variadic param is a compile-time type error.
See Tasks → Variadic parameters.
min and max prelude free functions
min and max are now available as global free functions — no namespace prefix needed.
They accept variadic positional arguments, an optional by: key-selector lambda, and
return none when called with zero items:
min(3, 1, 4) # 1
max("banana", "apple", "cherry") # cherry
scores = [4, 9, 2, 7]
min(scores) # 2 — single list auto-spread
max(...scores, 99) # 99
people = [{name: "Alice", age: 30}, {name: "Bob", age: 25}]
youngest = min(people, by: p => p.age) # {name: "Bob", age: 25}
oldest = max(people, by: p => p.age) # {name: "Alice", age: 30}
File namespace complete — mkdir, remove, copy, move, glob, mktemp
The File namespace now covers all common filesystem operations:
file.mkdir("output/reports") # create directory (and parents)
file.copy("template.txt", "output/r.txt") # copy a file; parent dirs created automatically
file.remove("tmp/scratch.txt") # delete file or directory (recursive for dirs)
file.move("draft.txt", "final.txt") # rename / move; creates dst parent dirs
paths = file.glob("output/*.txt") # list[str] of matching paths; empty list if none match
tmp = file.mktemp() # create a temp file; caller removes it
tmpdir = file.mktemp(dir: true) # create a temp directory
file.remove auto-detects files and directories — directories are removed recursively.
file.glob supports standard glob patterns (*, ?, **). An invalid pattern raises a runtime
error; no matches returns an empty list. file.mktemp returns the path and leaves cleanup to the
caller — use file.remove(path) when done.
Bug fix: datetime comparison and arithmetic in keel check
Comparisons and arithmetic involving datetime and duration values were incorrectly rejected
by the static type checker. datetime > datetime, datetime + duration, datetime - duration,
and datetime - datetime now all pass keel check.
v0.1.24 — 2026-05-16
Bug fixes
This release focuses on runtime safety and performance:
- Fixed potential deadlock in agent team queries through proper lock ordering.
- HTTP client now uses connection pooling, eliminating redundant TCP+TLS handshakes.
- File and memory I/O operations now use async-safe blocking thread pools, preventing event loop stalls.
- Fixed async task spawning to properly route events to the parent interpreter.
- Reduced allocations in string truncation and improved deduplication performance.
v0.1.23 — 2026-05-16
Augmented assignment — +=, -=, *=, /=
Mutate an existing variable in its nearest enclosing scope without shadowing it:
total = 0
for i in 1..5 {
total += i
}
# total is 15
Also works on self.field in agent handlers: self.counter += 1.
raise expr — throw errors symmetrically with try/catch
raise makes the error model complete — you can now throw as well as catch:
task validate(n: int) {
if n < 0 {
raise "n must be non-negative"
}
return n
}
Strings become the error message; other values are converted via their display representation. Caught by try/catch err: Error like any other runtime error.
break and continue in for loops
break exits the nearest enclosing for loop immediately. continue skips the rest of the current iteration and advances to the next. Both are reserved keywords.
# stop as soon as the target is found
for item in items {
if item == target {
break
}
process(item)
}
# skip even numbers
for n in 1..100 {
if n % 2 == 0 {
continue
}
process_odd(n)
}
Both affect only the innermost loop. No labeled jumps in v0.1.
list.zip(other) — pair two lists
zip pairs elements from two lists into a list of 2-element tuples, stopping at the shorter list.
The return type is inferred as list[(T, U)], so tuple destructuring in for loops is fully typed.
names = ["alice", "bob", "carol"]
scores = [90, 85, 95]
for (name, score) in names.zip(scores) {
log.info("{name} scored {score}")
}
v0.1.22 — 2026-05-14
@tools guards now use if
Conditional tool entries in @tools now use if instead of when:
@tools [
email.send if self.confirmed, # only after confirmation
db.exec if self.admin, # admin only
]
This separates tool guards from the when pattern-match keyword. Existing code using when self.* in @tools must be updated to if self.*.
when as an expression
when now works in expression position — the matched arm’s value becomes the result.
label = when score {
"A" => "excellent"
"B" => "good"
_ => "needs work"
}
All arms must produce the same type; a mismatch is a compile error. Exhaustiveness rules are identical to the statement form. See the control flow guide.
v0.1.21 — 2026-05-14
Nullable safety enforced at task call sites
The type checker now rejects nullable arguments passed to non-nullable parameters
at every task call site — top-level tasks, self.task(...), and self.method(...).
use std/env
task process(text: str) { ... }
task t() {
val: str? = env.get("PROMPT")
process(val) # error: expected str, got str? — use `!` or `??`
process(val!) # ok
process(val ?? "") # ok
}
Named arguments are also checked: process(text: val) produces the same error.
Type mismatches at call sites (e.g. int passed where str is expected) are
caught as well.
v0.1.20 — 2026-05-14
Generic type declarations
Type declarations can now be parameterised over one or more type variables.
type Paginated[T] {
items: list[T]
page: int
has_more: bool
}
type Pair[A, B] {
first: A
second: B
}
type Bag[T] = list[T]
The type checker resolves generic instantiations to concrete types.
Paginated[str].items is now checked as list[str] rather than unknown.
Generic enums register variant names for exhaustiveness checking.
Function type literals
Function types can now be written inline and used as type aliases or parameter types.
type Handler = (str) -> bool
type Reducer = (str, int) -> str
type Thunk = () -> none
type Predicate[T] = (T) -> bool
task t(h: Handler) {
ok: bool = h("hello")
}
Zero-parameter and multi-parameter forms both work. Tuple syntax (T1, T2) without -> continues to produce a tuple type.
Generic enum variant field types
Bindings destructured from a generic enum variant now resolve to the substituted
field type instead of unknown.
use std/io
type Pair[A, B] =
| both { first: A, second: B }
| only_first { value: A }
| only_second { value: B }
task t(p: Pair[str, int]) {
when p {
both { first, second } => {
f: str = first # type-checked as str
s: int = second # type-checked as int
}
only_first { value } => { io.show(value) }
only_second { value } => { io.show("{value}") }
}
}
Generic task declarations
Tasks may now declare type parameters. Type arguments are inferred at every call site — no explicit instantiation syntax is needed.
task identity[T](x: T) -> T { x }
task first[A, B](a: A, b: B) -> A { a }
task main() {
s: str = identity("hello") # T = str
n: int = identity(42) # T = int
f: str = first("hi", 99) # A = str, B = int
}
The formatter round-trips task name[T, U](...) syntax correctly.
v0.1.19 — 2026-05-14
Type checker improvements (additive)
Seven type checker gaps closed — no existing programs break.
?.propagates nullable —x?.fieldon a nullable struct now types asFieldType?instead ofunknown.??unwraps nullable —x ?? fallbacknow returns the inner type ofx, not the fallback’s type.ai.extract/ai.decidewithas:— when anas: Targument is present, the return type is inferred asT?rather thanunknown?. Downstream field accesses on the result are now checked.- Lambda block bodies —
x => { ... }now infers its return type from the last expression, matching expression-body lambdas. set[]literal type —set[1, 2, 3]now infers asset[int]instead oflist[int].- Implicit return checking — when a task’s last statement is an expression, its type is checked against the declared return type. Control-flow statements (
return,when,for) are excluded to avoid false positives. if-expression branch unification — both branches of anifexpression must produce the same concrete type. When one branch exits viareturn, the other branch’s type is used.
v0.1.18 — 2026-05-13
Explicit agent task calls
Agent-owned tasks are now invoked as self.task(...). Inside an agent body,
bare task(...) resolves only through lexical and top-level scope, while
cross-agent work stays on mailbox APIs such as send(...) and
delegate(...).
v0.1.17 — 2026-05-08
Conditional @tools guards
@tools entries now support a when guard — a boolean expression evaluated at the start of each handler turn. Tools whose guard is false are blocked for that turn. Guards can access self.* state and call tasks that return bool.
Entries can gate a whole namespace or a specific method:
use std/db
use std/email
agent SupportBot {
state { confirmed: bool = false, admin: bool = false }
@tools [
email.fetch, # always allowed
email.send when self.confirmed, # only after confirmation
db.query, # always allowed
db.exec when self.admin, # admin only
http, # whole namespace, always
]
}
Calling a blocked method raises a CapabilityError at runtime.
See the Agents guide for full details.
Readonly state fields
State fields declared with readonly between the colon and the type are compiler-enforced read-only. Any self.field = ... assignment is a compile-time error.
state {
turns: int = 0
session_id: readonly str = "default-session"
}
See the Agents guide for full details.
v0.1.16 — List & String Enhancements
Extended list operations
list[T] gains thirteen new methods: any, all, find, reduce, sum, min, max, join, sort, reverse, flatten, take, skip. See the Collections guide for full reference.
New string methods
Seven new string methods added: trim_start, trim_end, repeat, slice, index_of, to_int, to_float. See the String Interpolation guide for the full method table.
keel check --strict
keel check --strict <file> now rejects bindings whose type the checker cannot infer. Normal keel check still accepts them silently. Use --strict to verify that type annotations are complete. See keel check for details.
v0.1.15 — Error Handling Rework
Breaking: fallback: removed from all ai.* calls
fallback: is no longer a valid argument on any ai.* function. Replace every call site with ?? at the expression level:
| Before | After |
|---|---|
ai.classify(text, as: T, fallback: T.x) | ai.classify(text, as: T) ?? T.x |
ai.summarize(body, in: 3, unit: sentences, fallback: "none") | ai.summarize(body, in: 3, unit: sentences) ?? "none" |
The type-checker now always infers T? for ai.classify regardless of arguments.
Two-tier failure model
ai.* calls have two distinct failure modes:
| Failure | Result | Handle with |
|---|---|---|
| Network failure / mock mode / timeout | Returns none | ?? |
| LLM returned output that doesn’t match the schema | Throws AiSchemaError | try/catch |
try/catch — now wired
try/catch was parsed but ignored before this release. Catch clauses now execute and the bound err variable carries error fields:
try {
urgency = ai.classify(email.body, as: Urgency) ?? Urgency.medium
} catch err: AiSchemaError {
io.notify("Unexpected LLM output: {err.got}")
urgency = Urgency.medium
} catch err: Error {
io.notify("Failed: {err.message}")
}
AiSchemaError fields:
| Field | Type | Value |
|---|---|---|
message | str | Human-readable description |
got | str | The raw LLM output that failed to match |
Error is the catch-all for any other runtime error.
v0.1.14 — 2026-05-06
for loop if guard
for loops now accept an inline if filter, replacing the previous where keyword in that position:
for email in emails if email.unread { triage(email) }
for n in 1..10 if n % 2 == 0 { io.show(n) }
Breaking: for x in list where cond no longer parses. Use if instead.
Time namespace — full rework
now is no longer a keyword. The Time namespace now provides timezone-aware datetime handling with method syntax on values.
# Factories — namespace style
now = time.now() # UTC, millisecond precision
ny = time.now(tz: "America/New_York") # IANA timezone
dt = time.parse("2026-05-06T09:00:00Z") # datetime? (none on failure)
dt2 = time.parse("2026-05-06", tz: "UTC") # coerce naive string with tz:
# Methods on the datetime value
p = dt.parts() # {year, month, day, hour, minute, second, millisecond, tz}
s = dt.format(as: "%Y-%m-%d") # str?
# Operators
elapsed = finish - start # datetime - datetime → duration
deadline = time.now() + 3.days
ok = deadline > time.now()
# Millisecond duration
short = 500.ms # aliases: millis, millisecond, milliseconds
Breaking changes from the earlier v0.1.14 Time stub:
time.format(dt, as:)removed → usedt.format(as:)time.diff(a, b)removed → usea - btime.parse()rejects naive strings without a TZ offset — returnsnoneinstead of raising. Usetime.parse(str, tz: name)to coerce.
Naive strings (no UTC offset in the string) are rejected by design — all datetimes in Keel are timezone-aware.
v0.1.13 — 2026-05-05
Destructuring (§8.4)
All five destructuring forms from SPEC.md §8.4 are now implemented. No new keywords.
{urgency, category} = result # struct shorthand
{urgency: u, category: c} = result # struct rename
(label, count) = ("alpha", 42) # tuple
for {from, subject} in emails { ... } # in for loop
task handle({body, from}: Email) { ... } # in task params
Keyword-named fields (from, state, in, etc.) work in all positions. Missing struct fields and tuple arity mismatches are compile-time type errors.
See the Variables & Expressions guide for full documentation.
v0.1.12 — 2026-05-04
Range operator ..
start..end produces an inclusive list[int]. Both bounds must be int.
for i in 1..5 {
io.notify("{i}") # 1, 2, 3, 4, 5
}
xs = 0..3 # [0, 1, 2, 3]
xs.count() # 4
5..3→[](empty when start > end)4..4→[4](single element)- Non-integer bounds are a type error at compile time
- All list methods work on ranges:
.filter,.map,.count, etc. - SPEC grammar updated:
RangeExpr <- AddExpr (".." AddExpr)?
See the Collections guide for full documentation.
v0.1.11 — 2026-05-04
Memory — safe cross-process storage (breaking path change)
Breaking: The persistent memory directory format changed. Move existing data manually (see below).
Persistent memory is now both path-safe and cross-process safe:
New path format: ~/.keel/memory/<stem>_<hash12>/<agent>.json
The <hash12> is derived from the SHA-256 of the canonical source file path, ensuring two programs with the same filename in different directories never share a storage bucket.
Cross-process writes are now safe. Each memory.* operation holds an advisory flock on a sidecar <agent>.lock file. Multiple concurrent keel run processes against the same agent serialize correctly.
Crash durability. Writes call fsync on the temp file before rename, and fsync on the parent directory after rename.
Path validation. Agent names containing ., /, \, or \0 are rejected with a hard error (previously debug_assert).
Migration: data at the old path ~/.keel/memory/<stem>/<agent>.json is not auto-migrated. Move it to the new location:
# find the new directory name for your program
keel run --print-memory-dir myprog.keel # not yet available; use the hash formula
# or simply let Keel recreate it from scratch
See the Agents guide for the updated path format and multi-process safety notes.
v0.1.10 — 2026-05-03
Memory namespace
memory.remember, memory.recall, and memory.forget are now real — they were no-op stubs since v0.1.0.
The @memory attribute selects the scope:
session(default) — in-process, cleared on restart.persistent— file-backed JSON at~/.keel/memory/<stem>_<hash12>/<agent>.json, survives restarts. (Path format updated in v0.1.11; the directory includes a SHA-256 hash of the source file path to avoid cross-program collisions.)none—memory.*calls raiseCapabilityError.
use std/io
use std/memory
agent Counter {
@tools [io]
@memory session
@on_start {
prev = memory.recall("count")
count = if prev == none { 1 } else { prev + 1 }
memory.remember("count", count)
io.show("Visit {count}")
stop(self)
}
}
See the Agents guide for full syntax and the persistent mode example.
v0.1.9 — 2026-05-03
keel init fixes & stop(self)
keel init with no argument now initializes in the current directory instead of creating a duplicate subdirectory.
Path arguments (keel init /tmp/mybot) now use the basename as the project name — previously the full path was injected into the agent name.
Runnable scaffold — the generated template no longer uses schedule.every. It prints and exits immediately via stop(self), so keel run main.keel works out of the box.
stop(self) — bare self now resolves to an AgentRef for the current agent anywhere inside an agent body.
use std/io
agent Worker {
@tools [io]
@on_start {
io.show("Hello from Worker!")
stop(self)
}
}
Tooling — Linter & Sharper Errors
New keel lint command and source-span diagnostics in keel check.
keel lint — style and best-practice checks
keel lint <file.keel>
keel lint --fix <file.keel>
Four rules ship in v0.1.9:
| Rule | Trigger |
|---|---|
| Unused variable | binding assigned but never read |
| Uncalled task | task declared but never invoked |
ai.* outside agent | LLM call without @role / @model context |
| State written, never read | self.x = appears but self.x is never used |
--fix auto-removes unused variable assignments. Prefix a name with _ to suppress the unused warning.
keel check — source spans in every diagnostic
Every error from keel check now includes a line:column pointer and an underlined source excerpt. Arity errors include the expected parameter names as a hint:
× Type error
╭─[agent.keel:8:5]
7 │ @on_start {
8 │ greet(42)
· ────┬────
· ╰── task `greet` takes 1 argument(s), got 0 — expected: name
9 │ }
╰────
v0.1.8 — 2026-04-30
Reactive Agents & Text Processing
HTTP webhook handling, in-memory caching, regex & string tools, LSP go-to-definition and rename.
Cache namespace
Process-scoped in-memory cache with optional TTL. Useful for deduplication, rate-limit tokens, and short-lived computed results across agents.
cache.set("key", value, ttl: 5.minutes)
v = cache.get("key") # value or none
cache.delete("key")
cache.clear()
String value methods — regex & formatting
Regex matching and string manipulation are available as methods directly on string values. The Str namespace was removed in a later release; see the migration note in the changelog.
text.matches("\\d+") # bool
text.extract("(\\d+)") # str? — first capture group
text.find_all("\\d+") # list[str] — all matches
text.sub("\\d+", "N") # str — regex replace all
"hello world".truncate(5) # "hello…"
"42".pad(6, char: "0") # "000042"
http.serve — webhook listener
React to inbound HTTP requests without polling:
http.serve(8080, (req) => {
io.show("Got {req["method"]} {req["path"]}")
{ status: 200, body: "OK" }
})
The handler receives {method, path, body} and returns {status, body}. The event loop keeps running as long as at least one server is active.
LSP go-to-definition & rename
- Go-to-definition — jump to
task,agent, andtypedeclarations from any usage - Rename — rename a user-defined symbol and all its usages in the open file (prelude names are blocked)
v0.1.7 — 2026-04-30
Structured Concurrency & Agent Constraints
File I/O, JSON processing, async task spawning, cron scheduling, and agent capability enforcement.
File namespace
Read, write, and list files on disk. The runtime creates intermediate directories automatically for writes.
file.write("data.txt", "Hello Keel")
content = file.read("data.txt")
if file.exists("data.txt") {
io.show(content)
}
entries = file.list("data/")
Methods: read(path), write(path, content), exists(path), list(dir).
Json namespace
Serialize and deserialize JSON. parse deserializes a JSON string into Keel values (maps, lists, scalars). stringify turns Keel values back into JSON.
data = json.parse("{\"name\": \"Alice\", \"age\": 30}")
name = data["name"]
user = { name: "Bob", age: 25 }
json_str = json.stringify(user)
schedule.cron
Schedule tasks using 5-field cron expressions. Supports standard cron syntax for minute, hour, day, month, and weekday.
schedule.cron("0 9 * * 1-5", () => {
io.show("Morning digest")
})
schedule.cron("*/5 * * * *", () => {
io.show("Every 5 minutes")
})
Async namespace — structured concurrency
Spawn independent Tokio tasks and await completion. spawn returns a task handle; join_all awaits a list of handles; select races handles to the first completion.
task1 = async.spawn(() => {
result = http.get("https://api1.example.com")
io.show(result)
})
task2 = async.spawn(() => {
result = http.get("https://api2.example.com")
io.show(result)
})
results = async.join_all([task1, task2])
io.show("All done")
@tools capability gating
Restrict which prelude namespaces are accessible inside an agent. Calls to unlisted namespaces raise CapabilityError at runtime.
use std/file
use std/http
use std/io
agent RestrictedAgent {
@tools [io, file]
@on_start {
io.show("Allowed")
file.write("x.txt", "Allowed")
# http.get would raise CapabilityError
}
}
If no @tools attribute is specified, all namespaces are allowed.
@limits agent attributes
Extract and enforce resource limits (timeout, max_tokens, max_cost) on a per-agent basis. The infrastructure for extracting limits is in place; timeout wraps calls via control.with_timeout.
use std/ai
agent LimitedAgent {
@tools [ai]
@limits { timeout: 30s, max_tokens: 1000, max_cost: 5.0 }
@on_start {
response = ai.prompt("...")
}
}
LSP completion
textDocument/completion now suggests prelude namespace names, method names, and keywords. Triggered on . or manual invocation.
New example programs
Five example programs demonstrate the new v0.1.7 features:
file_processing.keel— File read, write, exists, listjson_processing.keel— JSON parse and stringifycron_schedule.keel— Cron expression schedulingparallel_execution.keel— Async task spawning and joiningcapability_gating.keel— @tools capability restrictions
v0.1.6 — 2026-04-28
Wiring & ergonomics
Every primitive that was a stub in 0.1.5 now does what its name promises.
Nested string literals inside {interp}
The lexer used to terminate a string at the first " it saw, even one
hiding inside a {...} slot. The lexer now scans slot bodies with brace
depth tracking and recursively handles nested "...":
name = "world"
io.show("hi {"there {name}"}") # → "hi there world"
control.retry / with_timeout / with_deadline
The three control-flow combinators now have real implementations.
# Re-invoke until success or the budget is spent.
result = control.retry(5, () => ai.prompt(system: "...", user: "..."))
# Abort if the closure runs past the duration.
fast = control.with_timeout(2.seconds, () => slow_call())
# Abort once the absolute deadline has passed.
done = control.with_deadline("2026-12-31T23:59:00Z", () => long_task())
with_timeout and with_deadline raise TimeoutError / DeadlineError
on expiry. retry surfaces the last attempt’s error if every attempt fails.
broadcast(team, data)
Tag agents with @team [...] and dispatch a single event to every
member of a named team:
agent Alpha { @team ["frontline"] on alert(m: str) { ... } }
agent Beta { @team ["frontline"] on alert(m: str) { ... } }
agent Gamma { @team ["backoffice"] on alert(m: str) { ... } }
broadcast("frontline", "incident", event: "alert")
# Alpha and Beta fire; Gamma stays silent.
email.archive(message)
email.archive performs an IMAP UID MOVE (with COPY + \Deleted +
EXPUNGE fallback for servers without the MOVE extension). The
destination folder is Archive by default; override with the
IMAP_ARCHIVE_FOLDER env var. email.fetch now returns each message’s
UID under the uid key so archive can target the right one.
map[K, V] method inference
Map literals support the common operations on both the type checker and runtime side.
| Expression | Inferred type |
|---|---|
map.get(k) | V? |
map.keys() | list[K] |
map.values() | list[V] |
map.len() / map.count() / map.size() | int |
map.is_empty() | bool |
map.contains(k) / map.has(k) | bool |
The checker also accepts a {k: v, ...} struct literal in any position
that expects map[str, V], so the same surface syntax serves both
struct and map construction.
LSP hover
textDocument/hover returns the inferred type of the identifier under
the cursor — let-bindings, function parameters, agent state fields,
and prelude namespaces (Io, Ai, Control, …) all light up.
v0.1.5 — 2026-04-27
Type checker hardening
Four new checks land in the type checker; zero breaking changes to valid programs.
Nullable safety
T? is now enforced at assignment and return sites. Passing a nullable value where a non-nullable is expected is a compile-time error. Use ! to assert non-null (throws NullError at runtime if none) or ?? to provide a fallback.
use std/env
task t() {
x: str = env.get("KEY") # error: expected str, got str?
y: str = env.get("KEY")! # ok — throws if none
z: str = env.get("KEY") ?? "" # ok — falls back to ""
}
Return-type matching
return expr is now checked against the task’s declared -> T.
task greet() -> str {
return 42 # error: return value: expected str, got int
}
Struct field checks
Struct literals are now checked against named type declarations. Missing required fields are reported; extra fields are allowed.
type Person { name: str, age: int }
task t() {
p: Person = { name: "Alice" } # error: missing field `age`
q: Person = { name: "Bob", age: 30 } # ok
}
List and string method type inference
Method calls on list[T] and str now return typed results:
| Method | Return type |
|---|---|
list.push(x) / list.filter(fn) | list[T] |
list.len() | int |
list.first() / list.last() | T? |
str.upper() / str.trim() / str.replace() | str |
str.split(sep) | list[str] |
str.len() | int |
v0.1.4 — 2026-04-27
Parser hardening — every expression-level feature now works
if-as-expression
if can now appear on any right-hand side, not just as a standalone statement. else if chains are fully supported.
label = if score > 0.8 { "high" } else if score > 0.5 { "medium" } else { "low" }
let type annotations validated
Declaring a type on a binding now produces a compile-time error when the value type doesn’t match.
x: int = "hello" # error: expected int, got str
y: str = "hello" # ok
list + list and list.push(item)
Lists now support concatenation with + and a functional push that returns a new list (no mutation).
all = ["a", "b"] + ["c", "d"] # ["a", "b", "c", "d"]
more = all.push("e") # ["a", "b", "c", "d", "e"]
Full string interpolation
{…} slots now accept any expression: method calls, binary operations, chained calls.
summary = "Items: {cart.count()}, subtotal: {price * qty}"
@on_stop lifecycle hook
Agents can now declare a shutdown block that runs before the agent is removed from the runtime.
use std/io
agent Logger {
@tools [io]
@on_stop { io.show("Logger shutting down") }
}
delegate(target, task, args)
Posts a named task event to another agent’s mailbox; the receiving agent handles it via its on <task> handler.
delegate(Processor, "handle", payload)
Search, Db, Time stub namespaces
Calling any method on these namespaces now raises a clear "… is planned for v0.2" error instead of a generic crash.
v0.1.3 — 2026-04-26
Declarations reach the model
@rules injected into every LLM system prompt
agent Support {
@role "Customer support specialist"
@rules [
"Never reveal internal pricing logic",
"Escalate if the user expresses frustration 3+ times"
]
}
Rules are prepended as a bullet list in the system prompt, between the role preamble and the operation instructions. Enable KEEL_TRACE=1 to see the full prompt for every call.
ai.summarize — format: and max: wired
summary = ai.summarize(article, format: bullets, max: 5, unit: sentences)
ai.prompt — response_format: json wired
Appends “Respond with valid JSON only” to the system prompt and validates the reply.
score = ai.prompt(system: "Rate 1–10.", user: review, response_format: json)
ai.extract(x, as: T) — schema derived from struct type
type Invoice { vendor: str, amount: float, date: str }
result = ai.extract("Invoice from ACME $99.99 on 2026-01-10", as: Invoice)
v0.1.2 — 2026-04-19
Internal hardening
- Bumped to Rust edition 2024 (requires rustc ≥ 1.85).
- Runtime config decoupled from environment:
--trace/--log-level/log.set_levelnow go through typed setters, removing allunsafeenv mutation. - Release pipeline: Homebrew tap push now uses a short-lived GitHub App installation token instead of a long-lived PAT.
v0.1.1 — 2026-04-19
Release
- Dropped prebuilt macOS Intel binaries. Apple Silicon and Linux x86_64 are shipped; Intel Macs build from source (
cargo build --release).
v0.1.0 — First public alpha
Everything is new
First release of the language, runtime, standard library, and tooling.
Language highlights:
- Statically typed, inference-first — 28 reserved keywords
agentis the only concurrency primitive (actor model, serial mailbox, isolatedselfstate)- Prelude-as-stdlib —
Ai,Io,Email,Http,Schedule,Agent,Log, … in scope everywhere, nouseneeded - Algebraic types: simple enums, rich enums with per-variant fields, structural interfaces
- Exhaustive
whenpattern matching, duration literals (5.minutes), nullable syntax (T?,??,?.)
Tooling:
keel run— execute, with--traceand--log-levelkeel check— static analysis (scope, arity, enum exhaustiveness)keel fmt— idempotent AST pretty-printerkeel repl— interactive, history-awarekeel lsp— diagnostics over stdio (VS Code extension included)
Distribution:
- GitHub releases for
aarch64-apple-darwin+x86_64-unknown-linux-gnu curl https://keel-lang.dev/install.sh | shbrew install keel-lang/tap/keel
Installation
Three install paths below — pick whichever you prefer.
One-liner
curl -sSf https://keel-lang.dev/install.sh | sh
Fetches the latest GitHub release matching your OS + architecture, extracts it to ~/.keel/bin (or $KEEL_INSTALL_DIR), and adds that directory to your shell rc file if it isn’t already on $PATH.
Homebrew
brew install keel-lang/tap/keel
Works on both macOS (Apple Silicon and Intel) and Linux. The formula lives in the keel-lang/homebrew-tap repo and is auto-regenerated by the release workflow on every tag — versions and checksums stay in sync with the binaries. brew upgrade keel picks up new releases.
From source
Requires the Rust toolchain (rustup).
git clone https://github.com/keel-lang/keel.git
cd keel
cargo build --release
./target/release/keel --version
Optionally put the binary on your PATH:
cp target/release/keel /usr/local/bin/
Verify installation
keel --help
You should see:
Keel — AI agents as first-class citizens
Usage: keel <COMMAND>
Commands:
run Execute a Keel program
check Type-check a Keel program without executing
init Scaffold a new Keel project
repl Interactive REPL
fmt Format a Keel file
build Stub: bytecode compiler deferred post-v0.1
help Print this message or the help of the given subcommand(s)
keel build is listed because the CLI verb is reserved, but v0.1 intentionally returns a deferred-feature error. Use keel run or keel check for current workflows.
LLM setup
Keel’s ai.* functions call a local Ollama instance. It is currently the only supported backend.
# Install Ollama from https://ollama.com
ollama pull gemma4
# Tell Keel which model to use
export KEEL_OLLAMA_MODEL=gemma4
See LLM Providers and Ollama Setup for details.
Editor support
VS Code extension (syntax highlighting + LSP) — maintained in its own repository:
git clone https://github.com/keel-lang/vscode-keel
cd vscode-keel
code --install-extension keel-lang-*.vsix
Next steps
Hello World
Create a file called hello.keel:
use std/io
use std/schedule
agent Hello {
@tools [io]
@role "A friendly greeter"
@on_start {
schedule.every(5.seconds, () => {
io.notify("Hello from Keel!")
})
}
}
run(Hello)
Run it:
KEEL_OLLAMA_MODEL=gemma4 keel run hello.keel
Output:
⚡ LLM provider: Ollama (http://localhost:11434)
▸ Starting agent Hello
role: A friendly greeter
model: gemma4 (ollama @ http://localhost:11434)
⏱ schedule.every(5.seconds)
▸ Hello from Keel!
▸ Agent running. Press Ctrl+C to stop.
▸ Hello from Keel!
▸ Hello from Keel!
Press Ctrl+C to stop.
What just happened?
use std/io,use std/schedule— imports the stdlib modules this file uses. Each import binds a lowercase module name (io,schedule).agent Hello— declares an agent.@tools [io]— grants the agent access to the effectfuliomodule. Capabilities are deny-by-default inside agents.@role "..."— an attribute describing what the agent does. Bound to the LLM provider for anyai.*calls.@on_start { ... }— a lifecycle hook that runs when the agent starts.schedule.every(5.seconds, () => { ... })— schedules a recurring block.scheduleis a stdlib module, not a keyword.io.notify(...)— prints to the terminal.run(Hello)— starts the agent.runis a built-in free function — no import needed.
Two imports declare everything this program touches — see The Standard Library.
Using AI
use std/ai
use std/io
use std/schedule
type Mood = happy | neutral | sad
task analyze(text: str) -> Mood {
ai.classify(text, as: Mood) ?? Mood.neutral
}
agent MoodBot {
@tools [io]
@role "Analyzes the mood of text"
@on_start {
schedule.every(10.seconds, () => {
mood = analyze("I love building programming languages!")
io.notify("Mood: {mood}")
})
}
}
run(MoodBot)
ai.classify sends the text to the LLM and parses the response into one of the enum variants. ?? supplies a default when the LLM is unavailable or the response doesn’t match.
Next: Your First Agent →
Your First Agent
Let’s build a task prioritizer that classifies items and reports them by urgency.
1. Scaffold
keel init task-prioritizer
cd task-prioritizer
This creates main.keel.
2. Define types
type Priority = low | medium | high | critical
type Task {
title: str
description: str
}
Types are either enums (a set of variants, optionally with data) or structs (named fields). The type checker enforces exhaustive matching on enums.
3. A classification task
use std/ai
task prioritize(t: Task) -> Priority {
ai.classify(t.description,
as: Priority,
considering: {
"blocks other people": Priority.critical,
"has a deadline this week": Priority.high,
"nice to have": Priority.low
}
) ?? Priority.medium
}
ai.classify sends t.description to the LLM with the hints and parses the response into the enum. ?? Priority.medium supplies a default when the LLM is unavailable or returns nothing parseable.
4. Build the agent
use std/io
use std/schedule
agent Prioritizer {
@tools [io]
@role "You help prioritize a task list"
state {
processed: int = 0
}
task run_batch(tasks: list[Task]) {
for t in tasks {
priority = prioritize(t)
self.processed = self.processed + 1
when priority {
critical => io.notify("CRITICAL: {t.title}")
high => io.notify("HIGH: {t.title}")
medium => io.notify("MEDIUM: {t.title}")
low => io.notify("LOW: {t.title}")
}
}
io.notify("Processed {self.processed} tasks total")
}
@on_start {
schedule.every(1.hour, () => {
tasks = [
{title: "Fix login bug", description: "Users can't log in, blocks the team"},
{title: "Update README", description: "Nice to have, not urgent"},
{title: "Deploy v2.0", description: "Release deadline is Friday"}
]
self.run_batch(tasks)
})
}
}
run(Prioritizer)
5. Run it
KEEL_OLLAMA_MODEL=gemma4 keel run main.keel
6. Type-check
keel check main.keel
Forget a when variant and the compiler stops you:
× Type error
╭─[main.keel:18:7]
18 │ when priority {
· ─────┬─────
· ╰── Non-exhaustive match on Priority: missing high, medium, low
19 │ critical => io.notify("CRITICAL")
╰────
Key takeaways
| Concept | What it does |
|---|---|
type Priority = low | medium | high | critical | Enum — the type checker enforces exhaustive matching |
ai.classify(x, as: T) ?? V | LLM-powered classification into an enum; ?? supplies a default when the result is absent |
considering: [...] | Hints to the LLM per variant |
when value { ... } | Exhaustive pattern matching, checked at compile time |
state { field: T = default } | Mutable agent state, accessed via self.field |
@on_start { ... } | Runs once when the agent starts |
schedule.every(duration, () => { ... }) | Recurring execution, from the stdlib |
io.notify(...) | Terminal notification from the stdlib |
run(MyAgent) | Starts the agent |
Three imports — use std/ai, use std/io, use std/schedule — declare everything this program touches. A file’s imports are its capability surface. See The Standard Library.
Next: Language Guide →
Types
Keel is statically typed with full inference as the design target. In the current alpha, the checker catches core mismatches before your code runs. Where the type cannot be determined statically, the checker uses one of three internal fall-back markers — Unknown(InferenceLimitation), Unknown(ExternalDynamic), or Unknown(UnsupportedFeature) — which are distinct from the programmer-written dynamic annotation and are surfaced by keel check --strict; see the ROADMAP for current checker coverage.
Primitive types
| Type | Example | Notes |
|---|---|---|
int | 42 | 64-bit integer |
float | 3.14 | 64-bit float |
str | "hello" | UTF-8, supports interpolation |
bool | true, false | |
none | none | Absence of value |
duration | 5.minutes | Time duration |
count = 42 # inferred as int
name = "Keel" # inferred as str
ratio = 3.14 # inferred as float
active = true # inferred as bool
Enums
Enums define a closed set of variants. The compiler enforces exhaustive handling.
type Urgency = low | medium | high | critical
type Category = bug | feature | question | billing
Enum values are accessed by name:
u = high # Urgency.high
c = bug # Category.bug
label = Urgency.high # explicit qualified access
Generic types
Type declarations can be parameterised over one or more type variables. The type checker substitutes the concrete arguments at each use site.
Generic structs:
use std/io
type Paginated[T] {
items: list[T]
page: int
has_more: bool
}
task show_page(p: Paginated[str]) {
io.show("{p.items.len()} item(s) on page {p.page}")
}
Multi-parameter generics:
type Pair[A, B] {
first: A
second: B
}
task t(p: Pair[str, int]) {
a: str = p.first # type-checked as str
b: int = p.second # type-checked as int
}
Generic aliases:
type Bag[T] = list[T]
task t(tags: Bag[str]) {
n: int = tags.len()
}
Generic enums:
type Pair[A, B] =
| both { first: A, second: B }
| only_first { value: A }
| only_second { value: B }
Variant names are registered for exhaustiveness checking. When you destructure a variant binding, the field type is resolved using the substituted type arguments:
use std/io
task t(p: Pair[str, int]) {
when p {
both { first, second } => {
f: str = first # type-checked as str
s: int = second # type-checked as int
}
only_first { value } => { io.show(value) }
only_second { value } => { io.show("{value}") }
}
}
Each destructured name must be a field the variant declares. Naming a field the
variant does not have (a typo, say) is a compile-time error rather than a silent
none binding.
Structs
Each declared struct type has a unique identity. Two types with the same fields are distinct types and are not interchangeable:
type Point { x: int, y: int }
type Offset { x: int, y: int }
task move(p: Point) -> Point { p }
o: Offset = { x: 1, y: 2 }
move(o) # error — Offset is not assignable to Point
move({ x: 1, y: 2 }) # ok — anonymous literal is compatible with Point
An untyped struct literal { x: 1, y: 2 } is an anonymous shape. It is
structurally compatible with any named struct type that has the required fields.
Assign to a typed variable or pass to a typed parameter to tag it:
use std/email
type EmailInfo {
sender: str
subject: str
body: str
unread: bool
}
# Inline struct types in parameters
task triage(msg: {body: str, from: str}) -> Urgency {
ai.classify(msg.body, as: Urgency) ?? Urgency.low
}
The checker verifies every required field is present. Extra fields on anonymous literals are allowed:
type Person { name: str, age: int }
task t() {
p: Person = { name: "Alice" } # error: missing field `age`
q: Person = { name: "Bob", age: 30 } # ok
r: Person = { name: "Eve", age: 25, extra: true } # ok — extras allowed
}
Impl dispatch and typed collections
impl methods are dispatched by the value’s type tag. A struct literal only
acquires its tag at the first typed boundary it crosses. For a list of struct
values, declare the list type so each element is promoted:
type Score { val: int }
impl Comparable for Score {
task compare(self, other: Score) -> int { self.val - other.val }
}
task run() {
# Without the annotation, elements stay as untagged maps and .sort() falls
# back to primitive ordering instead of using Comparable.compare.
scores: list[Score] = [{ val: 30 }, { val: 10 }, { val: 20 }]
sorted = scores.sort() # [10, 20, 30] — uses Comparable.compare
}
Spread-update
To create a modified copy of a struct (or map) without repeating every field, use the { ...base, field: new } syntax:
type Order { id: str, status: str, amount: float }
o: Order = { id: "ord-1", status: "pending", amount: 9.99 }
filled = { ...o, status: "filled" } # id and amount copied unchanged
copy = { ...o } # full copy, no overrides
Rules:
- The
...basespread must appear first, exactly once. - Zero or more
field: valueoverrides follow, separated by commas or newlines. - Spreading a
nonevalue raises at runtime.
Struct base — override field names must exist in the base struct; unknown fields are a compile-time error (and a runtime error on dynamic paths). The result preserves the base’s type tag so impl dispatch continues to work.
Map base — any key may be added or overridden freely (like Python’s {**d, "k": v}); override values must match the map’s declared value type. The result is the same map[K, V] type.
m: map[str, int] = { "a": 1, "b": 2 }
m2 = { ...m, "c": 3 } # adds key "c"; result is still map[str, int]
Spread-update is especially useful when updating one field of a struct or building configuration variants:
type Config { host: str, port: int, debug: bool }
base: Config = { host: "localhost", port: 8080, debug: false }
dev = { ...base, debug: true }
prod = { ...dev, host: "api.example.com", debug: false }
Nullable types
Types are non-nullable by default. Append ? to allow none:
name: str # field cannot be none
alias: str? # field can be none
type Email { subject: str, from: str }
task t(email: Email?) {
# Null-safe access
subject = email?.subject # str? — none if email is none
# Null coalescing
subject = email?.subject ?? "(no subject)" # str — guaranteed non-none
}
The checker enforces the ? boundary at every assignment, return, and
call site. Passing a nullable where a non-nullable is expected is a
compile-time error — use ! to assert non-null (raises a plain Error
at runtime if the value is none) or ?? to coalesce to a default.
use std/env
task t() {
x: str = env.get("KEY") # error: expected str, got str?
y: str = env.get("KEY")! # ok — raises Error if missing
z: str = env.get("KEY") ?? "" # ok — falls back to ""
}
Call sites are also checked — a nullable argument where a non-nullable parameter is declared is a type error:
task process(text: str) { ... } # expects non-nullable str
val: str? = env.get("PROMPT")
process(val) # error: task `process` arg `text`: expected str, got str?
process(val!) # ok — null-assertion (raises if none)
process(val ?? "") # ok — null coalescing
AI operations return nullable types when they can fail:
result = ai.classify(text, as: Urgency) # Urgency? — might be none
safe = result ?? Urgency.medium # Urgency — guaranteed
# Or supply the default inline:
safe = ai.classify(text, as: Urgency) ?? Urgency.medium # Urgency
Collections
nums = [1, 2, 3] # list[int]
names = ["alice", "bob"] # list[str]
info = {name: "Zied", role: "builder"} # map[str, str]
Map key types. The key type K in map[K, V] must be a hashable primitive: str, int, or bool. Other types are compile-time errors:
scores: map[str, int] = {alice: 100, bob: 95} # valid
lookup: map[int, str] = {1: "one", 2: "two"} # valid
flags: map[bool, str] = {true: "on"} # valid
# bad: map[float, str] — float is not hashable (NaN)
# bad: map[str?, int] — nullable key type
# bad: map[Point, str] — struct keys require interface Hashable (v0.2)
Subscript access (list[i]): integer index, returns T. Out-of-bounds and negative indices are runtime errors — use len() to guard or try/catch when the index may be invalid:
items = [10, 20, 30]
v = items[1] # int — 20
# items[99] # runtime error: index 99 out of bounds
String subscript (str[i]) returns a single-character str by the same rules.
List properties:
| Property | Returns | Description |
|---|---|---|
.count | int | Number of elements |
.first | T? | First element or none |
.last | T? | Last element or none |
.is_empty | bool | True if count == 0 |
Function types
Function types describe callable values. Write the parameter types in parentheses followed by -> and the return type:
type Handler = (str) -> bool
type Reducer = (str, int) -> str
type Predicate[T] = (T) -> bool # generic function type
task t(pred: Predicate[str]) {
ok: bool = pred("hello")
}
() -> noneis not valid syntax — a function type’s return must be a named type. Omit the return annotation on tasks to indicate “returns nothing”.
Tuples and function types share the (...) syntax — if -> follows the closing paren it is a function type; otherwise it is a tuple.
Type conversions
port_str = "8080"
port = port_str.to_int() ?? 3000 # int — 8080 or default
ratio = 3.to_float() / 4.to_float() # float — 0.75
label = Urgency.high.to_str() # str — "high"
Conversions that can fail return nullable types (str.to_int() → int?). Conversions that always succeed return non-nullable (int.to_str() → str).
Numeric value methods
int and float values expose four built-in methods. The return type always matches the receiver — calling a method on an int returns an int, and calling it on a float returns a float.
| Method | Returns | Notes |
|---|---|---|
.abs() | same type | Absolute value |
.floor() | same type | Round toward −∞; no-op on int |
.ceil() | same type | Round toward +∞; no-op on int |
.round() | same type | Round to nearest; no-op on int |
price = -3.75
price.abs() # 3.75
price.abs().ceil() # 4.0 — methods chain naturally
count = -5
count.abs() # 5 — int stays int
3.7.floor() # 3.0
3.2.ceil() # 4.0
3.5.round() # 4.0
Duration literals
a = 5.seconds
b = 30.minutes
c = 2.hours
d = 1.day
e = 7.days
# Short forms also work
f = 30.sec
g = 1.min
h = 2.hr
Type coercions — as T
expr as T coerces the value at runtime. Unsupported conversions raise a runtime error.
| From | To | Result |
|---|---|---|
int | float | Widens: 5 as float → 5.0 |
float | int | Truncates toward zero: 1.9 as int → 1 |
int / float / bool | str | Display string: 42 as str → "42" |
str | int | Parses; raises if not a valid integer |
str | float | Parses; raises if not a valid float |
str | bool | "true" → true, "false" → false; raises otherwise |
Uuid | str | Hyphenated string: "f47ac10b-..." |
str | Uuid | Validates UUID format; raises if invalid |
dynamic | any | Pass-through — used with ai.prompt(...) as T and json.parse |
none | any | Raises |
1 as float # 1.0
1.7 as int # 1 (truncated, not rounded)
-1.7 as int # -1
42 as str # "42"
"3.14" as float # 3.14
"99" as int # 99
"abc" as int # raises: cannot cast "abc" to int
none as int # raises: cannot cast none to int
typeof(x)
The built-in function typeof(x) — always in scope, no import needed — returns the runtime type name as a str. For struct and enum values it returns the declared type name, not the generic "struct" or "enum" tag.
typeof(42) # "int"
typeof(3.14) # "float"
typeof("hello") # "str"
typeof(true) # "bool"
typeof(none) # "none"
typeof([1, 2, 3]) # "list"
type Point { x: int, y: int }
p: Point = { x: 1, y: 2 }
typeof(p) # "Point"
type Color = red | green | blue
c: Color = Color.red
typeof(c) # "Color"
Type-mismatch diagnostics
When a let binding has an explicit type annotation and the assigned value does
not match, keel check underlines the annotation — not the whole statement —
so you can see at a glance which declared type is wrong:
error: `n`: expected int, got str
--> example.keel:2:5
|
2 | n: int = "hello"
| ^^^ — expected int here
This precision is available for all scalar, struct, and nullable mismatches on
annotated let bindings.
Variables & Expressions
Variables
Variables are immutable by default. Once bound, they can’t be reassigned — but you can shadow them:
name = "Keel"
name = "Keel v2" # shadows the previous binding — original value is gone
Agent state fields are the exception — they’re mutable via self.:
agent Counter {
state { count: int = 0 }
task increment() {
self.count = self.count + 1 # mutable state
}
}
Type annotations
Type annotations are optional — the compiler infers types. But you can add them:
x = 42 # inferred as int
y: int = 42 # explicit annotation
z: float = 3.14 # explicit
Arithmetic
x = 2 + 3 * 4 # 14 (standard precedence)
y = 10 / 3 # 3 (integer division)
z = 10.0 / 3.0 # 3.333... (float division)
r = 17 % 5 # 2 (modulo)
Comparison
5 > 3 # true
"abc" == "abc" # true
x != none # true if x is not none
Boolean logic
true and false # false
true or false # true
not true # false
if x > 0 and x < 100 {
io.notify("in range")
}
Type rules for operators
The type checker validates operand types at keel check time.
| Operator | Valid operand types |
|---|---|
+ | int, float (mixed ok), str + str, list + list, datetime + duration, duration + datetime, duration + duration |
- | int, float (mixed ok), datetime - duration, datetime - datetime, duration - duration |
* / % | int, float (mixed ok) |
< > <= >= | int, float (mixed ok), str + str, datetime, duration |
== != | any |
and or | any |
+= -= *= /= %= | same rules as the base operator |
unknown / dynamic values skip the check (gradual typing escape hatch).
Type mismatches are caught early:
x = "hello" + 5 # error: cannot apply `+` to str and int
x = "hello" < 42 # error: cannot apply `<` to str and int
x = 0
x += "oops" # error: cannot apply `+` to int and str
Null coalescing
The ?? operator provides a default when the left side is none:
name = user_input ?? "anonymous"
port = env.get("PORT")?.to_int() ?? 3000
mood = ai.classify(text, as: Mood) ?? Mood.neutral
Pipeline operator
Chain operations with |>:
email |> triage |> respond |> log
# Equivalent to:
log(respond(triage(email)))
# With extra arguments:
email |> triage |> respond(tone: "friendly") |> log("email_responses")
Field access
msg.subject # field access
msg?.subject # null-safe — returns none if msg is none
msg!.subject # null assertion — throws if msg is none
env.get("API_KEY") # environment variable (use std/env)
self.count # agent state field
Struct and list literals
# Struct (map)
person = {name: "Alice", age: 30, active: true}
# List
items = [1, 2, 3, 4, 5]
# Nested
records = [
{name: "Alice", score: 95},
{name: "Bob", score: 87}
]
Destructuring
Unpack struct fields or tuple elements directly into named bindings.
Struct shorthand — field names become variable names:
{name, age} = person
io.show("{name} is {age}")
Struct rename — bind a field under a different local name:
{urgency: u, category: c} = result
Tuple positional — bind list elements by position:
(label, count) = ("alpha", 42)
In a for loop — destructure each element as it’s iterated:
for {from, subject} in emails {
io.show("{from}: {subject}")
}
In a task parameter — destructure a struct argument at the call boundary:
use std/io
task handle({body, from}: Email) {
io.show("From {from}: {body}")
}
The type checker enforces that struct fields exist and that tuple arity matches. Missing fields and mismatches are compile-time errors.
Tasks
Tasks are Keel’s functions. They’re named, reusable, and the last expression in the body is the return value.
Basic tasks
task greet(name: str) -> str {
"Hello, {name}!"
}
Call it:
msg = greet("World") # "Hello, World!"
Parameters
use std/ai
# Typed parameters
task add(a: int, b: int) -> int {
a + b
}
# Default values
task compose(body: str, tone: str = "friendly") -> str {
ai.draft("response to {body}", tone: tone) ?? "(draft failed)"
}
# Struct parameters (inline type)
task triage(msg: {body: str, from: str}) -> Urgency {
ai.classify(msg.body, as: Urgency) ?? Urgency.medium
}
Implicit return
The last expression in a task body is the return value:
task double(x: int) -> int {
x * 2 # this is the return value
}
Use return for early exits:
use std/ai
task handle(msg: {body: str, from: str}) -> str {
if msg.from.contains("noreply") {
return "Skipped automated email"
}
ai.draft("response to {msg.body}", tone: "professional") ?? "(draft failed)"
}
return expr is checked against the declared -> T. A mismatch is a
compile-time error; bare return (no value) is always accepted.
task greet() -> str {
return 42 # error: return value: expected str, got int
}
Task composition
Tasks can call other tasks:
task add(a: int, b: int) -> int { a + b }
task double(x: int) -> int { add(x, x) }
result = double(5) # 10
Top-level vs agent tasks
Tasks defined outside agents are reusable and testable. Tasks defined inside agents can access self:
use std/ai
use std/email
# Top-level: shared, testable
task triage(email: {body: str}) -> Urgency {
ai.classify(email.body, as: Urgency) ?? Urgency.medium
}
# Agent-scoped: can access self.state
agent Bot {
state { count: int = 0 }
task increment() {
self.count = self.count + 1
}
}
Agent-owned tasks are explicit methods of the current agent:
agent Bot {
state { count: int = 0 }
task increment() {
self.count = self.count + 1
}
task run() {
self.increment()
}
}
Inside an agent, bare increment() does not search agent-owned tasks; it
resolves lexical and top-level scope only. Use self.increment() for
agent-local work. Prefer top-level tasks for any logic that doesn’t need agent
state.
Variadic parameters
A task can collect any number of positional arguments into a list by prefixing the last parameter with ...:
task greet(...names: str) -> str {
result = ""
for n in names { result += n + " " }
result
}
greet("Alice", "Bob") # names = ["Alice", "Bob"]
greet() # names = [] (empty list)
Spread at call sites: prefix any list[T] or set[T] with ... to expand it into individual variadic slots:
more = ["Dave", "Eve"]
greet("Alice", ...more) # names = ["Alice", "Dave", "Eve"]
greet(...more, ...more) # merge two lists
Fixed params before the variadic are supported:
task labeled(prefix: str, ...items: str) -> str {
result = prefix + ":"
for item in items { result += " " + item }
result
}
labeled("tags", "rust", "keel", "lang") # "tags: rust keel lang"
Important: passing list[T] without ... is a type error — the variadic expects T, not list[T]:
task sum(...nums: int) -> int {
total = 0
for n in nums { total += n }
total
}
scores = [1, 2, 3]
sum(scores) # ERROR: variadic arg `nums`: expected int, got list[int]
sum(...scores) # OK: expands list into slots
Inside the body the variadic parameter binds as list[T], so all list methods work on it.
Generic tasks
Tasks can declare type parameters in brackets after their name. Type arguments are inferred at every call site — no explicit instantiation syntax is needed.
task identity[T](x: T) -> T { x }
task first[A, B](a: A, b: B) -> A { a }
task main() {
s: str = identity("hello") # T = str
n: int = identity(42) # T = int
f: str = first("hi", 99) # A = str, B = int
}
Generic tasks work equally well inside agents:
agent Bot {
task wrap[T](x: T) -> list[T] { [x] }
}
Pipeline composition
email |> triage |> respond |> log
# With arguments
email |> triage |> respond(tone: "formal")
The |> operator passes the left-hand value as the first argument to the right-hand function.
Agents & Attributes
An agent is the one concurrency primitive Keel provides — a serial-handler mailbox with isolated mutable state accessible only via self. Everything else a program does (AI calls, scheduling, I/O, HTTP) is a library function; the agent is the only truly language-level construct.
Minimal agent
agent Greeter {
@role "You greet people warmly"
}
run(Greeter)
Full anatomy
use std/ai
use std/db
use std/email
use std/io
use std/schedule
agent EmailBot {
# --- Attributes ---
@role "Professional email triage"
@tools [email, Calendar]
@memory persistent
@rules [
"Never reveal internal pricing",
"Always disclaim medical advice"
]
@limits {
max_cost_per_request: 0.50
max_tokens_per_request: 4096
timeout: 30.seconds
require_confirmation: [email.send, db.exec]
}
# --- Mutable state (only via self.) ---
state {
processed: int = 0
last_run: datetime? = none
}
# --- Agent tasks (can access self) ---
task greet(name: str) -> str {
ai.draft("greeting for {name}", tone: "warm") ?? "Hello!"
}
# --- Event handlers ---
on message(msg: Message) {
response = self.greet(msg.from)
email.send(response, to: msg)
self.processed = self.processed + 1
}
# --- Lifecycle hooks ---
@on_start {
schedule.cron("0 9 * * *", () => {
io.notify("Processed {self.processed} messages yesterday")
})
}
}
run(EmailBot)
Attributes
Attributes are identifier-prefixed metadata clauses. They declare agent identity, capabilities, and lifecycle behavior without needing dedicated keywords. Only two attributes are built into the core language:
| Attribute | Core? | Status | Purpose |
|---|---|---|---|
@role | Yes | ✅ | The agent’s identity string. Currently it’s prepended as "You are {role}.\n\n..." to every ai.* system prompt, so the LLM sees the agent’s directive on every call |
@model | Yes | ✅ | The model name string; overrides the global default for this agent |
Everything else — @tools, @memory, @rules, @limits, @on_start, @on_stop, and user-defined attributes — is stdlib-registered. Adding a new attribute requires a library, not a language change.
@on_start, @on_stop, @rules, @tools, @memory, and @team are fully wired; @team is used by broadcast routing. @provider is parsed but has no runtime effect yet — Ollama is the only backend.
@tools — capability list
Inside an agent body:
@tools [email, http] # allowlist
@tools all # explicit unrestricted form
Binds stdlib modules as the agent’s declared capabilities. The runtime uses this list to:
- Allow/deny which std module entry points the agent can call
- Report the agent’s capabilities to the LLM (for tool-use style prompting, planned)
Status: capability gating is enforced, deny-by-default, for effectful modules. A capability guards authority over the world outside the process —
ai,io,http,file,shell,db,search,env— and an agent with no@toolsattribute may call none of them. Pure-compute and internal modules (json,math,time,schedule, …) are never gated; they only need their import.@tools allis the explicit, greppable opt-out for trusted agents.
Enforcement is two-layered. Direct std calls in the agent body that @tools does not cover are compile-time errors that name the fix:
agent `NoTools` calls `io.show` but @tools does not allow it —
declare `@tools [io]` on the agent, or use `@tools all`
Calls reached through helper tasks (in this file or imported modules) are checked at runtime per turn and raise CapabilityError with the same guidance. @tools must therefore cover the transitive effectful needs of the helpers an agent calls — if your agent calls validation.load() and that helper reads files, the agent needs file in its list. Helpers that only compute (json, math, strings) never require declarations.
Gating applies to effectful std module entry points inside agent turns only. Pure-compute modules, value methods (conn.query(...)), the built-in agent verbs, local module tasks, top-level statements, and test blocks are never gated.
Conditional guards with if
Entries can include an if guard — a boolean expression evaluated at the start of each handler turn. Guards can access self.* state and call tasks that return bool:
use std/db
use std/email
agent SupportBot {
state {
confirmed: bool = false
admin: bool = false
}
@tools [
email.fetch, # always allowed
email.send if self.confirmed, # only after confirmation
db.query, # always allowed
db.exec if self.admin, # admin only
http, # whole namespace, always
]
}
Calling a blocked method raises a CapabilityError at runtime. The guard is re-evaluated on every handler turn, so capabilities can be dynamically gated based on agent state.
@memory — agent memory scope
Inside an agent body:
@memory persistent # | session | none
persistent— survives restarts; stored in~/.keel/memory/<stem>_<hash12>/<agent>.json(JSON key-value store). The<hash12>is a SHA-256 fingerprint of the canonical source file path, so two programs with the same filename in different directories never share data.session— lives for the life of the process; cleared on restart. This is the default when@memoryis omitted.none— disablesmemory.*for this agent; any call raisesCapabilityError.
use std/io
use std/memory
agent Counter {
@tools [io]
@memory session
@on_start {
prev = memory.recall("count")
count = if prev == none { 1 } else { prev + 1 }
memory.remember("count", count)
io.show("Visit {count}")
stop(self)
}
}
The Memory namespace provides three operations:
| Call | Description |
|---|---|
memory.remember(key, value) | Store any Keel value under key |
memory.recall(key) | Return the stored value, or none if absent |
memory.forget(key) | Delete the key |
Note: The persistent backend is a simple JSON file, not a vector store. Semantic search (
ai.embed+ nearest-neighbour recall) is planned for v0.2 via theVectorStoreinterface.
@rules — natural-language guardrails
Inside an agent body:
@rules [
"Never reveal internal pricing logic",
"Escalate if the user expresses frustration 3+ times"
]
Rules are injected into every LLM prompt this agent makes as a bullet list under a Rules: heading. They are LLM-interpreted — compliance is best-effort.
@on_start / @on_stop — lifecycle hooks
use std/schedule
agent Worker {
@on_start { schedule.every(5.minutes, () => { heartbeat() }) }
@on_stop { flush_queue() }
}
Both hooks are fully wired.
State
state declares mutable fields. Access is only via self.:
agent Counter {
state {
count: int = 0
}
on message(_: Message) {
self.count = self.count + 1
}
}
- Handlers for one agent run sequentially — no data races on
state. - Different agents run concurrently but share no state.
- Cross-agent messaging:
send(Target, data)(v0.1),delegate(Target, task, args)(v0.1.4),broadcast(team, data, event:)(v0.1.6). See Agent Communication.
Readonly fields
Add readonly between the colon and the type to make a field compiler-enforced read-only. Any self.field = ... assignment is a compile-time error; reading is always allowed.
use std/io
agent SessionBot {
@tools [io]
state {
turns: int = 0
session_id: readonly str = "default-session"
}
on message(msg: str) {
self.turns = self.turns + 1 # ok — writable
# self.session_id = "x" # compile error
io.show(self.session_id) # reading is fine
}
}
Use readonly fields for runtime-provided context (session IDs, request metadata) that the agent must observe but never modify.
Lifecycle
run(MyAgent) # start
run(MyAgent) # background mode uses the event loop
stop(MyAgent) # graceful shutdown
stop(self) # self-stop from inside the agent
run and stop are built-in agent verbs — always in scope, no import needed. Inside an agent body, bare self resolves to the current agent reference, so stop(self) is equivalent to stop(MyAgent) without hard-coding the name.
The full set of built-in agent verbs:
| Function | Notes |
|---|---|
run(name) | Start a named agent |
stop(name) | Gracefully stop a running agent |
send(name, data) | Post a message to an agent’s mailbox |
delegate(symbol, data) | Post a named handler event to an agent’s mailbox |
broadcast(team, data) | Fan out an event to all agents on a team |
run and stop are always available without an import.
Composition over monoliths
Top-level tasks are reusable and testable. Prefer small agents that call shared top-level tasks over large agents with inline logic:
use std/ai
use std/email
use std/io
# Top-level, testable
task triage(email: EmailInfo) -> Urgency {
ai.classify(email.body, as: Urgency) ?? Urgency.medium
}
# Agent stays focused
agent EmailAssistant {
@tools [io]
@role "Triage and respond"
on message(msg: Message) {
urgency = triage(msg)
io.show({urgency: urgency, subject: msg.subject})
}
}
Tasks defined inside an agent are scoped to that agent and can access self. Use them only when you genuinely need agent state access.
Invoke them as self.task(...); bare task(...) remains a lexical/top-level call.
MyAgent.task(...) is not the cross-agent composition model — use
send, delegate, or broadcast instead.
Agent Communication
Keel agents communicate by sending events to each other. The sender posts a named event and returns immediately. The receiver handles it when the runtime delivers it.
How It Works
sequenceDiagram
participant A as Agent A
participant B as Agent B
note over A: @on_start { ... }
A->>B: send(B, data, event: "greeting")
note over A: returns immediately — continues execution
note over B: on greeting(msg: str) { ... }
B->>B: handler runs with msg = data
Event Routing
The event: parameter in send determines which on handler runs on the receiver.
flowchart LR
s1["send(B, data, event: "greeting")"]
s2["send(B, data, event: "payment")"]
s3["send(B, data)"]
h1["on greeting(msg) { ... }"]
h2["on payment(msg) { ... }"]
h3["on message(msg) { ... }"]
drop["silently dropped"]
s1 -->|"event matches"| h1
s2 -->|"event matches"| h2
s3 -->|"default event: message"| h3
s1 -->|"no matching handler"| drop
The default event name is "message" — omitting event: routes to on message.
Asynchronous Delivery
Send and receive are decoupled. Agent A does not wait for Agent B’s handler to finish.
sequenceDiagram
participant A as Agent A
participant Q as Queue
participant B as Agent B
A->>Q: post event (non-blocking)
A->>A: continues own work
Q->>B: deliver event
B->>B: on greeting runs
delegate vs send
delegate, send, and broadcast are built-in agent verbs — always in
scope, no import needed. delegate posts a named handler event to
another agent’s mailbox.
Symbol form (preferred)
delegate(Processor.handle, payload)
# Processor's `on handle` fires with payload
Processor.handle is a compile-time–resolved handler reference. The type checker
validates that handle is a declared on handler on Processor and that payload
matches the handler’s parameter type. Misspelled handler names and wrong argument
types are caught at compile time, not at runtime.
String form (legacy)
delegate(Processor, "handle", payload)
# Processor's `on handle` fires with payload
The handler name is a string literal. The type checker validates it when the literal is plain (no interpolation). Both forms are accepted; prefer the symbol form for new code — handler renames update the symbol reference automatically.
send
send(target, data, event: "...") posts a data event with explicit routing:
send(Processor, payload, event: "process")
# Processor's `on process` fires with payload
Both are non-blocking. Choose delegate when you want compile-time handler
validation; use send when you need the event: label to be determined
dynamically at runtime.
Direct cross-agent calls such as Worker.process(...) are not part of the
agent model. Inside an agent, call agent-owned helpers as self.task(...).
Across agents, use mailbox APIs so delivery remains explicit and asynchronous.
send vs ai.*
These are two completely separate communication paths.
flowchart LR
code["Agent code"]
code -->|"send(B, data)"| b["Agent B\n→ on <event> handler"]
code -->|"ai.classify / ai.prompt / ..."| llm["LLM\n→ returns a value"]
send is agent-to-agent messaging — no LLM involved.
ai.* calls send a prompt to the LLM and return its response.
Example: Bi-directional Communication
use std/ai
use std/io
agent Manager {
@tools [io]
state { done: int = 0 }
@on_start {
send(Worker, {id: 1}, event: "process")
}
on result(summary: str) {
io.show("Result received: {summary}")
self.done = self.done + 1
stop(Manager)
}
}
agent Worker {
@tools [ai]
on process(work: dynamic) {
output = ai.summarize(work, in: 1, unit: sentences)
send(Manager, output, event: "result")
}
}
run(Manager)
run(Worker)
sequenceDiagram
participant M as Manager
participant W as Worker
participant LLM as LLM (Ollama)
M->>W: send(event: "process", data: {id: 1})
W->>LLM: ai.summarize(...)
LLM-->>W: summary text
W->>M: send(event: "result", data: summary)
note over M: on result — prints summary, stops
Broadcasting to a team
broadcast(team, data, event: "...") fans out a single event to
every live agent whose @team [...] attribute contains the target team
name. Agents on other teams stay silent.
use std/io
agent Alpha {
@tools [io]
@team ["frontline"]
on alert(msg: str) { io.show("Alpha got {msg}") }
}
agent Beta {
@tools [io]
@team ["frontline"]
on alert(msg: str) { io.show("Beta got {msg}") }
}
agent Gamma {
@tools [io]
@team ["backoffice"]
on alert(msg: str) { io.show("Gamma got {msg}") }
}
agent Coordinator {
@on_start {
run(Alpha); run(Beta); run(Gamma)
broadcast("frontline", "production-down", event: "alert")
# Alpha and Beta fire; Gamma does not.
}
}
@team accepts a list, so an agent can belong to multiple teams. The
broadcast is non-blocking — every recipient handles the event on its
own mailbox in its own time.
Backpressure: RuntimeBusy
The interpreter event queue is bounded (default 1024; tunable via KEEL_EVENT_QUEUE_CAPACITY).
If the queue is full when send, delegate, or broadcast is called,
the call raises a RuntimeBusy error instead of blocking.
Catch it to apply your own backpressure strategy:
try {
send(Worker, payload)
} catch e: RuntimeBusy {
io.show("queue full — payload dropped")
}
A full queue typically means the event loop is processing a slow handler (e.g. an LLM call) while producers are sending faster than the loop can drain. Strategies:
- Catch and drop (log the miss)
- Catch and retry after a delay (
schedule.after(100.ms, ...)) - Reduce the send rate on the producer side
KEEL_EVENT_QUEUE_CAPACITY=<n>overrides the 1024 default. Use a small value (e.g. 2) in integration tests to deliberately triggerRuntimeBusywithout flooding the queue.
Key Properties
| Property | Behaviour |
|---|---|
| Routing | event: string in send matches the name in on <event> |
| Default event | Omitting event: routes to on message |
| No match | Unhandled events are silently dropped |
| Send | Non-blocking — sender continues immediately |
| Queue full | RuntimeBusy error raised — catch to handle backpressure |
| Execution | Handlers run one at a time — no race conditions on self. |
| Scope | In-process only — no network, no serialization |
See Also
Control Flow
if / else
if/else is an expression — it produces a value.
# Statement form (no value needed)
if urgency == high {
escalate(email)
}
# With else
if urgency == high {
escalate(email)
} else {
auto_reply(email)
}
# Expression form — else is required
reply = if guidance != none {
ai.draft("response", guidance: guidance)
} else {
ai.draft("response", tone: "friendly")
} ?? "(draft failed)"
when (pattern matching)
when is an exhaustive pattern match. The compiler requires all cases to be handled.
when works as both a statement (branches execute side effects) and an expression (produces a value).
Statement form
when urgency {
low => archive(email)
medium => auto_reply(email)
high => flag_and_draft(email)
critical => escalate(email)
}
Missing a case is a compile error:
Non-exhaustive match on Urgency: missing critical
Expression form
Use when anywhere a value is expected — assignment, return, argument. All arms must produce the same type.
label = when urgency {
low => "low priority"
medium => "medium priority"
high => "high priority"
critical => "critical"
}
task describe(score: str) -> str {
when score {
"A" => "excellent"
"B" => "good"
_ => "needs work"
}
}
Wildcard and multiple patterns
Use _ as a wildcard:
when urgency {
critical => escalate(email)
_ => auto_reply(email) # covers low, medium, high
}
Multiple patterns per arm:
when urgency {
low, medium => auto_reply(email)
high, critical => escalate(email)
}
Guards with where (block body required when using a guard):
when status {
active where user.is_admin => { grant_access() }
active => request_approval()
_ => deny()
}
Struct patterns
Bind named fields from a struct value directly in when arms using { field1, field2 } syntax:
type Signal { price: float, volume: float, rsi: float }
task classify(s: Signal) -> str {
when s {
{ price, volume } where price > 1000.0 and volume > 0.0 => "active"
{ price } where price > 1000.0 => "thin"
_ => "quiet"
}
}
The bound fields are available in both the where guard and the arm body.
An unguarded struct arm matches any value of that struct type and satisfies exhaustiveness — no _ is needed:
when order {
{ quantity, price } => {
total = quantity * price
io.show("total: {total}")
}
}
A guarded struct arm is not total; add a _ fallback if no other arm covers the remaining cases.
The subject must be a struct, and every field you name must exist on it. Matching a struct pattern against an enum or other non-struct value, or naming a field the struct does not declare, is a compile-time error — a mistyped field never silently binds none. An unguarded arm is only total against a non-nullable struct; for a nullable subject like Signal?, the none case still needs its own arm (or a _).
Struct patterns work in both statement and expression when forms.
for loops
for email in emails {
handle(email)
}
# With inline filter
for email in emails if email.unread {
triage(email)
}
# Works with destructuring too
for { from, subject } in emails if subject != "" {
io.show("{from}: {subject}")
}
# Works with ranges
for x in 1..10 if x % 2 == 0 {
io.show(x)
}
while loops
Repeat a body until the condition becomes false:
n = 5
while n > 0 {
io.show("tick: {n}")
n -= 1
}
Use while true { ... break } for indefinite loops that exit via break:
total = 0
i = 1
while true {
total += i
i += 1
if total > 10 { break }
}
The condition must be bool. break and continue work the same as in for loops.
break and continue
break exits the nearest enclosing for or while loop immediately. continue skips the rest of the current iteration and advances to the next.
# break — stop as soon as the target is found
for item in items {
if item == target {
break
}
process(item)
}
# continue — skip even numbers in a while loop
x = 0
while x < 10 {
x += 1
if x % 2 == 0 { continue }
process_odd(x)
}
Both keywords affect only the innermost loop. There are no labeled jumps yet.
# break inside a nested loop only exits the inner one
for outer in 1..5 {
for inner in 1..10 {
if inner > 2 {
break # exits inner loop only
}
}
io.show("outer={outer}") # still runs 5 times
}
breakandcontinueare reserved keywords. Using them outside a loop is a runtime error.
Augmented assignment
+=, -=, *=, and /= mutate an existing variable in its nearest enclosing scope. They do not create a new binding — a plain = in the same position would shadow; these update.
total = 0
for i in 1..5 {
total += i # updates outer `total`, not a loop-scoped shadow
}
# total is 15
Works on self.field inside an agent handler:
agent Counter {
state { count: int = 0 }
@on_start {
self.count += 1
}
}
Compound forms: total -= cost, total *= factor, total /= divisor.
return
Explicit early return from a task:
task check(x: int) -> str {
if x > 100 {
return "too big"
}
if x < 0 {
return "negative"
}
"ok"
}
Retry, timeout, deadline
The Control namespace wraps a closure with resilience primitives. Each
takes a 0-arg lambda and returns whatever the lambda returned (or
raises an error if the budget is exhausted).
control.retry(n, fn)
Re-invoke fn up to n times until it returns without raising. The
last attempt’s error is surfaced if every attempt fails.
result = control.retry(5, () => {
return ai.prompt(system: "rate 1-10", user: review, response_format: json)
})
control.with_timeout(duration, fn)
Race the closure against a duration. Raises TimeoutError if the
closure runs past the deadline.
fast = control.with_timeout(2.seconds, () => {
return slow_external_call()
})
control.with_deadline(datetime, fn)
Same shape as with_timeout, but the limit is an absolute RFC 3339
timestamp instead of a duration. Raises DeadlineError on expiry.
done = control.with_deadline("2026-12-31T23:59:00Z", () => {
return long_task()
})
These three primitives compose: a retry of a with_timeout block bounds
each attempt’s runtime, and the loop’s overall budget is the retry count.
Collections & Lambdas
Range operator ..
start..end produces an inclusive list[int] containing every integer from start to end.
digits = 1..5 # [1, 2, 3, 4, 5]
single = 4..4 # [4]
empty = 5..3 # [] (start > end → empty)
Both operands must be int — using a float or str is a type error.
Ranges work directly with for:
for i in 0..2 {
io.notify("{i}") # 0, 1, 2
}
Because the result is a plain list[int], all list methods apply:
count = (1..10).count() # 10
evens = (1..10).filter(n => n % 2 == 0) # [2, 4, 6, 8, 10]
Lists
nums = [1, 2, 3, 4, 5]
names = ["alice", "bob", "charlie"]
empty = []
List concatenation and adding elements
Concatenate lists with + or add a single element with push:
base = ["a", "b"]
extra = ["c", "d"]
all = base + extra # ["a", "b", "c", "d"]
more = all.push("e") # ["a", "b", "c", "d", "e"]
push returns a new list — it does not mutate in place. Reassign if needed: items = items.push(x).
Collection methods
All methods accept lambdas (x => expr) or task references.
map — transform each element
doubled = [1, 2, 3].map(n => n * 2) # [2, 4, 6]
names = contacts.map(c => c.name) # extract names
filter — keep matching elements
big = [1, 5, 10, 20].filter(n => n > 5) # [10, 20]
urgent = emails.filter(e => e.urgency == high)
find — first matching element
found = items.find(n => n > 15) # first match or none
admin = users.find(u => u.role == "admin") ?? default_user
any / all — boolean checks
has_urgent = emails.any(e => e.urgency == critical) # true/false
all_done = tasks.all(t => t.status == "complete")
reduce — fold to a single value
The first argument is the combining function (accumulator, element) => ...; the second is the initial accumulator value.
total = [1, 2, 3, 4, 5].reduce((acc, x) => acc + x, 0) # 15
joined = words.reduce((acc, w) => acc + " " + w, "")
sum / min / max — numeric aggregation
prices = [9.99, 4.50, 14.00]
prices.sum() # 28.49
prices.min() # 4.50
prices.max() # 14.00
min() and max() return none on an empty list. sum() requires a numeric list; calling it on non-numeric elements is a runtime error.
join — concatenate to string
["a", "b", "c"].join(", ") # "a, b, c"
tags.join(" | ")
sort — natural ordering
Returns a new sorted list. Integers, floats, and strings are compared by value. Structs with impl Comparable are sorted by their compare method.
[3, 1, 4, 1, 5].sort() # [1, 1, 3, 4, 5]
["cherry", "apple", "banana"].sort() # ["apple", "banana", "cherry"]
sort(by:) — sort by key function
Pass an optional by: key function to .sort() — consistent with the min(by:) / max(by:) pattern. The key must return an int, float, or str. Ascending only; negate numeric keys for descending.
type Product { name: str, price: float, rating: float }
# Sort by a single field
by_price = products.sort(by: p => p.price) # cheapest first
by_name = products.sort(by: p => p.name) # alphabetical
# Descending — negate the key
by_rating = products.sort(by: p => 0.0 - p.rating) # highest rated first
reverse — flip order
[1, 2, 3].reverse() # [3, 2, 1]
top3 = scores.sort().reverse().take(3)
flatten — unwrap one level of nesting
[[1, 2], [3], [4, 5]].flatten() # [1, 2, 3, 4, 5]
zip — pair two lists
Returns a new list where each element is a 2-element tuple [a, b] drawn from the two input
lists in order. Stops when the shorter list is exhausted.
names = ["alice", "bob", "carol"]
scores = [90, 85, 95]
pairs = names.zip(scores)
# → [["alice", 90], ["bob", 85], ["carol", 95]]
for (name, score) in names.zip(scores) {
log.info("{name} scored {score}")
}
The return type is inferred as list[(T, U)], so the loop variables name and score are
fully typed.
take / skip — slice by count
[10, 20, 30, 40, 50].take(3) # [10, 20, 30]
[10, 20, 30, 40, 50].skip(3) # [40, 50]
Lambda syntax
# Single parameter
n => n * 2
# Multi-parameter
(a, b) => a + b
# Block body
items.map(item => {
urgency = triage(item)
{item: item, urgency: urgency}
})
Task references
Named tasks can be passed directly to collection methods:
task double(n: int) -> int { n * 2 }
task is_even(n: int) -> bool { n % 2 == 0 }
result = [1, 2, 3].map(double) # [2, 4, 6]
evens = [1, 2, 3, 4].filter(is_even) # [2, 4]
Map / struct access
info = {name: "Alice", age: 30}
info.name # "Alice"
info.age # 30
# Nested
team = {lead: {name: "Bob", role: "eng"}}
team.lead.name # "Bob"
Maps
A typed map[K, V] literal is built with the same {k: v, ...} form.
The same syntax serves both struct construction and map[str, V]
construction; the checker resolves which based on the declared type.
stock: map[str, int] = {apples: 12, pears: 5, plums: 0}
Map methods
| Method | Returns |
|---|---|
map.get(k) | V? — none if the key is absent |
map.keys() | list[K] |
map.values() | list[V] |
map.len() / map.count() / map.size() | int |
map.is_empty() | bool |
map.contains(k) / map.has(k) | bool |
stock: map[str, int] = {apples: 12, pears: 5}
stock.count() # 2
stock.contains("apples") # true
stock.keys() # ["apples", "pears"]
stock.values() # [12, 5]
# get returns V? — coalesce or assert before using as V.
pears = stock.get("pears") ?? 0 # 5
missing = stock.get("oranges") ?? 0 # 0
String methods
s = " Hello, World! "
s.trim() # "Hello, World!"
s.upper() # " HELLO, WORLD! "
s.lower() # " hello, world! "
s.contains("Hello") # true
s.starts_with(" H") # true
s.split(", ") # [" Hello", "World! "]
s.replace("World", "Keel") # " Hello, Keel! "
"42".to_int() # 42 (int?)
Testing
Keel test blocks make agent-facing code deterministic without replacing your production program.
use std/ai
use std/testing
type Severity = low | medium | critical
task classify(text: str) -> Severity {
ai.classify(text, as: Severity) ?? Severity.low
}
test "mocked classify returns critical" {
testing.mock(ai.classify).returns(Severity.critical)
assert classify("payment outage") == Severity.critical
}
Run tests with:
keel test triage.keel
Run one group by name with:
keel test triage.keel --filter classify
List tests without running them with:
keel test triage.keel --list
Test Blocks
Test blocks live at the top level beside type, task, and agent declarations:
test "name" {
assert true
}
keel test type-checks each tested file, registers declarations, and runs each test block. Top-level statements such as run(MyAgent) are skipped unless a test calls them explicitly. keel run ignores test blocks.
Pass a directory to recursively run .keel files with test blocks. Files without test blocks are skipped.
--filter <text> runs only tests whose names contain text. Matching is case-sensitive, and the command fails if no tests match.
--list prints matching test names without running them. It can be combined with --filter. If a file has no test blocks, keel test prints 0 tests found and exits successfully.
--fail-fast stops after the first failing test. --quiet suppresses passing test result lines while still printing failures and the final summary.
Each executed test line includes elapsed time after the test name. The final summary includes total suite time, and failures print the source location when available before returning a failing exit status.
Assertions
assert expr
The expression must be bool. If it evaluates to false, the test fails.
test "math" {
assert 2 + 2 == 4
}
Use a custom failure message with a second str expression:
test "math" {
assert 2 + 2 == 5, "expected arithmetic to balance"
}
Parameterized Tests
Use for name in cases after the test name to run one test case for each item in a list:
test "validate status" for case in [
{ score: 95, expected: Status.valid },
{ score: 150, expected: Status.needs_review }
] {
assert validate_score(case.score) == case.expected
}
The runner prints each case with an index, such as validate status [0]. The case binding is available in setup and in the test body.
Setup
Use setup to prepare values that the test body can assert against:
use std/ai
test "summary" {
setup {
expected: str = "short"
actual: str = ai.summarize("long article") ?? ""
}
assert actual == expected
}
Mocks
Mocks replace stdlib module methods inside one test:
use std/ai
use std/testing
test "summary fallback" {
testing.mock(ai.summarize).returns("short")
assert ai.summarize("long article") == "short"
}
For enum classification, return the enum variant directly:
use std/ai
use std/testing
test "classification" {
testing.mock(ai.classify).returns(Severity.critical)
assert classify("payment outage") == Severity.critical
}
Mocks are scoped to a single test. If two tests mock the same method differently, each test sees only its own value.
Repeat a mock target to return a sequence of values. Once the sequence is exhausted, the last value repeats:
use std/ai
use std/testing
test "summaries" {
testing.mock(ai.summarize).returns("first")
testing.mock(ai.summarize).returns("second")
assert ai.summarize("a") == "first"
assert ai.summarize("b") == "second"
assert ai.summarize("c") == "second"
assert ai.summarize.called
assert ai.summarize.call_count == 3
assert ai.summarize.called_with("a")
}
Mocked methods expose test-local metadata:
use std/ai
use std/testing
test "draft" {
testing.mock(ai.draft).returns("Thanks")
reply = ai.draft("response to Ada", tone: "friendly") ?? ""
assert reply == "Thanks"
assert ai.draft.called
assert ai.draft.call_count == 1
assert ai.draft.called_with("response to Ada", tone: "friendly")
}
called_with(...) returns true when any recorded mock call matches the supplied evaluated arguments. Positional arguments match from the start, and named arguments match by name.
@tools capability checks still apply. A mock changes the method result; it does not grant an agent access to a namespace that its @tools block disallows.
Contextual Syntax
test, setup, and assert are not reserved keywords. They are recognized only in their testing positions, so existing identifiers with those names remain valid elsewhere.
Error Handling
The two-tier failure model
Keel separates absence from failure:
| Situation | Mechanism | Handle with |
|---|---|---|
| LLM unavailable / mock / network failure | Returns T? (none) | ?? or when |
| LLM gave output that didn’t match schema | Throws AiSchemaError | try/catch |
| Namespace operation failed (I/O, network, parse) | Throws a typed error | try/catch |
| Fatal config error | Hard error | fix the config |
Programmer fault (none!, bad cast) | Throws Error | try/catch or fix the code |
?? — null coalescing (simple default)
Provide a default when the result is none:
urgency = ai.classify(email.body, as: Urgency) ?? Urgency.medium
summary = ai.summarize(article, in: 3, unit: sentences) ?? "No summary"
name = user_input ?? "anonymous"
port = env.get("PORT")?.to_int() ?? 3000
This is the right tool when you don’t care why the result is absent — just provide a sensible default.
try / catch — typed error handling
Use try/catch when you need to distinguish failure causes. Every stdlib namespace that can fail raises a named error type; Error is the catch-all:
use std/ai
use std/csv
use std/email
use std/file
use std/http
use std/io
use std/shell
try {
data = file.read("config.json")
} catch e: FileError {
data = "{}" # handle missing/unreadable file
} catch e: Error {
io.show("unexpected: {e.message}")
}
try {
rows = csv.parse_records(raw)
} catch e: CsvError {
io.show("bad CSV: {e.message}")
}
try {
resp = http.get("https://api.example.com/data")
} catch e: HttpError {
io.show("network error: {e.message}")
}
try {
result = shell.run("build.sh")
} catch e: ShellError {
io.show("shell error: {e.message}")
}
try {
urgency = ai.classify(email.body, as: Urgency)
} catch err: AiSchemaError {
io.notify("Unexpected LLM output: {err.got}")
urgency = Urgency.medium
} catch err: Error {
io.notify("Failed: {err.message}")
}
catch matches by type name — the first matching clause runs. Error is the catch-all. The bound name carries at least message: str.
Error type registry
All stdlib error types and the namespaces that raise them:
| Error type | Raised by | Notes |
|---|---|---|
FileError | file.* | I/O failures: not found, permission denied, etc. |
CsvError | csv.* | Parse failures, bad row structure, non-string cells |
DbError | db.* | Query/exec failures, connection errors |
CacheError | cache.* | Serialization errors |
MathError | math.* | Domain errors: sqrt(-1), log(0), asin(2) |
MemoryError | memory.* | Persistence errors, @memory none restriction |
EmailError | email.* | IMAP/SMTP failures |
HttpError | http.* | Network failures (connection refused, timeout) |
ShellError | shell.* | Failed to spawn shell or wait for process |
JsonError | json.* | JSON parse errors, serialization failures |
EnvError | env.require | Required env variable not set |
AiError | ai.* | LLM config errors |
AiSchemaError | ai.extract, ai.classify | LLM output didn’t match expected schema (got field contains raw output) |
CapabilityError | any @tools-restricted method | Method not allowed by the agent’s @tools list |
TimeoutError | control.with_timeout | Closure exceeded the given duration |
DeadlineError | control.with_deadline | Closure ran past the deadline |
UserRaised | raise statement | User-raised error |
RuntimeBusy | any async event | Interpreter event queue full |
Catch any of these specifically, or use catch e: Error as a fallback for all.
AiSchemaError extra field:
| Field | Type | Value |
|---|---|---|
message | str | Human-readable description |
got | str | The raw LLM output that didn’t match |
Diagnostic codes
When an uncaught error reaches the CLI, the stable diagnostic code appears:
Error: keel::runtime::FileError
× FileError: file.read `missing.txt`: No such file or directory
Codes follow the pattern keel::runtime::<TypeName>. Tooling and host integrations can inspect the code directly without parsing the message string.
raise — throw an error
Throw an error from any point in a task or agent handler:
task divide(a: int, b: int) -> int {
if b == 0 {
raise "division by zero"
}
return a / b
}
raise produces a UserRaised error; caught by catch err: UserRaised or the generic catch err: Error:
try {
result = divide(10, 0)
} catch err: UserRaised {
io.notify("Raised: {err.message}")
} catch err: Error {
io.notify("Other failure: {err.message}")
}
Non-string values are converted using their display representation.
control.retry
Retry a failing operation:
# Retry up to 3 times; last error surfaces if all fail
control.retry(3, () => { email.send(reply, to: addr) })
# Combine with try/catch to handle specific errors after all retries fail
try {
control.retry(3, () => { http.get(url) })
} catch e: HttpError {
io.show("still failing after 3 tries: {e.message}")
}
control.with_timeout / control.with_deadline
try {
result = control.with_timeout(5.seconds, () => {
http.get("https://slow.example.com/api")
})
} catch e: TimeoutError {
io.show("timed out: {e.message}")
}
try {
result = control.with_deadline("2025-12-31T23:59:59Z", () => {
process_batch()
})
} catch e: DeadlineError {
io.show("deadline passed: {e.message}")
}
Null-safe access — ?.
Returns none instead of crashing if the left side is none:
subject = email?.subject # str? — none if email is none
length = email?.body?.length # chained
Null assertion — !
Asserts a value is not none; throws a plain Error at runtime if it is:
subject = email!.subject
Use sparingly — prefer ?? or when for safe handling.
Interfaces
An interface declares a set of required methods. Any struct type can satisfy an interface by providing an impl block. The compiler validates that every required method is present, that arities match, and that return types match.
Declaring an interface
interface Printable {
task print(self) -> str
}
interface Converter {
task to_json(self) -> str
task to_csv(self) -> str
}
Each entry is a task signature. The self parameter is written without a type annotation — the type is inferred from the for TypeName clause of each impl block.
Implementing an interface
Use impl InterfaceName for TypeName { ... } to provide an implementation:
use std/io
type Point {
x: float
y: float
}
impl Printable for Point {
task print(self) -> str {
"({self.x}, {self.y})"
}
}
p: Point = { x: 1.5, y: 2.0 }
io.show(p.print()) # → "(1.5, 2.0)"
implandforare reserved keywords.selfinside the block receives the struct value. Useself.fieldto access fields.- Each method listed in the interface must be provided — missing methods are a compile-time error.
- Extra methods not listed in the interface are a compile-time error.
- Return types must match exactly.
- Interfaces and their
implblocks can appear in any order in the same file.
Multiple types, one interface
Each type gets its own impl block:
interface Printable {
task print(self) -> str
}
type Circle { radius: float }
type Rect { width: float, height: float }
impl Printable for Circle {
task print(self) -> str { "Circle(r={self.radius})" }
}
impl Printable for Rect {
task print(self) -> str { "Rect({self.width}×{self.height})" }
}
Conformance errors
interface Scorer {
task score(self) -> int
}
type Game { pts: int }
impl Scorer for Game {
task score(self) -> str { "oops" } # error: must return int, not str
task extra(self) -> int { 0 } # error: not part of interface Scorer
# missing: score is required
}
Each of these is a separate compile-time error. The runtime will not run a program with a non-conforming impl.
Method priority
Impl methods take priority over built-in map methods. If a struct type has an interface method named size, it shadows the built-in map.size() length accessor for that type:
use std/io
interface Sizable {
task size(self) -> int
}
type Grid { rows: int, cols: int }
impl Sizable for Grid {
task size(self) -> int { self.rows * self.cols }
}
g: Grid = { rows: 3, cols: 4 }
io.show("{g.size()}") # → "12" (not the map key count)
Built-in interfaces
Five interfaces are built into the language. You cannot redeclare them with interface, but you can provide impl blocks for them.
Stringable
Enables "{expr}" string interpolation for user-defined types. See the String Interpolation guide for full details.
use std/io
impl Stringable for Point {
task to_str(self) -> str { "({self.x}, {self.y})" }
}
p: Point = { x: 3.0, y: 4.0 }
io.show("Point: {p}") # → "Point: (3.0, 4.0)"
Serializable
Overrides json.stringify for user-defined types. Implement to_json to produce a custom JSON representation.
use std/io
use std/json
type Event { name: str, score: int }
impl Serializable for Event {
task to_json(self) -> str {
"name={self.name};score={self.score}"
}
}
e: Event = { name: "goal", score: 3 }
io.show(json.stringify(e)) # → calls to_json instead of default serializer
Comparable
Enables sorting and comparison for user-defined types. compare(self, other) must return:
- a negative integer when
self < other - zero when
self == other - a positive integer when
self > other
Wired into list.sort(), list.min(), list.max(), and the global min() / max().
use std/io
type Score { val: int }
impl Comparable for Score {
task compare(self, other: Score) -> int {
self.val - other.val
}
}
# Declare the list type so each element is tagged as Score.
# Without the annotation, elements stay as anonymous maps and
# Comparable.compare is never found.
items: list[Score] = [{ val: 30 }, { val: 10 }, { val: 20 }]
io.show("{items.sort()}") # sorted ascending by val
lo = items.min()
hi = items.max()
Equatable
Adds a typed equality method. Note: == remains structural (field-by-field) comparison — equals is a separate, potentially richer check.
use std/io
type Point { x: int, y: int }
impl Equatable for Point {
task equals(self, other: Point) -> bool {
self.x == other.x and self.y == other.y
}
}
a: Point = { x: 1, y: 2 }
b: Point = { x: 1, y: 2 }
io.show("{a.equals(b)}") # → true
Iterable
Lets a struct be used in a for loop. items(self) must return a list of the elements to iterate. The return type may be list[T] for any concrete T — list[dynamic] is accepted but so is list[int], list[str], etc.
use std/io
type Range { lo: int, hi: int }
impl Iterable for Range {
task items(self) -> list[int] {
result: list[int] = []
i = self.lo
while i <= self.hi {
result += [i]
i += 1
}
result
}
}
r: Range = { lo: 1, hi: 4 }
for n in r {
io.show("{n}") # → 1, 2, 3, 4
}
Note:
Iterableis not a generator protocol —items()materialises the entire collection into a list before iteration begins.
What’s not supported yet
- Interface as a type annotation — you cannot write
task format(x: Printable) -> str. Values are structurally typed; interface names cannot appear in parameter or return type positions. This is deferred post-v0.1. - Generic interfaces —
interface Container[T] { task item(self) -> T }is not yet parsed. Deferred post-v0.1.
Modules & Imports
Keel has one source file type: .keel. Every file is both runnable (an
entrypoint when you keel run or keel test it directly) and importable
(a module when another file uses it). Stdlib modules and your own files are
the same concept, imported the same way:
use std/io
use std/file
use "./validation.keel"
task load_config() -> str {
file.read("config.json")
}
ok = validation.email("ada@example.com")
io.show("valid: {ok}")
Import forms
| Form | Binds | Example access |
|---|---|---|
use std/file | file | file.read(...) |
use std/file as f | f | f.read(...) |
use "./validation.keel" | validation (file stem) | validation.email(...) |
use "./validation.keel" as v | v | v.email(...) |
use email, helper as h from "./validation.keel" | email, h | email(...), h(...) |
use parse from std/json | parse | parse(...) |
The namespace is predictable: a std import binds the module’s name, a file
import binds the file stem, and as overrides either. Imports never dump
names into scope by default — use X from ... is the explicit opt-in for
unqualified names.
Relative paths resolve from the importing file’s directory and must end in
.keel. std/* modules ship inside the keel binary and never touch the
filesystem — a local ./std/ directory cannot shadow them.
What a module exports
Every top-level declaration — task, type, agent, interface,
extern — is exported under the module’s namespace. There is no pub
keyword yet; if it’s declared at the top level, it’s importable.
- Tasks are called qualified (
validation.email(...)) or imported by name (use email from "./validation.keel"). - Agents work with the built-in agent verbs:
run(watchers.Watcher),send(watchers.Watcher, msg). Symbol imports work too:use Watcher from "./watchers.keel"thenrun(Watcher). - Types and enums are imported by name —
use Urgency from "./models.keel"— and then used exactly like a local type, includingwhenexhaustiveness. Types keep their declared identity, so they cannot be renamed withas. - Interfaces and impls travel with the module: importing a module
activates its
implblocks program-wide.
Modules gate entry points; values carry their methods everywhere. A task
that receives a DbConnection can call .query() without importing
std/db — only db.connect(...) needs the import. The same goes for
dt.format(...), id.to_str(), and every other value method.
Top-level statements: the implicit main
Top-level statements are the file’s entrypoint. They run only when the file is executed directly — never when it is imported:
# validation.keel
use std/io
task email(s: str) -> bool {
s.contains("@") and s.contains(".")
}
# Implicit main: runs on `keel run validation.keel`, ignored on import.
sample = "ada@example.com"
io.show("email({sample}) = {email(sample)}")
This means a module can carry its own demo or smoke check with zero boilerplate, and importing a file can never surprise you with side effects.
Tests and modules
keel test file.keel runs only that file’s test blocks. Imported
modules contribute their declarations — including test helpers, which are
ordinary tasks — but never their tests. keel test <dir> runs each file’s
own tests. See Testing.
A dedicated test file is just a module that imports what it tests:
# validation_test.keel
use "./validation.keel"
use subject_priority from "./validation.keel"
test "qualified module call" {
assert validation.email("ada@example.com")
}
test "urgent subjects rank higher" {
assert subject_priority("urgent: prod is down") == 2
}
The full version lives in
examples/inbox_modules/.
One global namespace
The runtime registers every module’s declarations in one flat global table, so a name must mean the same thing across the whole program:
- Two modules may not declare the same top-level name.
- Two files may not bind the same import name to different targets.
- A declaration may not reuse a name bound by an import in the same file.
Each of these is a compile error that names both files and suggests a rename
or an as alias. Module-private scoping is planned.
Errors you might see
`File` is not ambient — add `use std/file` and write `file.<method>(...)`
The PascalCase prelude (File.read, ai.classify written as Ai.classify,
…) was removed when the module system landed. Add the use std/<name>
import and lowercase the call.
circular import: a.keel → b.keel → a.keel
Imports may not form cycles. Move the shared declarations into a third file both can import.
unknown std module `std/nope`
The error lists every available std module. See The Standard Library for the full table.
What stays built in
Agent lifecycle and messaging are part of the language, not the library:
run, stop, send, delegate, and broadcast are always in scope,
along with min, max, typeof, the built-in types (str, int,
datetime, duration, Uuid, …), and the built-in interfaces
(Stringable, Comparable, …).
community/... package paths are reserved for a future release.
The Standard Library
Keel’s standard library is a set of modules imported with use std/<name>:
use std/ai
use std/io
use std/file
text = file.read("inbox.txt")
mood = ai.classify(text, as: Sentiment)
io.show("mood: {mood}")
Stdlib modules and your own files are the same concept — see
Modules & Imports. Nothing from the stdlib is ambient; if a
file calls file.read(...), it says use std/file at the top. The only
always-in-scope names are the agent verbs (run, stop, send,
delegate, broadcast), generic utilities (min, max, typeof), the
built-in types, and the built-in interfaces.
Migrating from the ambient prelude? Earlier alphas auto-imported PascalCase namespaces (
File.read,Ai.classify). Add the matchinguse std/<name>import and lowercase the call:file.read,ai.classify. The compiler error tells you exactly which import to add.
Why Modules
- Small core. The compiler doesn’t know about
classify,fetch, orevery. Those are library function calls. Parser, lexer, and type checker stay free of domain-specific special cases. - Explicit dependencies. A file’s imports are its capability surface — auditable at a glance, and
@tools [...]gates them per agent. - Keyword feel.
ai.classify(...)is still ceremony-free; the import is one line and autocomplete does the rest. - Interface boundary. std modules are designed around interfaces so custom LLM providers, memory stores, schedulers, or HTTP clients can be installed in a later runtime.
The Modules
Status legend: ✅ shipping · 🟡 partial · ⏳ Coming soon
| Module | Status | Purpose |
|---|---|---|
std/ai | 🟡 | LLM operations: classify, extract, summarize, draft, translate, decide, prompt · embed ⏳ |
std/io | ✅ | Human interaction: ask, confirm, notify, show |
std/schedule | ✅ | Time-based scheduling: every, after, at, cron, sleep |
std/email | 🟡 | IMAP/SMTP: fetch, send, archive |
std/http | ✅ | HTTP client + server: get, post, request, serve |
std/env | ✅ | Environment: get(name), require(name) |
std/log | ✅ | Structured logging: info, warn, error, debug, plus set_level, level. Threshold default is info; raise via --log-level debug, KEEL_LOG_LEVEL=debug, or log.set_level("debug") at runtime. |
std/memory | ✅ | Per-agent key-value store: remember, recall, forget. Scope set by @memory session|persistent|none (default: session). Persistent mode writes ~/.keel/memory/<stem>_<hash12>/<agent>.json. |
std/control | ✅ | retry, with_timeout, with_deadline |
std/async | ✅ | Structured concurrency: spawn, join_all, select, sleep |
std/cache | 🟡 | In-memory process-scoped cache: set, get, delete, clear |
String value methods | ✅ | Regex and formatting directly on string values: matches, extract, find_all, sub, truncate, pad |
std/file | ✅ | Filesystem: read, write, exists, list, mkdir, remove, copy, move, glob, mktemp |
std/json | 🟡 | JSON: parse, stringify |
std/search | 🟡 | Web search providers: web(query) — registered; raises “planned for v0.2” error |
std/db | ✅ | SQLite: connect(url) → DbConnection; db.query(sql, params?) → list[map[str,dynamic]]; db.exec(sql, params?) → int |
std/time | 🟡 | Factories: now(), now(tz: name), parse(str), parse(str, tz: name), epoch_ms() → int. Methods on value: dt.parts() → map, dt.format(as: pattern) → str?. Duration literals: 500.ms … 1.week. Operators: dt ± dur → dt, dt - dt → duration, </> comparison. Naive strings rejected — use RFC 3339 or tz:. |
std/random | ✅ | Pseudo-random generation: float(), int(min:, max:), bool(). Use Crypto for security-sensitive randomness. |
std/uuid | ✅ | UUID values: v4, v7, deterministic v5, parse, uuid() alias, and value methods version, format, to_str. |
std/crypto | ✅ | Cryptographic primitives: fixed safe SHA-2 hash/HMAC methods, token, random_bytes. |
std/math | ✅ | Transcendentals: sqrt, pow, exp, log (natural), log2, log10, sin, cos, tan, asin, acos, atan, atan2. Constants: PI(), E(). All return float. Domain errors raise. |
std/shell | ✅ | Subprocess bridge: execute shell commands via /bin/sh -c and capture stdout/stderr. Requires @tools [shell]. |
std/csv | ✅ | CSV serialization: parse, parse_records, stringify — RFC 4180 compliant |
std/testing | ✅ | Test doubles: mock(...) — see Testing. |
Agent lifecycle and messaging — run, stop, send, delegate,
broadcast — are built into the language, not a module. Agents are
Keel’s core abstraction; their verbs are always in scope.
Modules gate entry points; values carry their methods. Calling
conn.query(...) on a DbConnection, dt.format(...) on a datetime, or
id.to_str() on a Uuid needs no import — only the entry point
(db.connect, time.now, uuid.v4) requires its use std/<name> line.
Free Functions
A small set of functions live directly in the global scope — no namespace prefix needed.
| Function | Signature | Returns | Notes |
|---|---|---|---|
run(agent) | (agent) -> none | none | Start an agent |
stop(agent) | (agent) -> none | none | Stop an agent |
send(agent, msg) | (agent, dynamic) -> none | none | Post to an agent’s mailbox |
delegate(Agent.handler, data) | (handler, dynamic) -> none | none | Post a named handler event |
broadcast(team, msg) | (str, dynamic) -> none | none | Fan out to a @team |
typeof(value) | (dynamic) -> str | str | Runtime type name |
min(...) | (...items: T, by: (T -> any)? = none) -> T? | T? | Minimum; none on empty |
max(...) | (...items: T, by: (T -> any)? = none) -> T? | T? | Maximum; none on empty |
min and max accept any number of positional arguments, an optional by: key-selector, and return none when called with no items.
min(3, 1, 4) # 1
max("banana", "apple", "cherry") # cherry
scores = [4, 9, 2, 7]
min(scores) # 2 — single list auto-spread
max(...scores, 99) # 99 — explicit spread
people = [{name: "Alice", age: 30}, {name: "Bob", age: 25}]
min(people, by: p => p.age) # {name: "Bob", age: 25}
max(people, by: p => p.age) # {name: "Alice", age: 30}
Random
Random produces non-cryptographic pseudo-random values for simulation, sampling, games, and tests where security is not involved. Use Crypto for tokens, secrets, signatures, or key material.
| Method | Signature | Description |
|---|---|---|
random.float | random.float() → float | Return a random float in the range [0, 1). |
random.int | random.int(min: int, max: int) → int | Return a random integer in the inclusive range [min, max]. |
random.bool | random.bool() → bool | Return a random boolean. |
roll = random.int(min: 1, max: 6)
sample = random.float()
enabled = random.bool()
Uuid
Uuid is a distinct value type, not a str. It displays and interpolates as a lowercase hyphenated UUID, and it can be converted explicitly with .to_str().
| Method | Signature | Description |
|---|---|---|
uuid.v4 | uuid.v4() → Uuid | Generate a random UUID v4. |
uuid.v7 | uuid.v7() → Uuid | Generate a time-sortable UUID v7. |
uuid.v5 | uuid.v5(ns: Uuid, name: str) → Uuid | Generate a deterministic UUID v5 from a namespace UUID and a name. |
uuid.parse | uuid.parse(s: str) → Uuid? | Parse a UUID string, returning none on failure. |
Namespace constants uuid.DNS, uuid.URL, uuid.OID, and uuid.X500 are available for uuid.v5.
| Method | Returns | Notes |
|---|---|---|
.version() | int | UUID version number |
.format(as:) | str | "hyphenated", "simple", or "urn" |
.to_str() | str | Lowercase hyphenated string |
id: Uuid = uuid.v4()
trace = uuid.v7()
site = uuid.v5(ns: uuid.DNS, name: "keel-lang.dev")
parsed = uuid.parse("f47ac10b-58cc-4372-a567-0e02b2c3d479")
simple = id.format(as: "simple")
Crypto
Crypto provides security-grade primitives backed by the operating system CSPRNG. It is distinct from Random; use Crypto for tokens, secrets, signatures, digests, and other security-sensitive work.
| Method | Signature | Description |
|---|---|---|
crypto.sha224 | crypto.sha224(data: str) → str | Return the SHA-224 hex digest of a string. |
crypto.sha256 | crypto.sha256(data: str) → str | Return the SHA-256 hex digest of a string. |
crypto.sha384 | crypto.sha384(data: str) → str | Return the SHA-384 hex digest of a string. |
crypto.sha512 | crypto.sha512(data: str) → str | Return the SHA-512 hex digest of a string. |
crypto.sha512_224 | crypto.sha512_224(data: str) → str | Return the SHA-512/224 hex digest of a string. |
crypto.sha512_256 | crypto.sha512_256(data: str) → str | Return the SHA-512/256 hex digest of a string. |
crypto.hmac_sha224 | crypto.hmac_sha224(key: str, data: str) → str | Return the HMAC-SHA-224 hex digest. |
crypto.hmac_sha256 | crypto.hmac_sha256(key: str, data: str) → str | Return the HMAC-SHA-256 hex digest. |
crypto.hmac_sha384 | crypto.hmac_sha384(key: str, data: str) → str | Return the HMAC-SHA-384 hex digest. |
crypto.hmac_sha512 | crypto.hmac_sha512(key: str, data: str) → str | Return the HMAC-SHA-512 hex digest. |
crypto.hmac_sha512_224 | crypto.hmac_sha512_224(key: str, data: str) → str | Return the HMAC-SHA-512/224 hex digest. |
crypto.hmac_sha512_256 | crypto.hmac_sha512_256(key: str, data: str) → str | Return the HMAC-SHA-512/256 hex digest. |
crypto.token | crypto.token(len: int) → str | Generate a random URL-safe token of the given byte length. |
crypto.random_bytes | crypto.random_bytes(len: int) → list[int] | Generate cryptographically random bytes as a list of integers. |
digest = crypto.sha256("hello")
wide = crypto.sha384("hello")
sig = crypto.hmac_sha256("message", key: secret)
token = crypto.token(bytes: 32)
bytes = crypto.random_bytes(16)
Crypto intentionally exposes fixed safe SHA-2 methods only. Legacy hashes such as MD5 and SHA-1, and string-selected hash algorithms, are not exposed.
Db
Db provides SQLite-backed durable storage. db.connect returns a connection value; .query and .exec are called directly on that value.
| Method | Signature | Description |
|---|---|---|
db.connect | db.connect(url: str) → DbConnection | Open a database connection and return a DbConnection. |
db.query(sql) and db.exec(sql) are called on a DbConnection value returned by db.connect. Both accept an optional second argument params: list for ? placeholder binding.
Connection URLs use the sqlite:// scheme:
| URL | Opens |
|---|---|
sqlite://trades.db | Relative path |
sqlite:///tmp/trades.db | Absolute path |
sqlite://:memory: | In-memory (tests, scratch) |
Other schemes (postgres://, mysql://) raise a clear error — “only sqlite:// is supported in v0.1”.
Row return type. Each row is a map[str, dynamic]. Access fields by column name and narrow with as T:
db = db.connect("sqlite://trades.db")
db.exec("CREATE TABLE IF NOT EXISTS trades (id TEXT, symbol TEXT, price REAL, qty REAL)")
db.exec("INSERT INTO trades VALUES (?, ?, ?, ?)", ["t1", "BTCUSDT", 67000.0, 0.01])
rows = db.query("SELECT symbol, price, qty FROM trades WHERE symbol = ?", ["BTCUSDT"])
for row in rows {
symbol = row["symbol"] as str
price = row["price"] as float
qty = row["qty"] as float
log.info("{symbol} — {qty} @ {price:.2f}")
}
Multiple databases in the same program — each db.connect call returns an independent connection value:
trades = db.connect("sqlite://trades.db")
analytics = db.connect("sqlite://analytics.db")
rows = trades.query("SELECT * FROM fills")
analytics.exec("INSERT INTO daily_pnl VALUES (?)", [pnl])
Parameterized queries. Use ? placeholders and pass a list as the second argument. Supported param types: str, int, float, bool (stored as 0/1), none (NULL).
v0.1 scope. SQLite only. Postgres and MySQL support, along with a pluggable multi-backend registry, are planned for v0.2. Requires
@tools [db].
Json
json.parse and json.stringify bridge between Keel values and JSON strings.
| Method | Signature | Description |
|---|---|---|
json.parse | json.parse(s: str) → dynamic | Parse a JSON string into a dynamic value. |
json.stringify | json.stringify(value: dynamic) → str | Serialize a value to a JSON string. |
json.parse return-type semantics. The result is dynamic — the type is not known statically. At runtime, JSON types map to Keel values as follows:
| JSON | Keel runtime value |
|---|---|
object {} | field-accessible — parsed.fieldName works, raises on missing key |
array [] | list[dynamic] — indexable and iterable |
| integer | int |
| float | float |
| string | str |
| boolean | bool |
| null | none |
Narrow with as T as early as possible:
body = http.get("https://api.example.com/ticker")?.body ?? ""
data = json.parse(body) as dynamic
price = (data.price as str).to_float() ?? 0.0
volume = data.volume as int
rows = data.candles as list[dynamic]
json.stringify serialises Keel structs, maps, lists, and primitives to JSON. If the value implements Serializable, its to_json() method is called instead of the default serialiser.
Cache
Cache is a process-scoped in-memory key-value store with optional TTL. It is shared across all agents in the same process — a convenient way to pass state between agents without serialising to disk.
| Method | Signature | Description |
|---|---|---|
cache.set | cache.set(key: str, value: dynamic, ttl?: duration) → none | Store a value in the in-process cache, with an optional TTL duration. |
cache.get | cache.get(key: str) → dynamic? | Retrieve a cached value, returning none if absent. |
cache.delete | cache.delete(key: str) → none | Remove a key from the cache. |
cache.clear | cache.clear() → none | Clear all entries from the cache. |
cache.get return-type semantics. The return type is dynamic?. The stored type is preserved exactly — a value written as str is read back as str, a value written as int is read back as int. Use as T to recover a concrete type, and ?? to supply a default:
cache.set("trading:halted", "true")
halted_raw = cache.get("trading:halted") # dynamic?
halted = if halted_raw == none { false } else { (halted_raw as str) == "true" }
cache.set("count", 0)
n = (cache.get("count") ?? 0) as int
cache.set("rate", 0.5, ttl: 30.seconds)
rate = (cache.get("rate") ?? 0.0) as float
Log
Log writes structured messages at four severity levels. Output goes to stderr; messages below the current threshold are suppressed.
| Method | Signature | Description |
|---|---|---|
log.info | log.info(message: dynamic) → none | Emit an info-level log message. |
log.warn | log.warn(message: dynamic) → none | Emit a warning-level log message. |
log.error | log.error(message: dynamic) → none | Emit an error-level log message. |
log.debug | log.debug(message: dynamic) → none | Emit a debug-level log message. |
log.set_level | log.set_level(level: str) → none | Set the minimum log level (“debug”, “info”, “warn”, or “error”). |
log.level | log.level() → str | Return the current log level as a string. |
The threshold can also be set before launch with --log-level debug (CLI flag) or KEEL_LOG_LEVEL=debug (env var). log.set_level changes it at runtime.
log.debug("cache miss for key {key}")
log.info("order filled: {order_id}")
log.warn("rate limit approaching: {remaining} calls left")
log.error("payment failed: {code}")
log.set_level("debug")
current = log.level() # "debug"
Search
Search provides web and news search. Both methods return a list of SearchResult values.
| Method | Signature | Description |
|---|---|---|
search.web | search.web(query: str) → dynamic | Search the web and return a list of SearchResult values. |
search.news | search.news(query: str) → dynamic | Search for recent news and return a list of SearchResult values. |
std/searchis registered but raises a “not yet implemented” error at runtime. Wire a custom backend via theSearchProviderinterface when it lands.
Async
Async provides structured concurrency — run multiple tasks in parallel and collect their results.
| Method | Signature | Description |
|---|---|---|
async.spawn | async.spawn() → dynamic | Spawn a concurrent task and return a handle. |
async.join_all | async.join_all(handles: dynamic) → dynamic | Wait for all async task handles to complete. |
async.select | async.select(handles: dynamic) → dynamic | Return the result of the first completed task handle. |
async.sleep | async.sleep(duration: duration) → none | Pause execution for the given duration. |
task1 = async.spawn(() => { fetch_price("BTC") })
task2 = async.spawn(() => { fetch_price("ETH") })
# Wait for both
prices = async.join_all([task1, task2])
# Or race — whichever resolves first
winner = async.select([task1, task2])
async.select cancels any handles that haven’t completed yet once a winner is found.
v0.1 scope. Anything marked ⏳ is reserved in the grammar but not yet wired. 🟡 means partial: something works, but not everything.
Searchis registered and raises a clear “planned for v0.2” error;ai.embedreturns an empty list. Track the full status in ROADMAP.md.
Interfaces
An interface declares a set of method signatures. Any type with matching methods structurally satisfies the interface — no explicit implements.
interface LlmProvider {
task complete(messages: list[Message], opts: LlmOpts) -> LlmResponse?
task embed(text: str) -> list[float]?
}
interface VectorStore {
task put(key: str, value: map[str, str], embedding: list[float])
task query(embedding: list[float], limit: int) -> list[str]
}
interface Tracer {
task on_event(event: str)
}
Every effectful stdlib module dispatches through one or more interfaces:
| Module | Interface(s) |
|---|---|
std/ai | LlmProvider |
std/memory | VectorStore, Embedder |
std/http | HttpClient |
std/email | EmailTransport |
std/search | SearchProvider |
std/log | Tracer |
Swapping Implementations
Per-call model selection works today via the using: keyword:
use std/ai
urgency = ai.classify(body, as: Urgency, using: "smart")
using: resolves via KEEL_MODEL_<ALIAS> env vars and Ollama tags. Ollama is the only wired backend; ai.install(...) and @provider are reserved and have no runtime effect yet.
Shadowing Module Bindings
Module bindings are identifiers, not keywords. A local binding can shadow one inside a body — the idiomatic connection pattern relies on it:
use std/db
conn = db.connect("sqlite://trades.db") # clearer: keep `db` for the module
The linter warns when a binding shadows a module import. Use deliberately.
Modules, Not Keywords
Operations like classify, draft, every, fetch, ask, and confirm
are stdlib functions on the ai, io, email, schedule, and http
modules — not reserved words. The core language stays small (~27 keywords),
and the stdlib is a normal Rust crate that anyone can extend or replace.
Stdlib: ai
use std/ai to access LLM-backed operations. Under the hood, every call dispatches through the LlmProvider interface; the default provider is selected from @model (on the agent) or KEEL_OLLAMA_MODEL.
ai.classify — categorize into an enum
urgency = ai.classify(email.body, as: Urgency) ?? Urgency.medium
sentiment = ai.classify(review, as: Sentiment) # returns Sentiment? (nullable)
With hints:
urgency = ai.classify(email.body,
as: Urgency,
considering: {
"mentions a deadline within 24h": Urgency.high,
"newsletter or automated": Urgency.low
}
) ?? Urgency.medium
considering: is a map from hint string to enum variant. The LLM gets the hints as classification nudges.
Returns: T? (where T is the enum). Use ?? T.variant to supply a default inline.
ai.extract — pull structured data from text
# Inline schema map
info = ai.extract(
from: email,
schema: { sender: str, subject: str, action_items: list[str] }
)
# Declared struct type (preferred — reusable, documentable)
type Invoice { vendor: str, amount: float, date: str }
result = ai.extract("Invoice from ACME $99.99 on 2026-01-10", as: Invoice)
Returns: a struct matching schema: or the fields of as: T, nullable.
Both forms are fully wired as of v0.1.3:
schema: { field: "type" }— inline map of field names to type strings.as: T— derives the schema from a declaredtype T { ... }struct; raises a runtime error ifTis not a known struct type. As of v0.1.19 the type checker resolvesTfrom theas:argument, so field accesses on the result are statically checked.
ai.summarize — condense content
brief = ai.summarize(article, in: 3, unit: sentences)
bullets = ai.summarize(report, format: bullets)
tldr = ai.summarize(thread, in: 1, unit: line)
capped = ai.summarize(article, format: bullets, max: 5, unit: sentences)
safe = ai.summarize(article, in: 3, unit: sentences) ?? "No summary"
Returns: str?. Use ?? "default" to supply a fallback inline.
All four arguments (in:, unit:, format:, max:) are fully wired as of v0.1.3:
format: bullets→ appends “Format your response as a bulleted list.” to the system prompt.format: prose→ appends “Format your response as flowing prose.”max: N→ appends “Use at most N {unit}.” (falls back to “items” if no unit is given).
ai.draft — generate text
# Minimal
reply = ai.draft("response to {email.body}")
# With constraints
reply = ai.draft("response to {email.body}",
tone: "professional",
max_length: 150,
guidance: user_guidance
)
The first positional argument is a prompt string; it supports interpolation like any other Keel string. Additional keyword arguments become hints for the model.
Returns: str?.
ai.translate — language translation
french = ai.translate(message, to: french)
multi = ai.translate(ui_strings, to: [spanish, german, japanese])
Returns: str? for a single target, map[str, str]? for multi-target.
ai.decide — structured decision with reasoning
action = ai.decide(email,
options: [reply, forward, archive, escalate]
)
# action: map with keys choice, reason, confidence
# action.choice — one of the enum options
# action.reason — LLM's explanation
# action.confidence — 0.0..1.0
Returns: a map {choice, reason, confidence: 1.0}. The choice value is the selected option; reason is the LLM’s explanation.
ai.prompt — raw LLM access (escape hatch)
When the higher-level functions don’t give you enough control:
type SentimentScore { score: int, explanation: str }
score = ai.prompt(
system: "Rate sentiment on a 1-10 scale.",
user: "Text: {review}",
response_format: json
) as SentimentScore
# score: SentimentScore?
ai.prompt(...) must be followed by as T. Use as dynamic if the response shape is truly unknown — this is a deliberate, visible opt-out.
Status: fully wired as of v0.1.3.
response_format: jsoninjects “Respond with valid JSON only. No prose, no markdown fences.” into the system prompt and validates the reply — a non-JSON reply is a runtime error.
Per-call model override
urgency = ai.classify(email.body, as: Urgency, using: "fast")
reply = ai.draft("response to {email}", using: "smart")
using: accepts a model alias that resolves via KEEL_MODEL_<ALIAS> environment variables, or a literal Ollama tag ("ollama:gemma4" or just "gemma4" if a single default is set). See LLM Providers.
Every ai.* call goes through the LlmProvider interface. Ollama is the only wired backend; @provider and ai.install(...) are reserved for a future release.
Why functions, not keywords
ai.classify, ai.draft, ai.extract, and friends are ordinary stdlib functions (imported with use std/ai) rather than built-in grammar. That keeps the parser, type checker, and LSP free of LLM-specific special cases: you still write ai.classify(...) with the same ergonomics, but the implementation lives in a normal stdlib module. Swap the LLM, add a new ai.* operation in a library, or shadow the ai binding with your own module — the core language is unchanged. See The Standard Library.
Stdlib: Io
The Io namespace provides human-in-the-loop interaction. All four functions route through a channel (terminal by default; Slack, email, or custom via interface).
io.ask — blocking input
Blocks the current handler until the user responds.
answer = io.ask("How should I respond to this?")
# answer: str
choice = io.ask("Pick a priority", options: [low, medium, high])
# choice: the enum
pick = io.ask("Approve deployment?", options: [yes, no], via: slack)
Returns: str for free text, or the enum type when options: is provided.
io.confirm — yes/no approval
approved = io.confirm("Send this reply?\n\n{draft_reply}")
if approved { email.send(draft_reply, to: email.from) }
# Via a specific channel
approved = io.confirm("Delete 50 files?", via: slack)
Returns: bool — true if approved, false otherwise.
io.notify — non-blocking message
io.notify("Email classified as critical")
io.notify("Weekly report ready", via: slack)
Does not wait for a response.
io.show — display structured data
io.show(email_summary)
io.show(table(results))
io.show(chart(metrics))
Formats structured data for the terminal or UI.
Channels
via: selects the channel. Default is terminal; the stdlib ships with slack and email channels, and additional channels can be installed by implementing the Channel interface.
io.notify("Deploy started", via: slack)
Why a library, not keywords
io.ask, io.confirm, io.notify, and io.show are ordinary stdlib functions (imported with use std/io) rather than reserved words. Treating them as regular functions keeps the grammar small and lets them compose with the rest of the language: they work inside pipelines, accept lambdas for formatting, and can be wrapped by user code without fighting the parser. See The Standard Library.
Stdlib: schedule
The schedule module (use std/schedule) provides recurring, delayed, and one-shot scheduling. It’s a library, not a keyword — which means you can use dynamic intervals, cron expressions, and user-defined event sources without language changes.
Under the hood, schedule sits on top of the runtime’s timer primitives and the agent mailbox, and emits events into whichever agent registered the schedule.
schedule.every — recurring execution
schedule.every(5.minutes, () => {
check_inbox()
})
schedule.every(1.hour, () => {
sync_data()
})
Dynamic intervals work because it’s just a function call:
interval = if load_is_high() { 10.minutes } else { 5.minutes }
schedule.every(interval, () => { heartbeat() })
Intervals fire relative to when the schedule is registered. To anchor recurring work to a clock time (every weekday at 9am, say), use schedule.cron.
schedule.after — delayed one-shot
schedule.after(30.minutes, () => {
follow_up(ticket)
})
schedule.after(2.hours, () => {
io.notify("Check on deployment")
})
schedule.at — absolute time
schedule.at takes an RFC 3339 / ISO 8601 datetime string. A naive datetime (no offset) is treated as UTC; a target already in the past fires immediately.
schedule.at("2026-04-20T10:00:00Z", () => {
launch_campaign()
})
schedule.cron — cron expressions
schedule.cron("0 9-17 * * 1-5", () => {
poll_status()
})
Status:
schedule.cronis wired for standard 5-field cron expressions with numeric fields and runs from inside an agent lifecycle or handler context.
schedule.sleep — pause execution
schedule.sleep suspends the current agent (or task) for a given duration without blocking the runtime event loop:
schedule.sleep(2.seconds)
schedule.sleep(500.ms)
Use schedule.sleep inside an agent body to introduce deliberate delays between steps — for example, a polling loop with backoff:
use std/schedule
agent Poller {
@on_start {
for i in 1..10 {
result = fetch_status()
if result.ready { stop(self) }
schedule.sleep(5.seconds)
}
}
}
Inside an agent
A schedule typically lives in an @on_start lifecycle attribute so it’s set up once when the agent starts:
use std/email
use std/env
use std/schedule
agent DailyDigest {
@tools [email, env]
@role "Produce a daily digest of important emails"
@on_start {
schedule.cron("0 8 * * *", () => {
summary = produce_digest()
email.send(summary, to: env.require("DIGEST_TO"))
})
}
}
Scheduling calls return none — there is no cancellation handle. A schedule lives for the lifetime of the enclosing agent; stopping the agent tears its schedules down.
Duration literals
Durations use the .unit suffix and are arithmetic-compatible:
short = 5.seconds
pause = 30.minutes
week = 7.days
extended = 30.seconds * 2 # 60 seconds
timeout = 5.minutes + 30.seconds
Both singular and plural forms work (1.day, 2.days).
Overflow behavior
Scheduled closures are delivered through the interpreter’s bounded event queue (default 1024; KEEL_EVENT_QUEUE_CAPACITY). The overflow policy differs by schedule type:
Recurring (schedule.every tick loop, schedule.cron): when the queue is full at the moment a tick fires, the tick is dropped and the schedule continues normally. This is a coalescing drop — a slow handler that misses one heartbeat does not accumulate a backlog of missed ticks, and the next tick fires on time.
One-shot (schedule.after, schedule.at) and the initial fire of schedule.every: these wait for queue space rather than dropping. Delivery is guaranteed as long as the event loop is still running — if the queue is momentarily full, the callback fires as soon as a slot opens. There is no RuntimeBusy error for schedule overflow — scheduler waits are silent and transparent to user code.
Why a library, not keywords
schedule.every, schedule.after, and schedule.at are ordinary stdlib functions (imported with use std/schedule) rather than hard-coded keywords. This matters because common patterns — dynamic intervals like every N.minutes where N depends on state, cron expressions, pause/resume logic, user-defined event sources (webhooks, subscriptions) — all fight fixed keyword syntax. Keeping schedule.* a library sidesteps every one of them. See The Standard Library.
Stdlib: email & http
External connections live in stdlib modules: use std/email for IMAP/SMTP, use std/http for HTTP. (See also std/db for SQLite access.) The interface boundary is planned so these backends can become swappable; the default email and http transports are the only ones wired today.
email
Default implementation uses imap (fetch) + lettre (send). v0.1 reads credentials from environment variables:
export IMAP_HOST=imap.gmail.com
export SMTP_HOST=smtp.gmail.com # optional — defaults to IMAP host with `imap.` → `smtp.`
export EMAIL_USER=you@example.com
export EMAIL_PASS=app-password
If those aren’t set, email.fetch returns [] and email.send is a no-op (with a stderr warning), so programs keep running.
Fetch messages
emails = email.fetch(unread: true) # up to 20 most recent unread from INBOX
Each returned map has from, subject, body, unread, and uid keys.
The uid is the IMAP UID of the message and is required by email.archive.
Send messages
email.send(reply, to: msg.from)
email.send(reply, to: address, subject: "Re: hello")
Positional body can be a str or a map with body (and optional subject). to: can be an address string or a map with from.
Archive
for msg in email.fetch(unread: true) {
email.archive(msg)
}
Don’t name the loop variable
email.archive(...)inside the body would then resolve against the message instead of the module.
email.archive performs an IMAP UID MOVE on the message, falling back
to COPY + \Deleted + EXPUNGE for servers without the MOVE extension.
The destination folder defaults to Archive; override with the
IMAP_ARCHIVE_FOLDER env var:
export IMAP_ARCHIVE_FOLDER="[Gmail]/All Mail"
The argument must be a message map with a positive uid field — the
shape returned by email.fetch. If credentials are not configured the
call is a silent no-op so programs keep running.
http
Default implementation wraps reqwest.
GET
response = http.get("https://api.example.com/data")
# response: HttpResponse?
if response?.is_ok {
users = response?.json_as[list[User]]() ?? []
}
POST
response = http.post("https://api.example.com/v2/events",
json: {kind: "email_processed", count: 42},
headers: {Authorization: "Bearer {env.require("API_KEY")}"}
)
Full request
response = http.request(
method: POST,
url: "https://api.example.com/v2/classify",
headers: {
Authorization: "Bearer {env.require("API_KEY")}",
"Content-Type": "application/json"
},
body: {text: msg.body},
timeout: 10.seconds
)
Returns: HttpResponse? — see Types for the shape.
http.serve — inbound HTTP (webhooks)
Start an HTTP listener on a port. Each incoming request invokes the handler closure:
http.serve(8080, (request) => {
method = request["method"] # "GET", "POST", …
path = request["path"] # "/webhook/events"
body = request["body"] # raw body string
if method == "POST" {
io.show("Received: {body}")
{ status: 200, body: "OK" }
} else {
{ status: 405, body: "Method Not Allowed" }
}
})
request— map withmethod,path,body(all strings)- Return a map with
status(integer, 100–999) andbody(string) - The server runs in a background task;
http.servereturns immediately - The event loop stays alive as long as at least one server is active, even with no running agents
- When the event queue is full, incoming HTTP requests receive a
503 Service Unavailableresponse automatically. No user code runs for that request.
Handlers run outside any agent context. An
http.servehandler is a top-level closure — it fires on the event loop with nocurrent_agentset. That has two consequences:
self.<field>raises a runtime error inside a handler. Agent state is only reachable from within an agent’s tasks /onhandlers / attribute blocks.ai.*calls are not agent-aware. No@role, no@rules, and no@modelinjection — calls fall back to the default model (KEEL_OLLAMA_MODEL) with a bare system prompt. Results are still returned, just without the agent’s identity layered in.To use agent state or an agent’s
@rolefrom a handler, route the request into a live agent. The matchingon http_request(req) { ... }handler onTriageruns withself.and@roleall wired up.
http.serve(8080, (request) => {
send(Triage, request, event: "http_request")
{ status: 202, body: "accepted" }
})
db
db.connect opens a SQLite database and returns a DbConnection value. All SQL is
executed through that value. Requires @tools [db] inside agents.
use std/db
use std/time
conn = db.connect("sqlite://interactions.db")
cutoff = time.now() - 30.days
rows = conn.query(
"SELECT * FROM interactions WHERE contact = ? AND created_at > ?",
[msg.from, cutoff]
)
# rows: list[map[str, dynamic]]
conn.exec("UPDATE status SET seen = true WHERE id = ?", [ticket.id])
# returns int — number of rows affected
Scope. SQLite only (
sqlite://path,sqlite:///abs/path,sqlite://:memory:).
See The Standard Library for how interface dispatch works.
Why a library, not connect + fetch keywords
Dedicated connect X via Y { ... } or fetch X where Y grammar wouldn’t compose well: connect is really a struct literal, and fetch generalizes badly across connection types (email’s unread is not SQL’s where). Per-connector libraries give better autocomplete, clearer types, and zero language changes when a new connector ships.
Filesystem — File
The File namespace gives agents access to the local filesystem. It is auto-imported — no use required.
Reading and writing
content = file.read("data/report.txt") # str — raises FileError if file missing
file.write("output/result.txt", content)
file.read returns str. If the file does not exist it raises a FileError at runtime — use file.exists to guard reads when the path may be absent.
file.write creates parent directories automatically.
All file.* paths and file.write content must be str values. Dynamic values with
another runtime type raise a clear type error; they are not silently formatted as strings.
Existence and listing
if file.exists("config.json") {
cfg = file.read("config.json")
}
entries = file.list("output") # list[str] — names only, not full paths
Creating directories
file.mkdir("output/reports/2026") # creates all missing parents
Copying and moving
file.copy("template.txt", "output/report.txt") # src unchanged
file.move("draft.txt", "published/final.txt") # src removed; atomic on same filesystem
Both copy and move create missing parent directories on the destination side automatically.
Removing files and directories
file.remove("tmp/scratch.txt") # single file
file.remove("tmp/cache") # directory — removed recursively (rm -rf semantics)
file.remove auto-detects whether the path is a file or a directory. Removing a non-existent path is a runtime error.
Glob patterns
reports = file.glob("output/*.txt") # files in output/ matching *.txt
all_rs = file.glob("src/**/*.rs") # recursive — all .rs files under src/
Returns a list[str] of matching paths. No matches → empty list. An invalid pattern is a runtime error.
Supported pattern syntax: * (any chars in one segment), ? (single char), ** (zero or more segments, recursive).
Temporary files and directories
tmp = file.mktemp() # creates a temp file; returns its path as str
tmpdir = file.mktemp(dir: true) # creates a temp directory; returns its path
file.mktemp creates the file or directory immediately and returns the path. Lifecycle is the caller’s responsibility — use file.remove(path) when done:
tmp = file.mktemp()
file.write(tmp, processed_content)
result = file.read(tmp)
file.remove(tmp)
Quick reference
| Method | Signature | Description |
|---|---|---|
file.read | file.read(path: str) → str | Read a file and return its contents as a string. |
file.write | file.write(path: str, content: str) → none | Write a string to a file, creating or overwriting it. |
file.exists | file.exists(path: str) → bool | Return true if the path exists on the filesystem. |
file.list | file.list(path: str) → list[str] | List the entries in a directory. |
file.mkdir | file.mkdir(path: str) → none | Create a directory and all intermediate parents. |
file.remove | file.remove(path: str) → none | Remove a file or directory. |
file.copy | file.copy(src: str, dst: str) → none | Copy a file from src to dst. |
file.glob | file.glob(pattern: str) → list[str] | Return file paths that match a glob pattern. |
file.move | file.move(src: str, dst: str) → none | Move (rename) a file from src to dst. |
file.mktemp | file.mktemp() → str | Create a temporary file and return its path. |
Error handling
All file.* methods that fail for I/O reasons throw a FileError. Catch it by type name:
try {
content = file.read("config.json")
} catch err: FileError {
io.notify("Could not read config: {err.message}")
content = "{}"
} catch err: Error {
io.notify("Unexpected error: {err.message}")
}
FileError carries a message: str field. Its diagnostic code is keel::runtime::FileError.
For subprocess execution, see Shell — subprocess bridge.
Shell — subprocess bridge
The Shell namespace lets agents invoke external commands and capture their output. It must be declared in @tools before use — an agent that calls shell.run without listing Shell in @tools raises CapabilityError at runtime.
use std/io
use std/shell
agent Builder {
@tools [shell, io]
@on_start {
r = shell.run("cargo test --quiet 2>&1")
if r.exit_code != 0 {
raise "tests failed:\n{r.stdout}"
}
io.show("all tests passed")
}
}
run(Builder)
shell.run
shell.run(cmd: str, stdin: str? = none, cwd: str? = none)
-> { stdout: str, stderr: str, exit_code: int }
cmd is passed to /bin/sh -c, so pipes, redirects, and shell builtins all work.
| Argument | Notes |
|---|---|
cmd | Required. The shell command to run. |
stdin: | Optional text piped to the process’s standard input. |
cwd: | Optional working directory for the subprocess. Defaults to the interpreter’s working directory. |
Return value — always a map with three keys:
| Key | Type | Notes |
|---|---|---|
stdout | str | Standard output captured from the process. |
stderr | str | Standard error captured from the process. |
exit_code | int | 0 on success; non-zero on failure; -1 if the OS cannot report one. |
A non-zero exit code is not automatically an error — check exit_code and raise yourself if needed. A spawn failure (e.g. /bin/sh not in PATH) does raise.
Environment isolation
The subprocess runs with a clean environment. Only the following variables are forwarded from the keel process:
| Variable | Why forwarded |
|---|---|
PATH | Command resolution |
HOME | Many tools read ~ |
SHELL | Scripts that re-exec themselves |
TMPDIR | Temp file location |
USER | Identity for tools that need it |
LANG | Locale / character encoding |
All other variables — including secrets, API keys, database URLs, and any other credentials present in the keel process environment — are not visible to the shell command. To read the keel process environment from Keel code, use env.*.
Capability gating
@tools restricts an agent to the listed namespaces. An agent that declares @tools [io] but not Shell will get a CapabilityError on any shell.run call. An agent with no @tools declaration is unrestricted.
Currently this is process-level gating only — there is no OS-level sandbox.
Security note
cmd is passed directly to /bin/sh -c. Never interpolate untrusted user input into cmd without sanitisation — shell injection is possible.
Json
The Json namespace serializes and deserializes JSON. No @tools annotation required.
text = json.stringify({ symbol: "BTC", price: 67000 })
data = json.parse(text) as dynamic
io.show("{data.symbol}") # BTC
json.stringify(value) -> str
Converts a Keel value to a JSON string.
json.stringify("hello") # "\"hello\""
json.stringify(42) # "42"
json.stringify(true) # "true"
json.stringify(none) # "null"
json.stringify([1, 2, 3]) # "[1,2,3]"
json.stringify({ a: 1, b: "x" }) # "{\"a\":1,\"b\":\"x\"}"
If a type implements the Serializable interface, json.stringify calls to_json() instead of the default serializer:
use std/json
type Order {
id: str
amount: float
}
impl Serializable for Order {
task to_json(self) -> str {
"{\"id\":\"{self.id}\",\"amount\":{self.amount:.2f}}"
}
}
o: Order = { id: "ord-1", amount: 99.5 }
json.stringify(o) # {"id":"ord-1","amount":99.50}
json.parse(text)
Parses a JSON string. The type checker does not infer a precise return type — treat the result as untyped and narrow with as T or annotate as dynamic before using it. The JSON-to-Keel type mapping at runtime is:
| JSON type | Keel runtime value |
|---|---|
object {} | map — field access parsed.name works at runtime |
array [] | list[dynamic] — index with parsed[i], iterate with for |
| number (integer) | int |
| number (float) | float |
| string | str |
| boolean | bool |
| null | none |
Named-field access (parsed.price) resolves at runtime. A missing key raises rather than returning none — use ?? or check existence explicitly.
Narrow to a concrete type with as T as early as possible:
body = http.get("https://api.example.com/ticker")?.body ?? ""
data = json.parse(body) as dynamic
price = data.price as str # str field
volume = data.volume as int # int field
rows = data.candles as list[dynamic] # array field
for row in rows {
close = (row as list[dynamic])[4] as str
}
Strict mode:
keel check --strictflags unannotatedjson.parsebindings because the return type cannot be inferred statically. Adddata: dynamic = json.parse(...)(explicitdynamicannotation, always clean) or cast immediately to a concrete type withas T.
Tip: narrow immediately after parsing. Keeping values as
dynamicthroughout a program defeats the type checker and suppresses autocomplete.
Csv — CSV serialization
The Csv namespace parses and produces RFC 4180–compliant CSV text. No @tools annotation is required.
use std/csv
use std/file
use std/log
agent TradeLoader {
@tools [file]
@on_start {
raw = file.read("trades.csv")
# list[map[str, str]] — first row becomes header keys
trades = csv.parse_records(raw)
for trade in trades {
log.info("{trade["symbol"]} @ {trade["price"] as float:.2f}")
}
# Build and write a filtered CSV
rows = [["symbol", "price"]]
for trade in trades {
if trade["price"] as float > 1000.0 {
rows.push([trade["symbol"], trade["price"]])
}
}
file.write("filtered.csv", csv.stringify(rows))
stop(self)
}
}
run(TradeLoader)
Functions
csv.parse(text: str) -> list[list[str]]
Parse a CSV string into rows of strings. Every cell becomes a str regardless of the original content. The first row is treated as data, not headers — use csv.parse_records if you want header-keyed maps.
rows = csv.parse("name,score\nAlice,10\nBob,20")
# rows[0] == ["name", "score"]
# rows[1] == ["Alice", "10"]
Quoted fields (containing commas, quotes, or newlines) are handled automatically per RFC 4180.
Raises CsvError on malformed input (e.g. unclosed quotes).
csv.parse_records(text: str) -> list[map[str, str]]
Parse a CSV string using the first row as header keys. Returns one map per data row. Absent cells (short rows) default to "".
trades = csv.parse_records("symbol,price\nBTC,67000\nETH,3500")
# trades[0] == {symbol: "BTC", price: "67000"}
# trades[1] == {symbol: "ETH", price: "3500"}
for t in trades {
log.info("{t["symbol"]}: {t["price"] as float:.2f}")
}
If the input has only a header row (no data rows), returns [].
csv.stringify(rows: list[list[str]]) -> str
Convert a list of rows to a CSV string. Each inner list is one row; every cell must be a str. Cells containing commas, quotes, or newlines are automatically quoted per RFC 4180. Raises CsvError if a row element is not a list or a cell is not a str.
Include a header row as the first inner list:
rows = [
["symbol", "price", "volume"],
["BTC", "67000", "1234.5"],
["ETH", "3500", "5678.9"],
]
text = csv.stringify(rows)
file.write("output.csv", text)
csv.stringify only accepts list[list[str]]. To convert list[map[str, str]] (e.g. from parse_records), project the fields you need:
lines = trades.map(t => [t["symbol"], t["price"]])
text = csv.stringify([["symbol", "price"]] + lines)
Round-trip example
use std/csv
use std/io
agent CsvRoundtrip {
@tools [io]
@on_start {
original = "name,score\nAlice,10\nBob,20"
rows = csv.parse(original)
back = csv.stringify(rows)
# back contains the same data, possibly with CRLF line endings
io.show(back)
stop(self)
}
}
run(CsvRoundtrip)
Stdlib: db — SQLite database
use std/db to connect to SQLite databases via db.connect(). Requires @tools [db] inside agents.
use std/db
use std/log
agent DataPipeline {
@tools [db]
@on_start {
conn = db.connect("sqlite://trades.db")
conn.exec("CREATE TABLE IF NOT EXISTS trades (id TEXT, symbol TEXT, price REAL)")
conn.exec("INSERT INTO trades VALUES (?, ?, ?)", ["t1", "BTCUSDT", 67000.0])
rows = conn.query("SELECT symbol, price FROM trades WHERE symbol = ?", ["BTCUSDT"])
for row in rows {
log.info("{row["symbol"]} @ {row["price"] as float}")
}
count = conn.exec("DELETE FROM trades WHERE symbol = ?", ["BTCUSDT"])
log.info("Deleted {count} row(s)")
stop(self)
}
}
run(DataPipeline)
Connection URLs
Use the sqlite:// scheme. All three forms are valid:
sqlite://path/to/db.db— file-relative pathsqlite:///abs/path/to/db.db— absolute pathsqlite://:memory:— in-memory database (not persisted)
SQLite is bundled into the Keel binary — no system library required. Other schemes (postgres://, mysql://) raise a clear error: “only sqlite:// is supported in v0.1”.
Functions
db.connect(url: str) -> DbConnection
Open or create a SQLite database. Returns a connection value with .query() and .exec() methods.
conn = db.connect("sqlite://data/app.db")
DbConnection.query(sql: str, params?: list[dynamic]) -> list[map[str, dynamic]]
Execute a SELECT query and return all matching rows. Each row is a map[str, dynamic] — field names are column names, values are their dynamic types.
rows = conn.query("SELECT name, score FROM users")
for row in rows {
log.info("{row["name"]}: {row["score"]}")
}
# Parameterized query — use ? placeholders
age = 21
results = conn.query("SELECT * FROM users WHERE age > ?", [age])
Column values are returned as:
intfor INTEGERfloatfor REALstrfor TEXTnonefor NULL
Access fields with map syntax: row["name"] or row["age"] as int.
DbConnection.exec(sql: str, params?: list[dynamic]) -> int
Execute an INSERT, UPDATE, or DELETE statement. Returns the number of rows affected. For CREATE TABLE and other DDL statements, returns 0.
# Create table
conn.exec("CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, name TEXT, age INT)")
# Insert
count = conn.exec("INSERT INTO users VALUES (?, ?, ?)", ["u1", "Alice", 30])
# Update
count = conn.exec("UPDATE users SET age = ? WHERE name = ?", [31, "Alice"])
# Delete
count = conn.exec("DELETE FROM users WHERE age < ?", [18])
log.info("Deleted {count} user(s)")
Parameterized queries
Always use parameterized queries (? placeholders) when inserting user-provided values. This prevents SQL injection:
# Safe — value is parameterized
conn.exec("INSERT INTO users (name, email) VALUES (?, ?)", [user_input_name, user_input_email])
# Unsafe — string interpolation allows injection
# conn.exec("INSERT INTO users (name) VALUES ('{user_input}')") # DON'T DO THIS
The params list is optional; omit it for queries with no placeholders:
conn.query("SELECT COUNT(*) as count FROM users")
Transactions and error handling
SQLite operates in autocommit mode by default — each exec() is its own transaction. Use BEGIN and COMMIT for multi-statement transactions:
conn.exec("BEGIN")
try {
conn.exec("INSERT INTO accounts (user, balance) VALUES (?, ?)", [user_id, 100.0])
conn.exec("UPDATE ledger SET total = total + ? WHERE id = ?", [100.0, ledger_id])
conn.exec("COMMIT")
} catch e: RuntimeError {
conn.exec("ROLLBACK")
raise "Transaction failed: {e}"
}
Malformed SQL, constraint violations, and I/O errors raise exceptions and halt execution.
In-memory databases
Use sqlite://:memory: for temporary data that doesn’t persist:
use std/db
use std/io
agent Scratchpad {
@tools [db, io]
@on_start {
scratch = db.connect("sqlite://:memory:")
scratch.exec("CREATE TABLE temp (id INT, value TEXT)")
scratch.exec("INSERT INTO temp VALUES (1, 'hello')")
results = scratch.query("SELECT * FROM temp")
io.show(results[0])
stop(self)
}
}
run(Scratchpad)
Performance notes
-
Each
.query()and.exec()call is a separate SQLite statement execution. For bulk inserts, use a transaction:conn.exec("BEGIN") for item in items { conn.exec("INSERT INTO data VALUES (?)", [item]) } conn.exec("COMMIT") -
SQLite is single-writer; concurrent writes from multiple agents may timeout.
String Interpolation
Keel strings support {expr} interpolation — variables and expressions inside strings are evaluated at runtime.
Basic interpolation
name = "Keel"
io.notify("Hello, {name}!") # "Hello, Keel!"
io.notify("Count: {items.count()}") # "Count: 3"
io.notify("Sum: {a + b}") # expression evaluation
Dotted paths
io.notify("From: {msg.from}")
io.notify("Status: {self.count}")
io.notify("Key: {env.get("API_KEY")}")
Nested string literals
A {...} slot accepts any expression — including another string literal
with its own slots. The lexer tracks brace depth and recurses into
nested "...", so quotes inside {} are not confused with the outer
string’s terminator.
name = "world"
mood = "cheerful"
io.show("hi {"there {name}"}")
# → "hi there world"
io.show("tone: {"speaking in a {mood.to_str()} voice"}")
# → "tone: speaking in a cheerful voice"
\" inside an outer string still produces a literal quote without
opening a nested string.
Escape sequences
| Sequence | Result |
|---|---|
\n | Newline |
\t | Tab |
\r | Carriage return |
\\ | Backslash |
\" | Double quote |
\{ | Literal { (prevents interpolation) |
\} | Literal } |
io.notify("Line 1\nLine 2")
io.notify("Price: \{not interpolated\}")
String methods
| Method | Returns | Example |
|---|---|---|
.len() / .length | int | "hello".len() → 5 |
.is_empty() | bool | "".is_empty() → true |
.contains(s) | bool | "hello".contains("ell") → true |
.starts_with(s) | bool | "hello".starts_with("hel") → true |
.ends_with(s) | bool | "hello".ends_with("lo") → true |
.trim() | str | " hi ".trim() → "hi" |
.trim_start() | str | " hi ".trim_start() → "hi " |
.trim_end() | str | " hi ".trim_end() → " hi" |
.upper() | str | "hello".upper() → "HELLO" |
.lower() | str | "HELLO".lower() → "hello" |
.repeat(n) | str | "ha".repeat(3) → "hahaha" |
.slice(start, end?) | str | "hello".slice(1, 3) → "el" |
.index_of(needle) | int? | "hello world".index_of("world") → 6 |
.split(sep) | list[str] | "a,b,c".split(",") → ["a","b","c"] |
.replace(old, new) | str | "hello".replace("l","r") → "herro" |
.to_int() | int? | "42".to_int() → 42; "bad".to_int() → none |
.to_float() | float? | "3.14".to_float() → 3.14; "x".to_float() → none |
.to_str() | str | identity — useful in generic contexts |
.truncate(max) | str | Truncates to max chars; appends "…" if cut. max must be ≥ 0 |
.pad(width, char?) | str | Left-pads to width with char (default " "). width must be ≥ 0 |
.matches(pattern) | bool | true if regex matches anywhere in the string |
.extract(pattern) | str? | First capture group of regex; none if no match |
.find_all(pattern) | list[str] | All non-overlapping regex matches; empty list if none |
.sub(pattern, replacement) | str | Replace all regex matches; supports $1 backreferences |
Patterns use standard regex syntax (Rust regex crate — no look-behind).
text = "Invoice #1042 — Total: $3,750.00 due 2026-05-15"
# Regex test
if text.matches("\\d{4}-\\d{2}-\\d{2}") {
io.show("looks like a date")
}
# Extract first capture group (returns str?)
amount = text.extract("\\$(\\S+)") # "3,750.00"
# All matches
dates = text.find_all("\\d{4}-\\d{2}-\\d{2}") # ["2026-05-15"]
# Regex replace (global)
redacted = text.sub("\\$[\\d,]+\\.\\d{2}", "[REDACTED]")
# Truncate and pad
short = text.truncate(20) # "Invoice #1042 — Tot…"
padded = "42".pad(6) # " 42"
zeroed = "42".pad(6, char: "0") # "000042"
Format specifiers
A slot may include a format spec after a colon — {expr:spec} — for precise number formatting and text alignment. The spec uses a Python-style mini-language.
Float precision
pi = 3.14159
io.show("{pi:.2f}") # → "3.14"
io.show("{pi:.4f}") # → "3.1416"
n = 42
io.show("{n:.2f}") # → "42.00" (int auto-promoted to float)
Width and alignment
| Spec | Alignment | Example |
|---|---|---|
:<N | Left-align, space-padded to N chars | {"hi":<8} → "hi " |
:>N | Right-align, space-padded to N chars | {42:>8} → " 42" |
:^N | Center, space-padded to N chars | {"hi":^8} → " hi " |
:N | Bare width — right-align shorthand | {42:8} → " 42" |
Combining alignment and precision
price = 19.5
io.show("{price:>10.2f}") # → " 19.50"
io.show("{price:<10.2f}") # → "19.50 "
Building tables
io.show("{"Name":<12} {"Score":>8}")
io.show("{"Alice":<12} {0.975:>8.3f}")
io.show("{"Bob":<12} {0.742:>8.3f}")
# →
# Name Score
# Alice 0.975
# Bob 0.742
Named arguments inside a slot are not confused with the format spec — {f(key: v):>10} parses f(key: v) as the expression and >10 as the spec, because the separator colon is only detected at outermost bracket depth.
Stringable interface — custom interpolation
By default, only primitive values (str, int, float, bool) and built-in types (Uuid, datetime) can be used inside "{...}" interpolation slots. To make your own struct type work in interpolation, implement the Stringable interface:
use std/io
type Point {
x: float
y: float
}
impl Stringable for Point {
task to_str(self) -> str {
"({self.x}, {self.y})"
}
}
p: Point = { x: 1.5, y: 2.0 }
io.show("Position: {p}") # → "Position: (1.5, 2.0)"
s = p.to_str() # explicit call — "(1.5, 2.0)"
Syntax
impl InterfaceName for TypeName {
task method(self) -> ReturnType {
...
}
}
implis a reserved keyword.foris the same keyword used inforloops; there is no ambiguity becauseimplalways precedes it here.selfinside the block refers to the receiver value (typed asTypeName). Useself.fieldto access struct fields.- Only methods matching a declared interface signature are valid inside
implblocks.
Multiple types
Each type needs its own impl block:
use std/io
type Color = red | green | blue
impl Stringable for Color {
task to_str(self) -> str {
when self {
red => "red"
green => "green"
blue => "blue"
}
}
}
c: Color = Color.green
io.show("Favourite: {c}") # → "Favourite: green"
Fallback behaviour
Values that do not implement Stringable still render in interpolation using their built-in display representation (the same output you see from io.show). Implementing Stringable lets you override that representation for your types.
Cache namespace — in-memory shared cache
Cache is a process-scoped, in-memory key-value store with optional TTL. It persists across agent restarts within the same process run but is cleared when the process exits.
cache.set("session:abc", user_data, ttl: 30.minutes)
session = cache.get("session:abc") # value or none (if expired/missing)
cache.delete("session:abc")
cache.clear() # flush everything
| Method | Returns | Notes |
|---|---|---|
cache.set(key, value, ttl?) | none | ttl is a duration literal; omit for no expiry |
cache.get(key) | dynamic? | none if missing or expired; stored type preserved |
cache.delete(key) | none | No-op if key doesn’t exist |
cache.clear() | none | Flushes all entries |
cache.get return type
cache.get returns dynamic? — none when the key is absent or expired, otherwise the value at its original type. A value stored as str comes back as str; a value stored as int comes back as int. Use as T to narrow to a concrete type:
cache.set("price", "50000.12")
raw = cache.get("price") # dynamic?
if raw != none {
price = raw as str # "50000.12"
}
cache.set("count", 42)
n = (cache.get("count") ?? 0) as int # 42
Scope:
Cacheis process-wide (all agents share one store) and cleared on restart.Memoryis per-agent and optionally persistent. UseCachefor rate-limiting tokens, deduplication keys, or short-lived shared results; useMemoryfor per-agent state that should survive across runs.
keel run
Execute a Keel program.
keel run <file.keel>
Pipeline
- Lex — tokenize the source
- Parse — build the AST
- Load modules — resolve
useimports transitively (each file parsed once; cycles rejected) - Type check — verify types, exhaustiveness, argument types across the whole module graph
- Execute — run with tree-walking interpreter
The file you pass is the entry file: its top-level statements form the implicit main and execute in order. Imported modules contribute declarations only — their top-level statements run solely when that file is executed directly. See Modules & Imports.
Global flags
These flags apply to every subcommand; keel run uses them most.
| Flag | Effect |
|---|---|
--trace | Surfaces internal runtime detail: LLM call metadata, input previews, per-call results, provider banner. Equivalent to KEEL_TRACE=1. Off by default. |
--log-level <LEVEL> | Sets the threshold for log.* calls. One of debug, info, warn, error. Default info. Equivalent to KEEL_LOG_LEVEL=<LEVEL>. |
Examples
# Run an agent
KEEL_OLLAMA_MODEL=gemma4 keel run examples/email_agent.keel
# Test without a real LLM
KEEL_LLM=mock keel run examples/hello_world.keel
# Verbose — show every ai.* call as it fires
keel --trace run examples/email_agent.keel
# Quiet production run — only warnings and errors from log.*
keel --log-level warn run examples/email_agent.keel
Behavior
- Agents with scheduled blocks (
schedule.every,schedule.after, etc.) run continuously untilCtrl+C - The first tick executes immediately
- Errors in the first tick are fatal (program exits)
- Errors in subsequent ticks are logged but don’t stop the agent
Ctrl+Cexits immediately regardless of what the runtime is blocked on (stdin prompt, LLM call, HTTP request, IMAP fetch) — exit code130
keel test
Run the test blocks in a Keel program or directory.
keel test <path>
keel test parses and type-checks each tested file, then executes only top-level test "name" { ... } blocks. Normal top-level program statements such as run(A) are not executed by the test command.
When <path> is a directory, Keel recursively discovers .keel files with test blocks and skips files without tests.
Tests are discovered per file: keel test file.keel runs only that file’s test blocks. Modules imported with use contribute their declarations — test helpers are ordinary tasks — but never their own tests. Point keel test at each file (or the directory) to run a whole project’s suite.
To run a subset by name:
keel test <path> --filter classify
The filter is a case-sensitive substring match against the test name. If no tests match, the command fails.
To list tests without running them:
keel test <path> --list
--list can be combined with --filter. If no tests are found while listing, the command prints 0 tests found.
To stop after the first failing test:
keel test <path> --fail-fast
To print only failing test lines and the final summary:
keel test <path> --quiet
Example
use std/ai
use std/testing
type Severity = low | medium | critical
task classify(text: str) -> Severity {
ai.classify(text, as: Severity) ?? Severity.low
}
test "mocked classify returns critical" {
testing.mock(ai.classify).returns(Severity.critical)
assert classify("payment outage") == Severity.critical, "expected critical"
}
keel test triage.keel
Success:
PASS mocked classify returns critical (2ms)
1 test passed in 2ms
Failure:
FAIL mocked classify returns critical (2ms)
assertion failed
0 tests passed, 1 test failed in 2ms
0 passed, 1 failed in triage.keel
Behavior
use std/testingbrings thetestingnamespace into scope for test helpers.testing.mock(Namespace.method).returns(value)overrides that namespace method for the current test only.- Repeating the same mock target returns values in order, then repeats the final value.
- Mocked methods expose test-local metadata:
Namespace.method.called,Namespace.method.call_count, andNamespace.method.called_with(...). setup { ... }runs before assertions in the same test and can bind values for the test body.test "name" for case in cases { ... }runs one indexed case per item in thecaseslist.assert exprrequiresexprto type-check asbool;falsefails the current test.assert expr, "message"uses a custom failure message. The message expression must type-check asstr.- Failed tests print the source location of the failing statement when available.
- Each test runs with its own mock set, so mocks do not leak between tests.
- Passing a directory recursively runs
.keelfiles with test blocks; files without test blocks are skipped. --filter <text>runs only tests whose names containtext.--listprints matching test names without running them.--fail-faststops after the first failing test.--quietsuppresses passing test result lines while still printing failures and the final summary.- A file with no test blocks prints
0 tests foundand exits successfully. - Test result lines include elapsed time after the test name, and the final summary includes total suite time.
keel runignores test blocks.test,setup, andassertare contextual syntax words, not reserved keywords.
keel check
Type-check a Keel program without executing it.
keel check <file.keel>
keel check --strict <file.keel>
What it checks
- Syntax — valid Keel grammar
- Types — type inference and compatibility
- Exhaustiveness —
whenmatches cover all enum variants - Arguments — task call parameter count and types
- Nullable safety —
T?vsTtracking - Scope —
selfonly inside agents, undefined variables
--strict mode
The checker’s default mode accepts bindings whose type cannot be inferred (for example, the result of json.parse or ai.extract without an explicit cast). It silently allows these rather than reporting an error.
--strict changes that: any binding whose inferred type is Unknown becomes a type error. Use it when you want higher confidence that the checker is actually verifying your code.
There are two kinds of unresolved types, and --strict treats them differently:
| Situation | Normal mode | --strict |
|---|---|---|
data: dynamic = json.parse(...) | ✓ silent | ✓ silent — programmer chose dynamic deliberately |
data = json.parse(...) (unannotated) | ✓ silent | ✗ error — no annotation, type cannot be inferred |
data = ai.extract(...) (unannotated) | ✓ silent | ✗ error |
In other words: an explicit dynamic annotation is always accepted. --strict only fires when the checker cannot infer any type, not when the programmer deliberately chose dynamic.
# Passes in normal mode, fails in strict
agent A {
@tools [io]
@on_start {
data = json.parse(raw_input) # unannotated — strict rejects this
io.show("{data}")
}
}
Fix with a dynamic annotation (accept the dynamic type) or a concrete cast:
# Option 1: annotate as dynamic — clean even in --strict
data: dynamic = json.parse(raw_input)
# Option 2: narrow to a concrete type immediately
type Payload { key: str, value: str }
data = json.parse(raw_input) as Payload
Example output
Success:
✓ examples/email_agent.keel is valid
Error with source span:
× Type error
╭─[agent.keel:8:5]
7 │ @on_start {
8 │ greet(42)
· ────┬────
· ╰── task `greet` takes 1 argument(s), got 0 — expected: name
9 │ }
╰────
Every error from keel check includes a line:column pointer and an underlined source excerpt. Arity errors list the expected parameter names as a hint.
keel lint
Check a Keel program for style and best-practice issues beyond what keel check covers.
keel lint <file.keel>
keel lint --fix <file.keel>
What it checks
| Rule | Description |
|---|---|
| Unused variable | A local binding is assigned but never read. Prefix the name with _ to suppress. |
| Uncalled task | A task is declared but never called anywhere in the program. |
| ai. outside agent* | ai.classify, ai.summarize, etc. called from a plain task or top-level statement — these calls lack an @role / @model context and may produce unexpected results. |
| State written, never read | An agent state field is assigned (self.x = …) but self.x is never read. |
Exit codes
| Code | Meaning |
|---|---|
0 | No warnings |
1 | One or more warnings found |
--fix
Applies safe, single-line automatic fixes. Currently fixes:
- Unused variable — removes the assignment statement entirely.
Other rules emit warnings only; no fix is applied.
keel lint --fix agent.keel
Suppressing warnings
Prefix a variable name with _ to tell the linter the value is intentionally unused:
use std/io
agent Processor {
@tools [io]
@on_start {
_unused = compute_something() # no warning
result = compute_other()
io.show(result)
}
}
Example output
⚠ Unused variable
╭─[agent.keel:4:5]
3 │ @on_start {
4 │ temp = "debug value"
· ──┬─
· ╰── assigned here but never read — prefix with _ to suppress
5 │ io.show("hello")
╰────
help: remove this line or rename to _temp
Relationship to keel check
keel check enforces type correctness — programs that fail check cannot run.keel lint enforces style and best practices — programs that fail lint may run, but likely contain dead code or misuse patterns.
Run check first; lint warnings are only meaningful on a type-correct program.
keel check file.keel && keel lint file.keel
keel build
keel buildis deferred post-v0.1. The tree-walking interpreter handles every alpha workload. Usekeel runto execute programs andkeel checkto type-check without running.
keel build is present in the CLI but returns an error today:
keel build examples/hello_world.keel
# error: keel build is deferred post-v0.1 — use `keel run`
keel fmt
Auto-format a Keel file with consistent style.
keel fmt <file.keel>
What it does
- 2-space indentation
- Consistent spacing around operators and
@attributevalues - One declaration per section with blank line separators
- Single-line
whenarms for simple expressions - Writes the formatted output back to the file
Example
Before:
use std/io
use std/schedule
agent Bot{@role "helper"
state{count:int=0}
@on_start{schedule.every(1.day, () => {io.notify("hello")
self.count=self.count+1})}}
run(Bot)
After keel fmt:
use std/io
use std/schedule
agent Bot {
@tools [io]
@role "helper"
state {
count: int = 0
}
@on_start {
schedule.every(1.day, () => {
io.notify("hello")
self.count = self.count + 1
})
}
}
run(Bot)
keel init
Scaffold a new Keel project.
keel init # initialize in the current directory
keel init <project-name> # create a new subdirectory
keel init <path> # create at an absolute or relative path
What it creates
├── main.keel # Starter agent
└── .gitignore
The generated main.keel:
use std/io
# myproject — built with Keel
agent Myproject {
@tools [io]
@role "Describe what this agent does"
@on_start {
io.show("Hello from Myproject!")
stop(self)
}
}
run(Myproject)
The agent name is derived from the project name in PascalCase: my-email-bot → MyEmailBot.
Examples
# Initialize in the current directory
mkdir myproject && cd myproject
keel init
keel run main.keel
# Create a named subdirectory
keel init task-sorter
keel run task-sorter/main.keel
# Use an absolute path (basename is used as the project name)
keel init /tmp/mybot
keel run /tmp/mybot/main.keel
keel init refuses to overwrite an existing main.keel or directory.
keel repl
The REPL pre-imports the entire standard library for convenience — file.read(...), ai.classify(...), etc. work without use std/<name> lines. Programs in files must import modules explicitly.
Interactive REPL for testing types, tasks, and expressions.
keel repl
Usage
Keel REPL v0.1
Type expressions, define tasks, or :help for commands.
keel> type Mood = happy | sad | neutral
✓ type Mood
keel> task greet(name: str) -> str {
... "Hello, {name}!"
... }
✓ task greet
keel> :types
Mood = happy | sad | neutral
keel> :quit
Goodbye.
Commands
| Command | Description |
|---|---|
:help | Show help |
:types | List defined types |
:env | Show environment |
:clear | Reset all state |
:quit | Exit |
Features
- Multi-line input — open braces automatically continue to the next line
- History — up/down arrows navigate command history (saved to
~/.keel_history) - Ctrl+C — cancels current input (doesn’t exit)
- Ctrl+D — exits
LLM Providers
Note: Ollama is the only supported backend. Additional providers implementing the
LlmProviderinterface (stdlib.md) will land in a future release.
Keel’s ai.* operations call a local Ollama instance.
Required
# Install Ollama from https://ollama.com, then pull a model:
ollama pull gemma4
# Point Keel at it:
export KEEL_OLLAMA_MODEL=gemma4
That’s the minimum. Every ai.classify(...), ai.draft(...), etc. now resolves through this model.
Custom host
export OLLAMA_HOST=http://192.168.1.10:11434 # default: http://localhost:11434
Named model aliases
If a program uses @model "fast" or using: "smart", Keel maps those names to Ollama tags via environment variables:
export KEEL_MODEL_FAST=gemma4
export KEEL_MODEL_SMART=mistral:7b-instruct
The lookup order when a call wants model X:
ollama:Xprefix — strip and useXdirectly as the Ollama tagKEEL_MODEL_<X>environment variable (Xuppercased,-→_)KEEL_OLLAMA_MODEL(catch-all)- Configuration error — the call fails with instructions for fixing it
Testing without a real LLM
export KEEL_LLM=mock
All ai.* calls return none (or throw AiSchemaError for schema mismatches). Absence is handled by ?? at the call site. Used by the integration test suite.
Troubleshooting
Ollama unreachable at http://localhost:11434 — the daemon isn’t running. Start it: ollama serve &.
Model 'X' has no mapping — you called @model "X" but there’s no matching KEEL_MODEL_X variable and no KEEL_OLLAMA_MODEL. Set one of them.
Ollama returned 404 — the tag isn’t pulled locally. Fix it: ollama pull <tag>.
Ollama Setup
Ollama runs LLMs locally. No API key, fully offline. It is currently the only supported backend for ai.*.
Install Ollama
# macOS
brew install ollama
# Or download from https://ollama.com
Pull a model
ollama pull gemma4 # general-purpose
ollama pull mistral:7b-instruct # smaller, fast for classification
Start the server
ollama serve
Default address: http://localhost:11434.
Configure Keel
# Use one model for everything
export KEEL_OLLAMA_MODEL=gemma4
keel run agent.keel
Per-model aliases
If your program uses @model "fast" or using: "smart", map those aliases to Ollama tags:
export KEEL_MODEL_FAST=gemma4
export KEEL_MODEL_SMART=mistral:7b-instruct
Then in your program:
urgency = ai.classify(email.body, as: Urgency, using: "fast")
reply = ai.draft("response to {email}", using: "smart")
Custom host
export OLLAMA_HOST=http://192.168.1.100:11434
keel run agent.keel
Verify
KEEL_OLLAMA_MODEL=gemma4 keel run examples/hello_world.keel
Expected output:
⚡ LLM provider: Ollama (http://localhost:11434)
* → gemma4
▸ Starting agent LocalTest
🤖 Classifying as [happy, neutral, sad] using gemma4 (ollama @ ...)
✓ Result: happy
Example: Email Assistant
A complete email agent that triages, auto-replies, and escalates.
use std/ai
use std/email
use std/io
use std/memory
use std/schedule
use std/time
type Urgency = low | medium | high | critical
task triage(msg: {body: str, from: str, subject: str}) -> Urgency {
ai.classify(msg.body,
as: Urgency,
considering: {
"from a known VIP or executive": Urgency.critical,
"mentions a deadline within 24h": Urgency.high,
"asks a direct question": Urgency.medium,
"newsletter or automated message": Urgency.low
},
using: "fast"
) ?? Urgency.medium
}
task brief(msg: {body: str}) -> str {
ai.summarize(msg.body,
in: 1, unit: sentences,
using: "fast"
) ?? "(no summary)"
}
task compose(msg: {body: str, from: str}, guidance: str? = none) -> str {
if guidance != none {
ai.draft("response to {msg.body}", tone: "professional", guidance: guidance)
} else {
ai.draft("response to {msg.body}", tone: "friendly", max_length: 150)
} ?? "(draft failed)"
}
agent EmailAssistant {
@role "You are a professional email assistant"
@tools [ai, io, email]
@memory persistent
state {
handled_count: int = 0
}
task handle(msg: {body: str, from: str, subject: str}) {
urgency = triage(msg)
summary = brief(msg)
when urgency {
low => {
io.notify("Archived: {msg.subject} [{urgency}]")
email.archive(msg)
}
medium => {
reply = compose(msg)
if io.confirm("Auto-reply to '{msg.subject}':\n\n{reply}") {
email.send(reply, to: msg.from)
}
}
high, critical => {
io.notify("{urgency} email from {msg.from}")
io.show({
from: msg.from,
subject: msg.subject,
summary: summary,
urgency: urgency
})
guidance = io.ask("How should I respond?")
reply = compose(msg, guidance)
if io.confirm(reply) {
email.send(reply, to: msg.from)
}
}
}
memory.remember(msg.from, {
subject: msg.subject,
urgency: urgency,
handled_at: time.now()
})
self.handled_count = self.handled_count + 1
}
@on_start {
schedule.every(5.minutes, () => {
inbox = email.fetch(unread: true)
io.notify("{inbox.count()} new emails")
for msg in inbox {
self.handle(msg)
}
})
}
}
run(EmailAssistant)
Setup
export IMAP_HOST=imap.gmail.com
export EMAIL_USER=you@gmail.com
export EMAIL_PASS=your-app-password
export KEEL_OLLAMA_MODEL=gemma4
keel run email_agent.keel
How it works
- Every 5 minutes,
email.fetch(unread: true)pulls new messages. triageclassifies each by urgency using a fast model.low→ auto-archive.medium→ draft a reply, confirm before sending.high/critical→ show a summary, ask for guidance, draft with it, confirm.- Each interaction is remembered for future context.
Six imports declare everything this program touches, and @tools [ai, io, email] grants the agent its effectful capabilities — including the ai.* calls reached through the helper tasks. See The Standard Library.
Example: Multi-Agent Email System
This first runnable workflow keeps synchronous return-value helpers as top-level
tasks. For mailbox-specific coordination between live agents, use the built-in
delegate, send, and broadcast verbs as described in
Agent Communication.
use std/ai
use std/email
use std/io
use std/schedule
type Urgency = low | medium | high | critical
type Category = question | request | info | complaint | spam
type TriageResult {
urgency: Urgency
category: Category
}
task triage_email(msg: {body: str}) -> TriageResult {
urgency = ai.classify(msg.body, as: Urgency) ?? Urgency.medium
category = ai.classify(msg.body, as: Category) ?? Category.question
{urgency: urgency, category: category}
}
task draft_reply(msg: {body: str, from: str}, guidance: str? = none) -> str {
ai.draft("response to {msg.body}",
tone: "professional",
guidance: guidance,
max_length: 200
) ?? "(draft failed)"
}
task plan_followup(msg: {subject: str}, urgency: Urgency) {
when urgency {
critical => schedule.after(2.hours, () => { io.notify("Follow up on: {msg.subject}") })
high => schedule.after(24.hours, () => { io.notify("Check status: {msg.subject}") })
medium => schedule.after(3.days, () => { io.notify("Pending reply: {msg.subject}") })
low => { }
}
}
agent InboxManager {
@tools [ai, email, io]
@role "You coordinate the email handling team"
task handle(msg: {body: str, from: str, subject: str}) {
result = triage_email(msg)
when result.urgency {
low => {
when result.category {
spam, info => email.archive(msg)
_ => {
reply = draft_reply(msg)
if io.confirm(reply) { email.send(reply, to: msg.from) }
}
}
}
medium => {
reply = draft_reply(msg)
if io.confirm(reply) { email.send(reply, to: msg.from) }
plan_followup(msg, result.urgency)
}
high, critical => {
summary = ai.summarize(msg.body, in: 2, unit: sentences) ?? "(no summary)"
io.notify("{result.urgency} {result.category} from {msg.from}")
io.show(summary)
guidance = io.ask("How should I respond?")
reply = draft_reply(msg, guidance)
if io.confirm(reply) { email.send(reply, to: msg.from) }
plan_followup(msg, result.urgency)
}
}
}
@on_start {
schedule.every(5.minutes, () => {
for msg in email.fetch(unread: true) {
self.handle(msg)
}
})
}
}
run(InboxManager)
Architecture
InboxManager (orchestrator agent)
├── triage_email(...) — synchronous classifier helper
├── draft_reply(...) — synchronous response helper
└── plan_followup(...) — synchronous scheduler helper
Use top-level tasks when the caller needs a return value immediately. Use
agents with mailboxes when work should cross a live actor boundary.
delegate(target, task, args) posts a named task event to the target
agent’s mailbox. @team [...] tags a running agent with one or more team names
for broadcast(team, data, event:).
use std/io
agent Classifier {
@tools [io]
@team ["email"]
on refresh(msg: str) { io.show("Classifier refresh: {msg}") }
}
agent Coordinator {
@on_start {
run(Classifier)
broadcast("email", "new batch", event: "refresh")
}
}
Status
Multi-agent collaboration is available with in-process mailboxes. Agent.delegate, Agent.send, Agent.broadcast, and @team routing are wired. Current limits: delivery is in-process only, broadcast is non-blocking, and agents without a matching handler silently ignore the event.
Changelog
See Release Notes for the full version history.