Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Keel Keel

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

  1. Agents are primitivesagent is a keyword, not a class pattern
  2. Intent over implementationclassify, draft, summarize are built-in keywords, not library calls
  3. Statically typed — full inference, every expression has a known type, mismatches are compile errors
  4. Humans in the loopask, confirm, notify are first-class
  5. Web is nativefetch, search, send need no imports
  6. Time is built inevery, after, at for 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

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?

  1. agent Hello — declares an agent named Hello
  2. role "..." — describes what the agent does (used as system prompt context)
  3. model "claude-haiku" — which LLM model to use (mapped to your local Ollama model)
  4. every 5.seconds { ... } — runs the block every 5 seconds
  5. notify user "..." — prints a message to the terminal
  6. run 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

ConceptWhat it does
type Priority = low | medium | high | criticalDefines an enum — the type checker enforces exhaustive matching
classify text as TypeAI-powered classification into your enum
considering [...]Gives the LLM hints for each variant
fallback valueGuarantees 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

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

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:

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

FieldRequiredDescription
roleYesNatural language description — used as LLM system prompt
modelNoLLM model name (default: claude-sonnet)
toolsNoList of connections this agent can use
memoryNonone, session, or persistent
stateNoMutable fields with types and defaults
configNoKey-value configuration (temperature, timeout, etc.)
teamNoList 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

ExpressionTypeWhy
classify X as TT?LLM might fail to classify
classify X as T fallback VTFallback guarantees a value
draft "..." { opts }str?LLM might fail
draft "..." ?? "default"strNull coalescing guarantees
ask user "prompt"strUser always responds
confirm user msgboolAlways 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

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

String methods

MethodReturnsExample
.lengthint"hello".length5
.is_emptybool"".is_emptytrue
.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

  1. Lex — tokenize the source
  2. Parse — build the AST
  3. Type check — verify types, exhaustiveness, argument types
  4. 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 every blocks 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

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
  • Exhaustivenesswhen matches cover all enum variants
  • Arguments — task call parameter count and types
  • Nullable safetyT? vs T tracking
  • Scopeself only 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 when arms 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-botMyEmailBot.

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

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

Keel supports multiple LLM providers. The provider is selected automatically based on environment variables.

Provider priority

  1. ANTHROPIC_API_KEY set → Anthropic Claude API
  2. 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 nameAPI model ID
claude-haikuclaude-haiku-4-5-20251001
claude-sonnetclaude-sonnet-4-6-20260415
claude-opusclaude-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

  1. Every 5 minutes, fetches unread emails via IMAP
  2. Classifies each by urgency using a fast model (claude-haiku → Ollama)
  3. Low urgency → auto-archive
  4. Medium → drafts a reply, asks for confirmation before sending
  5. High/critical → shows summary, asks for guidance, drafts with that guidance
  6. 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.