Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

v0.2.1 — latest release

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

  1. 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 same use syntax as your own files.
  2. Agents are primitives. agent is the only concurrency model. Per-agent serial mailboxes with isolated mutable state via self.
  3. Capabilities are deny-by-default. Effectful modules must be declared in @tools before an agent can use them. Undeclared calls are compile-time errors, not surprises in production.
  4. Interfaces everywhere. LLM providers, memory stores, HTTP clients, loggers — all behind interfaces. Users swap implementations without leaving the language.
  5. Statically typed. Full inference. Exhaustive pattern matching. Nullable safety. No implicit any.
  6. 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

Installation

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-in test blocks.
  • 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/file binds file; use "./validation.keel" binds validation (the file stem); as renames 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.keel runs 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 (the Agent.* namespace is gone).
  • Breaking: File.read(...) and friends now fail with a migration hint — add use std/file and write file.read(...). The bare uuid() free function is removed; use std/uuid and uuid.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:

ProducerOverflow 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.serve503 Service Unavailable returned to the HTTP client
Agent.send / Agent.delegate / Agent.broadcastRuntimeBusy 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:

ProducerOverflow 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.serve503 Service Unavailable returned to the HTTP client
Agent.send / Agent.delegate / Agent.broadcastRuntimeBusy 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:

VariantWhen it appearsFires in --strict?
dynamicExplicit dynamic annotation written by the programmerNever
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 implementYes

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 — converts list[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 database
  • DbConnection.query(sql, params?) -> list[map[str, dynamic]] — SELECT queries return rows as maps
  • DbConnection.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
FunctionReturnsNotes
math.PI()floatπ
math.E()floate
math.sqrt(x)floatRaises if x < 0
math.pow(x, y)float
math.exp(x)floate^x
math.log(x)floatNatural log; raises if x ≤ 0
math.log2(x)floatRaises if x ≤ 0
math.log10(x)floatRaises if x ≤ 0
math.sin(x)floatRadians
math.cos(x)floatRadians
math.tan(x)floatRadians
math.asin(x)floatRaises if x ∉ [-1, 1]
math.acos(x)floatRaises if x ∉ [-1, 1]
math.atan(x)floatRadians
math.atan2(y, x)floatTwo 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 impl blocks 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

Iterable materialises 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
  • impl is a new reserved keyword.
  • Any struct type can implement Stringable — each type gets its own impl block.
  • Values that do not implement Stringable still 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. Point and Vec2 both {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 with T automatically.

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}

See Prelude → Free Functions.

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 nullablex?.field on a nullable struct now types as FieldType? instead of unknown.
  • ?? unwraps nullablex ?? fallback now returns the inner type of x, not the fallback’s type.
  • ai.extract / ai.decide with as: — when an as: T argument is present, the return type is inferred as T? rather than unknown?. Downstream field accesses on the result are now checked.
  • Lambda block bodiesx => { ... } now infers its return type from the last expression, matching expression-body lambdas.
  • set[] literal typeset[1, 2, 3] now infers as set[int] instead of list[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 an if expression must produce the same concrete type. When one branch exits via return, 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:

BeforeAfter
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:

FailureResultHandle with
Network failure / mock mode / timeoutReturns none??
LLM returned output that doesn’t match the schemaThrows AiSchemaErrortry/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:

FieldTypeValue
messagestrHuman-readable description
gotstrThe 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 → use dt.format(as:)
  • time.diff(a, b) removed → use a - b
  • time.parse() rejects naive strings without a TZ offset — returns none instead of raising. Use time.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.)
  • nonememory.* calls raise CapabilityError.
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:

RuleTrigger
Unused variablebinding assigned but never read
Uncalled tasktask declared but never invoked
ai.* outside agentLLM call without @role / @model context
State written, never readself.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, and type declarations 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, list
  • json_processing.keel — JSON parse and stringify
  • cron_schedule.keel — Cron expression scheduling
  • parallel_execution.keel — Async task spawning and joining
  • capability_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.

ExpressionInferred 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:

MethodReturn 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.summarizeformat: and max: wired

summary = ai.summarize(article, format: bullets, max: 5, unit: sentences)

ai.promptresponse_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_level now go through typed setters, removing all unsafe env 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
  • agent is the only concurrency primitive (actor model, serial mailbox, isolated self state)
  • Prelude-as-stdlib — Ai, Io, Email, Http, Schedule, Agent, Log, … in scope everywhere, no use needed
  • Algebraic types: simple enums, rich enums with per-variant fields, structural interfaces
  • Exhaustive when pattern matching, duration literals (5.minutes), nullable syntax (T?, ??, ?.)

Tooling:

  • keel run — execute, with --trace and --log-level
  • keel check — static analysis (scope, arity, enum exhaustiveness)
  • keel fmt — idempotent AST pretty-printer
  • keel repl — interactive, history-aware
  • keel 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 | sh
  • brew 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?

  1. use std/io, use std/schedule — imports the stdlib modules this file uses. Each import binds a lowercase module name (io, schedule).
  2. agent Hello — declares an agent.
  3. @tools [io] — grants the agent access to the effectful io module. Capabilities are deny-by-default inside agents.
  4. @role "..." — an attribute describing what the agent does. Bound to the LLM provider for any ai.* calls.
  5. @on_start { ... } — a lifecycle hook that runs when the agent starts.
  6. schedule.every(5.seconds, () => { ... }) — schedules a recurring block. schedule is a stdlib module, not a keyword.
  7. io.notify(...) — prints to the terminal.
  8. run(Hello) — starts the agent. run is 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

ConceptWhat it does
type Priority = low | medium | high | criticalEnum — the type checker enforces exhaustive matching
ai.classify(x, as: T) ?? VLLM-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

TypeExampleNotes
int4264-bit integer
float3.1464-bit float
str"hello"UTF-8, supports interpolation
booltrue, false
nonenoneAbsence of value
duration5.minutesTime 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 ...base spread must appear first, exactly once.
  • Zero or more field: value overrides follow, separated by commas or newlines.
  • Spreading a none value 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:

PropertyReturnsDescription
.countintNumber of elements
.firstT?First element or none
.lastT?Last element or none
.is_emptyboolTrue 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")
}

