A complete R7RS-small Scheme implementation, written in Zig.
Website · Playground · Tour · Guide · Download
Kaappi implements every identifier from R7RS Appendix A — 692 built-in procedures, 32 syntax forms, and all 16 standard libraries — plus 178 SRFIs, a C FFI, OS threads and fibers, an LLVM native-code backend, a package manager, and a stepping debugger. The runtime is a register-based bytecode VM with generational garbage collection and stack-copying first-class continuations.
The name is Malayalam and Tamil for coffee — see the FAQ for the story.
Note: Kaappi was built with the assistance of AI (Claude by Anthropic).
Try it
No install needed — run Scheme in your browser at the playground, or take the guided 12-lesson tour.
Installation
Install script (macOS, Linux, FreeBSD, OpenBSD, NetBSD)
curl -fsSL https://kaappi-lang.org/install.sh | bashThis installs kaappi and thottam (the package manager) to ~/.local/bin/
and the standard libraries to ~/.kaappi/lib/, verifying SHA256 checksums
along the way. On the BSDs the script works from the base system alone —
when neither curl nor wget is installed it falls back to the base
fetch (FreeBSD) or ftp (OpenBSD, NetBSD) for downloads and sha256
for verification.
Prebuilt binaries for every platform are on the
releases page. macOS
binaries are Developer ID signed and notarized; all releases ship
SHA256SUMS with a GPG signature (SHA256SUMS.asc, key at
keybase.io/baijum). See the
download page for manual install and
verification steps.
Build from source
Requires Zig 0.16+ and a C toolchain (for the vendored isocline library):
git clone https://github.com/kaappi/kaappi.git cd kaappi zig build # → zig-out/bin/kaappi zig build run # launch the REPL zig build run -- program.scm # run a Scheme file zig build test # run the unit tests
Supported platforms
| OS | Architecture | Build | Tests | Native compilation |
|---|---|---|---|---|
| macOS | aarch64 (Apple Silicon) | yes | yes | LLVM backend |
| Linux | x86_64 | yes | yes | LLVM backend |
| Linux | aarch64 | yes | yes | LLVM backend |
| Linux | riscv64 | yes | yes | interpreter only |
| Linux | s390x (big-endian) | yes | yes | interpreter only |
| Linux | ppc64le | yes | yes | interpreter only |
| Windows | aarch64 (ARM64), x86_64 | yes | yes | LLVM backend (needs a C toolchain) |
| FreeBSD | x86_64, aarch64 | yes | yes | LLVM backend (base cc suffices) |
| OpenBSD | x86_64, aarch64 | yes | yes | LLVM backend (base cc suffices) |
| NetBSD | x86_64, aarch64 | yes | yes | LLVM backend (needs pkgsrc clang; base cc is GCC) |
| WebAssembly | wasm32-wasi | yes | — | interpreter only |
The WASM build (zig build wasm) runs in browsers and WASI runtimes — it
powers the playground.
Every non-macOS target cross-compiles with zig build -Dtarget=<arch>-<os>.
Per-platform notes, each linking to the full port document:
- Windows — the complete interpreter: REPL (plain
line editing, no history or completion), fibers, channels, OS threads, FFI
(
LoadLibrary), and thekaappi testrunner. thottam installs packages too (with Git for Windows on PATH); only manifests with abuild:command are refused, since the C-FFI packages' Makefiles target POSIX. fd readiness covers sockets (event-driven, WSAEventSelect) and pipes (polled) — file ports keep blocking reads, while timers and cross-thread wakeups always work. The POSIX-only slice of SRFI-170 (uid/gid, symlinks, chmod/umask, user/group info) raises a catchable file error. Windows builds expose thewindowscond-expandfeature identifier instead ofposix. - FreeBSD — full POSIX with no degradations:
kqueue-backed fiber I/O, OS threads, complete SRFI-170, the full isocline
REPL, and thottam with
build:support.kaappi compilelinks native binaries with the base system'scc— no extra toolchain needed. - OpenBSD — the same full-POSIX kqueue platform, with
two automatic accommodations for OpenBSD's hardening: each binary is marked
PT_OPENBSD_NOBTCFIat build time to opt out of BTCFI enforcement (Zig 0.16 emits no BTI landing pads), and the interpreter raises its own stack limit at startup to clear OpenBSD's tight 4 MiB default. - NetBSD — the same feature set, verified on NetBSD
10.1. The runtime binds NetBSD's versioned libc symbols explicitly
(
__kevent50,__opendir30,__getpwnam50— the plain names are old-ABI compat symbols that silently misparse modern structs) and resets the aarch64 FPCR at startup, which NetBSD boots in flush-to-zero mode that would break IEEE gradual underflow.kaappi compileneeds clang from pkgsrc; NetBSD's baseccis GCC, which can't consume LLVM IR.
A taste of Kaappi
$ kaappi kaappi> (define (fib n) ... (if (< n 2) n ... (+ (fib (- n 1)) (fib (- n 2))))) kaappi> (fib 20) 6765 kaappi> (map (lambda (x) (* x x)) '(1 2 3 4 5)) (1 4 9 16 25) kaappi> `(the answer is ,(* 6 7)) (the answer is 42) kaappi> (string-length "héllo") 5 kaappi> (char-alphabetic? #\λ) #t
The REPL has syntax highlighting, line editing, persistent history
(~/.kaappi/history), tab completion for all built-in and user-defined
symbols, and multi-line input with automatic paren balancing.
Hygienic macros
(define-syntax my-when (syntax-rules () ((my-when test body ...) (if test (begin body ...))))) (my-when #t (display "hello world") (newline))
Libraries
(define-library (mylib math) (export square cube) (import (scheme base)) (begin (define (square x) (* x x)) (define (cube x) (* x x x)))) (import (mylib math)) (cube 5) ;=> 125
First-class continuations
(define saved #f) (+ 1 (call/cc (lambda (k) (set! saved k) 10))) ;=> 11 (saved 42) ;=> 43
Features
Complete R7RS-small
- Proper tail calls —
(define (loop n) (loop (+ n 1)))runs forever without growing the stack - First-class continuations — multi-shot
call/ccvia stack copying,dynamic-windfor cleanup - Exception handling —
guard,raise,with-exception-handler, typed error objects (file-error?,read-error?) - Hygienic macros —
syntax-ruleswith scope-based renaming; pattern variables, ellipsis, literals, underscore wildcards - Library system —
define-library,importwithonly/except/rename/prefix,.sldfile loading,cond-expand - Numeric tower — fixnum, bignum (arbitrary precision), exact rational, flonum (IEEE 754 f64), complex; automatic promotion on overflow
- Full Unicode — UTF-8 strings indexed by codepoint, Unicode character classification and case mapping
- Records, ports, lazy evaluation, multiple values, parameters — the whole standard, with no known functional gaps
Beyond the standard
- 178 SRFIs — 12 built-in, 162 as portable
.sldlibraries, plus SRFI 261 portable library references ((srfi srfi-1),(srfi lists-1)) resolved in the importer and SRFI 226/160/211 as sub-libraries only (full list in CONFORMANCE.md) - Native binaries —
kaappi compile program.scm -o programcompiles Scheme to a native executable via LLVM, with self-tail-calls compiled as loops (details) - Standalone bundles —
zig build -Dbundle-src=program.scmembeds bytecode + libraries in a single executable - C FFI — call shared libraries from Scheme via
(kaappi ffi); 18 marshalled types, callbacks for passing Scheme procedures to C - Concurrency — green threads with channels via
(kaappi fibers), plus real OS threads via SRFI-18 - Stepping debugger — breakpoints (with conditions), watch expressions, step/next/step-out, frame navigation, locals — all from the REPL
- Profiler —
kaappi --profileor,profile expr: per-function self/total time, call counts, allocation bytes - Sandbox mode —
kaappi --sandboxblocks FFI, file I/O,eval,load, and environment access - Bytecode caching — compiled
.sbcfiles are reused when the source is unchanged - Machine-legible diagnostics — every error carries a stable
KPcode (error[KP3001]), with--diagnostics=json(LSP shape),kaappi explain <code>, and a Scheme accessor(error-object-code e)in(kaappi diagnostics)for dispatching on codes (details) - Capability discovery —
kaappi features [--json]reports this build's version, target, compiled-in subsystems, SRFIs, and limits from one source of truth (details) - Editor support — a bundled LSP server (
kaappi-lsp) and a VS Code extension
Ecosystem
Kaappi ships thottam, a package manager for its growing library ecosystem:
# Install the web framework (auto-installs kaappi-http, kaappi-json, kaappi-net) thottam install kaappi-web # Now it just works — no --lib-path flags needed kaappi app.scm
| Package | Description |
|---|---|
| kaappi-net | TCP/TLS networking |
| kaappi-http | HTTP/HTTPS client + server (pre-fork, threaded) |
| kaappi-web | Web framework — routing, middleware, JSON helpers |
| kaappi-json | JSON parser and serializer |
| kaappi-pg | PostgreSQL client with cursors and type conversion |
| kaappi-redis | Redis client — lists, hashes, pub/sub, pipelining |
| kaappi-examples | REST API, task queue, CRUD app, file server |
More libraries (CSV, TOML, YAML, logging, templates, testing, crypto, SQLite, email, CLI parsing) are listed in the ecosystem docs.
thottam install <pkg> resolves dependencies, supports version constraints
(thottam install kaappi-net@">=0.2.0"), and installs to ~/.kaappi/lib/
where libraries are discovered automatically.
A REST API in a few lines
(import (kaappi web) (kaappi pg) (kaappi json)) (define db (pg-connect "dbname=myapp")) (define app (routes (GET "/users/:id" (lambda (req params) (let ((rows (pg-query db "SELECT * FROM users WHERE id = $1" (param/number params "id")))) (json-response (if (null? rows) '(("error" . "not found")) (car rows)))))) (POST "/users" (lambda (req params) (let ((body (request-json req))) (pg-exec db "INSERT INTO users (name) VALUES ($1)" (cdr (assoc "name" body))) (json-response '(("created" . #t)) 201)))))) (serve (wrap app wrap-json-body wrap-logging wrap-errors) 8080)
Concurrency
Green threads (fibers) for cooperative multitasking within one OS thread:
(import (kaappi fibers)) (define ch (make-channel)) (spawn (lambda () (channel-send ch "hello from fiber"))) (display (channel-receive ch)) ;=> hello from fiber
Scheduling is cooperative: spawned fibers run when the main program blocks
(channel-receive on an empty channel, fiber-join) or calls (yield).
A fiber that blocks on an empty channel is parked and woken by the next
channel-send on that channel. When the main program ends, fibers that are
still parked (e.g. workers that never received a stop sentinel) are simply
discarded and the process exits — like goroutines in Go. If the main program
blocks on a channel that no runnable or parked-and-wakeable fiber can ever
send to, channel-receive raises a deadlock error (an error object,
catchable with guard); the same applies to fiber-join on a fiber that can
never complete.
Real OS threads via SRFI-18 — each thread gets its own VM and GC, enabling true parallel I/O (e.g., thread-per-connection servers):
(import (srfi 18)) (define t (thread-start! (make-thread (lambda () (display "running on OS thread") (newline))))) (thread-join! t)
Architecture
Source → Reader → Expander → IR → Bytecode emission → VM
(UTF-8 (syntax- (analysis + (register- (generational GC,
lexer) rules) optimization based) stack-copied
passes) continuations)
| Component | Role |
|---|---|
| Reader | Tokenizer + recursive descent parser for the full R7RS lexical syntax, including Unicode identifiers and #\λ character literals. |
| Expander | syntax-rules pattern matching and hygienic template instantiation. |
| IR | Tree-structured intermediate representation (18 node types) with a tail-position analysis pass and 5 optimization passes (constant folding, dead-branch elimination, and more). |
| Compiler | IR → register-based bytecode. |
| VM | Bytecode interpreter with growable register file and frame stack, exception handler and dynamic-wind stacks, stack-copying continuations, and a stepping debugger. |
| GC | Generational collector (young/old) with write barrier for old→young references. |
Values are NaN-boxed 64-bit words — flonums, fixnums, booleans, characters, and nil all fit in a single u64 with zero heap allocation:
Flonum: any f64 that is not a NaN ← stored directly
Pointer: 0xFFFC | 48-bit pointer ← heap object
Fixnum: 0xFFFD | 48-bit signed int ← up to ±2^47, auto-promotes to bignum
Immediate: 0xFFFE | payload ← nil, bool, void, eof, char
The full component map, file layout, and design notes are in docs/dev/architecture.md.
Testing
zig build test # Zig unit tests bash tests/scheme/run-all.sh # all Scheme-level suites
The Scheme suites include a 1,395-test R7RS conformance suite (via
(chibi test)), plus targeted suites for compliance, continuations, macro
hygiene, SRFIs, and the FFI. CI runs on every platform in the support matrix,
and per-commit performance trends are tracked on the
benchmark dashboard.
Documentation
| Document | Description |
|---|---|
| User Guide | Installation, REPL, language tutorial, CLI reference |
| Procedure Reference | Every built-in procedure, organized by domain |
| Cookbook | Task-oriented recipes: REST APIs, JSON, CSV, SQLite, testing |
| Ecosystem | thottam and all kaappi-* libraries |
| R7RS Conformance | Design choices and per-SRFI coverage details |
| Architecture | Pipeline, value representation, GC, file organization |
| Adding Features | Step-by-step guides for extending the implementation |
| Testing Guide | Unit tests, Scheme tests, benchmarks, CI |
| Developer Docs Index | All contributor docs: guides, design decisions, postmortems |
Known limitations
Continuations
call/cc captures continuations by copying the full VM state (registers, call
frames, exception handlers, dynamic-wind stack). Cost is O(stack depth) per
capture — negligible for most programs, but noticeable if continuations are
captured in tight inner loops. Continuations captured in one top-level REPL
expression cannot re-enter subsequent top-level expressions (standard behavior
shared by Guile, Chibi, Chicken, Chez, and Racket).
SRFI 248's delimited continuations (with-unwind-handler, and the extended
guard) are built on this call/cc via a sticky exception handler, with two
observable caveats:
- Single-shot — each captured delimited continuation may be resumed at most
once. Every SRFI 248 idiom (coroutine generators,
for-each->fold, effect handlers) resumes eachkonce, so this does not affect them; resuming the samektwice fails because it re-enters a native frame that has already returned. - Handler timing — the handler runs at the raise point rather than after
unwinding to
with-unwind-handler, so a handler side effect (and, sinceguardis built on it, aguardclause) runs before adynamic-windafter-thunk of the guarded body, where R7RS-small runs it after. All the effects still happen; only their order differs. See CONFORMANCE.md for details.
Both are limited to SRFI 248; plain call/cc, dynamic-wind, and the built-in
guard are unaffected unless you import (srfi 248).
Exceptions
A handler runs after the stack has unwound to the with-exception-handler (or
guard) that installed it, rather than at the raise point, so a parameterize
or dynamic-wind extent entered between the two is already gone by the time
the handler is called. R7RS-small calls the handler in the dynamic environment
of the raise. raise-continuable is unaffected — its handler does run in
place, as specified.
guard clauses are evaluated in the guard's own dynamic environment, as R7RS
4.2.7 requires. One consequence of the above shows in the implicit re-raise:
when no clause matches, raise-continuable is invoked in the guard's dynamic
environment rather than the original raise's, so an outer
with-exception-handler observes the guard's parameterization. Restoring the
raise point's dynamic environment needs a continuation captured under the
native raise frame, which cannot be resumed once that frame has returned.
This applies to the built-in guard. (srfi 248) replaces guard with its
own, whose separate timing caveat is above.
Fibers
Callbacks driven by map, for-each, vector-map, vector-for-each,
string-map, string-for-each, dynamic-wind, and force run in the
bytecode dispatch loop, so a fiber can park inside them (e.g. block on an
empty channel) and resume later. Other higher-order procedures are still
native drivers — SRFI-1 (fold, filter, find, any, every, ...),
hash-table-walk/hash-table-update!, assoc/member with a custom
predicate, string-index, eval, ... — and a fiber that blocks on
an empty channel inside one of those callbacks cannot be parked: the native
call's state lives on the Zig stack and cannot be suspended. If other fibers
are runnable the scheduler still makes progress, but if the blocked receive
is the only thing left it raises a deadlock error instead of suspending.
Move blocking channel-receive calls into plain Scheme loops (named let,
do) or the bytecode-driven procedures above when a fiber must wait inside
iteration.
Port I/O that would block (a socket or pipe read/write with no data or a
full kernel buffer) parks the fiber on the per-thread reactor instead of
blocking the OS thread, so fibers reading different connections interleave.
The main fiber — or a fiber inside a native-driver callback — cannot be
parked; it instead dispatches sibling fibers in place while it waits, so
progress continues either way. Ports on fds other than 0/1/2 buffer output
until flush-output-port, close-port, a read on the same port, the
buffer filling (8 KiB), or program exit; stdin/stdout/stderr remain
unbuffered.
On WASI, whether a port can park a fiber depends on the host. Ports flip to
non-blocking only if fd_fdstat_set_flags(NONBLOCK) succeeds; where it does
not — the playground's browser shim, for one — no fd is ever registered and the
reactor falls back to timer-only waits, leaving I/O blocking and single-fiber.
Timers and thread-sleep! work either way.
OS threads (SRFI-18)
Each OS thread gets its own VM and GC with an independent heap, and can allocate and collect without affecting the parent. A value reaches another thread by one of two routes, which behave differently:
- By copy — the thunk closure at
thread-start!, the result atthread-join!, and every channel message are deep-copied. Fourteen types are refused outright on this route (ports, continuations, fibers, mutexes, condition variables, and more). - By reference — top-level bindings are shared by pointer, so a thunk that merely names a global gets the parent's own object, uncopied. The refusal list above does not apply, and only four types (channels, thread handles, fibers, guardians) check that the caller owns them.
So threads can share mutable state, through a top-level binding — and for
mutexes and condition variables that is the only supported way to share one.
Doing it with ordinary data is a hazard rather than an idiom: nothing
synchronizes the writes, the child collects independently, and the child heap
is freed after thread-join!. Prefer channels and return values. The full
per-type matrix, and which route checks what, is in
docs/dev/thread-value-sharing.md.
A (kaappi fibers) channel captured by a thread's thunk (or nested inside a
value sent over one) crosses safely: it is promoted to a mutex-protected,
refcounted shared channel outside every GC heap, and every message crosses by
copy (KEP-0002). (kaappi parallel) builds worker pools and parallel-map/
parallel-for-each on top of this — see the Concurrency
guide for the higher-level API.
A channel must reach the other thread through lexical capture in the
thunk (or in a message sent over an already-promoted channel) — a channel
reached instead through a shared top-level define is never promoted, and
raises a descriptive error rather than corrupting memory
(#1742 is exactly this
trap). See Standards
Conformance
for current status.
parallel-map/parallel-for-each submit one task per list element. For
very large inputs, chunking manually with make-pool/pool-submit/
task-wait (one task per processor, each covering a slice of the input with
an ordinary sequential loop) reduces per-task submission overhead — see
kaappi-examples/parallel-primes for a worked example.
Macros
Only syntax-rules is supported. syntax-case was intentionally excluded from
R7RS-small and is not implemented.
SRFI coverage
178 SRFIs are supported. Some built-in SRFIs have minor coverage gaps (e.g.,
linear-update variants in SRFI-1, string-xcopy! in SRFI-13). See
CONFORMANCE.md for per-SRFI details.
SRFI 261 (Portable SRFI Library Reference) is supported as an import-resolver
convention: (import (srfi srfi-1)) and (import (srfi lists-1)) resolve to
(srfi 1) — the trailing number is authoritative — and sub-library tails pass
through ((srfi srfi-146 hash)). Literal names win when they exist, so a
library actually named (srfi srfi-x) is never shadowed. cond-expand's
(library …) test honors the same forms.
Contributing
Contributions are welcome — bug reports, SRFI implementations, documentation, and ecosystem libraries alike.
New here? Start with GitHub Discussions — ask questions, report bugs, propose ideas. Issues and PRs are open to org members; request an invite in Discussions when you're ready to contribute directly.
- CONTRIBUTING.md — how to get involved, build, test, and submit changes
- Community — all the ways to participate
- Code of Conduct
Every bug fix needs a regression test; see the testing guide.
Support This Project
If you find Kaappi useful, consider supporting its development:
