cd /news/developer-tools/kaappi-r7rs-scheme-written-in-zig · home topics developer-tools article
[ARTICLE · art-73995] src=github.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Kaappi – r7rs scheme written in Zig

Kaappi, a complete R7RS-small Scheme implementation written in Zig, has been released with 641 built-in procedures, 32 syntax forms, 14 standard libraries, 171 SRFIs, a C FFI, OS threads and fibers, an LLVM native-code backend, a package manager, and a stepping debugger. The runtime uses a register-based bytecode VM with generational garbage collection and stack-copying first-class continuations, and was built with assistance from Anthropic's Claude AI.

read14 min views1 publishedJul 26, 2026
Kaappi – r7rs scheme written in Zig
Image: source

A complete R7RS-small Scheme implementation, written in Zig.

Website · Playground · Tour · Guide · Download

Kaappi implements every identifier from R7RS Appendix A — 641 built-in procedures, 32 syntax forms, and all 14 standard libraries — plus 171 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).

No install needed — run Scheme in your browser at the playground, or take the guided 12-lesson

.

tour

curl -fsSL https://kaappi-lang.org/install.sh | bash

This 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.

Requires Zig 0.16+ and a C toolchain (for the vendored linenoise 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
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.

The Windows port (zig build -Dtarget=aarch64-windows

or -Dtarget=x86_64-windows

) covers the full interpreter — REPL (plain line editing, no history/completion), fibers, channels, OS threads, FFI (LoadLibrary

), and the kaappi test

runner. thottam installs packages on Windows too (with Git for Windows on PATH); only manifests with a build:

command are refused — the C-FFI packages' Makefiles target POSIX. Platform differences: fd readiness covers sockets (event-driven, WSAEventSelect) and pipes (polled) — file ports keep blocking reads (timers and cross-thread wakeups always work) — and the POSIX-only slice of SRFI-170 (uid/gid, symlinks, chmod/umask, user/group info) raises a catchable file error. cond-expand

distinguishes the platforms: Windows builds expose the windows

feature identifier instead of posix

.

The FreeBSD port (zig build -Dtarget=x86_64-freebsd

or aarch64-freebsd

) is full POSIX with no degradations: kqueue-backed fiber I/O, OS threads, complete SRFI-170, the full linenoise REPL, and thottam with build:

support. kaappi compile

links native binaries with the base system's cc

— no extra toolchain needed.

The OpenBSD port (zig build -Dtarget=x86_64-openbsd

or aarch64-openbsd

) is the same full-POSIX kqueue platform — fiber I/O, threads, complete SRFI-170, the full REPL, build:

support, and native compilation with base cc

. Two accommodations for OpenBSD's hardening, both automatic: each binary is marked PT_OPENBSD_NOBTCFI

at 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. See docs/dev/openbsd.md.

The NetBSD port (zig build -Dtarget=x86_64-netbsd

or aarch64-netbsd

) completes the BSD trio — the same full-POSIX kqueue 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. The native backend (kaappi compile

) needs clang from pkgsrc — NetBSD's base cc

is GCC, which can't consume LLVM IR. See docs/dev/netbsd.md.

$ 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.

(define-syntax my-when
  (syntax-rules ()
    ((my-when test body ...)
     (if test (begin body ...)))))

(my-when #t
  (display "hello world")
  (newline))
(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
(define saved #f)

(+ 1 (call/cc (lambda (k)
                (set! saved k)
                10)))
;=> 11

(saved 42)
;=> 43

Proper tail calls(define (loop n) (loop (+ n 1)))

runs forever without growing the stackFirst-class continuations— multi-shotcall/cc

via stack copying,dynamic-wind

for cleanupException handlingguard

,raise

,with-exception-handler

, typed error objects (file-error?

,read-error?

)Hygienic macrossyntax-rules

with scope-based renaming; pattern variables, ellipsis, literals, underscore wildcardsLibrary systemdefine-library

,import

withonly

/except

/rename

/prefix

,.sld

file ,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

171 SRFIs— 12 built-in, 156 as portable.sld

libraries, plus SRFI 261 portable library references ((srfi srfi-1)

,(srfi lists-1)

) resolved in the importer and SRFI 226/160 as sub-libraries only (full list inCONFORMANCE.md)Native binarieskaappi compile program.scm -o program

compiles Scheme to a native executable via LLVM, with self-tail-calls compiled as loops (details)Standalone bundleszig build -Dbundle-src=program.scm

embeds bytecode + libraries in a single executableC FFI— call shared libraries from Scheme via(kaappi ffi)

; 18 marshalled types, callbacks for passing Scheme procedures to CConcurrency— green threads with channels via(kaappi fibers)

, plus real OS threads via SRFI-18Stepping debugger— breakpoints (with conditions), watch expressions, step/next/step-out, frame navigation, locals — all from the REPL** Profiler**—kaappi --profile

or,profile expr

: per-function self/total time, call counts, allocation bytesSandbox modekaappi --sandbox

blocks FFI, file I/O,eval

,load

, and environment accessBytecode caching— compiled.sbc

files are reused when the source is unchangedMachine-legible diagnostics— every error carries a stableKP

code (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 discoverykaappi 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 aVS Code extension

Kaappi ships thottam, a package manager for its growing library ecosystem:

thottam install kaappi-web

kaappi app.scm
Package Description

kaappi-httpkaappi-webkaappi-jsonkaappi-pgkaappi-rediskaappi-examplesMore 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.

(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)

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)
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 (33 node types) with analysis passes (tail positions, primitives, constants) and 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.

zig build test                     # Zig unit tests
bash tests/scheme/run-all.sh       # all Scheme-level suites

The Scheme suites include a 1,391-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.

Document Description

Procedure ReferenceCookbookEcosystemR7RS ConformanceArchitectureAdding FeaturesTesting GuideDeveloper Docs Indexcall/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 eachk

once, so this does not affect them; resuming the samek

twice 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 towith-unwind-handler

, so a handler side effect (and, sinceguard

is built on it, aguard

clause) runsbeforeadynamic-wind

after-thunk of the guarded body, where R7RS-small runs it after. All the effects still happen; only their order differs. SeeCONFORMANCE.mdfor details.

Both are limited to SRFI 248; plain call/cc

, dynamic-wind

, and the built-in guard

are unaffected unless you import (srfi 248)

.

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

, ...), sort

, 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. WASI builds keep blocking single-fiber I/O (the reactor's WASI backend is timer-only until KEP-0001 Phase 4).

Each OS thread gets its own GC with an independent heap. Values are deep-copied when crossing thread boundaries (at thread-start!

and thread-join!

). This means threads cannot share mutable state directly — use channels or return values to communicate. Child threads can allocate and GC independently without affecting the parent.

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 reached through a shared global instead (not captured by a thunk or message) still raises a descriptive error rather than corrupting memory. Two narrow correctness issues are open in the cross-thread wakeup path (#1487, #1489) — not yet recommended for production concurrent workloads. See Standards Conformance for current status.

A closure that crosses a thread-start!

boundary must not, once running on the other thread, call a separately-defined top-level procedure from a library — doing so hangs (#1520). The identical logic inlined directly into the closure works correctly; (kaappi parallel)

's own worker loop is written this way for exactly this reason.

parallel-map

/parallel-for-each

submit one task per list element, so a large list means many concurrent pool-submit

/task-wait

round trips — which, given #1487/#1489 above, means an intermittent hang becomes increasingly likely somewhere past a few hundred concurrent submissions rather than a hard cutoff. Reliable in testing through list sizes in the low hundreds. For larger inputs, chunk manually with make-pool

/pool-submit

/task-wait

(one task per processor, each covering a slice of the input with an ordinary sequential loop) instead of one task per element — see kaappi-examples/parallel-primes

for a worked example.

Only syntax-rules

is supported. syntax-case

was intentionally excluded from R7RS-small and is not implemented.

171 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.

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 changesCommunity— all the ways to participateCode of Conduct

Every bug fix needs a regression test; see the testing guide.

── more in #developer-tools 4 stories · sorted by recency
── more on @kaappi 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/kaappi-r7rs-scheme-w…] indexed:0 read:14min 2026-07-26 ·