() -> none is 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.

MethodReturnsNotes
.abs()same typeAbsolute value
.floor()same typeRound toward −∞; no-op on int
.ceil()same typeRound toward +∞; no-op on int
.round()same typeRound 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.

FromToResult
intfloatWidens: 5 as float5.0
floatintTruncates toward zero: 1.9 as int1
int / float / boolstrDisplay string: 42 as str"42"
strintParses; raises if not a valid integer
strfloatParses; raises if not a valid float
strbool"true"true, "false"false; raises otherwise
UuidstrHyphenated string: "f47ac10b-..."
strUuidValidates UUID format; raises if invalid
dynamicanyPass-through — used with ai.prompt(...) as T and json.parse
noneanyRaises
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.

OperatorValid 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 orany
+= -= *= /= %=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:

AttributeCore?StatusPurpose
@roleYesThe 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
@modelYesThe 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, email, file, shell, db, search, env — and an agent with no @tools attribute may call none of them. Pure-compute and internal modules (json, math, time, schedule, …) are never gated; they only need their import. @tools all is 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 @memory is omitted.
  • none — disables memory.* for this agent; any call raises CapabilityError.
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:

CallDescription
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 the VectorStore interface.

@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:

FunctionNotes
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: &quot;greeting&quot;)"]
    s2["send(B, data, event: &quot;payment&quot;)"]
    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 &lt;event&gt; 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 trigger RuntimeBusy without flooding the queue.

Key Properties

PropertyBehaviour
Routingevent: string in send matches the name in on <event>
Default eventOmitting event: routes to on message
No matchUnhandled events are silently dropped
SendNon-blocking — sender continues immediately
Queue fullRuntimeBusy error raised — catch to handle backpressure
ExecutionHandlers run one at a time — no race conditions on self.
ScopeIn-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
}

break and continue are 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

MethodReturns
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:

