A programming language where AI agents are first-class citizens
The Keel Language
Keel is a programming language where AI agents are first-class citizens.
Building an AI agent that monitors your email, classifies messages, and drafts replies takes ~180 lines of Python with LangChain. In Keel, it takes 40:
type Urgency = low | medium | high | critical
agent EmailAssistant {
role "Professional email assistant"
model "claude-sonnet"
tools [email]
task triage(email: {body: str}) -> Urgency {
classify email.body as Urgency fallback medium
}
task handle(email: {body: str, from: str, subject: str}) {
urgency = triage(email)
when urgency {
low, medium => {
reply = draft "response to {email}" { tone: "friendly" }
confirm user reply then send reply to email
}
high, critical => {
notify user "{urgency}: {email.subject}"
guidance = ask user "How to respond?"
reply = draft "response to {email}" { guidance: guidance }
confirm user reply then send reply to email
}
}
}
every 5.minutes {
for email in fetch email where unread {
handle(email)
}
}
}
run EmailAssistant
Design Principles
- Agents are primitives —
agentis a keyword, not a class pattern - Intent over implementation —
classify,draft,summarizeare built-in keywords, not library calls - Statically typed — full inference, every expression has a known type, mismatches are compile errors
- Humans in the loop —
ask,confirm,notifyare first-class - Web is native —
fetch,search,sendneed no imports - Time is built in —
every,after,atfor scheduling
What Keel Looks Like
Classify with AI
urgency = classify email.body as Urgency considering [
"mentions a deadline" => high,
"newsletter" => low
] fallback medium using "claude-haiku"
Pattern matching
when urgency {
low => archive email
medium => auto_reply(email)
high => escalate(email)
critical => page_oncall(email)
}
Collections with lambdas
urgent = emails.filter(e => triage(e) == critical)
names = contacts.map(c => c.name).sort_by(n => n)
Retry with backoff
retry 3 times with backoff { send email }
Getting Started
Install Keel and build your first agent in 5 minutes: Installation →
Installation
From source (recommended)
Keel is built in Rust. You need the Rust toolchain installed.
# Clone the repository
git clone https://github.com/keel-lang/keel.git
cd keel
# Build the release binary
cargo build --release
# The binary is at target/release/keel
./target/release/keel --version
Optionally, add it to 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 an Keel program
check Type-check an Keel program without executing
init Scaffold a new Keel project
repl Interactive REPL
fmt Format an Keel file
build Compile an Keel file to bytecode
help Print this message or the help of the given subcommand(s)
LLM setup
Keel agents use LLMs for AI primitives (classify, draft, summarize, etc.). You need at least one provider:
Option A: Ollama (local, free)
# Install Ollama (https://ollama.com)
ollama pull gemma4
# Tell Keel which model to use
export KEEL_OLLAMA_MODEL=gemma4
Option B: Anthropic Claude API
export ANTHROPIC_API_KEY=sk-ant-...
See LLM Providers for detailed configuration.
Editor support
Install the VS Code extension for syntax highlighting:
# From the project directory
cd editors/vscode
# Install via VS Code's extension manager, or:
code --install-extension keel-lang-0.1.0.vsix
Next steps
Hello World
Create a file called hello.keel:
agent Hello {
role "A friendly greeter"
model "claude-haiku"
every 5.seconds {
notify user "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)
⏱ polling every 5 seconds
▸ Hello from Keel!
▸ Agent running. Press Ctrl+C to stop.
▸ Hello from Keel!
▸ Hello from Keel!
The agent prints “Hello from Keel!” every 5 seconds until you press Ctrl+C.
What just happened?
agent Hello— declares an agent namedHellorole "..."— describes what the agent does (used as system prompt context)model "claude-haiku"— which LLM model to use (mapped to your local Ollama model)every 5.seconds { ... }— runs the block every 5 secondsnotify user "..."— prints a message to the terminalrun Hello— starts the agent
Using AI
Let’s make the agent actually use AI:
type Mood = happy | neutral | sad
task analyze(text: str) -> Mood {
classify text as Mood fallback neutral
}
agent MoodBot {
role "Analyzes the mood of text"
model "claude-haiku"
every 10.seconds {
mood = analyze("I love building programming languages!")
notify user "Mood: {mood}"
}
}
run MoodBot
The classify keyword sends the text to the LLM and maps the response to one of the enum variants. The fallback neutral ensures you always get a valid result.
Next: Your First Agent →
Your First Agent
Let’s build a real agent — a task prioritizer that classifies items and presents them sorted by urgency.
Step 1: Scaffold the project
keel init task-prioritizer
cd task-prioritizer
This creates main.keel with a starter agent.
Step 2: Define your types
Replace main.keel with:
type Priority = low | medium | high | critical
type Task {
title: str
description: str
}
Types in Keel are either enums (a set of variants) or structs (named fields). The type checker ensures you handle all variants in when blocks.
Step 3: Write the classification task
task prioritize(task: Task) -> Priority {
classify task.description as Priority considering [
"blocks other people" => critical,
"has a deadline this week" => high,
"nice to have" => low
] fallback medium
}
This sends the task description to the LLM with classification hints. The fallback medium ensures you always get a valid Priority — never none.
Step 4: Build the agent
agent Prioritizer {
role "You help prioritize a task list"
model "claude-sonnet"
state {
processed: int = 0
}
task run_batch(tasks: list[Task]) {
for task in tasks {
priority = prioritize(task)
self.processed = self.processed + 1
when priority {
critical => notify user "🔴 CRITICAL: {task.title}"
high => notify user "🟠 HIGH: {task.title}"
medium => notify user "🟡 MEDIUM: {task.title}"
low => notify user "🟢 LOW: {task.title}"
}
}
notify user "Processed {self.processed} tasks total"
}
every 1.hour {
tasks = [
{title: "Fix login bug", description: "Users can't log in, blocks the whole team"},
{title: "Update README", description: "Nice to have, not urgent"},
{title: "Deploy v2.0", description: "Release deadline is Friday"}
]
run_batch(tasks)
}
}
run Prioritizer
Step 5: Run it
KEEL_OLLAMA_MODEL=gemma4 keel run main.keel
Step 6: Type-check it
Before running, you can verify your code has no type errors:
keel check main.keel
If you forget a variant in a when block:
× Type error
╭─[main.keel:25:5]
24 │ when priority {
25 │ critical => notify user "CRITICAL"
· ─────────┬─────────
· ╰── Non-exhaustive match on Priority: missing high, medium, low
26 │ }
╰────
Key takeaways
| Concept | What it does |
|---|---|
type Priority = low | medium | high | critical | Defines an enum — the type checker enforces exhaustive matching |
classify text as Type | AI-powered classification into your enum |
considering [...] | Gives the LLM hints for each variant |
fallback value | Guarantees a non-nullable result |
when value { ... } | Pattern matching — compiler checks all variants are covered |
state { field: Type = default } | Mutable agent state, accessed via self.field |
every duration { ... } | Scheduled recurring execution |
Next: Language Guide →
Types
Keel is statically typed with full inference. You rarely need to write type annotations — the compiler figures them out — but every expression has a known type, and mismatches are caught before your code runs.
Primitive types
| Type | Example | Notes |
|---|---|---|
int | 42 | 64-bit integer |
float | 3.14 | 64-bit float |
str | "hello" | UTF-8, supports interpolation |
bool | true, false | |
none | none | Absence of value |
duration | 5.minutes | Time 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
Structs
Structs are structural types — any value with matching fields satisfies the type.
type EmailInfo {
sender: str
subject: str
body: str
unread: bool
}
# Inline struct types in parameters
task triage(email: {body: str, from: str}) -> Urgency {
classify email.body as Urgency
}
You don’t need to declare a struct to use it:
info = {name: "Alice", age: 30} # inferred as {name: str, age: int}
notify user info.name # "Alice"
Nullable types
Types are non-nullable by default. Append ? to allow none:
name: str # cannot be none
alias: str? # can be none
# Null-safe access
subject = email?.subject # str? — none if email is none
# Null coalescing
subject = email?.subject ?? "(no subject)" # str — guaranteed non-none
AI operations return nullable types when they can fail:
result = classify text as Urgency # Urgency? — might be none
safe = result ?? medium # Urgency — guaranteed
# Or use fallback for non-nullable directly:
result = classify text as Urgency fallback medium # Urgency
Collections
nums = [1, 2, 3] # list[int]
names = ["alice", "bob"] # list[str]
info = {name: "Zied", role: "builder"} # map[str, str]
List properties:
| Property | Returns | Description |
|---|---|---|
.count | int | Number of elements |
.first | T? | First element or none |
.last | T? | Last element or none |
.is_empty | bool | True if count == 0 |
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).
Duration literals
5.seconds 30.minutes 2.hours 1.day 7.days
# Short forms
30.sec 1.min 2.hr 1.d
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 {
notify user "in range"
}
Null coalescing
The ?? operator provides a default when the left side is none:
name = user_input ?? "anonymous"
port = env.PORT.to_int() ?? 3000
result = classify text as 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
email.subject # field access
email?.subject # null-safe — returns none if email is none
email!.subject # null assertion — throws if email is none
env.API_KEY # environment variable
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}
]
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
# Typed parameters
task add(a: int, b: int) -> int {
a + b
}
# Default values
task compose(email: str, tone: str = "friendly") -> str {
draft "response to {email}" { tone: tone }
}
# Struct parameters (inline type)
task triage(email: {body: str, from: str}) -> Urgency {
classify email.body as Urgency fallback 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:
task handle(email: {body: str, from: str}) -> str {
if email.from.contains("noreply") {
return "Skipped automated email"
}
draft "response to {email}" { tone: "professional" } ?? "(draft failed)"
}
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:
# Top-level: shared, testable
task triage(email: {body: str}) -> Urgency {
classify email.body as Urgency fallback medium
}
# Agent-scoped: can access self.state
agent Bot {
state { count: int = 0 }
task increment() {
self.count = self.count + 1
}
}
Prefer top-level tasks for any logic that doesn’t need agent state.
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
An agent is the core building block — an autonomous entity with a role, capabilities, and behavior.
Minimal agent
agent Greeter {
role "You greet people warmly"
}
run Greeter
Full anatomy
agent EmailAssistant {
# Identity
role "Professional email assistant for the team"
model "claude-sonnet"
# Capabilities
tools [email, calendar]
memory persistent
# Mutable state
state {
processed: int = 0
last_run: str = "never"
}
# Agent-scoped tasks
task handle(email: {body: str, from: str}) {
urgency = triage(email)
self.processed = self.processed + 1
notify user "Handled email #{self.processed}"
}
# Scheduled behavior
every 5.minutes {
emails = fetch email where unread
for email in emails {
handle(email)
}
self.last_run = "just now"
}
}
run EmailAssistant
Agent fields
| Field | Required | Description |
|---|---|---|
role | Yes | Natural language description — used as LLM system prompt |
model | No | LLM model name (default: claude-sonnet) |
tools | No | List of connections this agent can use |
memory | No | none, session, or persistent |
state | No | Mutable fields with types and defaults |
config | No | Key-value configuration (temperature, timeout, etc.) |
team | No | List of other agents (for multi-agent systems) |
State
Agent state is the only place where mutation is allowed. Access via self.:
state {
count: int = 0
last_seen: str = ""
}
task increment() {
self.count = self.count + 1
self.last_seen = now
}
State is isolated per agent — different agents don’t share state.
Scheduling
Agents come alive with every, after, and on blocks:
# Recurring
every 5.minutes { check_inbox() }
# One-time delayed
after 30.minutes { follow_up(ticket) }
# Event handler
on message(msg: Message) {
response = draft "reply to {msg}" { tone: "warm" }
send response to msg
}
Running agents
run MyAgent # foreground — blocks until Ctrl+C
run MyAgent in background # non-blocking
stop MyAgent # graceful shutdown
Composition
Keep agents small and focused. Use top-level tasks for shared logic:
# Shared logic
task triage(email: {body: str}) -> Urgency {
classify email.body as Urgency fallback medium
}
# Focused agent
agent Classifier {
role "Classifies incoming email"
every 5.minutes {
for email in fetch email where unread {
urgency = triage(email)
notify user "{urgency}: {email.subject}"
}
}
}
AI Primitives
Keel has six built-in AI keywords. They’re not library functions — they’re language-level constructs with custom grammar, type inference, and LLM routing.
classify
Categorize input into a predefined enum type.
urgency = classify email.body as Urgency
With classification hints:
urgency = classify email.body as Urgency considering [
"mentions a deadline within 24h" => high,
"uses urgent/angry language" => critical,
"newsletter or automated message" => low
] fallback medium using "claude-haiku"
Return type: T? without fallback, T with fallback.
summarize
Condense content.
brief = summarize article in 3 sentences
bullets = summarize report format bullets
tldr = summarize thread in 1 line fallback "(no summary)"
Return type: str? without fallback, str with fallback.
draft
Generate text content.
reply = draft "response to {email}" {
tone: "friendly",
max_length: 150,
guidance: "include action items"
}
The description is a string with interpolation — variables are included as context in the LLM prompt.
Return type: str?
extract
Pull structured data from unstructured text.
info = extract {
sender: str,
subject: str,
action_items: list[str]
} from email_body
The LLM extracts the specified fields and returns them as a struct/map.
Return type: {fields}?
translate
Language translation.
french = translate message to french
# Multi-target
localized = translate ui_text to [spanish, german, japanese]
# Returns: {spanish: "...", german: "...", japanese: "..."}
Return type: str? for single target, map[str, str]? for multi-target.
decide
Structured decision with reasoning.
action = decide email {
options: [reply, forward, archive, escalate],
based_on: [urgency, sender, content]
}
# action.choice — the selected option
# action.reason — LLM's explanation
Return type: {choice: str, reason: str}?
Model override with using
All AI primitives inherit the agent’s model. Override per-operation:
# Fast model for classification
urgency = classify email.body as Urgency using "claude-haiku"
# Capable model for drafting
reply = draft "response" { tone: "formal" } using "claude-sonnet"
prompt — raw LLM access
When the built-in primitives don’t fit, use prompt for full control:
result = prompt {
system: "You are a legal document analyzer.",
user: "Extract all liability clauses from: {document}"
} as LiabilityClauses
prompt always requires as Type — there’s no untyped path.
Nullable safety
| Expression | Type | Why |
|---|---|---|
classify X as T | T? | LLM might fail to classify |
classify X as T fallback V | T | Fallback guarantees a value |
draft "..." { opts } | str? | LLM might fail |
draft "..." ?? "default" | str | Null coalescing guarantees |
ask user "prompt" | str | User always responds |
confirm user msg | bool | Always true or false |
Human Interaction
Keel has four built-in keywords for human-in-the-loop workflows.
ask
Prompts the user and blocks until they respond.
answer = ask user "How should I respond to this email?"
Return type: str
confirm
Asks for yes/no approval. Returns bool.
approved = confirm user "Send this reply?\n\n{draft_reply}"
if approved { send draft_reply to email }
Shorthand with then:
confirm user reply then send reply to email
# Equivalent to: if confirm user reply { send reply to email }
Return type: bool
notify
Non-blocking notification. Does not wait for a response.
notify user "Email classified as critical"
notify user "{emails.count} new messages"
show
Presents structured data to the user. Automatically formats maps as key-value displays and lists of maps as tables.
# Key-value display
show user {
from: email.from,
subject: email.subject,
urgency: urgency
}
Output:
┌
│ from alice@example.com
│ subject Q3 Review
│ urgency Urgency.high
└
List of maps renders as a table:
show user [
{name: "Alice", status: "active"},
{name: "Bob", status: "away"}
]
Output:
name status
───── ──────
Alice active
Bob away
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 has_guidance {
draft "response" { guidance: guidance }
} else {
draft "response" { tone: "friendly" }
} ?? "(draft failed)"
when (pattern matching)
when is an exhaustive pattern match. The compiler requires all cases to be handled.
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
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:
when status {
active where user.is_admin => grant_access()
active => request_approval()
_ => deny()
}
for loops
for email in emails {
handle(email)
}
# With filter
for email in emails where email.unread {
triage(email)
}
return
Explicit early return from a task:
task check(x: int) -> str {
if x > 100 {
return "too big"
}
if x < 0 {
return "negative"
}
"ok"
}
Collections & Lambdas
Lists
nums = [1, 2, 3, 4, 5]
names = ["alice", "bob", "charlie"]
empty = []
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")
sort_by — sort by derived key
sorted = items.sort_by(item => item.name)
by_age = people.sort_by(p => p.age)
flat_map — map and flatten
all_tags = posts.flat_map(p => p.tags) # flattens nested lists
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"
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?)
Error Handling
try / catch
try {
send email
} catch err: NetworkError {
retry 3 times with backoff { send email }
} catch err: Error {
notify user "Failed: {err.message}"
}
retry
Retry a failing operation with optional exponential backoff:
# Fixed delay (1s between attempts)
retry 3 times { send email }
# Exponential backoff: 1s, 2s, 4s
retry 3 times with backoff { send email }
Output on failure:
↻ Retry 1/3 in 1s: Network error
↻ Retry 2/3 in 2s: Network error
✗ Failed after 3 retries: Network error
fallback
AI operations that can fail use fallback to guarantee a value:
# Without fallback: returns Urgency? (nullable)
result = classify text as Urgency
# With fallback: returns Urgency (guaranteed)
result = classify text as Urgency fallback medium
Null coalescing
?? provides a default for nullable values:
name = user_input ?? "anonymous"
reply = draft "response" ?? "(draft failed)"
port = env.PORT.to_int() ?? 3000
Null-safe access
?. returns none instead of crashing if the left side is none:
subject = email?.subject # none if email is none
length = email?.body?.length # chained null-safe access
Null assertion
! asserts a value is not none — crashes if it is:
subject = email!.subject # throws NullError if email is none
Use sparingly — prefer ?? or fallback.
Scheduling
every — recurring execution
every 5.minutes { check_inbox() }
every 1.hour { sync_data() }
every 30.seconds { heartbeat() }
The agent stays alive and repeats the block on schedule. Press Ctrl+C to stop.
Duration units: seconds/sec/s, minutes/min/m, hours/hr/h, days/day/d
after — delayed one-time execution
after 30.minutes { follow_up(ticket) }
after 2.hours { remind user "Check on deployment" }
The block executes once after the delay.
wait — pause execution
# Wait a fixed duration
wait 5.seconds
# Wait until a condition is true (polls every second)
wait until is_ready
Scheduling in agents
Agents can have multiple every blocks:
agent Monitor {
role "System monitor"
every 30.seconds {
check_health()
}
every 1.hour {
send_report()
}
}
The first tick executes immediately, then repeats on schedule.
Connections
Connections establish authenticated links to external services.
Email (IMAP/SMTP)
connect email via imap {
host: env.IMAP_HOST,
user: env.EMAIL_USER,
pass: env.EMAIL_PASS
}
Set the environment variables:
export IMAP_HOST=imap.gmail.com
export EMAIL_USER=you@gmail.com
export EMAIL_PASS=your-app-password
Fetch emails
emails = fetch email where unread
Returns a list of email maps with fields: from, subject, body, unread.
Send emails
send reply to email
Sends via SMTP. The SMTP host is derived from the IMAP host (imap. → smtp.), or set explicitly:
connect email via imap {
host: env.IMAP_HOST,
smtp_host: env.SMTP_HOST,
user: env.EMAIL_USER,
pass: env.EMAIL_PASS
}
HTTP fetch
Fetch URLs directly:
response = fetch "https://api.example.com/data"
# response.status — int (200, 404, etc.)
# response.body — str (response body)
# response.headers — map[str, str]
# response.is_ok — bool (status 200-299)
Archive
Move an item to its archive (connection-specific behavior):
archive email # moves to archive folder in IMAP
Environment variables
Access environment variables with env.:
api_key = env.API_KEY
host = env.IMAP_HOST
debug = env.DEBUG
String Interpolation
Keel strings support {expr} interpolation — variables and expressions inside strings are evaluated at runtime.
Basic interpolation
name = "Keel"
notify user "Hello, {name}!" # "Hello, Keel!"
notify user "Count: {items.count}" # "Count: 3"
notify user "Sum: {a + b}" # expression evaluation
Dotted paths
notify user "From: {email.from}"
notify user "Status: {self.count}"
notify user "Key: {env.API_KEY}"
Escape sequences
| Sequence | Result |
|---|---|
\n | Newline |
\t | Tab |
\r | Carriage return |
\\ | Backslash |
\" | Double quote |
\{ | Literal { (prevents interpolation) |
\} | Literal } |
notify user "Line 1\nLine 2"
notify user "Price: \{not interpolated\}"
String methods
| Method | Returns | Example |
|---|---|---|
.length | int | "hello".length → 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" |
.upper() | str | "hello".upper() → "HELLO" |
.lower() | str | "HELLO".lower() → "hello" |
.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 |
.to_float() | float? | "3.14".to_float() → 3.14 |
keel run
Execute an Keel program.
keel run <file.keel>
Pipeline
- Lex — tokenize the source
- Parse — build the AST
- Type check — verify types, exhaustiveness, argument types
- Execute — run with tree-walking interpreter
Examples
# Run an agent
keel run examples/email_agent.keel
# With Ollama
KEEL_OLLAMA_MODEL=gemma4 keel run agent.keel
# With Anthropic
ANTHROPIC_API_KEY=sk-ant-... keel run agent.keel
Behavior
- Agents with
everyblocks run continuously untilCtrl+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
keel check
Type-check an Keel program without executing it.
keel check <file.keel>
What it checks
- Syntax — valid Keel grammar
- Types — type inference and compatibility
- Exhaustiveness —
whenmatches cover all enum variants - Arguments — task call parameter count and types
- Nullable safety —
T?vsTtracking - Scope —
selfonly inside agents, undefined variables
Example output
Success:
✓ examples/email_agent.keel is valid
Error:
× Type error
╭─[agent.keel:8:5]
7 │ every 1.day {
8 │ greet(42)
· ────┬────
· ╰── Argument 'name' of task 'greet': expected str, got int
9 │ }
╰────
keel build
Compile an Keel program to bytecode.
keel build <file.keel>
Produces a .keelc file (JSON-serialized bytecode) that can be cached for faster loading.
Example
keel build examples/minimal.keel
# ✓ Compiled examples/minimal.keel → examples/minimal.keelc (28 ops, 2 functions)
Bytecode format
The .keelc file contains:
- main chunk — top-level agent code
- function chunks — compiled tasks
- string pool — deduplicated string constants
- register count — per function/chunk
The bytecode is a register-based instruction set with 40+ opcodes covering arithmetic, comparison, control flow, function calls, data structures, and human interaction.
keel fmt
Auto-format an Keel file with consistent style.
keel fmt <file.keel>
What it does
- 2-space indentation
- Consistent spacing around operators
- One declaration per section with blank line separators
- Single-line
whenarms for simple expressions - Writes the formatted output back to the file
Example
Before:
agent Bot{
role "helper"
model "claude-haiku"
state{count:int=0}
every 1.day{notify user "hello"
self.count=self.count+1}}
run Bot
After keel fmt:
agent Bot {
role "helper"
model "claude-haiku"
state {
count: int = 0
}
every 1.days {
notify user "hello"
self.count = self.count + 1
}
}
run Bot
keel init
Scaffold a new Keel project.
keel init <project-name>
What it creates
my-project/
├── main.keel # Starter agent
└── .gitignore
The generated main.keel:
# my-project — built with Keel
agent MyProject {
role "Describe what this agent does"
model "claude-sonnet"
every 1.hour {
notify user "Hello from my-project!"
}
}
run MyProject
The agent name is automatically derived from the project name in PascalCase: my-email-bot → MyEmailBot.
Example
keel init task-sorter
# ✓ Created project 'task-sorter'
# task-sorter/main.keel
#
# Run it: keel run task-sorter/main.keel
keel repl
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
| Command | Description |
|---|---|
:help | Show help |
:types | List defined types |
:env | Show environment |
:clear | Reset all state |
:quit | Exit |
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
Keel supports multiple LLM providers. The provider is selected automatically based on environment variables.
Provider priority
ANTHROPIC_API_KEYset → Anthropic Claude API- Otherwise → Ollama (local)
There is no silent fallback. If a model can’t be reached, the program fails with a clear error message.
Anthropic Claude
export ANTHROPIC_API_KEY=sk-ant-api03-...
keel run agent.keel
Model names in .keel files map to API model IDs:
| Keel name | API model ID |
|---|---|
claude-haiku | claude-haiku-4-5-20251001 |
claude-sonnet | claude-sonnet-4-6-20260415 |
claude-opus | claude-opus-4-6-20260415 |
Ollama (local)
See Ollama Setup for detailed instructions.
Model mapping
When using Ollama, Keel model names (like claude-haiku) need to be mapped to local models:
# Catch-all: all models → one Ollama model
export KEEL_OLLAMA_MODEL=gemma4
# Per-model: different local models for different roles
export KEEL_MODEL_CLAUDE_HAIKU=gemma4 # fast/cheap
export KEEL_MODEL_CLAUDE_SONNET=mistral:7b-instruct # capable
export KEEL_MODEL_CLAUDE_OPUS=gpt-oss:20b # heavy
Direct Ollama model names
You can also use Ollama model names directly in .keel files:
classify text as Mood using "ollama:gemma4"
draft "response" using "ollama:mistral:7b-instruct"
Strict validation
If a model name can’t be resolved, Keel fails immediately with instructions:
✗ Model 'claude-haiku' is not available locally.
Set one of:
export KEEL_MODEL_CLAUDE_HAIKU=<ollama_model>
export KEEL_OLLAMA_MODEL=<ollama_model>
Test mode
For automated tests:
KEEL_LLM=mock keel run agent.keel
AI primitives use fallback values. No network calls are made.
Ollama Setup
Ollama lets you run LLMs locally. No API key needed, fully offline.
Install Ollama
# macOS
brew install ollama
# Or download from https://ollama.com
Pull a model
ollama pull gemma4 # 9.6 GB — good general model
ollama pull mistral:7b-instruct # 4.4 GB — fast, good for classification
Start the server
ollama serve
Ollama runs at http://localhost:11434 by default.
Configure Keel
# Use one model for everything
export KEEL_OLLAMA_MODEL=gemma4
keel run agent.keel
Per-model mapping
Different AI operations benefit from different models. Map them individually:
export KEEL_MODEL_CLAUDE_HAIKU=gemma4 # fast: classify, triage
export KEEL_MODEL_CLAUDE_SONNET=mistral:7b-instruct # capable: draft, summarize
Then in your .keel file:
# Uses gemma4 (fast)
urgency = classify email.body as Urgency using "claude-haiku"
# Uses mistral (capable)
reply = draft "response to {email}" using "claude-sonnet"
Custom Ollama host
export OLLAMA_HOST=http://192.168.1.100:11434
keel run agent.keel
Verify it works
KEEL_OLLAMA_MODEL=gemma4 keel run examples/test_ollama.keel
You should see:
⚡ 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.
type Urgency = low | medium | high | critical
connect email via imap {
host: env.IMAP_HOST,
user: env.EMAIL_USER,
pass: env.EMAIL_PASS
}
task triage(email: {body: str, from: str, subject: str}) -> Urgency {
classify email.body as Urgency considering [
"from a known VIP or executive" => critical,
"mentions a deadline within 24h" => high,
"asks a direct question" => medium,
"newsletter or automated message" => low
] fallback medium using "claude-haiku"
}
task brief(email: {body: str}) -> str {
summarize email.body in 1 sentence fallback "(no summary)" using "claude-haiku"
}
task compose(email: {body: str, from: str}, guidance: str? = none) -> str {
if guidance != none {
draft "response to {email}" {
tone: "professional",
guidance: guidance
}
} else {
draft "response to {email}" {
tone: "friendly",
max_length: 150
}
} ?? "(draft failed)"
}
agent EmailAssistant {
role "You are a professional email assistant"
model "claude-sonnet"
tools [email]
memory persistent
state {
handled_count: int = 0
}
task handle(email: {body: str, from: str, subject: str}) {
urgency = triage(email)
summary = brief(email)
when urgency {
low => {
notify user "Archived: {email.subject} [{urgency}]"
archive email
}
medium => {
reply = compose(email)
confirm user "Auto-reply to '{email.subject}':\n\n{reply}" then send reply to email
}
high, critical => {
notify user "{urgency} email from {email.from}"
show user {
from: email.from,
subject: email.subject,
summary: summary,
urgency: urgency
}
guidance = ask user "How should I respond?"
reply = compose(email, guidance)
confirm user reply then send reply to email
}
}
remember {
contact: email.from,
subject: email.subject,
urgency: urgency,
handled_at: now
}
self.handled_count = self.handled_count + 1
}
every 5.minutes {
emails = fetch email where unread
notify user "{emails.count} new emails"
for email in emails {
handle(email)
}
}
}
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
- Every 5 minutes, fetches unread emails via IMAP
- Classifies each by urgency using a fast model (claude-haiku → Ollama)
- Low urgency → auto-archive
- Medium → drafts a reply, asks for confirmation before sending
- High/critical → shows summary, asks for guidance, drafts with that guidance
- Remembers each interaction for future context
Example: Multi-Agent Email System
A v2 preview showing how multiple agents collaborate with delegate and team.
type Urgency = low | medium | high | critical
type Category = question | request | info | complaint | spam
type TriageResult {
urgency: Urgency
category: Category
}
connect email via imap {
host: env.IMAP_HOST,
user: env.EMAIL_USER,
pass: env.EMAIL_PASS
}
agent Classifier {
role "You classify emails by urgency and category"
model "claude-haiku"
task triage(email: {body: str}) -> TriageResult {
urgency = classify email.body as Urgency fallback medium
category = classify email.body as Category fallback question
{urgency: urgency, category: category}
}
}
agent Responder {
role "You draft professional, helpful email replies"
model "claude-sonnet"
task reply_to(email: {body: str, from: str}, guidance: str? = none) -> str {
draft "response to {email}" {
tone: "professional",
guidance: guidance,
max_length: 200
} ?? "(draft failed)"
}
}
agent Scheduler {
role "You manage follow-ups and reminders"
model "claude-haiku"
task plan_followup(email: {subject: str}, urgency: Urgency) {
when urgency {
critical => after 2.hours { notify user "Follow up on: {email.subject}" }
high => after 24.hours { notify user "Check status: {email.subject}" }
medium => after 3.days { notify user "Pending reply: {email.subject}" }
low => { }
}
}
}
agent InboxManager {
role "You coordinate the email handling team"
model "claude-sonnet"
team [Classifier, Responder, Scheduler]
task handle(email: {body: str, from: str, subject: str}) {
result = delegate triage(email) to Classifier ?? {
urgency: medium,
category: question
}
when result.urgency {
low => {
when result.category {
spam, info => archive email
_ => {
reply = delegate reply_to(email) to Responder ?? "(could not draft)"
confirm user reply then send reply to email
}
}
}
medium => {
reply = delegate reply_to(email) to Responder ?? "(could not draft)"
confirm user reply then send reply to email
delegate plan_followup(email, result.urgency) to Scheduler
}
high, critical => {
summary = summarize email.body in 2 sentences fallback "(no summary)"
notify user "{result.urgency} {result.category} from {email.from}"
show user summary
guidance = ask user "How should I respond?"
reply = delegate reply_to(email, guidance) to Responder ?? "(could not draft)"
confirm user reply then send reply to email
delegate plan_followup(email, result.urgency) to Scheduler
}
}
}
every 5.minutes {
emails = fetch email where unread
for email in emails {
handle(email)
}
}
}
run InboxManager
Architecture
InboxManager (orchestrator)
├── Classifier — fast model, triages urgency + category
├── Responder — capable model, drafts quality replies
└── Scheduler — manages follow-up reminders
Each agent has its own role and model. The orchestrator delegates tasks to specialists.
Changelog
See the full CHANGELOG.md in the repository.