The guide

The whole language, in order. Every example here runs as written — paste them into the REPL.

1. Running it

eelisp                  # interactive REPL
eelisp script.eelisp    # run a file
eelisp --serve          # JSON-line RPC over stdin/stdout

Or open the REPL inside EEditor with ⌘J, which is the friendlier way to meet the database and the agenda, since results render as tables and forms rather than text.

2. Values

42        -3.5              ; numbers — one numeric type
"hello"                     ; strings
true      false             ; booleans
nil                         ; nothing
foo                         ; a symbol — a name
:name                       ; a keyword — a name that stands for itself
(list 1 2 3)                ; a list
{:name "Ada" :born 1815}   ; a dict

Keywords are the workhorse: they name dict fields, they name arguments (:where, :order), and they never need quoting.

Quoting stops evaluation, as in any Lisp: '(1 2 3) is a list of three numbers, not a call to the function 1.

3. Defining things

(def pi 3.14159)              ; a value
(defn area (r) (* pi r r))    ; a function
(area 2)                      ; → 12.56636

(fn (x) (* x x))              ; an anonymous function
((fn (x) (* x x)) 5)          ; → 25

(let (a 1 b 2) (+ a b))       ; local bindings — a flat list of pairs

(def counter 0)
(set! counter (+ counter 1))  ; reassign an existing binding

let takes its bindings as one flat list — (let (a 1 b 2) …), not nested pairs. Bindings are visible to the body only.

4. Control flow

(if (> x 10) "big" "small")

(cond
  (< x 5)   "small"
  (< x 20)  "medium"
  true      "large")          ; cond is flat: test, result, test, result…

(and a b)   (or a b)   (not a)

(begin (println "first") (println "second"))   ; several expressions, last one wins

(when  ready (println "go"))
(unless ready (println "wait"))

(for-each n (list 1 2 3) (println n))    ; a loop, not a function

Recursion without a stack

loop and recur give you iteration with proper tail calls, so this runs in constant stack space no matter how large n is:

(defn fact (n)
  (loop (i n acc 1)
    (if (<= i 1)
        acc
        (recur (- i 1) (* acc i)))))

(fact 10)   ; → 3628800

recur jumps back to the enclosing loop with new values for its bindings. Tail calls in ordinary functions are optimised too, so mutual recursion won't blow up either.

5. Functions

(defn greet (name) (str "Hello, " name))

;; rest parameters, with a dot
(defn total (first . others)
  (+ first (reduce (fn (a b) (+ a b)) 0 others)))

(total 1 2 3 4)   ; → 10

;; functions are values
(map (fn (x) (* x x)) (range 1 6))    ; → (1 4 9 16 25)
(apply + (list 1 2 3))                ; → 6
((compose inc inc) 5)                 ; → 7

A parameter list of (a b . rest) binds rest to a list of whatever is left. Macros take rest parameters too — the fix that makes when and unless possible.

6. Macros

A macro runs at expansion time and returns code. Quasiquote (`) builds that code, , drops a value into it, and ,@ splices a list in.

(defmacro unless (c body)
  `(if ,c nil ,body))

(unless false "ran")   ; → "ran"