SituationMechanismHandle with
LLM unavailable / mock / network failureReturns T? (none)?? or when
LLM gave output that didn’t match schemaThrows AiSchemaErrortry/catch
Namespace operation failed (I/O, network, parse)Throws a typed errortry/catch
Fatal config errorHard errorfix the config
Programmer fault (none!, bad cast)Throws Errortry/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 typeRaised byNotes
FileErrorfile.*I/O failures: not found, permission denied, etc.
CsvErrorcsv.*Parse failures, bad row structure, non-string cells
DbErrordb.*Query/exec failures, connection errors
CacheErrorcache.*Serialization errors
MathErrormath.*Domain errors: sqrt(-1), log(0), asin(2)
MemoryErrormemory.*Persistence errors, @memory none restriction
EmailErroremail.*IMAP/SMTP failures
HttpErrorhttp.*Network failures (connection refused, timeout)
ShellErrorshell.*Failed to spawn shell or wait for process
JsonErrorjson.*JSON parse errors, serialization failures
EnvErrorenv.requireRequired env variable not set
AiErrorai.*LLM config errors
AiSchemaErrorai.extract, ai.classifyLLM output didn’t match expected schema (got field contains raw output)
CapabilityErrorany @tools-restricted methodMethod not allowed by the agent’s @tools list
TimeoutErrorcontrol.with_timeoutClosure exceeded the given duration
DeadlineErrorcontrol.with_deadlineClosure ran past the deadline
UserRaisedraise statementUser-raised error
RuntimeBusyany async eventInterpreter event queue full

Catch any of these specifically, or use catch e: Error as a fallback for all.

AiSchemaError extra field:

