The whole language, in order. Every example here runs as written — paste them into the REPL.
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.
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.
(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.
(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
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.
(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.
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.
(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.
(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)
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)
(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).
(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)
(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.
(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:
| Bound | Is |
|---|---|
text, notes, id | the item's own fields |
categories | a list of category paths |
props | everything 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.
(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
(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.
(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.