(defmacro my-when (c . body)
  `(if ,c (begin ,@body) nil))

when, unless and compose in the standard prelude are written this way, in EELisp, not in Rust.

7. Lists and dicts

(list 1 2 3)              (cons 0 (list 1 2))       ; → (0 1 2)
(head lst)   (tail lst)   (nth lst 1)   (length lst)
(append a b) (reverse l)  (range 1 10)
(first l) (second l) (third l) (last l) (take 2 l) (drop 2 l)

(map    (fn (x) (* 2 x)) (range 1 5))
(filter (fn (n) (even? n)) (range 1 10))    ; → (2 4 6 8)
(reduce (fn (a b) (+ a b)) 0 (range 1 5))   ; → 10
(sort-by (fn (p) (nth p 1)) pairs)
(zip a b)   (some? f l)   (every? f l)   (count l)
(def d {:name "Ada" :born 1815})

(dict-get d :name)          ; → "Ada"
(dict-set d :born 1816)     ; → a new dict; the original is untouched
(dict-keys d)               ; → (:name :born)
(dict-has d :name)          ; → true
(dict-merge a b)

Dict operations return new dicts rather than mutating the one you passed in.

8. Strings and dates

(str "a" 1 "b")                  ; → "a1b" — str takes anything
(str-len s)   (str-upper s)   (str-lower s)   (str-trim s)
(str-split "a,b,c" ",")          ; → (a b c)
(str-join ", " parts)
(str-contains s "needle")
(str-matches s "^[0-9]+$")        ; regular expression
(str-replace "14:30" ":" "")      ; → "1430"
(substr s 0 4)  (str-starts-with s "a")  (str-ends-with s "z")
(now)                         ; current epoch seconds
(today)                       ; midnight today, as epoch seconds
(date-format (today) "EEEE")   ; → "Sunday"
(date-format "2026-08-09" "EEEE") ; date strings work too
(date-add (today) 7 :days)
(date-diff a b :days)

9. The database

Tables are SQLite tables. The schema is stored as JSON alongside them, so field types, defaults, required flags and choice lists survive a reload.

;; short form — name:type, all in one list
(deftable books (title:string author:string year:number))

;; long form, when you want constraints
(deftable tasks
  ((title  :type string :required true)
   (status :type string :default "open" :choices ("open" "done"))
   (due    :type date)))

Types are string, number, bool and date.

(insert books {:title "Thinking Forth" :author "Brodie" :year 1984})

(query books)                                  ; everything
(query books :where "year < ?" :params (list 1980)
             :order "year" :desc true :limit 10
             :select (list "title" "year"))

(records (query books))    ; the rows as a list
(length (records (query books)))

(update books 1 {:year 1985})
(delete books 1)           ; soft delete
(pack books)               ; really remove the deleted rows

(tables)                   (describe books)
(count-records books)      (drop-table books)

Grids and forms

(browse books)      ; a table widget — a grid you can move around in
(edit books 1)      ; a form for one record

(defform book-form books
  (title) (author) (year))

In a plain terminal these print as ASCII grids. Inside EEditor they become real interactive widgets, which is the point of the host boundary described in embedding.

Field access on a record: (field-get r "title"), (field-set r "title" "New"), (record-id r).

10. The agenda

Items

(add-item "call Bob" :when "2026-08-10" :priority 1)
(add-item-today "stand-up")
(add "call Bob tomorrow !!")     ; parses date, priority and people out of the text
(smart-parse "call Bob tomorrow !!")
;; → {:text "call Bob" :when "2026-08-10" :priority 2 :who ("Bob")}

(items)                                ; everything
(items :category "work" :priority 1)
(items :search "budget" :when-before (today))
(items-on "2026-08-10")   (items-between a b)

(item-get 1)   (item-set 1 :priority 3)   (item-done 1)   (item-count)

Categories

(defcategory work/calls)      ; hierarchical: work/calls belongs to work
(assign 1 "work/calls")
(unassign 1 "work/calls")
(categories)

Assigning a child implies its parents. A parent marked exclusive permits only one of its children per item, which is how you model states that can't overlap.

Rules

(defrule calls
  :when   (str-matches text "call|phone|ring")
  :assign "work/calls")

(apply-rules)         ; → how many items changed
(apply-rules 7)       ; just item 7
(auto-categorize true)
(rules)   (drop-rule "calls")

Inside :when, the item is in scope:

BoundIs
text, notes, idthe item's own fields
categoriesa list of category paths
propseverything else, as a dict
(get "field")one property by name
(has-category "work")membership test
(overdue?)due before today
(match n)capture group n of the last regular expression

:assign may be repeated, and :action takes arbitrary EELisp to run against a matching item.

Views, templates, recurrence

(defview overdue-work (:category "work"))
(show overdue-work)   (views)   (drop-view "overdue-work")

(deftemplate weekly-review (:text "weekly review" :priority 2))
(from-template weekly-review)   (templates)

(every 2 :weeks)      ; recurrence, used as an item's :recurrence

Several agendas at once

(open-agenda "work.db")   (use-agenda work)   (close-agenda work)
(agendas)
(export-agenda "backup.json")   (import-agenda "backup.json")

Each agenda is its own database file, fully isolated. Imports run in a transaction.

11. JSON and the network

(json-parse "{\"a\":1}")      ; → {:a 1}
(json-stringify {:a 1})       ; → "{\"a\":1}"

(http-get "https://example.com/api")
(http-post "https://example.com/api" body)

These are the only functions that touch the network, and they are synchronous. Nothing else in the language opens a socket.

Where next