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