FieldTypeValue
messagestrHuman-readable description
gotstrThe 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)"
  • impl and for are reserved keywords.
  • self inside the block receives the struct value. Use self.field to 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 impl blocks 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 Tlist[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: Iterable is 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 interfacesinterface 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

FormBindsExample access
use std/filefilefile.read(...)
use std/file as fff.read(...)
use "./validation.keel"validation (file stem)validation.email(...)
use "./validation.keel" as vvv.email(...)
use email, helper as h from "./validation.keel"email, hemail(...), h(...)
use parse from std/jsonparseparse(...)

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" then run(Watcher).
  • Types and enums are imported by name — use Urgency from "./models.keel" — and then used exactly like a local type, including when exhaustiveness. Types keep their declared identity, so they cannot be renamed with as.
  • Interfaces and impls travel with the module: importing a module activates its impl blocks 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 matching use 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, or every. 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

ModuleStatusPurpose
std/ai🟡LLM operations: classify, extract, summarize, draft, translate, decide, prompt · embed
std/ioHuman interaction: ask, confirm, notify, show
std/scheduleTime-based scheduling: every, after, at, cron, sleep
std/email🟡IMAP/SMTP: fetch, send, archive
std/httpHTTP client + server: get, post, request, serve
std/envEnvironment: get(name), require(name)
std/logStructured 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/memoryPer-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/controlretry, with_timeout, with_deadline
std/asyncStructured concurrency: spawn, join_all, select, sleep
std/cache🟡In-memory process-scoped cache: set, get, delete, clear
String value methodsRegex and formatting directly on string values: matches, extract, find_all, sub, truncate, pad
std/fileFilesystem: 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/dbSQLite: 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.ms1.week. Operators: dt ± dur → dt, dt - dt → duration, </> comparison. Naive strings rejected — use RFC 3339 or tz:.
std/randomPseudo-random generation: float(), int(min:, max:), bool(). Use Crypto for security-sensitive randomness.
std/uuidUUID values: v4, v7, deterministic v5, parse, uuid() alias, and value methods version, format, to_str.
std/cryptoCryptographic primitives: fixed safe SHA-2 hash/HMAC methods, token, random_bytes.
std/mathTranscendentals: sqrt, pow, exp, log (natural), log2, log10, sin, cos, tan, asin, acos, atan, atan2. Constants: PI(), E(). All return float. Domain errors raise.
std/shellSubprocess bridge: execute shell commands via /bin/sh -c and capture stdout/stderr. Requires @tools [shell].
std/csvCSV serialization: parse, parse_records, stringify — RFC 4180 compliant
std/testingTest 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.

FunctionSignatureReturnsNotes
run(agent)(agent) -> nonenoneStart an agent
stop(agent)(agent) -> nonenoneStop an agent
send(agent, msg)(agent, dynamic) -> nonenonePost to an agent’s mailbox
delegate(Agent.handler, data)(handler, dynamic) -> nonenonePost a named handler event
broadcast(team, msg)(str, dynamic) -> nonenoneFan out to a @team
typeof(value)(dynamic) -> strstrRuntime 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.

MethodSignatureDescription
random.floatrandom.float() → floatReturn a random float in the range [0, 1).
random.intrandom.int(min: int, max: int) → intReturn a random integer in the inclusive range [min, max].
random.boolrandom.bool() → boolReturn 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().

MethodSignatureDescription
uuid.v4uuid.v4() → UuidGenerate a random UUID v4.
uuid.v7uuid.v7() → UuidGenerate a time-sortable UUID v7.
uuid.v5uuid.v5(ns: Uuid, name: str) → UuidGenerate a deterministic UUID v5 from a namespace UUID and a name.
uuid.parseuuid.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.

MethodReturnsNotes
.version()intUUID version number
.format(as:)str"hyphenated", "simple", or "urn"
.to_str()strLowercase 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.

MethodSignatureDescription
crypto.sha224crypto.sha224(data: str) → strReturn the SHA-224 hex digest of a string.
crypto.sha256crypto.sha256(data: str) → strReturn the SHA-256 hex digest of a string.
crypto.sha384crypto.sha384(data: str) → strReturn the SHA-384 hex digest of a string.
crypto.sha512crypto.sha512(data: str) → strReturn the SHA-512 hex digest of a string.
crypto.sha512_224crypto.sha512_224(data: str) → strReturn the SHA-512/224 hex digest of a string.
crypto.sha512_256crypto.sha512_256(data: str) → strReturn the SHA-512/256 hex digest of a string.
crypto.hmac_sha224crypto.hmac_sha224(key: str, data: str) → strReturn the HMAC-SHA-224 hex digest.
crypto.hmac_sha256crypto.hmac_sha256(key: str, data: str) → strReturn the HMAC-SHA-256 hex digest.
crypto.hmac_sha384crypto.hmac_sha384(key: str, data: str) → strReturn the HMAC-SHA-384 hex digest.
crypto.hmac_sha512crypto.hmac_sha512(key: str, data: str) → strReturn the HMAC-SHA-512 hex digest.
crypto.hmac_sha512_224crypto.hmac_sha512_224(key: str, data: str) → strReturn the HMAC-SHA-512/224 hex digest.
crypto.hmac_sha512_256crypto.hmac_sha512_256(key: str, data: str) → strReturn the HMAC-SHA-512/256 hex digest.
crypto.tokencrypto.token(len: int) → strGenerate a random URL-safe token of the given byte length.
crypto.random_bytescrypto.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.

MethodSignatureDescription
db.connectdb.connect(url: str) → DbConnectionOpen 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:

URLOpens
sqlite://trades.dbRelative path
sqlite:///tmp/trades.dbAbsolute 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.

MethodSignatureDescription
json.parsejson.parse(s: str) → dynamicParse a JSON string into a dynamic value.
json.stringifyjson.stringify(value: dynamic) → strSerialize 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:

JSONKeel runtime value
object {}field-accessible — parsed.fieldName works, raises on missing key
array []list[dynamic] — indexable and iterable
integerint
floatfloat
stringstr
booleanbool
nullnone

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.

MethodSignatureDescription
cache.setcache.set(key: str, value: dynamic, ttl?: duration) → noneStore a value in the in-process cache, with an optional TTL duration.
cache.getcache.get(key: str) → dynamic?Retrieve a cached value, returning none if absent.
cache.deletecache.delete(key: str) → noneRemove a key from the cache.
cache.clearcache.clear() → noneClear 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.

MethodSignatureDescription
log.infolog.info(message: dynamic) → noneEmit an info-level log message.
log.warnlog.warn(message: dynamic) → noneEmit a warning-level log message.
log.errorlog.error(message: dynamic) → noneEmit an error-level log message.
log.debuglog.debug(message: dynamic) → noneEmit a debug-level log message.
log.set_levellog.set_level(level: str) → noneSet the minimum log level (“debug”, “info”, “warn”, or “error”).
log.levellog.level() → strReturn 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 provides web and news search. Both methods return a list of SearchResult values.

MethodSignatureDescription
search.websearch.web(query: str) → dynamicSearch the web and return a list of SearchResult values.
search.newssearch.news(query: str) → dynamicSearch for recent news and return a list of SearchResult values.

std/search is registered but raises a “not yet implemented” error at runtime. Wire a custom backend via the SearchProvider interface when it lands.

Async

Async provides structured concurrency — run multiple tasks in parallel and collect their results.

MethodSignatureDescription
async.spawnasync.spawn() → dynamicSpawn a concurrent task and return a handle.
async.join_allasync.join_all(handles: dynamic) → dynamicWait for all async task handles to complete.
async.selectasync.select(handles: dynamic) → dynamicReturn the result of the first completed task handle.
async.sleepasync.sleep(duration: duration) → nonePause 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. Search is registered and raises a clear “planned for v0.2” error; ai.embed returns 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:

ModuleInterface(s)
std/aiLlmProvider
std/memoryVectorStore, Embedder
std/httpHttpClient
std/emailEmailTransport
std/searchSearchProvider
std/logTracer

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 declared type T { ... } struct; raises a runtime error if T is not a known struct type. As of v0.1.19 the type checker resolves T from the as: 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: json injects “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: booltrue 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.cron is 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 — it would shadow the email module binding, and 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 with method, path, body (all strings)
  • Return a map with status (integer, 100–999) and body (string)
  • The server runs in a background task; http.serve returns 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 Unavailable response automatically. No user code runs for that request.

Handlers run outside any agent context. An http.serve handler is a top-level closure — it fires on the event loop with no current_agent set. That has two consequences:

  • self.<field> raises a runtime error inside a handler. Agent state is only reachable from within an agent’s tasks / on handlers / attribute blocks.
  • ai.* calls are not agent-aware. No @role, no @rules, and no @model injection — 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 @role from a handler, route the request into a live agent. The matching on http_request(req) { ... } handler on Triage runs with self. and @role all 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

MethodSignatureDescription
file.readfile.read(path: str) → strRead a file and return its contents as a string.
file.writefile.write(path: str, content: str) → noneWrite a string to a file, creating or overwriting it.
file.existsfile.exists(path: str) → boolReturn true if the path exists on the filesystem.
file.listfile.list(path: str) → list[str]List the entries in a directory.
file.mkdirfile.mkdir(path: str) → noneCreate a directory and all intermediate parents.
file.removefile.remove(path: str) → noneRemove a file or directory.
file.copyfile.copy(src: str, dst: str) → noneCopy a file from src to dst.
file.globfile.glob(pattern: str) → list[str]Return file paths that match a glob pattern.
file.movefile.move(src: str, dst: str) → noneMove (rename) a file from src to dst.
file.mktempfile.mktemp() → strCreate 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.

ArgumentNotes
cmdRequired. 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:

KeyTypeNotes
stdoutstrStandard output captured from the process.
stderrstrStandard error captured from the process.
exit_codeint0 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:

VariableWhy forwarded
PATHCommand resolution
HOMEMany tools read ~
SHELLScripts that re-exec themselves
TMPDIRTemp file location
USERIdentity for tools that need it
LANGLocale / 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 typeKeel 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
stringstr
booleanbool
nullnone

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 --strict flags unannotated json.parse bindings because the return type cannot be inferred statically. Add data: dynamic = json.parse(...) (explicit dynamic annotation, always clean) or cast immediately to a concrete type with as T.

Tip: narrow immediately after parsing. Keeping values as dynamic throughout 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 path
  • sqlite:///abs/path/to/db.db — absolute path
  • sqlite://: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:

  • int for INTEGER
  • float for REAL
  • str for TEXT
  • none for 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

SequenceResult
\nNewline
\tTab
\rCarriage return
\\Backslash
\"Double quote
\{Literal { (prevents interpolation)
\}Literal }
io.notify("Line 1\nLine 2")
io.notify("Price: \{not interpolated\}")

String methods

MethodReturnsExample
.len() / .lengthint"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()stridentity — useful in generic contexts
.truncate(max)strTruncates to max chars; appends "…" if cut. max must be ≥ 0
.pad(width, char?)strLeft-pads to width with char (default " "). width must be ≥ 0
.matches(pattern)booltrue 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)strReplace 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

