String Interpolation
Keel strings support {expr} interpolation — variables and expressions inside strings are evaluated at runtime.
Basic interpolation
name = "Keel"
io.notify("Hello, {name}!") # "Hello, Keel!"
io.notify("Count: {items.count()}") # "Count: 3"
io.notify("Sum: {a + b}") # expression evaluation
Dotted paths
io.notify("From: {msg.from}")
io.notify("Status: {self.count}")
io.notify("Key: {env.get("API_KEY")}")
Nested string literals
A {...} slot accepts any expression — including another string literal
with its own slots. The lexer tracks brace depth and recurses into
nested "...", so quotes inside {} are not confused with the outer
string’s terminator.
name = "world"
mood = "cheerful"
io.show("hi {"there {name}"}")
# → "hi there world"
io.show("tone: {"speaking in a {mood.to_str()} voice"}")
# → "tone: speaking in a cheerful voice"
\" inside an outer string still produces a literal quote without
opening a nested string.
Escape sequences
| Sequence | Result |
|---|---|
\n | Newline |
\t | Tab |
\r | Carriage return |
\\ | Backslash |
\" | Double quote |
\{ | Literal { (prevents interpolation) |
\} | Literal } |
io.notify("Line 1\nLine 2")
io.notify("Price: \{not interpolated\}")
String methods
| Method | Returns | Example |
|---|---|---|
.len() / .length | int | "hello".len() → 5 |
.is_empty() | bool | "".is_empty() → true |
.contains(s) | bool | "hello".contains("ell") → true |
.starts_with(s) | bool | "hello".starts_with("hel") → true |
.ends_with(s) | bool | "hello".ends_with("lo") → true |
.trim() | str | " hi ".trim() → "hi" |
.trim_start() | str | " hi ".trim_start() → "hi " |
.trim_end() | str | " hi ".trim_end() → " hi" |
.upper() | str | "hello".upper() → "HELLO" |
.lower() | str | "HELLO".lower() → "hello" |
.repeat(n) | str | "ha".repeat(3) → "hahaha" |
.slice(start, end?) | str | "hello".slice(1, 3) → "el" |
.index_of(needle) | int? | "hello world".index_of("world") → 6 |
.split(sep) | list[str] | "a,b,c".split(",") → ["a","b","c"] |
.replace(old, new) | str | "hello".replace("l","r") → "herro" |
.to_int() | int? | "42".to_int() → 42; "bad".to_int() → none |
.to_float() | float? | "3.14".to_float() → 3.14; "x".to_float() → none |
.to_str() | str | identity — useful in generic contexts |
.truncate(max) | str | Truncates to max chars; appends "…" if cut. max must be ≥ 0 |
.pad(width, char?) | str | Left-pads to width with char (default " "). width must be ≥ 0 |
.matches(pattern) | bool | true if regex matches anywhere in the string |
.extract(pattern) | str? | First capture group of regex; none if no match |
.find_all(pattern) | list[str] | All non-overlapping regex matches; empty list if none |
.sub(pattern, replacement) | str | Replace all regex matches; supports $1 backreferences |
Patterns use standard regex syntax (Rust regex crate — no look-behind).
text = "Invoice #1042 — Total: $3,750.00 due 2026-05-15"
# Regex test
if text.matches("\\d{4}-\\d{2}-\\d{2}") {
io.show("looks like a date")
}
# Extract first capture group (returns str?)
amount = text.extract("\\$(\\S+)") # "3,750.00"
# All matches
dates = text.find_all("\\d{4}-\\d{2}-\\d{2}") # ["2026-05-15"]
# Regex replace (global)
redacted = text.sub("\\$[\\d,]+\\.\\d{2}", "[REDACTED]")
# Truncate and pad
short = text.truncate(20) # "Invoice #1042 — Tot…"
padded = "42".pad(6) # " 42"
zeroed = "42".pad(6, char: "0") # "000042"
Format specifiers
A slot may include a format spec after a colon — {expr:spec} — for precise number formatting and text alignment. The spec uses a Python-style mini-language.
Float precision
pi = 3.14159
io.show("{pi:.2f}") # → "3.14"
io.show("{pi:.4f}") # → "3.1416"
n = 42
io.show("{n:.2f}") # → "42.00" (int auto-promoted to float)
Width and alignment
| Spec | Alignment | Example |
|---|---|---|
:<N | Left-align, space-padded to N chars | {"hi":<8} → "hi " |
:>N | Right-align, space-padded to N chars | {42:>8} → " 42" |
:^N | Center, space-padded to N chars | {"hi":^8} → " hi " |
:N | Bare width — right-align shorthand | {42:8} → " 42" |
Combining alignment and precision
price = 19.5
io.show("{price:>10.2f}") # → " 19.50"
io.show("{price:<10.2f}") # → "19.50 "
Building tables
io.show("{"Name":<12} {"Score":>8}")
io.show("{"Alice":<12} {0.975:>8.3f}")
io.show("{"Bob":<12} {0.742:>8.3f}")
# →
# Name Score
# Alice 0.975
# Bob 0.742
Named arguments inside a slot are not confused with the format spec — {f(key: v):>10} parses f(key: v) as the expression and >10 as the spec, because the separator colon is only detected at outermost bracket depth.
Stringable interface — custom interpolation
By default, only primitive values (str, int, float, bool) and built-in types (Uuid, datetime) can be used inside "{...}" interpolation slots. To make your own struct type work in interpolation, implement the Stringable interface:
use std/io
type Point {
x: float
y: float
}
impl Stringable for Point {
task to_str(self) -> str {
"({self.x}, {self.y})"
}
}
p: Point = { x: 1.5, y: 2.0 }
io.show("Position: {p}") # → "Position: (1.5, 2.0)"
s = p.to_str() # explicit call — "(1.5, 2.0)"
Syntax
impl InterfaceName for TypeName {
task method(self) -> ReturnType {
...
}
}
implis a reserved keyword.foris the same keyword used inforloops; there is no ambiguity becauseimplalways precedes it here.selfinside the block refers to the receiver value (typed asTypeName). Useself.fieldto access struct fields.- Only methods matching a declared interface signature are valid inside
implblocks.
Multiple types
Each type needs its own impl block:
use std/io
type Color = red | green | blue
impl Stringable for Color {
task to_str(self) -> str {
when self {
red => "red"
green => "green"
blue => "blue"
}
}
}
c: Color = Color.green
io.show("Favourite: {c}") # → "Favourite: green"
Fallback behaviour
Values that do not implement Stringable still render in interpolation using their built-in display representation (the same output you see from io.show). Implementing Stringable lets you override that representation for your types.
Cache namespace — in-memory shared cache
Cache is a process-scoped, in-memory key-value store with optional TTL. It persists across agent restarts within the same process run but is cleared when the process exits.
cache.set("session:abc", user_data, ttl: 30.minutes)
session = cache.get("session:abc") # value or none (if expired/missing)
cache.delete("session:abc")
cache.clear() # flush everything
| Method | Returns | Notes |
|---|---|---|
cache.set(key, value, ttl?) | none | ttl is a duration literal; omit for no expiry |
cache.get(key) | dynamic? | none if missing or expired; stored type preserved |
cache.delete(key) | none | No-op if key doesn’t exist |
cache.clear() | none | Flushes all entries |
cache.get return type
cache.get returns dynamic? — none when the key is absent or expired, otherwise the value at its original type. A value stored as str comes back as str; a value stored as int comes back as int. Use as T to narrow to a concrete type:
cache.set("price", "50000.12")
raw = cache.get("price") # dynamic?
if raw != none {
price = raw as str # "50000.12"
}
cache.set("count", 42)
n = (cache.get("count") ?? 0) as int # 42
Scope:
Cacheis process-wide (all agents share one store) and cleared on restart.Memoryis per-agent and optionally persistent. UseCachefor rate-limiting tokens, deduplication keys, or short-lived shared results; useMemoryfor per-agent state that should survive across runs.