The Prelude & Interfaces
Alpha (v0.1). Breaking changes expected.
Keel’s standard library is auto-imported into every program. You never write use keel/ai to get Ai.classify. The namespace is already in scope.
This page explains how the prelude works, why it exists, and where custom implementations fit into the design. v0.1 ships Ollama as the only LLM backend; broad runtime swapping is planned.
Why a Prelude
- Small core. The compiler doesn’t know about
classify,fetch, orevery. Those are library function calls that happen to always be in scope. Parser, lexer, and type checker stay free of domain-specific special cases. - Keyword feel. You still write
Ai.classify(...)without ceremony. The namespace qualifier is short; autocomplete does the work. - Interface boundary. Prelude namespaces are designed around interfaces so custom LLM providers, memory stores, schedulers, or HTTP clients can be installed in a later runtime. v0.1 exposes
using:model aliases forAi.*; it does not yet expose a general provider registry. - No grammatical ambiguity. Every stdlib call is an ordinary function call. No
fetch X where Yspecial parsing.
The Namespaces
Status legend: ✅ shipping · 🟡 partial · ⏳ Coming soon
| Namespace | Status | Purpose |
|---|---|---|
Ai | 🟡 | LLM operations: classify, extract, summarize, draft, translate, decide, prompt · embed ⏳ |
Io | ✅ | Human interaction: ask, confirm, notify, show |
Http | ✅ | HTTP client + server: get, post, request, serve |
Email | 🟡 | IMAP/SMTP: fetch, send, archive |
File | ✅ | Filesystem: read, write, exists, list, mkdir, remove, copy, move, glob, mktemp |
Shell | ✅ | Subprocess bridge: execute shell commands via /bin/sh -c and capture stdout/stderr. Requires @tools [Shell]. |
String value methods | ✅ | Regex and formatting directly on string values: matches, extract, find_all, sub, truncate, pad |
Json | 🟡 | JSON: parse, stringify |
Csv | ✅ | CSV serialization: parse, parse_records, stringify — RFC 4180 compliant |
Cache | 🟡 | In-memory process-scoped cache: set, get, delete, clear |
Search | 🟡 | Web search providers: web(query) — registered; raises “planned for v0.2” error |
Db | ✅ | SQLite: connect(url) → DbConnection; db.query(sql, params?) → list[map[str,dynamic]]; db.exec(sql, params?) → int |
Memory | ✅ | Per-agent key-value store: remember, recall, forget. Scope set by @memory session|persistent|none (default: session). Persistent mode writes ~/.keel/memory/<stem>_<hash12>/<agent>.json. |
Schedule | ✅ | Time-based scheduling: every, after, at, cron, sleep |
Async | ✅ | Structured concurrency: spawn, join_all, select, sleep |
Control | ✅ | retry, with_timeout, with_deadline |
Env | ✅ | Environment: get(name), require(name) |
Time | 🟡 | Factories: now(), now(tz: name), parse(str), parse(str, tz: name), epoch_ms() → int. Methods on value: dt.parts() → map, dt.format(as: pattern) → str?. Duration literals: 500.ms … 1.week. Operators: dt ± dur → dt, dt - dt → duration, </> comparison. Naive strings rejected — use RFC 3339 or tz:. |
Math | ✅ | Transcendentals: sqrt, pow, exp, log (natural), log2, log10, sin, cos, tan, asin, acos, atan, atan2. Constants: PI(), E(). All return float. Domain errors raise. |
Random | ✅ | Pseudo-random generation: float(), int(min:, max:), bool(). Use Crypto for security-sensitive randomness. |
Uuid | ✅ | UUID values: v4, v7, deterministic v5, parse, uuid() alias, and value methods version, format, to_str. |
Crypto | ✅ | Cryptographic primitives: fixed safe SHA-2 hash/HMAC methods, token, random_bytes. |
Log | ✅ | Structured logging: info, warn, error, debug, plus set_level, level. Threshold default is info; raise via --log-level debug, KEEL_LOG_LEVEL=debug, or Log.set_level("debug") at runtime. |
Agent | ✅ | run, stop, send, delegate, broadcast |
run and stop are re-exported at the top level so programs can end with run(MyAgent) without the namespace prefix.
Free Functions
A small set of functions live directly in the global scope — no namespace prefix needed.
| Function | Signature | Returns | Notes |
|---|---|---|---|
run(agent) | (agent) -> none | none | Start an agent |
stop(agent) | (agent) -> none | none | Stop an agent |
uuid() | () -> Uuid | Uuid | Alias for Uuid.v4() |
min(...) | (...items: T, by: (T -> any)? = none) -> T? | T? | Minimum; none on empty |
max(...) | (...items: T, by: (T -> any)? = none) -> T? | T? | Maximum; none on empty |
min and max accept any number of positional arguments, an optional by: key-selector, and return none when called with no items.
min(3, 1, 4) # 1
max("banana", "apple", "cherry") # cherry
scores = [4, 9, 2, 7]
min(scores) # 2 — single list auto-spread
max(...scores, 99) # 99 — explicit spread
people = [{name: "Alice", age: 30}, {name: "Bob", age: 25}]
min(people, by: p => p.age) # {name: "Bob", age: 25}
max(people, by: p => p.age) # {name: "Alice", age: 30}
Random
Random produces non-cryptographic pseudo-random values for simulation, sampling, games, and tests where security is not involved. Use Crypto for tokens, secrets, signatures, or key material.
| Method | Signature | Description |
|---|---|---|
Random.float | Random.float() → float | Return a random float in the range [0, 1). |
Random.int | Random.int(min: int, max: int) → int | Return a random integer in the inclusive range [min, max]. |
Random.bool | Random.bool() → bool | Return a random boolean. |
roll = Random.int(min: 1, max: 6)
sample = Random.float()
enabled = Random.bool()
Uuid
Uuid is a distinct value type, not a str. It displays and interpolates as a lowercase hyphenated UUID, and it can be converted explicitly with .to_str().
uuid() is a free-function alias for Uuid.v4().
| Method | Signature | Description |
|---|---|---|
Uuid.v4 | Uuid.v4() → Uuid | Generate a random UUID v4. |
Uuid.v7 | Uuid.v7() → Uuid | Generate a time-sortable UUID v7. |
Uuid.v5 | Uuid.v5(ns: Uuid, name: str) → Uuid | Generate a deterministic UUID v5 from a namespace UUID and a name. |
Uuid.parse | Uuid.parse(s: str) → Uuid? | Parse a UUID string, returning none on failure. |
Namespace constants Uuid.DNS, Uuid.URL, Uuid.OID, and Uuid.X500 are available for Uuid.v5.
| Method | Returns | Notes |
|---|---|---|
.version() | int | UUID version number |
.format(as:) | str | "hyphenated", "simple", or "urn" |
.to_str() | str | Lowercase hyphenated string |
id: Uuid = uuid()
trace = Uuid.v7()
site = Uuid.v5(ns: Uuid.DNS, name: "keel-lang.dev")
parsed = Uuid.parse("f47ac10b-58cc-4372-a567-0e02b2c3d479")
simple = id.format(as: "simple")
Crypto
Crypto provides security-grade primitives backed by the operating system CSPRNG. It is distinct from Random; use Crypto for tokens, secrets, signatures, digests, and other security-sensitive work.
| Method | Signature | Description |
|---|---|---|
Crypto.sha224 | Crypto.sha224(data: str) → str | Return the SHA-224 hex digest of a string. |
Crypto.sha256 | Crypto.sha256(data: str) → str | Return the SHA-256 hex digest of a string. |
Crypto.sha384 | Crypto.sha384(data: str) → str | Return the SHA-384 hex digest of a string. |
Crypto.sha512 | Crypto.sha512(data: str) → str | Return the SHA-512 hex digest of a string. |
Crypto.sha512_224 | Crypto.sha512_224(data: str) → str | Return the SHA-512/224 hex digest of a string. |
Crypto.sha512_256 | Crypto.sha512_256(data: str) → str | Return the SHA-512/256 hex digest of a string. |
Crypto.hmac_sha224 | Crypto.hmac_sha224(key: str, data: str) → str | Return the HMAC-SHA-224 hex digest. |
Crypto.hmac_sha256 | Crypto.hmac_sha256(key: str, data: str) → str | Return the HMAC-SHA-256 hex digest. |
Crypto.hmac_sha384 | Crypto.hmac_sha384(key: str, data: str) → str | Return the HMAC-SHA-384 hex digest. |
Crypto.hmac_sha512 | Crypto.hmac_sha512(key: str, data: str) → str | Return the HMAC-SHA-512 hex digest. |
Crypto.hmac_sha512_224 | Crypto.hmac_sha512_224(key: str, data: str) → str | Return the HMAC-SHA-512/224 hex digest. |
Crypto.hmac_sha512_256 | Crypto.hmac_sha512_256(key: str, data: str) → str | Return the HMAC-SHA-512/256 hex digest. |
Crypto.token | Crypto.token(len: int) → str | Generate a random URL-safe token of the given byte length. |
Crypto.random_bytes | Crypto.random_bytes(len: int) → list[int] | Generate cryptographically random bytes as a list of integers. |
digest = Crypto.sha256("hello")
wide = Crypto.sha384("hello")
sig = Crypto.hmac_sha256("message", key: secret)
token = Crypto.token(bytes: 32)
bytes = Crypto.random_bytes(16)
Crypto intentionally exposes fixed safe SHA-2 methods only. Legacy hashes such as MD5 and SHA-1, and string-selected hash algorithms, are not exposed.
Db
Db provides SQLite-backed durable storage. Db.connect returns a connection value; .query and .exec are called directly on that value.
| Method | Signature | Description |
|---|---|---|
Db.connect | Db.connect(url: str) → DbConnection | Open a database connection and return a DbConnection. |
db.query(sql) and db.exec(sql) are called on a DbConnection value returned by Db.connect. Both accept an optional second argument params: list for ? placeholder binding.
Connection URLs use the sqlite:// scheme:
| URL | Opens |
|---|---|
sqlite://trades.db | Relative path |
sqlite:///tmp/trades.db | Absolute path |
sqlite://:memory: | In-memory (tests, scratch) |
Other schemes (postgres://, mysql://) raise a clear error — “only sqlite:// is supported in v0.1”.
Row return type. Each row is a map[str, dynamic]. Access fields by column name and narrow with as T:
db = Db.connect("sqlite://trades.db")
db.exec("CREATE TABLE IF NOT EXISTS trades (id TEXT, symbol TEXT, price REAL, qty REAL)")
db.exec("INSERT INTO trades VALUES (?, ?, ?, ?)", ["t1", "BTCUSDT", 67000.0, 0.01])
rows = db.query("SELECT symbol, price, qty FROM trades WHERE symbol = ?", ["BTCUSDT"])
for row in rows {
symbol = row["symbol"] as str
price = row["price"] as float
qty = row["qty"] as float
Log.info("{symbol} — {qty} @ {price:.2f}")
}
Multiple databases in the same program — each Db.connect call returns an independent connection value:
trades = Db.connect("sqlite://trades.db")
analytics = Db.connect("sqlite://analytics.db")
rows = trades.query("SELECT * FROM fills")
analytics.exec("INSERT INTO daily_pnl VALUES (?)", [pnl])
Parameterized queries. Use ? placeholders and pass a list as the second argument. Supported param types: str, int, float, bool (stored as 0/1), none (NULL).
v0.1 scope. SQLite only. Postgres and MySQL support, along with a pluggable multi-backend registry, are planned for v0.2. Requires
@tools [Db].
Json
Json.parse and Json.stringify bridge between Keel values and JSON strings.
| Method | Signature | Description |
|---|---|---|
Json.parse | Json.parse(s: str) → dynamic | Parse a JSON string into a dynamic value. |
Json.stringify | Json.stringify(value: dynamic) → str | Serialize a value to a JSON string. |
Json.parse return-type semantics. The result is dynamic — the type is not known statically. At runtime, JSON types map to Keel values as follows:
| JSON | Keel runtime value |
|---|---|
object {} | field-accessible — parsed.fieldName works, raises on missing key |
array [] | list[dynamic] — indexable and iterable |
| integer | int |
| float | float |
| string | str |
| boolean | bool |
| null | none |
Narrow with as T as early as possible:
body = Http.get("https://api.example.com/ticker")?.body ?? ""
data = Json.parse(body) as dynamic
price = (data.price as str).to_float() ?? 0.0
volume = data.volume as int
rows = data.candles as list[dynamic]
Json.stringify serialises Keel structs, maps, lists, and primitives to JSON. If the value implements Serializable, its to_json() method is called instead of the default serialiser.
Cache
Cache is a process-scoped in-memory key-value store with optional TTL. It is shared across all agents in the same process — a convenient way to pass state between agents without serialising to disk.
| Method | Signature | Description |
|---|---|---|
Cache.set | Cache.set(key: str, value: dynamic, ttl?: duration) → none | Store a value in the in-process cache, with an optional TTL duration. |
Cache.get | Cache.get(key: str) → dynamic? | Retrieve a cached value, returning none if absent. |
Cache.delete | Cache.delete(key: str) → none | Remove a key from the cache. |
Cache.clear | Cache.clear() → none | Clear all entries from the cache. |
Cache.get return-type semantics. The return type is dynamic?. The stored type is preserved exactly — a value written as str is read back as str, a value written as int is read back as int. Use as T to recover a concrete type, and ?? to supply a default:
Cache.set("trading:halted", "true")
halted_raw = Cache.get("trading:halted") # dynamic?
halted = if halted_raw == none { false } else { (halted_raw as str) == "true" }
Cache.set("count", 0)
n = (Cache.get("count") ?? 0) as int
Cache.set("rate", 0.5, ttl: 30.seconds)
rate = (Cache.get("rate") ?? 0.0) as float
Log
Log writes structured messages at four severity levels. Output goes to stderr; messages below the current threshold are suppressed.
| Method | Signature | Description |
|---|---|---|
Log.info | Log.info(message: dynamic) → none | Emit an info-level log message. |
Log.warn | Log.warn(message: dynamic) → none | Emit a warning-level log message. |
Log.error | Log.error(message: dynamic) → none | Emit an error-level log message. |
Log.debug | Log.debug(message: dynamic) → none | Emit a debug-level log message. |
Log.set_level | Log.set_level(level: str) → none | Set the minimum log level (“debug”, “info”, “warn”, or “error”). |
Log.level | Log.level() → str | Return the current log level as a string. |
The threshold can also be set before launch with --log-level debug (CLI flag) or KEEL_LOG_LEVEL=debug (env var). Log.set_level changes it at runtime.
Log.debug("cache miss for key {key}")
Log.info("order filled: {order_id}")
Log.warn("rate limit approaching: {remaining} calls left")
Log.error("payment failed: {code}")
Log.set_level("debug")
current = Log.level() # "debug"
Search
Search provides web and news search. Both methods return a list of SearchResult values.
| Method | Signature | Description |
|---|---|---|
Search.web | Search.web(query: str) → dynamic | Search the web and return a list of SearchResult values. |
Search.news | Search.news(query: str) → dynamic | Search for recent news and return a list of SearchResult values. |
Status:
Searchis registered in v0.1 and raises a clear “planned for v0.2” error at runtime. Coming soon
Async
Async provides structured concurrency — run multiple tasks in parallel and collect their results.
| Method | Signature | Description |
|---|---|---|
Async.spawn | Async.spawn() → dynamic | Spawn a concurrent task and return a handle. |
Async.join_all | Async.join_all(handles: dynamic) → dynamic | Wait for all async task handles to complete. |
Async.select | Async.select(handles: dynamic) → dynamic | Return the result of the first completed task handle. |
Async.sleep | Async.sleep(duration: duration) → none | Pause execution for the given duration. |
task1 = Async.spawn(() => { fetch_price("BTC") })
task2 = Async.spawn(() => { fetch_price("ETH") })
# Wait for both
prices = Async.join_all([task1, task2])
# Or race — whichever resolves first
winner = Async.select([task1, task2])
Async.select cancels any handles that haven’t completed yet once a winner is found.
v0.1 scope. Anything marked ⏳ is reserved in the grammar but not yet wired. 🟡 means partial: something works, but not everything.
Searchis registered and raises a clear “planned for v0.2” error;Ai.embedreturns an empty list. Track the full status in ROADMAP.md.
Interfaces
An interface declares a set of method signatures. Any type with matching methods structurally satisfies the interface — no explicit implements.
interface LlmProvider {
task complete(messages: list[Message], opts: LlmOpts) -> LlmResponse?
task embed(text: str) -> list[float]?
}
interface VectorStore {
task put(key: str, value: map[str, str], embedding: list[float]) -> none
task query(embedding: list[float], limit: int) -> list[Memory]
}
interface Tracer {
task on_event(event: TraceEvent) -> none
}
Every prelude namespace dispatches through one or more interfaces:
| Namespace | Interface(s) |
|---|---|
Ai | LlmProvider |
Memory | VectorStore, Embedder |
Http | HttpClient |
Email | EmailTransport |
Search | SearchProvider |
Log | Tracer |
Swapping Implementations
The planned custom-provider flow looks like this:
# Use a custom LLM provider for the whole program
Ai.install(MyCustomProvider) # Coming soon
# Or per-agent, via a stdlib attribute
agent Specialist {
@provider MyFinetunedProvider # Coming soon
@role "..."
}
# Or per-call
urgency = Ai.classify(body, as: Urgency, using: "smart")
The language doesn’t know what an LLM is. The design dispatches through LlmProvider; once provider installation is wired, any value with complete and embed methods of the right shape can satisfy it.
Status:
using:is wired in v0.1 (resolves viaKEEL_MODEL_*env vars and Ollama tags).Ai.install(...)and@providerComing soon — v0.1 ships with Ollama only.
Shadowing the Prelude
Ai, Io, and other namespaces are identifiers, not keywords. A program can shadow them:
Ai = my_custom_module # legal, though usually a bad idea
The compiler will warn on shadowing a built-in name. Use deliberately.
Adding Your Own Prelude Module
Not yet in v0.1. A future release will expose this via the package system: a library declares itself “prelude-eligible,” and users opt in once in keel.config to include it in their prelude globally.
Namespaces, Not Keywords
Operations like classify, draft, every, fetch, ask, confirm, and send are prelude functions on the Ai, Io, Email, Schedule, and Http namespaces — not reserved words. The core language stays small (~27 keywords), and the stdlib is a normal Rust crate that anyone can extend or replace.