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

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, or every. 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 for Ai.*; it does not yet expose a general provider registry.
  • No grammatical ambiguity. Every stdlib call is an ordinary function call. No fetch X where Y special parsing.

The Namespaces

Status legend: ✅ shipping · 🟡 partial · ⏳ Coming soon

NamespaceStatusPurpose
Ai🟡LLM operations: classify, extract, summarize, draft, translate, decide, prompt · embed
IoHuman interaction: ask, confirm, notify, show
HttpHTTP client + server: get, post, request, serve
Email🟡IMAP/SMTP: fetch, send, archive
FileFilesystem: read, write, exists, list, mkdir, remove, copy, move, glob, mktemp
ShellSubprocess bridge: execute shell commands via /bin/sh -c and capture stdout/stderr. Requires @tools [Shell].
String value methodsRegex and formatting directly on string values: matches, extract, find_all, sub, truncate, pad
Json🟡JSON: parse, stringify
CsvCSV 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
DbSQLite: connect(url)DbConnection; db.query(sql, params?)list[map[str,dynamic]]; db.exec(sql, params?)int
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.
ScheduleTime-based scheduling: every, after, at, cron, sleep
AsyncStructured concurrency: spawn, join_all, select, sleep
Controlretry, with_timeout, with_deadline
EnvEnvironment: 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.ms1.week. Operators: dt ± dur → dt, dt - dt → duration, </> comparison. Naive strings rejected — use RFC 3339 or tz:.
MathTranscendentals: sqrt, pow, exp, log (natural), log2, log10, sin, cos, tan, asin, acos, atan, atan2. Constants: PI(), E(). All return float. Domain errors raise.
RandomPseudo-random generation: float(), int(min:, max:), bool(). Use Crypto for security-sensitive randomness.
UuidUUID values: v4, v7, deterministic v5, parse, uuid() alias, and value methods version, format, to_str.
CryptoCryptographic primitives: fixed safe SHA-2 hash/HMAC methods, token, random_bytes.
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.
Agentrun, 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.

FunctionSignatureReturnsNotes
run(agent)(agent) -> nonenoneStart an agent
stop(agent)(agent) -> nonenoneStop an agent
uuid()() -> UuidUuidAlias 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.

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

uuid() is a free-function alias for Uuid.v4().

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

Status: Search is 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.

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]) -> 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:

NamespaceInterface(s)
AiLlmProvider
MemoryVectorStore, Embedder
HttpHttpClient
EmailEmailTransport
SearchSearchProvider
LogTracer

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 via KEEL_MODEL_* env vars and Ollama tags). Ai.install(...) and @provider Coming 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.