SpecAlignmentExample
:<NLeft-align, space-padded to N chars{"hi":<8}"hi "
:>NRight-align, space-padded to N chars{42:>8}" 42"
:^NCenter, space-padded to N chars{"hi":^8}" hi "
:NBare 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 {
    ...
  }
}
  • impl is a reserved keyword.
  • for is the same keyword used in for loops; there is no ambiguity because impl always precedes it here.
  • self inside the block refers to the receiver value (typed as TypeName). Use self.field to access struct fields.
  • Only methods matching a declared interface signature are valid inside impl blocks.

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
MethodReturnsNotes
cache.set(key, value, ttl?)nonettl is a duration literal; omit for no expiry
cache.get(key)dynamic?none if missing or expired; stored type preserved
cache.delete(key)noneNo-op if key doesn’t exist
cache.clear()noneFlushes 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: Cache is process-wide (all agents share one store) and cleared on restart. Memory is per-agent and optionally persistent. Use Cache for rate-limiting tokens, deduplication keys, or short-lived shared results; use Memory for per-agent state that should survive across runs.

keel run

Execute a Keel program.

keel run <file.keel>

Pipeline

  1. Lex — tokenize the source
  2. Parse — build the AST
  3. Load modules — resolve use imports transitively (each file parsed once; cycles rejected)
  4. Type check — verify types, exhaustiveness, argument types across the whole module graph
  5. 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.

FlagEffect
--traceSurfaces 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 until Ctrl+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+C exits immediately regardless of what the runtime is blocked on (stdin prompt, LLM call, HTTP request, IMAP fetch) — exit code 130

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/testing brings the testing namespace 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, and Namespace.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 the cases list.
  • assert expr requires expr to type-check as bool; false fails the current test.
  • assert expr, "message" uses a custom failure message. The message expression must type-check as str.
  • 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 .keel files with test blocks; files without test blocks are skipped.
  • --filter <text> runs only tests whose names contain text.
  • --list prints matching test names without running them.
  • --fail-fast stops after the first failing test.
  • --quiet suppresses passing test result lines while still printing failures and the final summary.
  • A file with no test blocks prints 0 tests found and exits successfully.
  • Test result lines include elapsed time after the test name, and the final summary includes total suite time.
  • keel run ignores test blocks.
  • test, setup, and assert are 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
  • Exhaustivenesswhen matches cover all enum variants
  • Arguments — task call parameter count and types
  • Nullable safetyT? vs T tracking
  • Scopeself only 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:

SituationNormal mode--strict
data: dynamic = json.parse(...)✓ silentsilent — programmer chose dynamic deliberately
data = json.parse(...) (unannotated)✓ silenterror — no annotation, type cannot be inferred
data = ai.extract(...) (unannotated)✓ silenterror

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

RuleDescription
Unused variableA local binding is assigned but never read. Prefix the name with _ to suppress.
Uncalled taskA 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 readAn agent state field is assigned (self.x = …) but self.x is never read.

Exit codes

CodeMeaning
0No warnings
1One 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 build is deferred post-v0.1. The tree-walking interpreter handles every alpha workload. Use keel run to execute programs and keel check to 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 @attribute values
  • One declaration per section with blank line separators
  • Single-line when arms 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-botMyEmailBot.

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

CommandDescription
:helpShow help
:typesList defined types
:envShow environment
:clearReset all state
:quitExit

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 LlmProvider interface (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:

  1. ollama:X prefix — strip and use X directly as the Ollama tag
  2. KEEL_MODEL_<X> environment variable (X uppercased, -_)
  3. KEEL_OLLAMA_MODEL (catch-all)
  4. 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

  1. Every 5 minutes, email.fetch(unread: true) pulls new messages.
  2. triage classifies each by urgency using a fast model.
  3. low → auto-archive.
  4. medium → draft a reply, confirm before sending.
  5. high/critical → show a summary, ask for guidance, draft with it, confirm.
  6. 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.