"))))
(define routes
[[:get "/" handle-home]
[:get "/greet/:name" handle-greet]])
(http/serve (http/router routes) {:port 3000})
```
### SPA with Static Assets
Serve a single-page application with static assets and a catch-all for client-side routing.
```sema
(define routes
[[:get "/api/health" (fn (_) (http/ok {:status "up"}))]
[:static "/assets" "./dist/assets"]
[:get "/*" (fn (_) (http/file "./dist/index.html"))]])
(http/serve (http/router routes) {:port 3000})
```
CSS, JS, and images under `./dist/assets/` are served with correct MIME types and cache headers. All other GET requests serve `index.html` for client-side routing.
## Architecture Notes
* **Single-threaded evaluation**: All Sema code runs on the main thread. HTTP I/O runs on a background Tokio runtime. Requests are bridged via channels.
* **Concurrency model**: Requests are processed sequentially by the evaluator. For LLM-backed services (where each request takes 1–5s of LLM latency), this is fine. For high-throughput APIs, consider a reverse proxy.
* **Graceful shutdown**: Ctrl+C breaks the channel and the server exits cleanly.
* **Sandbox-aware**: `http/serve` requires the `NETWORK` capability when running in sandbox mode.
## See Also
* [HTTP Client & JSON](./http-json) — outbound HTTP requests and JSON encoding/decoding
* [LLM Primitives](/docs/llm/) — building LLM-powered endpoints
* [Key-Value Store](./kv-store) — persistent storage for server state
---
---
url: 'https://sema-lang.com/docs/stdlib/system.md'
---
# System
::: tip Sandbox capability
Several `sys/*` functions are gated by sandbox capabilities: environment access (`env`, `sys/env-all`, `sys/set-env`) requires `ENV_READ` or `ENV_WRITE`, and process operations (`shell`, `sys/which`, signal hooks, `exit`) require `PROCESS`. They run unrestricted under `sema` by default but are restricted in sandboxed environments (e.g., the WASM playground). A sandboxed script that attempts to use them without the capability will receive an error.
:::
## Environment Variables
### `env`
Get the value of an environment variable. Returns `nil` if not set.
```sema
(env "HOME") ; => "/Users/ada"
(env "PATH") ; => "/usr/bin:/bin:..."
(env "MISSING") ; => nil
```
### `sys/env-all`
Return all environment variables as a map.
```sema
(sys/env-all) ; => {:HOME "/Users/ada" :PATH "..." ...}
```
### `sys/set-env`
Set an environment variable for the current process.
```sema
(sys/set-env "KEY" "value")
(env "KEY") ; => "value"
```
## System Information
### `sys/args`
Return the command-line arguments as a list.
```sema
(sys/args) ; => ("sema" "script.sema" "--flag")
```
### `sys/cwd`
Return the current working directory.
```sema
(sys/cwd) ; => "/current/dir"
```
### `sys/platform`
Return the platform name.
```sema
(sys/platform) ; => "macos" / "linux" / "windows"
```
### `sys/os`
Return the operating system name.
```sema
(sys/os) ; => "macos"
```
### `sys/arch`
Return the CPU architecture.
```sema
(sys/arch) ; => "aarch64" / "x86_64"
```
## Process Information
### `sys/pid`
Return the current process ID.
```sema
(sys/pid) ; => 12345
```
### `sys/tty`
Return the TTY device path, or `nil` if not running in a terminal.
```sema
(sys/tty) ; => "/dev/ttys003" or nil
```
### `sys/which`
Find the full path to an executable, or `nil` if not found.
```sema
(sys/which "cargo") ; => "/Users/ada/.cargo/bin/cargo"
(sys/which "nonexistent") ; => nil
```
### `sys/elapsed`
Return nanoseconds elapsed since the process started.
```sema
(sys/elapsed) ; => 482937100
```
## Session Information
### `sys/interactive?`
Test if stdin is a TTY (i.e., running interactively).
```sema
(sys/interactive?) ; => #t in REPL, #f in scripts
```
### `sys/hostname`
Return the system hostname.
```sema
(sys/hostname) ; => "my-machine"
```
### `sys/user`
Return the current username.
```sema
(sys/user) ; => "ada"
```
## Directory Paths
### `sys/home-dir`
Return the user's home directory.
```sema
(sys/home-dir) ; => "/Users/ada"
```
### `sys/temp-dir`
Return the system temporary directory.
```sema
(sys/temp-dir) ; => "/tmp"
```
## Terminal
### `sys/term-size`
Return the terminal's current size as a map `{:rows N :cols M}`, or `nil` when no controlling TTY is attached (e.g., when stdout is redirected to a file). Queries `ioctl(TIOCGWINSZ)` against stdout, then stderr, then stdin.
```sema
(sys/term-size)
;; => {:rows 47 :cols 180}
```
Pair with `sys/on-signal :winch` to redraw on terminal resize:
```sema
(define (redraw size)
;; ... layout for size ...
)
(redraw (sys/term-size))
(sys/on-signal :winch (fn () (redraw (sys/term-size))))
```
::: warning Unix only
Returns `nil` on Windows and any non-Unix target.
:::
## Signals
Async-signal-safe handlers backed by atomic flags. Signal handlers themselves only flip a flag — your callbacks run later, in the main thread, when you call `sys/check-signals`. This keeps the single-threaded `Rc`-based runtime intact.
::: warning Unix only
Signal hooks are no-ops on Windows.
:::
### `sys/on-signal`
Register a callback for a signal. Multiple callbacks per signal are supported; they fire in registration order.
Supported signals:
| Keyword | Signal | Typical use |
|----------|------------|--------------------------------------|
| `:winch` | `SIGWINCH` | Terminal resize — redraw the UI |
| `:int` | `SIGINT` | Ctrl-C — clean shutdown |
| `:term` | `SIGTERM` | Termination request — clean shutdown |
```sema
(sys/on-signal :int (fn ()
(println "interrupted, cleaning up")
(exit 0)))
```
### `sys/check-signals`
Dispatch any pending signal callbacks. Call this from your event loop (typically right after `io/read-key` / `io/read-key-timeout` returns) so handlers run in a predictable place rather than asynchronously interrupting Sema code.
```sema
(let loop ()
(sys/check-signals)
(let ((key (io/read-key-timeout 50)))
(when key (handle-key key))
(loop)))
```
If no signals are pending, this is essentially free — it just checks three atomic booleans.
## Shell & Process Control
### `shell`
Run a shell command. Returns a map with `:stdout`, `:stderr`, and `:exit-code`. A single-string command runs through the system shell (`sh -c` / `cmd /C`); passing extra arguments runs the command directly, without shell parsing. Requires the `PROCESS` capability.
```sema
(shell "echo hello") ; => {:stdout "hello\n" :stderr "" :exit-code 0}
(:stdout (shell "ls -la")) ; => "total 42\n..."
(:exit-code (shell "false")) ; => 1
```
### `exit`
Exit the process with a given status code.
```sema
(exit 0) ; exit successfully
(exit 1) ; exit with error
```
---
---
url: 'https://sema-lang.com/docs/stdlib/sqlite.md'
---
# SQLite
Sema includes built-in SQLite support via the `db/*` functions, backed by [rusqlite](https://docs.rs/rusqlite). Databases are opened by name (a logical handle) and can be either file-backed or in-memory. WAL mode and foreign keys are enabled by default.
::: tip
`db/open` and `db/open-memory` require filesystem write capabilities (they are gated by `FS_WRITE`).
:::
## Opening & Closing
### `db/open`
Open (or create) a SQLite database file. Returns a handle string for use in subsequent calls. Enables WAL journal mode and foreign keys automatically.
```sema
;; Open with path as handle
(db/open "mydata.db") ; => "mydata.db"
;; Open with a named handle
(db/open "mydb" "/path/to/data.db") ; => "mydb"
```
### `db/open-memory`
Open an in-memory SQLite database. Useful for tests, temporary data, and caching.
```sema
(db/open-memory) ; handle is ":memory:"
(db/open-memory "testdb") ; handle is "testdb"
```
### `db/close`
Close a database connection and release the handle. Returns `nil`.
```sema
(db/close "mydb")
```
## Executing SQL
### `db/exec`
Execute a SQL statement that modifies data (INSERT, UPDATE, DELETE, CREATE TABLE, etc.). Returns the number of affected rows as an integer. Supports parameterized queries.
```sema
(db/exec "mydb" "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)")
; => 0
(db/exec "mydb" "INSERT INTO users (name, age) VALUES (?, ?)" "Alice" 30)
; => 1
(db/exec "mydb" "UPDATE users SET age = ? WHERE name = ?" 31 "Alice")
; => 1
```
### `db/exec-batch`
Execute multiple SQL statements at once. **Static SQL only** — there is no parameter binding, so the entire string is run verbatim. Useful for schema setup and migrations. Returns `nil`.
::: danger SQL injection
Never interpolate user-controlled input into the SQL string passed to `db/exec-batch` — doing so is a SQL injection vulnerability. For any value that comes from outside the program, use the parameterized [`db/exec`](#db-exec) (with `?` placeholders) instead, one statement at a time.
:::
```sema
(db/exec-batch "mydb" "
CREATE TABLE posts (id INTEGER PRIMARY KEY, user_id INTEGER, title TEXT);
CREATE TABLE tags (id INTEGER PRIMARY KEY, name TEXT);
CREATE INDEX idx_posts_user ON posts(user_id);
")
```
## Querying
### `db/query`
Execute a SELECT query and return all results as a list of maps. Column names become keyword keys. Supports parameterized queries.
```sema
(db/query "mydb" "SELECT * FROM users")
; => ({:id 1 :name "Alice" :age 31})
(db/query "mydb" "SELECT name, age FROM users WHERE age > ?" 25)
; => ({:age 31 :name "Alice"})
```
### `db/query-one`
Execute a SELECT query and return only the first row as a map, or `nil` if no rows match.
```sema
(db/query-one "mydb" "SELECT * FROM users WHERE name = ?" "Alice")
; => {:id 1 :name "Alice" :age 31}
(db/query-one "mydb" "SELECT * FROM users WHERE name = ?" "Nobody")
; => nil
```
## Utility
### `db/last-insert-id`
Return the rowid of the last inserted row.
```sema
(db/exec "mydb" "INSERT INTO users (name, age) VALUES (?, ?)" "Bob" 25)
(db/last-insert-id "mydb")
; => 2
```
### `db/tables`
List all user-created tables in the database (excludes internal SQLite tables). Returns a list of strings.
```sema
(db/tables "mydb")
; => ("posts" "tags" "users")
```
## Type Mapping
| Sema type | SQLite type | Notes |
| ----------- | ----------- | ---------------------------- |
| `nil` | NULL | |
| Boolean | INTEGER | `#t` = 1, `#f` = 0 |
| Integer | INTEGER | |
| Float | REAL | |
| String | TEXT | |
| Bytevector | BLOB | |
| Other | TEXT | Converted via `to-string` |
SQLite values map back as: NULL to `nil`, INTEGER to int, REAL to float, TEXT to string, BLOB to bytevector.
## Examples
### Basic CRUD
```sema
(db/open-memory "app")
(db/exec "app" "CREATE TABLE todos (id INTEGER PRIMARY KEY, task TEXT, done INTEGER DEFAULT 0)")
;; Insert
(db/exec "app" "INSERT INTO todos (task) VALUES (?)" "Buy groceries")
(db/exec "app" "INSERT INTO todos (task) VALUES (?)" "Write docs")
;; Query
(db/query "app" "SELECT * FROM todos WHERE done = 0")
; => ({:done 0 :id 1 :task "Buy groceries"} {:done 0 :id 2 :task "Write docs"})
;; Update
(db/exec "app" "UPDATE todos SET done = 1 WHERE id = ?" 1)
;; Delete
(db/exec "app" "DELETE FROM todos WHERE done = 1")
(db/close "app")
```
### Using with LLM extraction
```sema
(db/open-memory "contacts")
(db/exec "contacts" "CREATE TABLE people (name TEXT, email TEXT, company TEXT)")
;; Extract structured data from text and insert directly
(define info (llm/extract
{:name {:type :string} :email {:type :string} :company {:type :string}}
"Contact Alice at alice@acme.com, she works at Acme Corp"))
(db/exec "contacts" "INSERT INTO people (name, email, company) VALUES (?, ?, ?)"
(:name info) (:email info) (:company info))
(db/query "contacts" "SELECT * FROM people")
; => ({:company "Acme Corp" :email "alice@acme.com" :name "Alice"})
(db/close "contacts")
```
---
---
url: 'https://sema-lang.com/docs/stdlib/kv-store.md'
---
# Key-Value Store
Sema includes a persistent, JSON-backed key-value store for storing structured data across sessions. Data is automatically flushed to disk on every write.
::: tip
`kv/open`, `kv/set`, and `kv/delete` require filesystem write capabilities (they are gated by `FS_WRITE`).
:::
## How It Works
**File path** — You control where data is stored via the second argument to `kv/open`. Relative paths resolve from the current working directory. The file is **not created until the first write** (`kv/set` or `kv/delete`).
**Store names** — The first argument to `kv/open` is a logical handle used to reference the store in subsequent calls. Store names are scoped to the current process. Opening the same name twice replaces the previous handle.
**Flushing** — Every `kv/set` and `kv/delete` rewrites the entire backing file immediately. `kv/close` also flushes. There is no separate manual flush — persistence is automatic.
**JSON format** — The backing file is pretty-printed JSON, so you can inspect or edit it with any text editor. If an existing file contains malformed JSON, `kv/open` raises an error.
**Supported value types:**
| Sema | JSON | Notes |
|------|------|-------|
| `nil` | `null` | |
| `#t` / `#f` | `true` / `false` | |
| Integers | number | |
| Floats | number | `NaN` and `Infinity` become `null` |
| Strings | string | |
| Lists | array | Recursive |
| Maps (keyword keys) | object | Keys become strings |
**Performance** — Each write rewrites the whole file. This is ideal for small-to-medium stores (config, caches, counters). For large datasets or high-frequency writes, consider using `file/write` directly.
## Functions
### `kv/open`
Open (or create) a named KV store backed by a JSON file. If the file exists, its contents are loaded. Returns the store name.
```sema
(kv/open "config" "/path/to/config.json") ; => "config"
(kv/open "cache" "cache.json") ; relative to CWD
```
If the file doesn't exist yet, no file is created — that happens on the first `kv/set`.
### `kv/get`
Get a value by key. Returns `nil` if the key doesn't exist.
```sema
(kv/get "config" "api-key") ; => "sk-..." or nil
```
### `kv/set`
Set a key-value pair. The value is serialized as JSON. Returns the value. Flushes to disk immediately.
```sema
(kv/set "config" "api-key" "sk-...")
(kv/set "config" "retries" 3)
(kv/set "config" "tags" '("a" "b" "c"))
(kv/set "config" "user" {:name "Alice" :role "admin"})
```
### `kv/delete`
Delete a key. Returns `#t` if the key existed, `#f` otherwise. Flushes to disk immediately.
```sema
(kv/delete "config" "api-key") ; => #t
(kv/delete "config" "api-key") ; => #f (already deleted)
```
### `kv/keys`
List all keys in the store. Returns a list of strings.
```sema
(kv/keys "config") ; => ("api-key" "retries" "tags")
```
### `kv/close`
Close a store, flushing data and freeing the handle. Returns `nil`.
```sema
(kv/close "config")
```
Data is safe even without calling `kv/close` (every write already flushes), but closing frees memory and releases the store name.
## Examples
### Basic usage
```sema
;; Create a persistent store for caching API results
(kv/open "cache" "api-cache.json")
;; Store some data
(kv/set "cache" "user:123" {:name "Alice" :email "alice@example.com"})
(kv/set "cache" "user:456" {:name "Bob" :email "bob@example.com"})
;; Retrieve it
(kv/get "cache" "user:123")
; => {:email "alice@example.com" :name "Alice"}
;; List keys
(kv/keys "cache")
; => ("user:123" "user:456")
;; Clean up
(kv/delete "cache" "user:123")
(kv/close "cache")
```
### Application configuration with defaults
```sema
(kv/open "config" "app-config.json")
;; Set defaults only if not already configured
(when (nil? (kv/get "config" "theme"))
(kv/set "config" "theme" "dark"))
(when (nil? (kv/get "config" "max-retries"))
(kv/set "config" "max-retries" 3))
;; Use config values
(def theme (kv/get "config" "theme"))
(println (string/append "Using theme: " theme))
```
On first run this creates `app-config.json` with defaults. On subsequent runs, existing values are preserved.
### Persistent run counter
```sema
(kv/open "stats" "run-stats.json")
;; Increment run count across sessions
(let ((runs (or (kv/get "stats" "run-count") 0)))
(kv/set "stats" "run-count" (+ runs 1))
(kv/set "stats" "last-run" (time/format (time/now) "%Y-%m-%d %H:%M:%S")))
(println (string/append "Run #" (string (kv/get "stats" "run-count"))))
(kv/close "stats")
```
### Structured data with maps and lists
```sema
(kv/open "contacts" "contacts.json")
(kv/set "contacts" "alice"
{:name "Alice" :email "alice@example.com" :tags '("admin" "dev")})
(kv/set "contacts" "bob"
{:name "Bob" :email "bob@example.com" :tags '("dev")})
;; Retrieve and destructure
(def alice (kv/get "contacts" "alice"))
(:name alice) ; => "Alice"
(:tags alice) ; => ("admin" "dev")
;; List all contacts
(for-each (fn (key) (println (:name (kv/get "contacts" key))))
(kv/keys "contacts"))
(kv/close "contacts")
```
## Tips
* The backing file is human-readable JSON — you can inspect or hand-edit it between runs.
* Store names are just logical handles. Choose descriptive names like `"config"`, `"cache"`, or `"sessions"`.
* Use `kv/keys` with iteration for bulk operations like export or cleanup.
* For write-heavy workloads on large datasets, consider writing JSON directly with `file/write` to avoid rewriting the entire file on each operation.
---
---
url: 'https://sema-lang.com/docs/stdlib/serial.md'
---
# Serial Ports
Talk to microcontrollers, USB-CDC devices, and any UART over a host serial port. Wraps the cross-platform [`serialport`](https://crates.io/crates/serialport) crate.
::: warning Not available in WASM
Serial ports require the host OS — this module is unavailable in the browser playground.
:::
::: tip Sandbox capability
All `serial/*` functions require the `serial` capability. They are denied under `--sandbox=strict` and `--sandbox=all`. Allow with the default sandbox or explicitly opt in (see [CLI sandbox docs](../cli#sandbox)).
:::
## Connection Lifecycle
### `serial/list`
List the available serial port device paths on the host.
```sema
(serial/list)
;; macOS: ("/dev/tty.usbmodem1201" "/dev/tty.Bluetooth-Incoming-Port")
;; Linux: ("/dev/ttyUSB0" "/dev/ttyACM0")
```
### `serial/open`
```sema
(serial/open path baud) ; default 2000 ms read timeout
(serial/open path baud timeout-ms)
```
Open a serial port and return an integer **handle** used by every other function in this module. Raises an error if the device is busy or doesn't exist; the message includes the path and baud rate as a hint.
```sema
(define pico (serial/open "/dev/tty.usbmodem1201" 115200))
(define modem (serial/open "/dev/ttyUSB0" 9600 5000)) ; 5s read timeout
```
### `serial/close`
```sema
(serial/close handle)
```
Close the port and free the handle. Subsequent calls with that handle raise `invalid handle`.
## I/O
### `serial/write`
```sema
(serial/write handle string)
```
Write a raw string to the port and flush. No newline appended — append `"\n"` yourself if your protocol expects it.
```sema
(serial/write modem "AT\r\n")
```
### `serial/read-line`
```sema
(serial/read-line handle) → string
```
Read until `\n`, then trim trailing `\r` / `\n` and return the line. Blocks until either a newline arrives or the port's read timeout elapses (configured at `serial/open` time) — on timeout, raises an error.
```sema
(serial/read-line pico) ; => "ready"
```
### `serial/send`
```sema
(serial/send handle command) → parsed-json | nil
```
Convenience for line-oriented JSON protocols (such as the [sema-bridge](https://github.com/HelgeSverre/sema/tree/main/examples) firmware that ships with the Pico examples). Writes `command + "\n"`, flushes, reads one line back, and parses it as JSON. Returns `nil` if the response line is empty.
```sema
(serial/send pico "{\"cmd\":\"led-on\",\"pin\":25}")
;; => {:ok #t}
(serial/send pico "{\"cmd\":\"adc-read\",\"pin\":26}")
;; => {:ok #t :value 2048}
```
## Example: Pico 2 LED control
```sema
(define pico (serial/open "/dev/tty.usbmodem1201" 115200))
(println "bridge:" (serial/read-line pico)) ; "ready"
(define (pico-cmd cmd)
(let ((resp (serial/send pico cmd)))
(when (not (get resp :ok))
(error (format "pico error: ~a" (get resp :error))))
resp))
(pico-cmd "{\"cmd\":\"led-on\",\"pin\":25}")
(sleep 500)
(pico-cmd "{\"cmd\":\"led-off\",\"pin\":25}")
(serial/close pico)
```
See `examples/pico-blink.sema`, `pico-piano.sema`, `pico-jukebox.sema`, `pico-midi.sema`, and `pico-show.sema` for full demos.
---
---
url: 'https://sema-lang.com/docs/stdlib/regex.md'
---
# Regex
Regular expression functions for pattern matching, searching, replacement, and splitting. Sema uses the Rust [`regex`](https://docs.rs/regex) engine.
::: warning Rust regex limitations
Rust regex intentionally does **not** support features that require backtracking:
* No lookahead / lookbehind (`(?=...)`, `(?!...)`, `(?<=...)`, `(?`)
If you need those, consider a multi-step approach using string functions.
:::
## Regex Literals: `#"..."`
Normal strings require double-escaping backslashes (`"\\d+"`). Sema's regex literal syntax avoids this:
```sema
(regex/match? "\\d+" "abc123") ; normal string — needs \\
(regex/match? #"\d+" "abc123") ; regex literal — cleaner
```
Inside `#"..."`, backslashes are literal (no escape processing). The only special case is `\"` to insert a quote character.
::: tip
Prefer `#"..."` for regex patterns. It's easier to read and avoids escaping mistakes.
:::
## Matching
### `regex/match?`
Test if a pattern matches anywhere in a string. Returns `#t` or `#f`.
```sema
(regex/match? #"\d+" "abc123") ; => #t
(regex/match? #"\d+" "no digits") ; => #f
(regex/match? #"^\d+$" "abc123") ; => #f (anchored — must match entire string)
(regex/match? #"^\d+$" "123") ; => #t
```
### `regex/match`
Match a pattern and return match details as a map, or `nil` if no match.
**Signature:** `(regex/match pattern text) → map | nil`
The returned map contains:
| Key | Value |
|-----|-------|
| `:match` | The full matched substring |
| `:groups` | List of capture groups (group 1, 2, …) |
| `:start` | Start byte offset in the input |
| `:end` | End byte offset in the input |
```sema
(regex/match #"(\d+)-(\w+)" "item-42-foo")
; => {:match "42-foo" :groups ("42" "foo") :start 5 :end 11}
(regex/match #"xyz" "abc")
; => nil
```
Optional capture groups that don't participate in the match become `nil`:
```sema
(regex/match #"(\d+)(?:-(\d+))?" "42")
; => {:match "42" :groups ("42" nil) :start 0 :end 2}
```
::: info Byte offsets
`:start` and `:end` are byte offsets (UTF-8). For ASCII text they match character indices, but for non-ASCII they may differ.
:::
### `regex/find-all`
Find all non-overlapping matches of a pattern.
```sema
(regex/find-all #"\d+" "a1b2c3") ; => ("1" "2" "3")
(regex/find-all #"[A-Z]" "Hello World") ; => ("H" "W")
```
## Replacement
### `regex/replace`
Replace the **first** match of a pattern.
**Signature:** `(regex/replace pattern replacement text) → string`
```sema
(regex/replace #"\d+" "X" "a1b2c3") ; => "aXb2c3"
```
Capture group references (`$1`, `$2`, …) work in the replacement string:
```sema
(regex/replace #"(\d+)-(\w+)" "$2:$1" "item-42-foo")
; => "item-foo:42"
```
Named capture groups also work:
```sema
(regex/replace #"(?P\d+)-(?P\w+)" "$word:$num" "item-42-foo")
; => "item-foo:42"
```
### `regex/replace-all`
Replace **all** matches of a pattern.
```sema
(regex/replace-all #"\d" "X" "a1b2") ; => "aXbX"
(regex/replace-all #"\s+" " " "a b c") ; => "a b c"
```
## Splitting
### `regex/split`
Split a string by a regex delimiter.
```sema
(regex/split #"," "a,b,c") ; => ("a" "b" "c")
(regex/split #"\s+" "hello world") ; => ("hello" "world")
(regex/split #"[,;]" "a,b;c,d") ; => ("a" "b" "c" "d")
```
## Supported Syntax
Sema uses Rust regex syntax. Common constructs:
| Pattern | Meaning |
|---------|---------|
| `.` | Any character (except newline by default) |
| `\d`, `\w`, `\s` | Digit, word char, whitespace |
| `\D`, `\W`, `\S` | Negated versions |
| `+`, `*`, `?` | One+, zero+, optional |
| `{m,n}` | Between m and n repetitions |
| `^`, `$` | Start/end anchors |
| `(...)` | Capture group |
| `(?:...)` | Non-capturing group |
| `(?P...)` | Named capture group |
| `[abc]`, `[^abc]` | Character class |
| `a\|b` | Alternation |
See the [Rust regex docs](https://docs.rs/regex) for the full reference.
## Escaping Guide
### Regex literals vs normal strings
| Intent | Normal string | Regex literal |
|--------|---------------|---------------|
| One or more digits | `"\\d+"` | `#"\d+"` |
| A literal dot | `"\\."` | `#"\."` |
| A backslash | `"\\\\\\\\"` | `#"\\"` |
### Matching a literal `"` in a regex literal
Inside `#"..."`, use `\"`:
```sema
(regex/match? #"\"[^\"]+\"" "say \"hello\"")
; => #t
```
## Regex vs String Functions
Prefer string functions when possible — they're simpler and faster:
| Need | String function | Regex equivalent |
|------|----------------|------------------|
| Contains? | `string/contains?` | `regex/match?` |
| Starts with? | `string/starts-with?` | `regex/match?` with `^` |
| Simple split | `string/split` | `regex/split` |
| Simple replace | `string/replace` | `regex/replace` |
Use regex when you need character classes, repetition, alternation, or capture groups.
## Practical Examples
### Validate an identifier
```sema
(define (identifier? s)
(regex/match? #"^[A-Za-z_][A-Za-z0-9_]*$" s))
(identifier? "foo_1") ; => #t
(identifier? "1foo") ; => #f
```
### Extract a number from text
```sema
(define (extract-first-int s)
(let ((m (regex/match #"\d+" s)))
(if (nil? m)
nil
(:match m))))
(extract-first-int "x=42; y=9") ; => "42"
```
### Normalize whitespace
```sema
(regex/replace-all #"\s+" " " "a b\n\nc\t\t d")
; => "a b c d"
```
### Parse key-value pairs
```sema
(define (parse-kv line)
(let ((m (regex/match #"^(\w+)\s*=\s*(.+)$" line)))
(if (nil? m)
nil
(let ((groups (:groups m)))
{:key (first groups) :value (first (rest groups))}))))
(parse-kv "name = Alice")
; => {:key "name" :value "Alice"}
```
### Find all email-like strings
```sema
(regex/find-all #"[\w.+-]+@[\w-]+\.[\w.]+" "Contact ada@example.com or bob@test.org")
; => ("ada@example.com" "bob@test.org")
```
## Performance Notes
* Each function call **compiles the regex pattern** internally
* For occasional use, this is fine
* For hot loops, consider using `regex/find-all` once instead of many `regex/match?` calls
* Rust regex guarantees **linear-time** matching — no catastrophic backtracking
---
---
url: 'https://sema-lang.com/docs/stdlib/crypto.md'
---
# Crypto & Encoding
UUID generation, Base64 encoding, and cryptographic hashing.
## UUID
### `uuid/v4`
Generate a random UUID v4 string.
**Signature:** `(uuid/v4) → string`
```sema
(uuid/v4) ; => "550e8400-e29b-41d4-a716-446655440000" (varies)
```
Each call returns a new unique identifier:
```sema
(equal? (uuid/v4) (uuid/v4)) ; => #f
```
## Base64 Encoding
Functions for Base64 encoding and decoding of strings and binary data. Uses the standard Base64 alphabet (RFC 4648).
### `base64/encode`
Encode a string to Base64.
**Signature:** `(base64/encode string) → string`
```sema
(base64/encode "hello") ; => "aGVsbG8="
(base64/encode "") ; => ""
```
### `base64/decode`
Decode a Base64 string back to a UTF-8 string. Errors if the decoded bytes are not valid UTF-8.
**Signature:** `(base64/decode base64-string) → string`
```sema
(base64/decode "aGVsbG8=") ; => "hello"
```
### `base64/encode-bytes`
Encode a bytevector to Base64.
**Signature:** `(base64/encode-bytes bytevector) → string`
```sema
(base64/encode-bytes #u8(104 101 108 108 111)) ; => "aGVsbG8="
```
### `base64/decode-bytes`
Decode a Base64 string to a bytevector. Unlike `base64/decode`, this does not require valid UTF-8.
**Signature:** `(base64/decode-bytes base64-string) → bytevector`
```sema
(base64/decode-bytes "aGVsbG8=") ; => #u8(104 101 108 108 111)
```
### Use cases
**Data URIs:**
```sema
(string/append "data:image/png;base64," (base64/encode-bytes (file/read-bytes "icon.png")))
```
**API authentication (Basic Auth):**
```sema
(define auth-header
(string/append "Basic " (base64/encode (string/append username ":" password))))
```
## Hashing
Cryptographic hash functions that return hex-encoded strings.
::: warning Security note
**MD5** is cryptographically broken — do not use it for passwords, signatures, or any security-sensitive purpose. Use `hash/sha256` or `hash/hmac-sha256` instead. MD5 is still fine for checksums and non-security uses (cache keys, deduplication).
:::
### `hash/sha256`
Compute the SHA-256 hash of a string. Returns a 64-character hex string.
**Signature:** `(hash/sha256 string) → string`
```sema
(hash/sha256 "hello")
; => "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
```
### `hash/md5`
Compute the MD5 hash of a string. Returns a 32-character hex string.
**Signature:** `(hash/md5 string) → string`
```sema
(hash/md5 "hello") ; => "5d41402abc4b2a76b9719d911017c592"
```
### `hash/hmac-sha256`
Compute an HMAC-SHA256 message authentication code. Returns a 64-character hex string.
**Signature:** `(hash/hmac-sha256 key message) → string`
```sema
(hash/hmac-sha256 "secret-key" "message")
; => "hex-encoded-hmac..."
```
**Webhook verification example:**
```sema
;; Verify a webhook signature from a provider
(define (verify-webhook payload secret signature)
(equal? (hash/hmac-sha256 secret payload) signature))
```
---
---
url: 'https://sema-lang.com/docs/stdlib/datetime.md'
---
# Date & Time
All timestamps in Sema are **UTC Unix timestamps** — the number of seconds since January 1, 1970 00:00:00 UTC. Timestamps are floating-point numbers with millisecond fractional precision.
::: tip
All `time/` functions operate in UTC. There is no timezone conversion support — if you need local time handling, compute the offset manually with `time/add`.
:::
## Current Time
### `time/now`
Return the current time as a UTC Unix timestamp in seconds, with fractional milliseconds.
```sema
(time/now) ; => 1707955200.123
```
The integer part is seconds since the Unix epoch; the fractional part provides millisecond precision.
```sema
(define now (time/now))
(println "Current timestamp: " now)
;; Extract just the seconds (truncate fractional part)
(define whole-seconds (floor now))
```
### `time-ms`
Return the current time as Unix milliseconds (integer). Defined in the system module but useful alongside datetime operations.
```sema
(time-ms) ; => 1707955200123
```
## Formatting
### `time/format`
Format a UTC Unix timestamp using a [strftime](#strftime-format-directives)-style format string.
```sema
(time/format timestamp format-string) ; => string
```
```sema
(define ts 1736943000.0) ; 2025-01-15 12:10:00 UTC
(time/format ts "%Y-%m-%d") ; => "2025-01-15"
(time/format ts "%H:%M:%S") ; => "12:10:00"
(time/format ts "%Y-%m-%d %H:%M:%S") ; => "2025-01-15 12:10:00"
(time/format ts "%A, %B %d, %Y") ; => "Wednesday, January 15, 2025"
(time/format ts "%F") ; => "2025-01-15" (shorthand for %Y-%m-%d)
(time/format ts "%T") ; => "12:10:00" (shorthand for %H:%M:%S)
```
## Parsing
### `time/parse`
Parse a date string into a UTC Unix timestamp using a [strftime](#strftime-format-directives)-style format string. The input is treated as a **UTC naive datetime** — no timezone information is expected or applied.
```sema
(time/parse date-string format-string) ; => float (UTC timestamp)
```
```sema
(time/parse "2025-01-15 12:10:00" "%Y-%m-%d %H:%M:%S") ; => 1736943000.0
(time/parse "2025-01-15 00:00:00" "%Y-%m-%d %H:%M:%S") ; => 1736899200.0
(time/parse "15/01/2025 14:30:00" "%d/%m/%Y %H:%M:%S") ; => 1736951400.0
```
::: info
The format string must provide enough directives to fully specify a date and time. Parsing a date-only string like `"%Y-%m-%d"` without time components will fail — always include time directives (e.g., `%H:%M:%S`).
:::
::: tip
The wall-clock time in the string is **always interpreted as UTC**, regardless of any offset present. `time/parse` does not apply timezone offsets — `"2025-01-15 12:10:00"` always yields the UTC timestamp for 12:10:00 UTC. To work with another timezone, convert the value to UTC yourself (subtract the offset) before parsing, then format/compute in UTC.
:::
**Roundtrip** — formatting a timestamp and parsing it back yields the original value:
```sema
(define ts 1700000000.0)
(define formatted (time/format ts "%Y-%m-%d %H:%M:%S"))
(define parsed (time/parse formatted "%Y-%m-%d %H:%M:%S"))
(= parsed ts) ; => #t
```
::: warning
`time/parse` returns whole seconds — sub-second precision from the original timestamp is lost when roundtripping through format/parse.
:::
## Date Decomposition
### `time/date-parts`
Decompose a UTC Unix timestamp into a map of date/time components.
```sema
(time/date-parts timestamp) ; => map
```
```sema
(define ts 1736943000.0) ; 2025-01-15 12:10:00 UTC
(define parts (time/date-parts ts))
(get parts :year) ; => 2025
(get parts :month) ; => 1
(get parts :day) ; => 15
(get parts :hour) ; => 12
(get parts :minute) ; => 10
(get parts :second) ; => 0
(get parts :weekday) ; => "Wednesday"
```
The returned map contains these keys:
| Key | Type | Description | Example |
|-----|------|-------------|---------|
| `:year` | integer | Four-digit year | `2025` |
| `:month` | integer | Month (1–12) | `1` |
| `:day` | integer | Day of month (1–31) | `15` |
| `:hour` | integer | Hour (0–23) | `12` |
| `:minute` | integer | Minute (0–59) | `10` |
| `:second` | integer | Second (0–59) | `0` |
| `:weekday` | string | Full weekday name | `"Wednesday"` |
The `:weekday` value is the full English weekday name: `"Monday"`, `"Tuesday"`, `"Wednesday"`, `"Thursday"`, `"Friday"`, `"Saturday"`, `"Sunday"`.
## Arithmetic
### `time/add`
Add seconds to a timestamp. Returns a new timestamp. Use negative values to subtract.
```sema
(time/add timestamp seconds) ; => float (timestamp)
```
```sema
(define ts 1736943000.0) ; 2025-01-15 12:10:00 UTC
(time/add ts 3600) ; one hour later => 1736946600.0
(time/add ts 86400) ; one day later => 1737029400.0
(time/add ts -3600) ; one hour earlier => 1736939400.0
(time/add ts (* 7 86400)) ; one week later
```
Common durations in seconds:
| Duration | Seconds |
|----------|---------|
| 1 minute | `60` |
| 1 hour | `3600` |
| 1 day | `86400` |
| 1 week | `604800` |
| 30 days | `2592000` |
### `time/diff`
Compute the difference between two timestamps in seconds. Returns `t1 - t2` (the first argument minus the second). The result can be negative.
```sema
(time/diff t1 t2) ; => float (seconds)
```
```sema
(define morning 1736935800.0) ; 2025-01-15 10:10:00 UTC
(define afternoon 1736943000.0) ; 2025-01-15 12:10:00 UTC
(time/diff afternoon morning) ; => 7200.0 (2 hours)
(time/diff morning afternoon) ; => -7200.0 (negative — morning is earlier)
(time/diff morning morning) ; => 0.0
```
::: tip
`time/diff` returns a signed value: positive when `t1 > t2`, negative when `t1 < t2`. Use `abs` if you need the absolute elapsed time regardless of order.
:::
## Delay
### `sleep`
Pause execution for a given number of milliseconds. Returns `nil`.
```sema
(sleep milliseconds) ; => nil
```
```sema
(sleep 1000) ; sleep for 1 second
(sleep 500) ; sleep for 500ms
(sleep 0) ; yield (no-op pause)
```
Note that `sleep` takes **milliseconds** (not seconds), unlike the `time/` functions which work in seconds.
## strftime Format Directives
The `time/format` and `time/parse` functions use [chrono strftime](https://docs.rs/chrono/latest/chrono/format/strftime/index.html) format directives. Here are the most common ones:
### Date
| Directive | Description | Example |
|-----------|-------------|---------|
| `%Y` | Four-digit year | `2025` |
| `%m` | Month (zero-padded, 01–12) | `01` |
| `%d` | Day of month (zero-padded, 01–31) | `15` |
| `%e` | Day of month (space-padded) | `15` |
| `%B` | Full month name | `January` |
| `%b` | Abbreviated month name | `Jan` |
| `%A` | Full weekday name | `Wednesday` |
| `%a` | Abbreviated weekday name | `Wed` |
| `%u` | Day of week (1=Monday, 7=Sunday) | `3` |
| `%j` | Day of year (001–366) | `015` |
| `%F` | ISO 8601 date (`%Y-%m-%d`) | `2025-01-15` |
### Time
| Directive | Description | Example |
|-----------|-------------|---------|
| `%H` | Hour, 24-hour (zero-padded, 00–23) | `12` |
| `%I` | Hour, 12-hour (zero-padded, 01–12) | `12` |
| `%M` | Minute (zero-padded, 00–59) | `10` |
| `%S` | Second (zero-padded, 00–59) | `00` |
| `%p` | AM/PM | `PM` |
| `%T` | Time (`%H:%M:%S`) | `12:10:00` |
| `%R` | Short time (`%H:%M`) | `12:10` |
### Combined & Special
| Directive | Description | Example |
|-----------|-------------|---------|
| `%c` | Locale date and time | `Wed Jan 15 12:10:00 2025` |
| `%s` | Unix timestamp (seconds) | `1736943000` |
| `%Z` | Timezone abbreviation | `UTC` |
| `%%` | Literal `%` | `%` |
## Common Patterns
### Measuring elapsed time
```sema
(define start (time/now))
;; ... do some work ...
(define end (time/now))
(define elapsed (time/diff end start))
(println (format "Took ~a seconds" elapsed))
```
### ISO 8601 formatting
```sema
(define ts (time/now))
(time/format ts "%Y-%m-%dT%H:%M:%SZ") ; => "2025-01-15T12:10:00Z"
(time/format ts "%F") ; => "2025-01-15" (date only)
```
### Calculating "N days ago"
```sema
(define now (time/now))
(define one-week-ago (time/add now (* -7 86400)))
(define thirty-days-ago (time/add now (* -30 86400)))
(println "One week ago: " (time/format one-week-ago "%Y-%m-%d"))
```
### Formatting for display
```sema
(define ts (time/now))
(time/format ts "%A, %B %d, %Y") ; => "Wednesday, January 15, 2025"
(time/format ts "%I:%M %p") ; => "12:10 PM"
(time/format ts "%b %d at %H:%M") ; => "Jan 15 at 12:10"
```
### Checking the day of the week
```sema
(define parts (time/date-parts (time/now)))
(define day (get parts :weekday))
(if (or (= day "Saturday") (= day "Sunday"))
(println "It's the weekend!")
(println "It's a weekday."))
```
### Computing duration between dates
```sema
(define start (time/parse "2025-01-01 00:00:00" "%Y-%m-%d %H:%M:%S"))
(define end (time/parse "2025-03-15 00:00:00" "%Y-%m-%d %H:%M:%S"))
(define diff-seconds (time/diff end start))
(define diff-days (/ diff-seconds 86400))
(println (format "~a days between dates" diff-days))
```
## Edge Cases
### Unix epoch
```sema
(time/format 0.0 "%Y-%m-%d %H:%M:%S") ; => "1970-01-01 00:00:00"
(time/date-parts 0.0)
; => {:day 1 :hour 0 :minute 0 :month 1 :second 0 :weekday "Thursday" :year 1970}
```
### Negative timestamps (dates before 1970)
```sema
(time/format -86400.0 "%Y-%m-%d") ; => "1969-12-31"
(time/format -31536000.0 "%Y-%m-%d") ; => "1969-01-01"
```
### Sub-second precision
`time/now` returns millisecond fractional precision. `time/add` and `time/diff` preserve fractional seconds. However, `time/parse` returns whole seconds only.
```sema
(define ts (time/add 1736943000.0 0.5)) ; add 500ms
(time/diff ts 1736943000.0) ; => 0.5
```
---
---
url: 'https://sema-lang.com/docs/stdlib/context.md'
---
# Context
Sema provides an ambient context system — a key-value store that flows through your entire execution without explicit parameter passing. Inspired by [Laravel's Context](https://laravel.com/docs/12.x/context), it's designed for tracing, metadata propagation, and sharing configuration across deeply nested calls.
Context data is automatically appended as metadata to log output (`log/info`, `log/warn`, `log/error`, `log/debug`).
## Core Functions
### `context/set`
Set a key-value pair in the current context frame.
```sema
(context/set :trace-id "abc-123")
(context/set :user-id 42)
```
### `context/get`
Retrieve a value by key. Returns `nil` if the key doesn't exist.
```sema
(context/get :trace-id) ; => "abc-123"
(context/get :missing) ; => nil
```
### `context/has?`
Check if a key exists in the context.
```sema
(context/has? :trace-id) ; => #t
(context/has? :missing) ; => #f
```
### `context/remove`
Remove a key from all context frames. Returns the removed value, or `nil`.
```sema
(context/set :temp "data")
(context/remove :temp) ; => "data"
(context/remove :temp) ; => nil (already gone)
```
### `context/pull`
Get a value and remove it in one step (identical to `context/remove`).
```sema
(context/set :token "abc")
(context/pull :token) ; => "abc"
(context/has? :token) ; => #f
```
### `context/all`
Get all context as a merged map.
```sema
(context/set :a 1)
(context/set :b 2)
(context/all) ; => {:a 1 :b 2}
```
### `context/merge`
Merge a map of key-value pairs into the current context.
```sema
(context/merge {:trace-id "abc" :env "production" :version "1.0"})
(context/get :env) ; => "production"
```
### `context/clear`
Clear all context, resetting to an empty state.
```sema
(context/clear)
(context/all) ; => {}
```
## Scoped Overrides
### `context/with`
Push a temporary context frame for the duration of a thunk. The frame is automatically popped when the thunk completes — even if it raises an error.
```sema
(context/set :env "production")
(context/with {:env "staging" :debug #t}
(lambda ()
(context/get :env) ; => "staging"
(context/get :debug))) ; => #t
(context/get :env) ; => "production" (restored)
(context/get :debug) ; => nil (gone)
```
Scopes nest naturally — inner values shadow outer ones:
```sema
(context/set :a 1)
(context/with {:b 2}
(lambda ()
(context/with {:c 3}
(lambda ()
(list (context/get :a) (context/get :b) (context/get :c))))))
; => (1 2 3)
```
::: warning
Values set with `context/set` inside a `context/with` block are written to the inner frame and discarded when the scope exits. If you need a value to persist, set it before entering `context/with`.
:::
## Stacks
Context stacks are ordered lists of values that you can push to and pop from. Unlike key-value context, stacks are **not scoped** by `context/with` — pushes persist across scope boundaries.
### `context/push`
Append a value to a named stack.
```sema
(context/push :breadcrumbs "login")
(context/push :breadcrumbs "dashboard")
(context/push :breadcrumbs "settings")
```
### `context/stack`
Get all values in a named stack as a list.
```sema
(context/stack :breadcrumbs)
; => ("login" "dashboard" "settings")
```
### `context/pop`
Remove and return the last value from a stack. Returns `nil` if the stack is empty.
```sema
(context/pop :breadcrumbs) ; => "settings"
(context/stack :breadcrumbs)
; => ("login" "dashboard")
```
## Hidden Context
Hidden context stores values that are **not visible** via `context/get`, `context/all`, or log metadata. Use it for sensitive data like API keys or internal state.
### `context/set-hidden`
```sema
(context/set-hidden :api-key "sk-secret-123")
```
### `context/get-hidden`
```sema
(context/get-hidden :api-key) ; => "sk-secret-123"
(context/get :api-key) ; => nil (not visible in regular context)
```
### `context/has-hidden?`
```sema
(context/has-hidden? :api-key) ; => #t
```
## Log Integration
When context is non-empty, `log/info`, `log/warn`, `log/error`, and `log/debug` automatically append the context map as metadata:
```sema
(context/set :trace-id "abc-123")
(context/set :user-id 42)
(log/info "Request processed")
```
Output:
```
[INFO] Request processed {:trace-id "abc-123" :user-id 42}
```
Hidden context is **not** included in log output.
## Examples
### Request tracing
```sema
(context/set :request-id (uuid/v4))
(context/set :method "GET")
(context/set :path "/api/users")
(log/info "Request started")
; [INFO] Request started {:method "GET" :path "/api/users" :request-id "a1b2c3..."}
;; All downstream functions automatically include this context in their logs
(process-request)
```
### Pipeline breadcrumbs
```sema
(define (process-document doc)
(context/push :steps "parse")
(let ((parsed (parse doc)))
(context/push :steps "validate")
(let ((valid (validate parsed)))
(context/push :steps "transform")
(transform valid))))
(process-document input)
(context/stack :steps)
; => ("parse" "validate" "transform")
```
### Scoped configuration
```sema
;; Set default model
(context/set :model "claude-sonnet")
;; Override for a specific block
(context/with {:model "gpt-5.5" :temperature 0.9}
(lambda ()
;; Code here sees the overridden values
(context/get :model))) ; => "gpt-5.5"
(context/get :model) ; => "claude-sonnet"
```
## Function Reference
| Function | Args | Description |
| --------------------- | ----------- | --------------------------------- |
| `context/set` | `key value` | Set a context value |
| `context/get` | `key` | Get a value (or `nil`) |
| `context/has?` | `key` | Check if key exists |
| `context/remove` | `key` | Remove and return value |
| `context/pull` | `key` | Get and remove (alias for remove) |
| `context/all` | | Get all context as a map |
| `context/merge` | `map` | Merge map into context |
| `context/clear` | | Clear all context |
| `context/with` | `map thunk` | Scoped override |
| `context/push` | `key value` | Push to named stack |
| `context/stack` | `key` | Get stack as list |
| `context/pop` | `key` | Pop from named stack |
| `context/set-hidden` | `key value` | Set hidden value |
| `context/get-hidden` | `key` | Get hidden value |
| `context/has-hidden?` | `key` | Check hidden key exists |
---
---
url: 'https://sema-lang.com/docs/stdlib/terminal.md'
---
# Terminal Styling
Functions for styling terminal output with ANSI escape codes, true color, and animated spinners.
All style functions take a string and return a new string wrapped in ANSI escape sequences. The styled text is reset after the content, so styles don't bleed into subsequent output.
::: tip Terminal output
Styled output renders correctly in terminals that support ANSI escape codes. When piping or redirecting output (e.g., to a file), the raw escape sequences are included in the output. Use `term/strip` to produce clean text for non-terminal destinations.
:::
## Modifiers
Modifier functions change how text is displayed without altering its color.
### `term/bold`
Render text in **bold** (increased intensity).
```sema
(term/bold "important")
(println (term/bold "Warning: check your input"))
```
### `term/dim`
Render text with decreased intensity.
```sema
(term/dim "less important")
```
### `term/italic`
Render text in *italic*.
```sema
(term/italic "emphasis")
```
### `term/underline`
Render text with an underline.
```sema
(term/underline "click here")
```
### `term/inverse`
Swap foreground and background colors.
```sema
(term/inverse "highlighted")
```
### `term/strikethrough`
Render text with a ~~strikethrough~~.
```sema
(term/strikethrough "deprecated")
```
## Colors
Color functions set the foreground (text) color.
### `term/black`
```sema
(term/black "dark text")
```
### `term/red`
```sema
(term/red "error message")
```
### `term/green`
```sema
(term/green "success")
```
### `term/yellow`
```sema
(term/yellow "warning")
```
### `term/blue`
```sema
(term/blue "info")
```
### `term/magenta`
```sema
(term/magenta "special")
```
### `term/cyan`
```sema
(term/cyan "highlight")
```
### `term/white`
```sema
(term/white "bright text")
```
### `term/gray`
```sema
(term/gray "muted text")
```
## Combined Styles
### `term/style`
Apply multiple styles at once using keywords. The first argument is the text, followed by one or more style keywords.
```sema
(term/style "danger" :bold :red)
(term/style "notice" :italic :yellow :underline)
(term/style "subtle" :dim :gray)
```
Internally, `term/style` combines ANSI codes with `;` separators into a single escape sequence (e.g., `ESC[1;31m` for bold red), which is more efficient than nesting individual style functions.
If called with no style keywords, the text is returned unstyled.
```sema
(term/style "plain text") ; => "plain text" (no ANSI codes)
```
An unknown keyword produces an error:
```sema
(term/style "text" :blink) ; Error: unknown style keyword :blink
```
#### Style keyword reference
| Keyword | Effect | ANSI Code |
|------------------|----------------|-----------|
| `:bold` | Bold | 1 |
| `:dim` | Dim | 2 |
| `:italic` | Italic | 3 |
| `:underline` | Underline | 4 |
| `:inverse` | Inverse | 7 |
| `:strikethrough` | Strikethrough | 9 |
| `:black` | Black text | 30 |
| `:red` | Red text | 31 |
| `:green` | Green text | 32 |
| `:yellow` | Yellow text | 33 |
| `:blue` | Blue text | 34 |
| `:magenta` | Magenta text | 35 |
| `:cyan` | Cyan text | 36 |
| `:white` | White text | 37 |
| `:gray` | Gray text | 90 |
### Composing Styles
There are two ways to combine styles:
**Using `term/style` (recommended):** produces a single escape sequence with combined codes.
```sema
(term/style "alert" :bold :red :underline)
;; Produces: ESC[1;31;4m alert ESC[0m
```
**Nesting individual functions:** each function wraps the text in its own escape sequence. This works but produces more verbose output.
```sema
(term/bold (term/red (term/underline "alert")))
;; Produces: ESC[1m ESC[31m ESC[4m alert ESC[0m ESC[0m ESC[0m
```
Both approaches render identically in terminals, but `term/style` is cleaner.
## True Color
### `term/rgb`
Apply 24-bit true color to text. Takes the text followed by red, green, and blue values (integers 0–255).
```sema
(term/rgb "orange" 255 165 0)
(term/rgb "coral" 255 127 80)
(term/rgb "teal" 0 128 128)
(term/rgb "hot pink" 255 105 180)
```
Uses the `ESC[38;2;r;g;bm` escape sequence format, which is supported by most modern terminals.
```sema
;; Build a gradient
(for-each
(lambda (i)
(display (term/rgb "█" (* i 25) 50 (- 255 (* i 25)))))
(range 11))
(println)
```
## Stripping ANSI Codes
### `term/strip`
Remove all ANSI escape sequences from a string, returning plain text.
```sema
(term/strip (term/bold "hello")) ; => "hello"
(term/strip (term/style "hi" :red :bold)) ; => "hi"
(term/strip (term/rgb "color" 255 0 0)) ; => "color"
(term/strip "no codes here") ; => "no codes here"
```
This is useful when you need plain text for logging to files, comparisons, or passing to functions that don't understand ANSI codes:
```sema
;; Write clean text to a file, styled text to terminal
(define msg (term/green "Build succeeded"))
(println msg) ; styled on terminal
(file/write "build.log" (term/strip msg)) ; clean in log file
```
## Spinners
Animated terminal spinners for indicating progress during long-running operations. Spinners use braille animation frames (`⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`) cycling at 80ms intervals, and render to **stderr** so they don't interfere with stdout output.
### `term/spinner-start`
Start a spinner with a message. Returns an integer spinner ID used to update or stop it.
```sema
(define id (term/spinner-start "Loading data..."))
```
### `term/spinner-update`
Update the message displayed next to a running spinner.
```sema
(term/spinner-update id "Processing records...")
(term/spinner-update id "Almost done...")
```
### `term/spinner-stop`
Stop a running spinner and optionally display a final status line. The spinner line is cleared from the terminal before the final status is printed.
**Without options** — just clears the spinner:
```sema
(term/spinner-stop id)
```
**With options map** — displays a final symbol and text:
```sema
(term/spinner-stop id {:symbol "✔" :text "Done"})
```
The options map supports two keys:
| Key | Type | Description |
|-----------|--------|--------------------------------------|
| `:symbol` | string | Symbol to display (e.g., `"✔"`, `"✗"`, `"⚠"`) |
| `:text` | string | Final status message |
Both keys are optional. The final line is printed to stderr as `symbol text`.
### Spinner Lifecycle Example
```sema
;; Start spinner
(define spinner (term/spinner-start "Fetching data..."))
;; ... do some work ...
(term/spinner-update spinner "Processing results...")
;; ... do more work ...
(term/spinner-update spinner "Writing output...")
;; Stop with success indicator
(term/spinner-stop spinner {:symbol "✔" :text "Complete"})
```
Multiple spinners can run concurrently — each gets a unique ID:
```sema
(define s1 (term/spinner-start "Task A..."))
(define s2 (term/spinner-start "Task B..."))
;; ... work ...
(term/spinner-stop s1 {:symbol "✔" :text "Task A done"})
(term/spinner-stop s2 {:symbol "✔" :text "Task B done"})
```
## Line Input
Read whole lines from standard input (cooked mode — the terminal buffers a line until Enter). Useful for simple prompts and for piping data into a script.
### `io/read-line`
Block until a full line is available on stdin and return it as a string (without the trailing newline). Returns `nil` at end of input.
```sema
(define name (io/read-line))
(println (str "Hello, " name))
```
### `io/eof?`
Return `#t` once stdin has hit end of input (set when `io/read-line` / `io/read-stdin` / `io/read-key` returns `nil`). Pair it with `io/read-line` to consume piped input line by line:
```sema
(let loop ()
(let ((line (io/read-line)))
(unless (io/eof?)
(println (string/upper line))
(loop))))
```
## Raw-Mode Input
Primitives for building interactive TUIs: per-keystroke input, EOF detection, and signal-aware event loops. **Unix only** — these functions are no-op stubs on Windows.
In cooked mode (the default), the terminal driver buffers a whole line and only delivers it to your program when the user hits Enter. Raw mode disables that — every key press, including Ctrl-C and arrow keys, is delivered as it happens. Pair these with `sys/term-size` and `sys/on-signal` (in the [System](system) docs) to build full TUIs.
### `io/tty-raw!`
Put stdin into raw mode. Returns an **integer restore-token** on success, or `nil` if stdin is not a TTY (e.g., when input is piped from a file). Always pair with `io/tty-restore!` so the user's shell isn't left in raw mode if your program crashes.
```sema
(define tok (io/tty-raw!))
(when tok
;; ... read keys, draw UI ...
(io/tty-restore! tok))
```
### `io/tty-restore!`
Restore the TTY to cooked mode using the token returned by `io/tty-raw!`.
```sema
(io/tty-restore! tok)
```
### `io/read-key`
Block until a single keypress arrives, then return a map describing it. Returns `nil` on EOF (after which `io/eof?` returns `#t`).
```sema
(io/read-key)
;; => {:kind :char :char "a"}
```
The map's `:kind` field is one of:
| `:kind` | Other keys | Meaning |
|-----------|-------------------------|-------------------------------------------------|
| `:char` | `:char` (string) | A printable character (UTF-8 multi-byte handled) |
| `:ctrl` | `:char` (string) | Ctrl + letter (e.g., Ctrl-C → `{:kind :ctrl :char "c"}`) |
| `:alt` | `:char` (string) | Alt/Meta + character (ESC + char sequence) |
| `:key` | `:name` (keyword) | Named key — see table below |
Named keys (`:kind :key`) currently emitted:
`:enter` `:tab` `:backspace` `:esc` `:up` `:down` `:left` `:right` `:home` `:end` `:delete` `:page-up` `:page-down` `:f1` `:f2` `:f3` `:f4`
CSI/SS3 escape sequences (arrow keys, F1–F4, Page Up/Down, Delete) and UTF-8 continuation bytes are decoded for you with a 20 ms continuation-byte window. F5–F12 and Insert use longer escape sequences that aren't decoded yet — they fall through as raw characters.
### `io/read-key-timeout`
Like `io/read-key`, but returns `nil` after `timeout-ms` milliseconds with no input. Backed by `select(2)`, so it doesn't burn CPU.
```sema
(io/read-key-timeout 100) ; => key map, or nil after 100ms
```
Use this to drive an animation loop or to poll signals between renders:
```sema
(let loop ()
(sys/check-signals)
(let ((key (io/read-key-timeout 50)))
(when key (handle-key key))
(loop)))
```
### Minimal TUI skeleton
Assumes interactive stdin — `io/tty-raw!` returns `nil` when stdin isn't a TTY, so guard with `when tok` if the program may run with input piped from a file.
```sema
(define tok (io/tty-raw!))
(when tok
(sys/on-signal :winch (fn () (redraw (sys/term-size))))
(sys/on-signal :int (fn () (io/tty-restore! tok) (exit 0)))
(let loop ()
(sys/check-signals)
(let ((key (io/read-key)))
(cond
((nil? key) ; EOF
(io/tty-restore! tok))
((and (= (:kind key) :ctrl) (= (:char key) "c")) ; Ctrl-C
(io/tty-restore! tok))
(else
(handle-key key)
(loop))))))
```
## Common Patterns
### Colored Log Levels
```sema
(define (log-error msg) (println (term/style "✗ ERROR" :bold :red) " " msg))
(define (log-warn msg) (println (term/style "⚠ WARN " :bold :yellow) " " msg))
(define (log-info msg) (println (term/style "ℹ INFO " :bold :blue) " " msg))
(define (log-success msg) (println (term/style "✔ OK " :bold :green) " " msg))
(log-error "Connection refused")
(log-warn "Retrying in 5s")
(log-info "Connecting to server")
(log-success "Connected")
```
### CLI Status Output
```sema
(define (print-step label detail)
(println (term/style label :bold :cyan) " " (term/dim detail)))
(print-step "Compile" "src/main.sema")
(print-step "Link" "3 modules")
(print-step "Write" "build/output")
```
### Progress with Spinners
```sema
(define steps '("Downloading" "Extracting" "Installing" "Configuring"))
(define sp (term/spinner-start "Starting..."))
(for-each
(lambda (step)
(term/spinner-update sp (string/append step "..."))
(sleep 1000))
steps)
(term/spinner-stop sp {:symbol "✔" :text "Installation complete"})
```
### Conditional Styling
```sema
(define (color-status code)
(cond
((< code 300) (term/green (number/to-string code)))
((< code 400) (term/yellow (number/to-string code)))
(else (term/red (number/to-string code)))))
(println "Status: " (color-status 200)) ; green "200"
(println "Status: " (color-status 301)) ; yellow "301"
(println "Status: " (color-status 404)) ; red "404"
```
---
---
url: 'https://sema-lang.com/docs/stdlib/playground.md'
---
# Playground & WASM
When running in the browser playground at [sema.run](https://sema.run), Sema executes as WebAssembly. Most stdlib functions work identically, but some behave differently due to browser sandbox constraints, and a few web-only functions are available.
## Web-Only Functions
These functions are **only available in the WASM playground** — they access browser APIs that don't exist in the native CLI.
### `web/user-agent`
Return the browser's `navigator.userAgent` string. Works in all browsers.
```sema
(web/user-agent)
; => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ..."
```
### `web/user-agent-data`
Return structured browser information from `navigator.userAgentData`. Returns a map on Chromium-based browsers (Chrome, Edge, Opera), `nil` on Firefox and Safari.
```sema
(web/user-agent-data)
; Chromium => {:mobile false :platform "macOS" :brands ("Chromium/120" "Google Chrome/120")}
; Firefox/Safari => nil
```
::: tip
`userAgentData` is the modern replacement for UA string parsing — it returns structured, reliable data instead of a messy string. However, it's Chromium-only. Use `web/user-agent` for cross-browser compatibility.
:::
## WASM Behavior Differences
### System Information
System functions return web-appropriate values instead of OS-specific ones:
| Function | Native | WASM |
| ------------------ | ----------------------------------- | --------------------------- |
| `sys/platform` | `"macos"` / `"linux"` / `"windows"` | `"web"` |
| `sys/os` | `"macos"` | `"web"` |
| `sys/arch` | `"aarch64"` / `"x86_64"` | `"wasm32"` |
| `sys/cwd` | Current directory path | `"/"` |
| `sys/interactive?` | `#t` in REPL | `#f` |
| `sys/pid` | Process ID | `0` |
| `sys/elapsed` | Nanoseconds since process start | Nanoseconds since page load |
| `time-ms` | `SystemTime` milliseconds | `Date.now()` milliseconds |
These always return `nil` in WASM: `sys/hostname`, `sys/user`, `sys/home-dir`, `sys/which`, `sys/tty`.
### File I/O (Virtual Filesystem)
File operations work against an **in-memory virtual filesystem** (VFS). Files persist for the duration of your session but are **lost on page reload**.
```sema
;; These all work in the playground
(file/write "/hello.txt" "Hello from WASM!")
(file/read "/hello.txt") ; => "Hello from WASM!"
(file/exists? "/hello.txt") ; => #t
(file/mkdir "/mydir")
(file/is-directory? "/mydir") ; => #t
(file/list "/") ; => ("hello.txt")
```
All file functions are supported: `file/read`, `file/write`, `file/append`, `file/delete`, `file/rename`, `file/copy`, `file/exists?`, `file/list`, `file/mkdir`, `file/is-file?`, `file/is-directory?`, `file/is-symlink?`, `file/info`, `file/read-lines`, `file/write-lines`. Path functions (`path/join`, `path/dirname`, `path/basename`, `path/extension`, `path/absolute`) also work.
**Quotas**: The VFS enforces limits to prevent runaway memory usage — 1 MB per file, 16 MB total, and 256 files max. Exceeding these limits returns an error.
The `load` function reads from the VFS and evaluates the parsed expressions.
### Terminal Styling
All `term/*` functions work but return text **without ANSI formatting** (since the browser has no terminal):
```sema
(term/bold "hello") ; => "hello" (no bold applied)
(term/red "error") ; => "error" (no color applied)
(term/style "hi" :bold :cyan) ; => "hi"
```
### HTTP Functions
HTTP functions work in the playground via the browser's `fetch()` API. They return the same `{:status :headers :body}` map as the native CLI.
```sema
(define resp (http/get "https://httpbin.org/get"))
(:status resp) ; => 200
(:body resp) ; => "{\"args\": {}, ...}"
(http/post "https://httpbin.org/post" {:name "sema"})
; => {:status 200 :headers {...} :body "..."}
```
All HTTP functions are supported: `http/get`, `http/post`, `http/put`, `http/delete`, `http/request`.
::: warning CORS Restrictions
Browser security rules (CORS) may block requests to servers that don't include `Access-Control-Allow-Origin` headers. Public APIs like httpbin.org work fine. If you get a network error, the target server likely doesn't allow cross-origin requests.
:::
### Not Available in WASM
These functions return an error when called in the playground:
| Function | Reason |
| ------------ | -------------------------------------------- |
| `shell` | No subprocess execution in browser |
| `exit` | No process to exit |
| `io/read-line` | No stdin in browser |
| `io/read-stdin` | No stdin in browser |
| `sleep` | Cannot block the browser main thread (no-op) |
---
---
url: 'https://sema-lang.com/docs/stdlib/streams.md'
---
# Streams
Streams are first-class byte-oriented I/O handles for reading and writing data incrementally. They provide a unified interface across files, in-memory buffers, strings, and standard I/O — the same `stream/read` and `stream/write` work regardless of the underlying source.
```sema
;; Read a file line by line
(with-stream (s (stream/open-input "data.txt"))
(let loop ((line (stream/read-line s)))
(when line
(println line)
(loop (stream/read-line s)))))
;; In-memory buffer
(let ((buf (stream/byte-buffer)))
(stream/write-string buf "hello")
(stream/to-string buf)) ;; => "hello"
```
## Creating Streams
### `stream/from-string`
Create a read-only stream from a string's UTF-8 bytes.
```sema
(define s (stream/from-string "hello world"))
(stream/read-byte s) ;; => 104 (ASCII 'h')
(stream/read s 5) ;; => #u8(101 108 108 111 32) ("ello ")
```
### `stream/from-bytes`
Create a readable stream from a bytevector.
```sema
(define s (stream/from-bytes (bytevector 1 2 3)))
(stream/read-byte s) ;; => 1
(stream/read-byte s) ;; => 2
```
### `stream/byte-buffer`
Create a read/write in-memory buffer. Writes append to the buffer; reads consume from the current position.
```sema
(define buf (stream/byte-buffer))
(stream/write buf (string->utf8 "hello"))
(stream/to-string buf) ;; => "hello"
```
### `stream/open-input`
Open a file for reading. Returns a buffered input stream. Sandbox-gated (`FS_READ`).
```sema
(define s (stream/open-input "data.csv"))
(define contents (stream/read-all s))
(stream/close s)
```
### `stream/open-output`
Open (or create) a file for writing. Returns a buffered output stream. Sandbox-gated (`FS_WRITE`).
```sema
(define s (stream/open-output "output.txt"))
(stream/write-string s "hello world\n")
(stream/close s)
```
## Reading
### `stream/read`
Read up to `n` bytes, returning a bytevector. Returns fewer bytes at EOF.
```sema
(stream/read s 1024) ;; => bytevector (up to 1024 bytes)
```
### `stream/read-byte`
Read a single byte. Returns an integer 0–255, or `nil` at EOF.
```sema
(stream/read-byte s) ;; => 65 (or nil at EOF)
```
### `stream/read-line`
Read until newline (`\n`), returning a string without the newline. Strips trailing `\r` for Windows line endings. Returns `nil` at EOF.
```sema
(stream/read-line s) ;; => "first line" (or nil)
```
### `stream/read-all`
Read the entire stream into a bytevector.
```sema
(define data (stream/read-all s))
(utf8->string data) ; convert to string if text
```
## Writing
### `stream/write`
Write a bytevector. Returns the number of bytes written.
```sema
(stream/write s (bytevector 72 101 108 108 111)) ;; => 5
```
### `stream/write-byte`
Write a single byte (integer 0–255).
```sema
(stream/write-byte s 10) ; write a newline
```
### `stream/write-string`
Write a string as UTF-8 bytes. Returns the number of bytes written.
```sema
(stream/write-string s "hello") ;; => 5
```
## Control
### `stream/close`
Close a stream, releasing the underlying resource. Double-close is a no-op.
```sema
(stream/close s)
(stream/close s) ; safe, does nothing
```
### `stream/flush`
Flush any buffered output to the underlying sink.
```sema
(stream/flush s)
```
### `stream/copy`
Copy all bytes from one stream to another. Returns total bytes copied.
```sema
(with-stream (in (stream/open-input "src.bin"))
(with-stream (out (stream/open-output "dst.bin"))
(stream/copy in out))) ;; => bytes copied
```
## Introspection
### `stream?`
Type predicate — returns `#t` if the value is a stream.
```sema
(stream? (stream/byte-buffer)) ;; => #t
(stream? 42) ;; => #f
```
### `stream/readable?`, `stream/writable?`
Check the direction of a stream.
```sema
(stream/readable? (stream/from-string "x")) ;; => #t
(stream/writable? (stream/from-string "x")) ;; => #f
(stream/writable? (stream/byte-buffer)) ;; => #t
```
### `stream/available?`
Returns `#t` if data is ready to read without blocking.
```sema
(stream/available? (stream/from-string "x")) ;; => #t
(stream/available? (stream/from-string "")) ;; => #f
```
### `stream/type`
Returns a string describing the stream implementation.
```sema
(stream/type (stream/byte-buffer)) ;; => "byte-buffer"
(stream/type (stream/from-string "x")) ;; => "string"
(stream/type (stream/open-input "f.txt")) ;; => "file-input"
(stream/type *stdout*) ;; => "stdout"
```
## Extraction (Byte Buffers)
### `stream/to-bytes`
Extract the accumulated contents of a byte-buffer stream as a bytevector.
```sema
(let ((s (stream/byte-buffer)))
(stream/write s (bytevector 1 2 3))
(stream/to-bytes s)) ;; => #u8(1 2 3)
```
### `stream/to-string`
Extract the contents of a byte-buffer stream as a UTF-8 string.
```sema
(let ((s (stream/byte-buffer)))
(stream/write-string s "hello")
(stream/to-string s)) ;; => "hello"
```
## Standard I/O
Three global streams are available for console I/O:
| Stream | Direction | Description |
|--------|-----------|-------------|
| `*stdin*` | Readable | Standard input |
| `*stdout*` | Writable | Standard output |
| `*stderr*` | Writable | Standard error |
```sema
(stream/write-string *stdout* "prompt> ")
(stream/flush *stdout*)
(stream/write-string *stderr* "warning: something happened\n")
```
## Resource Management
### `with-stream`
Macro that binds a stream, executes the body, and automatically closes the stream on exit — even if an error is thrown.
```sema
(with-stream (s (stream/open-input "data.txt"))
(stream/read-all s))
;; s is closed here, even if read-all threw an error
;; Write to a file
(with-stream (out (stream/open-output "output.txt"))
(stream/write-string out "line 1\n")
(stream/write-string out "line 2\n"))
;; file is flushed and closed
```
## Patterns
### Line-by-Line Processing
```sema
(with-stream (s (stream/open-input "log.txt"))
(let loop ((line (stream/read-line s))
(count 0))
(if (nil? line)
count
(loop (stream/read-line s) (+ count 1)))))
```
### Building a String Incrementally
```sema
(let ((buf (stream/byte-buffer)))
(stream/write-string buf "{")
(stream/write-string buf "\"key\": \"value\"")
(stream/write-string buf "}")
(stream/to-string buf)) ;; => "{\"key\": \"value\"}"
```
### File Copy
```sema
(with-stream (in (stream/open-input "photo.jpg"))
(with-stream (out (stream/open-output "backup.jpg"))
(stream/copy in out)))
```
## Error Handling
Reading a closed stream or writing to a read-only stream throws an error caught with `try`/`catch`:
```sema
(try
(let ((s (stream/from-string "x")))
(stream/close s)
(stream/read s 1)) ; throws "stream is closed"
(catch e
(println (str "Error: " e))))
```
---
---
url: 'https://sema-lang.com/docs/stdlib/concurrency.md'
---
# Concurrency
Cooperative async concurrency with promises and channels. Tasks run on the VM's cooperative scheduler, interleaving at yield points (channel operations, `await`, `sleep`).
## Scheduling guarantees
* **Spawn order is preserved.** When several tasks are simultaneously ready to run, the scheduler picks them in the order they were spawned. A pipeline of `(async (send-1)) (async (send-2)) (async (send-3))` followed by sequential receives yields `1 2 3`, not a reordered surface.
* **Wake order is FIFO.** When a value becomes available on a channel, the longest-waiting receiver is woken first.
* **Cooperation, not parallelism.** Tasks interleave at yield points (channel ops, `await`, `sleep`). CPU-bound tasks without yield points run to completion before other tasks get a turn.
## Promises
### `async/spawn`
```sema
(async/spawn thunk) → async-promise
```
Spawn a zero-argument function as an async task. Returns a promise that resolves when the task completes.
```sema
(define p (async/spawn (fn () (+ 1 2))))
(async/await p) ; => 3
```
Usually called via the `async` special form:
```sema
(define p (async (+ 1 2)))
(await p) ; => 3
```
### `async/await`
```sema
(async/await promise) → value
```
Wait for a promise to resolve. Inside an async task, yields to the scheduler. At the top level, runs the scheduler inline until the promise resolves. Raises an error if the promise was rejected.
### `async/all`
```sema
(async/all promises) → list
```
Run all promises to completion and return a list of their results. Takes a list or vector of promises.
```sema
(let ((p1 (async 10))
(p2 (async 20))
(p3 (async 30)))
(async/all (list p1 p2 p3))) ; => (10 20 30)
```
### `async/race`
```sema
(async/race promises) → value
```
Return the value of the first promise to resolve. Takes a list or vector of promises.
### `async/resolved`
```sema
(async/resolved value) → async-promise
```
Create an already-resolved promise wrapping `value`.
### `async/rejected`
```sema
(async/rejected message) → async-promise
```
Create an already-rejected promise with `message`.
### `async/run`
```sema
(async/run)
```
Run all pending async tasks to completion.
### `async/sleep`
```sema
(async/sleep ms)
```
Inside an async task, yield for `ms` milliseconds on the scheduler's **virtual clock**. The clock only advances when every task is blocked, jumping to the nearest deadline — so a shorter sleep always wakes before a longer one, deterministically. The scheduler then waits the real time when it advances: on native via `thread::sleep`, and in the **browser playground** by running eval on a Web Worker that blocks on `Atomics.wait` (so a sleep really pauses while the page stays responsive). Browsers without cross-origin isolation fall back to advancing the clock instantly — durations still order tasks correctly, just without the real wait. Outside async, calls `thread::sleep` on native. Durations are capped at `86_400_000` ms (1 day).
### `async/timeout`
```sema
(async/timeout ms promise) → value
```
Wait for `promise` to resolve, but raise an error if it takes longer than `ms` milliseconds. On expiry the target task **is cancelled** — and any in-flight offloaded I/O it holds is aborted for real (an HTTP connection is torn down, a subprocess is killed; LLM calls are best-effort — see [`async/cancel`](#async-cancel)). So a timed-out `http/get`/`shell` stops consuming resources immediately rather than running to completion in the background.
```sema
(async/timeout 100 (async (do-slow-work)))
;; raises: async/timeout: operation timed out
```
A `ms = 0` (or very short) timeout still lets work that is **synchronously ready** finish — it only fires once the virtual clock actually reaches the deadline with the task still pending (i.e. the task had to block/wait). Durations are capped at `86_400_000` ms (1 day).
### `async/cancel`
```sema
(async/cancel promise) → bool
```
Request cancellation of a spawned task. Returns `#t` if the call actually transitioned the promise into the `Cancelled` state, `#f` if there was nothing to cancel — the promise was already terminal (resolved, rejected, previously cancelled) or was never spawned in the first place (e.g. created via `async/resolved`).
Cancellation never errors. The task transitions to `Cancelled`; subsequent `(await p)` raises `"async/await: task was cancelled"` (distinct from a normal rejection).
**What actually gets aborted.** If the cancelled task is parked on offloaded I/O, the underlying work is aborted where the runtime allows it:
* `http/*` — the in-flight request's future is dropped, **tearing down the connection** (no wasted round-trip).
* `shell` — the subprocess is **killed** (`SIGKILL`), not left running in the background.
* `llm/*` (`embed`, `complete`, `classify`, `extract`) — **best-effort**: the request runs on a blocking worker that can't be interrupted mid-call, so the in-flight call completes and its result is discarded. A multi-round caller stops issuing further rounds.
```sema
(async/cancel (async/resolved 1)) ;; => #f (never spawned)
(let ((p (async 42))) (await p) (async/cancel p)) ;; => #f (already resolved)
(let ((p (async (async/sleep 100)))) (async/cancel p)) ;; => #t
```
### `async/cancelled?`
```sema
(async/cancelled? promise) → bool
```
`#t` if `promise` is in the `Cancelled` state — distinct from `async/rejected?`. Matches the state variant directly rather than the rejection message, so a user `(async/rejected "cancelled")` no longer aliases:
```sema
(async/cancelled? (async/rejected "cancelled")) ;; => #f
```
### Promise predicates
The four predicates **partition** the terminal states: a promise is at most one of resolved / rejected / cancelled, and `pending?` is the complement of those three.
| Function | Description |
| --- | --- |
| `(async/promise? x)` | Is `x` an async promise? |
| `(async/resolved? p)` | Is promise `p` resolved? |
| `(async/rejected? p)` | Is promise `p` rejected? (excludes cancelled) |
| `(async/pending? p)` | Is promise `p` still pending? |
| `(async/cancelled? p)` | Was promise `p` cancelled? |
### `async/pool-map`
```sema
(async/pool-map f items n) → list
```
Map `f` over `items` with **bounded concurrency**: at most `n` calls run at once, results returned in input order. A semaphore (an `n`-capacity channel) gates how many tasks are in flight, so you can fan a large batch across a rate-limited resource without launching everything at once. The token is released on both success and error, so a failing item never deadlocks the pool.
```sema
;; Embed 10 000 chunks, but only 8 requests in flight at a time:
(async/pool-map (fn (chunk) (llm/embed chunk)) chunks 8)
;; Fetch many URLs, 16 at a time:
(async/pool-map (fn (u) (http/get u)) urls 16)
```
### `async/map`
```sema
(async/map f items) → list
```
Concurrent `map`: apply `f` to each item in its **own** task, results in input order. The unbounded sibling of `async/pool-map` (no cap — every item gets a task at once). Use `async/pool-map` when you need to limit how many run together.
```sema
(async/map (fn (u) (http/get u)) urls) ; fetch every url concurrently
(async/map (fn (i) (* i i)) '(1 2 3 4)) ; => (1 4 9 16)
```
### `async/spawn-all`
```sema
(async/spawn-all thunks) → list
```
Spawn a list of zero-arg functions concurrently and await them all, in input order — the ergonomic form of `(async/all (map (fn (th) (async/spawn th)) thunks))`.
```sema
(async/spawn-all (list (fn () (http/get a)) (fn () (http/get b))))
```
## Concurrent I/O — what actually overlaps
The scheduler's payoff is **latency overlap**: when several tasks each wait on I/O, the waits happen *simultaneously* instead of one after another. The blocking leaves below now yield to the scheduler while their work runs on a background runtime, so spawning them as tasks (via `async/spawn` + `async/all`, or `async/pool-map`) makes wall-clock approach `max(latency)` instead of `sum(latency)`:
| Operation | Overlaps when spawned concurrently |
| --- | --- |
| `http/get` and the other `http/*` verbs | ✅ |
| `shell` (subprocess) | ✅ |
| `llm/embed` | ✅ |
| `llm/complete`, `llm/classify`, `llm/extract` | ✅ |
```sema
;; Four independent LLM calls — concurrent, not serial:
(async/all
(map (fn (q) (async/spawn (fn () (llm/complete q))))
'("summarize A" "summarize B" "summarize C" "summarize D")))
;; wall-clock ≈ one call, not four.
```
Outside a scheduler task (a plain top-level call) these run **synchronously**, byte-identical to before — the concurrency only engages inside `async`/`async/spawn`. Tasks still interleave at I/O boundaries on the single VM thread; this is cooperative concurrency, not parallel CPU execution.
**Tracing nests across spawns.** Spans (`with-span`, the auto-instrumented `llm/*` spans) opened inside a spawned task nest under the spawning task's active span and share its trace — so `(with-span "batch" (async/map llm/complete prompts))` shows up as one connected tree in Jaeger/Phoenix/Langfuse (the `batch` span with the concurrent LLM spans beneath it), not a pile of disconnected single-span traces. Each task still keeps its own span stack, so concurrent spans never cross-contaminate. A spawn at the top level (no active span) starts its own trace.
## Channels
Bounded FIFO channels for communication between async tasks.
### `channel/new`
```sema
(channel/new) → channel ; capacity 1
(channel/new capacity) → channel
```
Create a bounded channel. Default capacity is 1. Capacity must be at least 1.
### `channel/send`
```sema
(channel/send ch value)
```
Send a value to the channel. If the channel is full and inside an async task, yields until space is available. Outside async context, raises an error if full. Raises an error if the channel is closed.
### `channel/recv`
```sema
(channel/recv ch) → value
```
Receive a value from the channel. If the channel is empty and inside an async task, yields until data is available. Outside async context, raises an error if empty. Returns `nil` if the channel is closed and empty.
### `channel/try-recv`
```sema
(channel/try-recv ch) → value | nil
```
Non-blocking receive. Returns the next value or `nil` if the channel is empty.
### `channel/close`
```sema
(channel/close ch)
```
Close the channel. Subsequent sends will error. Blocked receivers will wake with `nil`.
### Channel predicates
| Function | Description |
| --- | --- |
| `(channel? x)` | Is `x` a channel? |
| `(channel/closed? ch)` | Is the channel closed? |
| `(channel/empty? ch)` | Is the channel buffer empty? |
| `(channel/full? ch)` | Is the channel buffer at capacity? |
| `(channel/count ch)` | Number of values in the buffer |
## Examples
### Producer/Consumer
```sema
(let ((ch (channel/new 1)))
(let ((producer (async
(channel/send ch 10)
(channel/send ch 20)
(channel/send ch 30)
(channel/close ch)))
(consumer (async
(let loop ((sum 0))
(let ((val (channel/recv ch)))
(if (nil? val)
sum
(loop (+ sum val))))))))
(await consumer))) ; => 60
```
### Parallel computation
```sema
(let ((p1 (async (fib 30)))
(p2 (async (fib 31))))
(+ (await p1) (await p2)))
```
See [Scheduling guarantees](#scheduling-guarantees) above for the full ordering / cooperation rules.
## Async ops inside higher-order functions
Stdlib higher-order functions like `for-each`, `map`, `filter`, `foldl`, `sort-by`, `apply`, `reduce`, `partition`, `any`, `every` can call **lambdas** that perform async operations (`channel/send`, `channel/recv`, `await`, `async/sleep`). The yield suspends inside the callback and resumes correctly:
```sema
(let ((ch (channel/new 3)))
(let ((producer (async
(for-each (fn (n) (channel/send ch n))
(list 1 2 3 4 5 6 7))
(channel/close ch)))
(consumer (async
(let loop ((sum 0))
(let ((v (channel/recv ch)))
(if (nil? v) sum (loop (+ sum v))))))))
(await consumer))) ;; => 28
```
Yielding **native** functions (e.g., `channel/recv`, `async/sleep`) passed *directly* as the callback produce a clear error pointing to the workaround:
```sema
;; Error: yielding native passed directly to a higher-order function — wrap in a lambda
(map channel/recv (list ch ch ch))
;; Correct: wrap the native in a lambda
(map (fn (c) (channel/recv c)) (list ch ch ch))
```
---
---
url: 'https://sema-lang.com/docs/stdlib/records.md'
---
# Records
Records are **user-defined, named product types** created with the `define-record-type` special form. They provide constructors, type predicates, and field accessors.
::: tip Records vs Maps
If you need an *open* data shape that's easy to serialize and manipulate generically, use [maps](./maps). If you want a *closed* domain type with a predicate and fixed fields, use records.
:::
## Defining Record Types
### `define-record-type`
Define a new record type, generating a constructor, predicate, and one accessor per field.
```sema
(define-record-type point
(make-point x y) ; constructor (positional args)
point? ; predicate
(x point-x) ; (field-name accessor-name)
(y point-y))
```
General syntax:
```sema
(define-record-type
( ...)
() ...)
```
### What Gets Defined
For the `point` example above:
| Binding | Signature | Purpose |
|---------|-----------|---------|
| `make-point` | `(x y) → point` | Constructor |
| `point?` | `(value) → bool` | Type predicate |
| `point-x` | `(point) → value` | Field accessor |
| `point-y` | `(point) → value` | Field accessor |
```sema
(define p (make-point 3 4))
(point? p) ; => #t
(point? 42) ; => #f
(point-x p) ; => 3
(point-y p) ; => 4
```
### Constructor Arity
The constructor is positional — its arity must match exactly:
```sema
(make-point 1 2) ; ok
(make-point 1) ; error: wrong arity
(make-point 1 2 3) ; error: wrong arity
```
### Immutability
Sema records are immutable. To "update" a record, construct a new one:
```sema
(define (move-point p dx dy)
(make-point (+ (point-x p) dx)
(+ (point-y p) dy)))
(move-point (make-point 10 20) 5 -2)
; => a new point record with x=15, y=18
```
## Equality
Two records are `equal?` if they have the **same type** and their fields are pairwise `equal?`:
```sema
(define a (make-point 1 2))
(define b (make-point 1 2))
(define c (make-point 9 9))
(equal? a b) ; => #t (same type, same fields)
(equal? a c) ; => #f (same type, different fields)
```
Records of different types are never equal, even if they have the same field values.
## Introspection
### `record?`
Test if a value is any record instance (of any record type).
```sema
(record? (make-point 3 4)) ; => #t
(record? {:x 3 :y 4}) ; => #f
(record? 42) ; => #f
```
### `type`
Return the type of a value as a keyword. For records, returns the record's type name:
```sema
(type (make-point 3 4)) ; => :point
(type [1 2 3]) ; => :vector
(type {:a 1}) ; => :map
```
## Records vs Maps
Both model "structured data", but they serve different purposes.
### Use records when…
* You want a **distinct type**: `person?`, `invoice?`, `token?`
* Your data has a **fixed schema** enforced at construction
* You want named field accessors and clear domain boundaries
### Use maps when…
* You need easy **serialization** (JSON, TOML, etc.)
* You want to add/remove keys dynamically
* You want generic operations like `get`, `assoc`, `merge`, `keys`, `map/get-in`, `map/update-in`
* You're interacting with external APIs
::: tip Common pattern
**Maps at the boundary, records internally.** Parse/validate external maps into records early, and convert records back to maps for output.
:::
## Nested Records
Records can contain any values, including other records:
```sema
(define-record-type address
(make-address line1 city country)
address?
(line1 address-line1)
(city address-city)
(country address-country))
(define-record-type user
(make-user id name addr)
user?
(id user-id)
(name user-name)
(addr user-addr))
(define u
(make-user 123 "Ada"
(make-address "12 St James" "London" "UK")))
(user-name u) ; => "Ada"
(address-city (user-addr u)) ; => "London"
```
## Pattern Matching with Records
Records don't have a dedicated pattern form, but you can use binding patterns with `when` guards:
```sema
(define (describe v)
(match v
(p when (point? p)
(string/append "point("
(number/to-string (point-x p))
", "
(number/to-string (point-y p))
")"))
(_ "not a point")))
(describe (make-point 3 4)) ; => "point(3, 4)"
(describe {:x 3 :y 4}) ; => "not a point"
```
You can also match on `type`:
```sema
(define (record-type-name v)
(match (type v)
(:point "a point")
(:person "a person")
(_ "something else")))
```
## Domain Modeling Example
Use records to represent values that have been validated:
```sema
(define-record-type email
(make-email value)
email?
(value email-value))
(define (parse-email s)
(if (regex/match? #".+@.+\..+" s)
(make-email s)
(error "invalid email")))
(define e (parse-email "ada@example.com"))
(email? e) ; => #t
(email-value e) ; => "ada@example.com"
```
## Multiple Record Types
```sema
(define-record-type color
(make-color r g b)
color?
(r color-r)
(g color-g)
(b color-b))
(define-record-type person
(make-person name age)
person?
(name person-name)
(age person-age))
(define red (make-color 255 0 0))
(define ada (make-person "Ada" 36))
(color? red) ; => #t
(person? ada) ; => #t
(color? ada) ; => #f
(color-r red) ; => 255
(person-name ada) ; => "Ada"
(type red) ; => :color
(type ada) ; => :person
```
## Serialization
Records are **not JSON-encodable** directly. If you need to serialize a record, convert it to a map first:
```sema
(define (point->map p)
{:x (point-x p) :y (point-y p)})
(json/encode (point->map (make-point 1 2)))
; => "{\"x\":1,\"y\":2}"
```
Similarly, when loading data from JSON or the KV store, convert maps to records after parsing.
## Tips & Edge Cases
* **Accessor type-checking:** calling `point-x` on a non-point value errors
* **Type tag:** the tag returned by `type` is derived from the record type name — `point` → `:point`
* **No generic field access:** you can't use `get` or keyword-as-function on records — use the generated accessors
---
---
url: 'https://sema-lang.com/docs/stdlib/text-processing.md'
---
# Text Processing
Sema includes utilities for text chunking, cleaning, prompt templates, and structured documents — building blocks for LLM pipelines.
## Text Chunking
### `text/chunk`
Recursively split text into chunks, trying natural boundaries (paragraphs, sentences, words) before hard-splitting. Takes text and an optional options map.
```sema
(text/chunk "Long text here...")
(text/chunk "Long text here..." {:size 500 :overlap 100})
```
Options: `:size` (default 1000), `:overlap` (default 200). Returns a list of strings.
### `text/chunk-by-separator`
Split text by a specific separator string.
```sema
(text/chunk-by-separator "a\nb\nc" "\n") ; => ("a" "b" "c")
```
### `text/split-sentences`
Split text into sentences at `.`, `!`, `?` boundaries.
```sema
(text/split-sentences "Hello world. How are you? Fine.")
; => ("Hello world." "How are you?" "Fine.")
```
## Text Cleaning
### `text/clean-whitespace`
Collapse multiple whitespace characters (spaces, newlines, tabs) into single spaces.
```sema
(text/clean-whitespace " hello world \n\n foo ")
; => "hello world foo"
```
### `text/strip-html`
Remove HTML tags and decode common entities (`&`, `<`, `>`, `"`, `'`, `'`, ` `).
```sema
(text/strip-html "
Hello world
") ; => "Hello world"
(text/strip-html "a & b < c") ; => "a & b < c"
```
### `text/truncate`
Truncate text to a maximum length with a suffix. Takes text, max-length, and optional suffix (default `"..."`).
```sema
(text/truncate "hello world" 5) ; => "he..."
(text/truncate "hello world" 8 "…") ; => "hello w…"
(text/truncate "hi" 10) ; => "hi"
```
### `text/word-count`
Count words in text (split by whitespace).
```sema
(text/word-count "hello world foo bar") ; => 4
```
### `text/trim-indent`
Remove common leading indentation from all lines.
```sema
(text/trim-indent " hello\n world") ; => "hello\nworld"
(text/trim-indent " hello\n world") ; => "hello\n world"
```
### `text/excerpt`
Extract a snippet around a search term with omission markers. Case-insensitive search. Returns `nil` if query not found.
```sema
(text/excerpt "The quick brown fox jumps over the lazy dog" "fox" {:radius 10})
; => "...brown fox jumps ov..."
(text/excerpt "Hello world" "Hello")
; => "Hello world"
;; Custom omission marker
(text/excerpt "Long text here..." "text" {:radius 5 :omission "[…]"})
; => "[…]g text here[…]"
```
Options map (optional third argument):
* `:radius` — number of characters to show on each side (default: 100)
* `:omission` — marker string for truncated parts (default: `"..."`)
### `text/normalize-newlines`
Convert `\r\n` (Windows) and `\r` (old Mac) line endings to `\n` (Unix).
```sema
(text/normalize-newlines "line1\r\nline2\rline3")
; => "line1\nline2\nline3"
```
## Prompt Templates
### `prompt/template`
Create a template string for use with `prompt/render`.
```sema
(define tmpl (prompt/template "Hello {{name}}, welcome to {{place}}."))
```
### `prompt/render`
Render a template by substituting `{{key}}` placeholders with values from a map. Missing keys are left as-is.
```sema
(prompt/render "Hello {{name}}, welcome to {{place}}."
{:name "Alice" :place "Wonderland"})
; => "Hello Alice, welcome to Wonderland."
(prompt/render "Hello {{name}}, {{missing}}." {:name "Bob"})
; => "Hello Bob, {{missing}}."
;; Non-string values are stringified
(prompt/render "Count: {{n}}" {:n 42})
; => "Count: 42"
```
## Documents
Structured documents with metadata, designed for use with chunking and vector stores.
### `document/create`
Create a document map with `:text` and `:metadata`.
```sema
(document/create "Hello world" {:source "test.txt" :page 1})
; => {:metadata {:page 1 :source "test.txt"} :text "Hello world"}
```
### `document/text`
Extract the text from a document.
```sema
(document/text doc) ; => "Hello world"
```
### `document/metadata`
Extract the metadata from a document.
```sema
(document/metadata doc) ; => {:source "test.txt" :page 1}
```
### `document/chunk`
Chunk a document, preserving and extending metadata. Each chunk gets `:chunk-index` and `:total-chunks` added to its metadata.
```sema
(document/chunk
(document/create "long text..." {:source "paper.pdf"})
{:size 500})
; => ({:text "chunk 1..." :metadata {:source "paper.pdf" :chunk-index 0 :total-chunks 3}}
; {:text "chunk 2..." :metadata {:source "paper.pdf" :chunk-index 1 :total-chunks 3}}
; ...)
```
---
---
url: 'https://sema-lang.com/docs/llm.md'
---
# LLM Primitives
Sema's differentiating feature: LLM operations are first-class language primitives with prompts, conversations, tools, and agents as native data types.
## Setup
Set one or more API keys as environment variables:
```bash
export ANTHROPIC_API_KEY=sk-ant-...
export OPENAI_API_KEY=sk-...
# or any other supported provider
```
Sema auto-detects and configures all available providers on startup. Use `--no-llm` to skip auto-configuration.
See [Provider Management](./providers.md) for the full list of supported providers and configuration options.
## Features
### [Completion & Chat](./completion.md)
Simple completions, multi-message chat, and streaming responses.
### [Prompts & Messages](./prompts.md)
Prompts as composable s-expressions, message construction, and prompt inspection.
### [Conversations](./conversations.md)
Persistent, immutable conversation state with automatic LLM round-trips.
### [Tools & Agents](./tools-agents.md)
Define tools the LLM can invoke, and build agents with system prompts, tools, and multi-turn loops.
### [Embeddings & Similarity](./embeddings.md)
Generate embeddings (as bytevectors), compute cosine similarity, and access embedding dimensions.
### [Structured Extraction](./extraction.md)
Extract structured data from text and images, classify inputs, and work with multi-modal content.
### [Vector Store & Math](./vector-store.md)
In-memory vector store for semantic search, plus vector math utilities (cosine similarity, dot product, normalize, distance).
### [Caching](./caching.md)
In-memory LLM response caching for iterative development and deduplication.
### [Cassettes (Record & Replay)](./cassettes.md)
Record real LLM/agent responses to a file once, then replay them deterministically — keyless, offline tests and reproducible demos.
### [Resilience & Retry](./resilience.md)
Fallback provider chains, rate limiting, generic retry with exponential backoff, and convenience functions (`llm/summarize`, `llm/compare`).
### [Provider Management](./providers.md)
Auto-configuration, runtime provider switching, custom providers, and OpenAI-compatible endpoints.
### [Cost Tracking & Budgets](./cost.md)
Usage tracking, budget enforcement, and batch/parallel operations.
### Observability (OpenTelemetry)
Built-in, standards-compliant OpenTelemetry tracing + metrics for **every** LLM and
agent run — no manual instrumentation. Each completion and tool call is auto-traced
(`invoke_agent → chat → execute_tool`) with tokens, cost, and latency, exportable to
any OTLP backend or a JSONL file. Off by default, zero-cost when off.
* **[Tracing & Metrics](./observability.md)** — the GenAI spans and metrics, sessions,
privacy controls, and embedding Sema in your own app.
* **[Backend Compatibility](./otel-compat.md)** — label the data so tools that use their
own attribute names (Arize Phoenix, Langfuse, Traceloop, LangSmith) read it too via
`SEMA_OTEL_COMPAT`. Most other tools work with no extra setup.
---
---
url: 'https://sema-lang.com/docs/llm/completion.md'
---
# Completion & Chat
## Completion
### `llm/complete`
Send a single prompt string and get a completion back.
```sema
;; Simple completion
(llm/complete "Say hello in 5 words" {:max-tokens 50})
```
With options:
```sema
(llm/complete "Explain monads"
{:model "claude-haiku-4-5-20251001"
:max-tokens 200
:temperature 0.3
:system "You are a Haskell expert."})
```
### `llm/stream`
Stream a completion, printing chunks as they arrive.
```sema
(llm/stream "Tell me a story" {:max-tokens 200})
```
With a callback function:
```sema
(llm/stream "Tell me a story"
(fn (chunk) (display chunk))
{:max-tokens 200})
```
`llm/stream` **returns the full accumulated response string** once streaming finishes — so
you can show the live stream *and* keep the final text:
```sema
(define story
(llm/stream "Tell me a story" (fn (c) (display c)) {:max-tokens 200}))
;; `story` is the complete text after the stream ends.
```
## Chat
### `llm/chat`
Send a list of messages and get a response. Supports system, user, and assistant messages.
```sema
(llm/chat
(list (message :system "You are a helpful assistant.")
(message :user "What is Lisp? One sentence."))
{:max-tokens 100})
```
When you pass `:tools`, `llm/chat` runs the tool-execution loop for you (see
[Tools & Agents](./tools-agents)). Two options bound it: `:tool-mode :none` lets the model
*see* the tools but never auto-executes them, and `:max-tool-rounds N` caps the loop
(default 10).
### Multi-Modal Chat
Send messages that include images alongside text using `message/with-image`.
```sema
;; Load an image and ask the LLM about it
(define img (file/read-bytes "photo.jpg"))
(define msg (message/with-image :user "Describe this image." img))
(llm/chat (list msg))
```
Combine with regular messages:
```sema
(llm/chat
(list (message :system "You are an image analyst.")
(message/with-image :user "What text is in this image?" (file/read-bytes "doc.png"))))
```
The image must be a bytevector. Media type (PNG, JPEG, GIF, WebP, PDF) is detected automatically from magic bytes. See [Vision Extraction](./extraction.md#vision-extraction) for structured data extraction from images.
### `llm/send`
Send a prompt value (composed from `prompt` expressions) to the LLM.
```sema
(define review-prompt
(prompt
(system "You are a code reviewer. Be concise.")
(user "Review this function.")))
(llm/send review-prompt {:max-tokens 200})
```
## Options
All completion and chat functions accept an options map with these keys:
| Key | Description |
| -------------- | ------------------------------------------------------------- |
| `:model` | Model name (e.g. `"claude-haiku-4-5-20251001"`) |
| `:max-tokens` | Maximum tokens in response |
| `:temperature` | Sampling temperature (0.0–1.0) |
| `:system` | System prompt (for `llm/complete`) |
| `:reasoning-effort` | Reasoning effort for thinking models — see below |
| `:tools` | List of tool values (see [Tools & Agents](./tools-agents.md)) |
| `:timeout` | Per-call HTTP timeout in **milliseconds** (network providers; non-streaming) |
| `:tags` / `:metadata` | Observability tags/metadata — see [Backend Compatibility](./otel-compat.md) |
### Reasoning effort
`:reasoning-effort` controls how much a reasoning/thinking model deliberates
before answering. It takes a keyword or string: `:minimal`, `:low`, `:medium`,
`:high`, `:none`, or `:xhigh`. It is a single **portable** option — Sema maps it
to each provider's native control, so the same code works everywhere:
```sema
(llm/complete "Prove that sqrt(2) is irrational."
{:model "gpt-5.4-mini" :reasoning-effort :high :max-tokens 4000})
```
| Provider | Mapped to |
| --------- | ------------------------------------------------------------------------------------- |
| OpenAI | native `reasoning_effort` (gpt-5 / o-series) |
| Anthropic | extended **thinking** — effort sets the thinking `budget_tokens` (and raises `max_tokens` above it; `temperature` is forced to default while thinking) |
| Gemini | `thinkingConfig.thinkingBudget` (`:none`/`:minimal` disable thinking) |
Models and providers that don't support reasoning effort ignore the option (no-op).
It is also accepted by `llm/chat` and per-run on `agent/run` (`{:reasoning-effort :high}`).
---
---
url: 'https://sema-lang.com/docs/llm/tools-agents.md'
---
# Tools & Agents
## Tools
Tools let you define functions that the LLM can invoke during a conversation. The LLM sees the tool's name, description, and parameter schema, and can call it when appropriate.
### `deftool`
Define a tool with a name, description, parameter schema, and handler function.
```sema
(deftool lookup-capital
"Look up the capital of a country"
{:country {:type :string :description "Country name"}}
(lambda (country)
(cond
((= country "Norway") "Oslo")
((= country "France") "Paris")
(else "Unknown"))))
```
### Using Tools with Chat
Pass tools to `llm/chat` — the LLM will call them automatically when needed.
```sema
(llm/chat
(list (message :user "What is the capital of Norway?"))
{:tools (list lookup-capital) :max-tokens 100})
```
### Inspecting Tools
### `tool/name`
```sema
(tool/name lookup-capital) ; => "lookup-capital"
```
### `tool/description`
```sema
(tool/description lookup-capital) ; => "Look up the capital..."
```
### `tool/parameters`
```sema
(tool/parameters lookup-capital) ; => {:country {:type :string ...}}
```
### `tool?`
```sema
(tool? lookup-capital) ; => #t
```
## Agents
Agents combine a system prompt, tools, and a multi-turn loop. They handle the back-and-forth of tool calls automatically.
### `defagent`
Define an agent with a system prompt, tools, model, and turn limit.
```sema
(deftool get-weather
"Get weather for a city"
{:city {:type :string}}
(lambda (city)
(format "~a: 22°C, sunny" city)))
(defagent weather-bot
{:system "You are a weather assistant. Use the get-weather tool."
:tools [get-weather]
:model "claude-haiku-4-5-20251001"
:max-turns 3})
```
### `agent/run`
Run an agent with a user message. The agent loops, calling tools as needed, until it has a final answer or hits the turn limit. The two-argument form returns the final answer as a **string**:
```sema
(agent/run weather-bot "What's the weather in Tokyo?") ; => "It's sunny, 22°C."
```
An optional third argument takes per-run options. **Passing an options map changes the return value** to a map with the final reply *and* the full message history:
```sema
(define result
(agent/run weather-bot "What's the weather in Tokyo?"
{:reasoning-effort :high ; reasoning effort for this run (see Completion)
:messages prior-history ; seed the loop with prior conversation
:on-tool-call observe-tool})) ; observe each tool call — see below
(:response result) ; => the final answer string
(:messages result) ; => the full conversation (to continue or inspect)
```
**Observing tool calls.** `:on-tool-call` fires once when each tool starts and once when it ends. The event is a map — branch on `(:event e)`, the string `"start"` or `"end"`:
```sema
(define (observe-tool e)
(when (= (:event e) "end")
(println (:tool e) "→" (:result e) (format "(~ams)" (:duration-ms e)))))
```
The event map carries `:event` (`"start"` / `"end"`), `:tool` (the tool name), and `:args`; on `"end"` it adds `:result` (a preview of the return value), `:error` (a boolean), and `:duration-ms`.
**Error recovery.** A tool that throws, isn't found, or is called with arguments
that don't match its declared schema does **not** abort the run — the error is
fed back to the model as the tool result so it can correct itself and continue.
The loop is bounded by `:max-turns` and aborts after 5 consecutive tool errors.
### Inspecting Agents
### `agent/name`
```sema
(agent/name weather-bot) ; => "weather-bot"
```
### `agent/system`
```sema
(agent/system weather-bot) ; => "You are a weather assistant..."
```
### `agent/tools`
```sema
(agent/tools weather-bot) ; => list of tool values
```
### `agent/model`
```sema
(agent/model weather-bot) ; => "claude-haiku-4-5-20251001"
```
### `agent/max-turns`
```sema
(agent/max-turns weather-bot) ; => 3
```
### `agent?`
```sema
(agent? weather-bot) ; => #t
```
---
---
url: 'https://sema-lang.com/docs/llm/conversations.md'
---
# Conversations
Conversations are immutable data structures that maintain chat history. Each operation returns a new conversation value — the original is never modified. This means you always re-bind the result:
```sema
(define conv (conversation/new {:model "claude-haiku-4-5-20251001"}))
(define conv (conversation/set-system conv "You are a concise tutor."))
(define conv (conversation/say conv "Explain closures in 2 bullets."))
(println (conversation/last-reply conv))
;; Branch to explore a different direction
(define alt (conversation/fork conv))
(define alt (conversation/say alt "Now explain with JavaScript examples."))
;; conv is unchanged — alt is an independent conversation
```
## Creating Conversations
### `conversation/new`
Create a new conversation, optionally with a model. `(conversation/new)` is equivalent to `(conversation/new {})`.
```sema
(define conv (conversation/new {:model "claude-haiku-4-5-20251001"}))
(define conv (conversation/new))
```
## Interacting
### `conversation/say`
Send a user message to the LLM and get a response. Returns a new conversation with both the user message and the assistant's reply appended.
```sema
(define conv (conversation/new {:model "claude-haiku-4-5-20251001"}))
(define conv (conversation/say conv "Remember: the secret number is 7"))
(define conv (conversation/say conv "What is the secret number?"))
(conversation/last-reply conv) ; => "The secret number is 7."
```
With options:
```sema
(define conv (conversation/say conv "Explain more"
{:temperature 0.5 :max-tokens 500}))
```
### `conversation/add-message`
Manually add a message without making an LLM call. Useful for constructing conversation history programmatically.
```sema
(define c (conversation/new))
(define c (conversation/add-message c :system "You are helpful."))
(define c (conversation/add-message c :user "hello"))
(define c (conversation/add-message c :assistant "hi there"))
```
### `conversation/say-as`
Send a message with a different system prompt for one turn only. The override applies to the API call but doesn't change the conversation's stored system message. Accepts a system string or a prompt value.
```sema
;; With a prompt value — uses its system message for this turn
(define argue-for (prompt (system "You argue IN FAVOR of Lisp.")))
(define conv (conversation/new {:model "claude-sonnet-4-6"}))
(define conv (conversation/say-as conv argue-for "Make your case."))
;; With a plain string — treated as system content
(define conv (conversation/say-as conv "You argue AGAINST Lisp." "Rebut the argument."))
```
## Inspecting
### `conversation/last-reply`
Get the content of the last assistant message.
```sema
(conversation/last-reply conv) ; => "The secret number is 7."
```
### `conversation/messages`
Get the full list of messages as message values.
```sema
(conversation/messages conv) ; => list of message values
(length (conversation/messages conv)) ; => 5
```
### `conversation/model`
Get the model associated with the conversation.
```sema
(conversation/model conv) ; => "claude-haiku-4-5-20251001"
```
## System Message
### `conversation/system`
Get the system message content, or `nil` if none is set.
```sema
(define c (conversation/add-message (conversation/new) :system "Be helpful."))
(conversation/system c) ; => "Be helpful."
(conversation/system (conversation/new)) ; => nil
```
### `conversation/set-system`
Set or replace the system message. All existing system messages are replaced with one new one; other messages are preserved.
```sema
(define c (conversation/set-system (conversation/new) "You are a code reviewer."))
(conversation/system c) ; => "You are a code reviewer."
```
## Filtering & Transforming
### `conversation/filter`
Keep only messages matching a predicate. Returns a new conversation.
```sema
;; Keep only user messages
(define user-only
(conversation/filter conv (fn (m) (= (message/role m) :user))))
;; Remove system messages
(define no-system
(conversation/filter conv (fn (m) (not (= (message/role m) :system)))))
```
### `conversation/map`
Apply a function to each message, returning a list of results (not a conversation).
```sema
;; Extract all message contents
(conversation/map conv message/content)
;; Build a summary with role prefixes
(conversation/map conv
(fn (m) (string/append "[" (keyword/to-string (message/role m)) "] "
(message/content m))))
```
## Usage & Cost
### `conversation/token-count`
Estimated token count for the conversation (heuristic: ~4 characters per token).
```sema
(conversation/token-count conv) ; => 342
```
### `conversation/cost`
Estimated input cost in dollars based on the conversation's model pricing. Returns `nil` if pricing is unavailable for the model.
```sema
(conversation/cost conv) ; => 0.00034 (or nil)
```
## Branching
### `conversation/fork`
Create an independent copy of a conversation. Since conversations are immutable, forking lets you explore different directions from the same point.
```sema
(define conv (conversation/new {:model "claude-haiku-4-5-20251001"}))
(define conv (conversation/say conv "Remember the number 7"))
;; Fork and take two different paths
(define branch-a (conversation/say (conversation/fork conv) "What about Python?"))
(define branch-b (conversation/say (conversation/fork conv) "What about Rust?"))
;; conv, branch-a, branch-b are all independent
```
## Type Predicate
### `conversation?`
Check if a value is a conversation.
```sema
(conversation? conv) ; => #t
(conversation? 42) ; => #f
```
---
---
url: 'https://sema-lang.com/docs/llm/prompts.md'
---
# Prompts & Messages
Prompts in Sema are composable data structures — not string templates. They are built from message expressions, and can be inspected, transformed, and composed before being sent to an LLM.
The core idea: build small prompt pieces, compose them together, fill in template slots, and send the result. Everything is a value you can pass around, store, and introspect.
```sema
;; Build reusable prompt pieces
(define safety
(prompt (system "Follow policy. Refuse unsafe requests.")))
(define domain
(prompt (system "You are a senior Lisp developer.")))
(define task
(prompt (user "Review this function:\n\n{{code}}")))
;; Compose, fill, and send
(define p (prompt/concat safety domain task))
(define ready (prompt/fill p {:code "(define (f x) (+ x 1))"}))
(llm/send ready {:max-tokens 300})
```
## Messages
A message is a role–content pair. The role is a keyword: `:system`, `:user`, or `:assistant`.
### `message`
Create a message with a role and content.
```sema
(message :system "You are a helpful assistant.")
(message :user "What is Lisp?")
(message :assistant "Lisp is a family of programming languages.")
```
### `message/role`
Get the role of a message as a keyword.
```sema
(message/role (message :user "hi")) ; => :user
```
### `message/content`
Get the text content of a message.
```sema
(message/content (message :user "hi")) ; => "hi"
```
## Building Prompts
### `prompt`
Build a prompt from message expressions. Inside `prompt`, use the shorthand constructors `(system ...)`, `(user ...)`, and `(assistant ...)` — these are equivalent to `(message :system ...)`, etc.
```sema
(define review-prompt
(prompt
(system "You are a code reviewer. Be concise.")
(user "Review this function.")))
```
### `prompt/messages`
Get the list of messages from a prompt.
```sema
(prompt/messages my-prompt) ; => list of message values
(length (prompt/messages my-prompt)) ; => 2
```
## Composing Prompts
### `prompt/append`
Compose prompts by appending their messages together. Variadic — accepts 2 or more prompts.
```sema
(define base (prompt (system "You are helpful.")))
(define question (prompt (user "What is 2+2?")))
(define full (prompt/append base question))
;; Three or more prompts
(define safety (prompt (system "Be safe.")))
(define full (prompt/append base safety question))
(llm/send full)
```
### `prompt/concat`
Alias for `prompt/append`. Use whichever name reads better in context.
```sema
(define full (prompt/concat base-prompt safety-prompt domain-prompt))
```
## Templating
### `prompt/fill`
Substitute `{{key}}` placeholders in all message contents using a map. Unfilled slots are left as-is, so you can partially fill a template and fill the rest later.
```sema
(define template
(prompt
(system "You are a {{role}} reviewing {{language}} code.")
(user "{{query}}")))
;; Full fill
(define filled (prompt/fill template {:role "expert" :language "Rust" :query "Explain this."}))
;; Partial fill — unfilled slots remain as {{...}}
(define partial (prompt/fill template {:role "code reviewer"}))
;; partial still has {{language}} and {{query}} unfilled
```
### `prompt/slots`
Return a list of unfilled `{{slot}}` names as keywords. Duplicates are removed.
```sema
(prompt/slots template) ; => (:role :language :query)
;; After partial fill, only unfilled slots remain
(prompt/slots (prompt/fill template {:role "expert"}))
;; => (:language :query)
;; After full fill, no slots remain
(prompt/slots filled) ; => ()
```
Use `prompt/slots` to validate that all required slots are filled before sending:
```sema
(when (not (null? (prompt/slots my-prompt)))
(error "unfilled slots remain"))
```
## Modifying Prompts
### `prompt/set-system`
Replace all system messages with a single new one. Non-system messages are preserved.
```sema
(define p (prompt (system "old system") (user "hello")))
(define p2 (prompt/set-system p "new system instructions"))
;; p2 has: [(system "new system instructions"), (user "hello")]
```
## Type Predicates
### `prompt?`
Check if a value is a prompt.
```sema
(prompt? review-prompt) ; => #t
(prompt? 42) ; => #f
```
### `message?`
Check if a value is a message.
```sema
(message? (message :user "hi")) ; => #t
(message? "not a message") ; => #f
```
---
---
url: 'https://sema-lang.com/docs/llm/extraction.md'
---
# Structured Extraction
Extract structured data from unstructured text using LLM-powered schema-based extraction and classification.
## Extraction
### `llm/extract`
Extract structured data from text according to a schema. The schema defines the expected fields and their types.
```sema
(llm/extract
{:vendor {:type :string}
:amount {:type :number}
:date {:type :string}}
"I bought coffee for $4.50 at Blue Bottle on Jan 15, 2025")
; => {:amount 4.5 :date "2025-01-15" :vendor "Blue Bottle"}
```
The schema map specifies field names as keys and type descriptors as values. Supported types include `:string`, `:number`, `:boolean`, and `:list`/`:array`.
A field value can be written two ways, and they behave differently:
* **Descriptor map** — `{:amount {:type :number}}`. This form is **type-checked**, and
supports `:optional` and a custom `:validate` predicate (below). Use it for any field you
want validated.
* **Bare type keyword** — `{:amount :number}` is shorthand, but the type is sent to the
model only as an untyped hint — it is **not** validated. Reach for the descriptor map when
correctness matters.
Only `:type`, `:optional`, and `:validate` on a field descriptor affect behavior; a
`:description` on a field is currently ignored by extraction (it isn't sent to the model).
### Options
`llm/extract` accepts an optional third argument — an options map:
```sema
(llm/extract schema text {:model "claude-haiku-4-5-20251001"})
```
| Option | Type | Default | Description |
| ----------- | ------- | ------- | -------------------------------------------------- |
| `:model` | string | — | Override the default model |
| `:validate` | boolean | `#t` | Validate response against the schema |
| `:retries` | integer | `2` | Max retry attempts on validation failure |
| `:reask?` | boolean | `#t` | Feed validation errors back to the LLM on retry |
### Schema Validation
By default, the extracted result is validated against the schema:
* All required schema keys must be present in the result
* Types must match: `:string` → string, `:number` → integer or float, `:boolean` → boolean, `:list`/`:array` → list or vector
```sema
(llm/extract
{:name {:type :string}
:age {:type :number}}
"Alice is 30 years old")
; => {:age 30 :name "Alice"}
```
If validation fails, an error is raised with details about which fields didn't match.
### Optional Fields
Mark fields as optional with `:optional #t`. Missing optional fields won't trigger validation errors:
```sema
(llm/extract
{:name {:type :string}
:nickname {:type :string :optional #t}}
"Her name is Ada Lovelace.")
; => {:name "Ada Lovelace"}
;; No error even though :nickname is missing
```
### Custom Validation Predicates
Use `:validate` on individual field specs to run a custom predicate after type checking. If the predicate returns falsy, the field fails validation and triggers a retry:
```sema
(llm/extract
{:amount {:type :number :validate #(> % 0)}
:vendor {:type :string :validate #(> (string/length %) 0)}}
"Invoice from Acme Corp for $42.50")
; => {:amount 42.5 :vendor "Acme Corp"}
```
Add `:message` to provide a human-readable error description. This message is fed back to the LLM in re-ask prompts, helping it correct its response:
```sema
(llm/extract
{:age {:type :number
:validate #(and (>= % 0) (<= % 150))
:message "age must be between 0 and 150"}}
"She is 30 years old.")
; => {:age 30}
```
Without `:message`, the default error text includes the field value: `"custom validation failed for value -5"`.
### Retry on Mismatch
Validation failures automatically trigger retries (up to `:retries`, default 2). On each retry, the validation errors are fed back to the LLM to improve the next attempt. After exhausting retries, the final validation error is raised.
```sema
(llm/extract
{:items {:type :list}
:total {:type :number :validate pos?}}
"3 apples, 2 oranges, total 5 items")
```
Disable automatic retries with `{:retries 0}` or disable validation entirely with `{:validate #f}`.
## Classification
### `llm/classify`
Classify text into one of a set of categories. Returns the matching keyword.
```sema
(llm/classify (list :positive :negative :neutral)
"This product is amazing!")
; => :positive
```
Pass a list of keyword labels and the text to classify. The LLM picks the best-matching label.
An optional third options map takes `:model` — handy for using a cheap, fast model for
classification:
```sema
(llm/classify (list :spam :ham) text {:model "claude-haiku-4-5-20251001"})
```
The return type follows the labels: a list of **keywords** classifies to a keyword, a list
of **strings** to a string.
## Vision Extraction
### `llm/extract-from-image`
Extract structured data from images using vision-capable LLMs. Accepts a schema, an image source (file path or bytevector), and optional options.
```sema
;; Extract from a file path
(llm/extract-from-image
{:text :string :background_color :string}
"assets/logo.png")
; => {:background_color "white" :text "Sema"}
;; Extract from a bytevector
(define img (file/read-bytes "invoice.jpg"))
(llm/extract-from-image
{:invoice_number :string :date :string :total :string}
img)
; => {:date "2025-03-15" :invoice_number "12345" :total "$139.96"}
```
Supported image formats (detected automatically via magic bytes): PNG, JPEG, GIF, WebP, PDF.
### Options
`llm/extract-from-image` accepts an optional third argument — an options map:
```sema
(llm/extract-from-image schema source {:model "gpt-5.5"})
```
| Option | Type | Default | Description |
| -------- | ------ | ------- | -------------------------- |
| `:model` | string | — | Override the default model |
## Multi-Modal Messages
### `message/with-image`
Create a message that includes both text and an image, for use with `llm/chat`.
```sema
(define img (file/read-bytes "photo.jpg"))
(define msg (message/with-image :user "What do you see?" img))
(llm/chat (list msg))
```
The image must be a bytevector (use `file/read-bytes` to load from disk). The media type is detected automatically.
You can combine image messages with regular messages:
```sema
(llm/chat
(list (message :system "You are a helpful image analyst.")
(message/with-image :user "Describe this chart." (file/read-bytes "chart.png"))))
```
### Provider Support
Vision features work with providers that support multi-modal input:
| Provider | `llm/extract-from-image` | `message/with-image` |
| ------------- | ------------------------ | -------------------- |
| **Anthropic** | ✅ | ✅ |
| **OpenAI** | ✅ | ✅ |
| **Gemini** | ✅ | ✅ |
| **Ollama** | ✅ (model-dependent) | ✅ (model-dependent) |
For Ollama, use a vision-capable model like `gemma3:4b` or `llava`.
---
---
url: 'https://sema-lang.com/docs/llm/providers.md'
---
# Provider Management
## Auto-Configuration
Sema auto-detects and configures all available providers from environment variables on startup. No manual setup is required — just set the API key for your provider.
### `llm/auto-configure`
Manually trigger auto-configuration (runs automatically on startup unless `--no-llm` is used).
```sema
(llm/auto-configure)
```
## Manual Configuration
### `llm/configure`
Manually configure a known provider with specific options.
```sema
(llm/configure :anthropic {:api-key "sk-..."})
;; Ollama with custom host
(llm/configure :ollama {:host "http://localhost:11434"
:default-model "llama3"})
```
### OpenAI-Compatible Providers
Any provider with an OpenAI-compatible API can be registered by passing `:api-key` and `:base-url` with any provider name — no custom code needed, just configuration.
```sema
;; Together AI
(llm/configure :together
{:api-key (env "TOGETHER_API_KEY")
:base-url "https://api.together.xyz/v1"
:default-model "meta-llama/Llama-3-70b-chat-hf"})
;; Azure OpenAI
(llm/configure :azure
{:api-key (env "AZURE_OPENAI_KEY")
:base-url "https://my-resource.openai.azure.com/openai/deployments/gpt-4/v1"
:default-model "gpt-4"})
;; Local vLLM / LiteLLM / text-generation-inference
(llm/configure :local
{:api-key "not-needed"
:base-url "http://localhost:8000/v1"
:default-model "my-model"})
;; Once configured, use like any other provider
(llm/complete "Hello from Together!" {:model "meta-llama/Llama-3-70b-chat-hf"})
```
This works for any service that implements the OpenAI chat completions API: Together, Fireworks, Perplexity, Azure OpenAI, Anyscale, vLLM, LiteLLM, text-generation-inference, and others.
> **Sandbox note.** Local endpoints like `http://localhost:8000/v1` and Ollama on `localhost:11434` work normally in the REPL, CLI, and notebook. When running **untrusted code under `--sandbox`**, a `:base-url`/`:host` pointing at a loopback or private address (`localhost`, `127.0.0.1`, `10.x`, `169.254.169.254`, …) is rejected to prevent SSRF. Run unsandboxed to use a local endpoint.
## Lisp-Defined Providers
For full control over request/response handling, you can define providers entirely in Sema using `llm/define-provider`. The provider's `:complete` function receives the request as a map and returns either a string or a response map.
### `llm/define-provider`
```sema
(llm/define-provider :name {:complete fn :default-model "..."})
```
**Parameters:**
* `:complete` — **(required)** A function that takes a request map and returns a response
* `:default-model` — Model name used when none is specified (default: `"default"`)
### Request Map
The `:complete` function receives a map with these keys:
| Key | Type | Description |
| ----------------- | -------------- | ---------------------------------- |
| `:model` | string | Model name |
| `:messages` | list of maps | Each has `:role` and `:content` |
| `:max-tokens` | integer or nil | Token limit |
| `:temperature` | float or nil | Sampling temperature |
| `:system` | string or nil | System prompt |
| `:tools` | list or nil | Tool schemas (if tools are in use) |
| `:stop-sequences` | list or nil | Stop sequences for generation |
### Response Format
The function can return either:
* **A string** — used as the assistant's response content
* **A map** with optional keys:
| Key | Type | Default |
| -------------- | ------ | ------------- |
| `:content` | string | `""` |
| `:role` | string | `"assistant"` |
| `:model` | string | request model |
| `:stop-reason` | string | `"end_turn"` |
| `:usage` | map | zero tokens |
| `:tool-calls` | list | empty list |
The `:usage` map can contain `:prompt-tokens` and `:completion-tokens` (both integers).
The `:tool-calls` list contains maps with `:id` (string), `:name` (string), and `:arguments` (map). This enables Lisp-defined providers to work with tool-calling agents.
### Examples
**Echo provider** — returns the user's message back:
```sema
(llm/define-provider :echo
{:complete (fn (req)
(string/append "Echo: " (:content (last (:messages req)))))
:default-model "echo-v1"})
(llm/complete "hello") ;; => "Echo: hello"
```
**HTTP proxy** — forward to a custom API:
```sema
(llm/define-provider :my-api
{:complete (fn (req)
(define resp (json/decode
(http/post "https://my-api.example.com/chat"
{:headers {"Authorization" (string/append "Bearer " (env "MY_API_KEY"))
"Content-Type" "application/json"}
:body (json/encode {:model (:model req)
:prompt (:content (last (:messages req)))})})))
{:content (:text resp)
:usage {:prompt-tokens (:input-tokens resp)
:completion-tokens (:output-tokens resp)}})
:default-model "my-model-v2"})
```
**Mock provider for testing** — deterministic responses without API calls:
```sema
(define responses (list "First response" "Second response" "Third response"))
(define call-count (atom 0))
(llm/define-provider :mock
{:complete (fn (req)
(let ((i (deref call-count)))
(swap! call-count (fn (n) (+ n 1)))
(nth responses (mod i (length responses)))))
:default-model "mock-v1"})
;; Now all llm/complete calls return deterministic values
(llm/complete "anything") ;; => "First response"
(llm/complete "anything") ;; => "Second response"
```
**Routing provider** — dispatch to different backends by model name:
```sema
(llm/configure :anthropic {:api-key (env "ANTHROPIC_API_KEY")})
(llm/configure :openai {:api-key (env "OPENAI_API_KEY")})
(llm/define-provider :router
{:complete (fn (req)
(let ((model (:model req)))
(cond
((string/starts-with? model "claude")
(begin (llm/set-default :anthropic)
(llm/complete (:content (last (:messages req))) {:model model})))
((string/starts-with? model "gpt")
(begin (llm/set-default :openai)
(llm/complete (:content (last (:messages req))) {:model model})))
(else (error (string/append "Unknown model: " model))))))
:default-model "claude-sonnet-4-6"})
```
### Switching Between Providers
Lisp-defined providers integrate with the standard provider management functions:
```sema
(llm/define-provider :mock
{:complete (fn (req) "mock response") :default-model "m1"})
(llm/configure :anthropic {:api-key (env "ANTHROPIC_API_KEY")})
(llm/set-default :mock) ;; use mock
(llm/complete "test") ;; => "mock response"
(llm/set-default :anthropic) ;; switch to real API
(llm/complete "test") ;; => real API response
```
## Runtime Provider Switching
### `llm/list-providers`
List all configured providers.
```sema
(llm/list-providers) ; => (:anthropic :gemini :openai ...)
(llm/providers) ; => same (alias)
```
### `llm/current-provider`
Get the currently active provider and model.
```sema
(llm/current-provider) ; => {:name :anthropic :model "claude-sonnet-4-6"}
(llm/default-provider) ; => same (alias)
```
### `llm/set-default`
Switch the active provider at runtime.
```sema
(llm/set-default :openai)
```
## Supported Providers
All providers are auto-configured from environment variables. Use `(llm/configure :provider {...})` for manual setup.
| Provider | Type | Chat | Stream | Tools | Embeddings | Vision |
| ------------------- | --------------------- | ---- | ------ | ----- | ---------- | ------ |
| **Anthropic** | Native | ✅ | ✅ | ✅ | — | ✅ |
| **OpenAI** | Native | ✅ | ✅ | ✅ | ✅ | ✅ |
| **Google Gemini** | Native | ✅ | ✅ | ✅ | — | ✅ |
| **Ollama** | Native (local) | ✅ | ✅ | ✅ | — | ✅ ² |
| **Groq** | OpenAI-compat | ✅ | ✅ | ✅ | — | — |
| **xAI** | OpenAI-compat | ✅ | ✅ | ✅ | — | — |
| **Mistral** | OpenAI-compat | ✅ | ✅ | ✅ | — | — |
| **Moonshot** | OpenAI-compat | ✅ | ✅ | ✅ | — | — |
| **Jina** | Embedding-only | — | — | — | ✅ | — |
| **Voyage** | Embedding-only | — | — | — | ✅ | — |
| **Cohere** | Embedding-only | — | — | — | ✅ | — |
| *Any OpenAI-compat* | `llm/configure` | ✅ | ✅ | ✅ | — | ✅ |
| *Custom Lisp* | `llm/define-provider` | ✅ | ¹ | ✅ | — | — |
¹ Streaming falls back to non-streaming (sends complete response as a single chunk).
² Vision requires a vision-capable model (e.g., `gemma3:4b`, `llava`).
### Default Models
When you don't pass `:default-model` to `llm/configure` (or pin `:model` on a call), each provider uses the following default. These are also what `llm/with-fallback` substitutes per provider when the body doesn't pin a model.
| Provider | Default model |
| ------------ | ---------------------------- |
| `:anthropic` | `claude-sonnet-4-6` |
| `:openai` | `gpt-5.5` |
| `:gemini` | `gemini-3.5-flash` |
| `:ollama` | `gemma4` |
| `:groq` | `llama-3.3-70b-versatile` |
| `:xai` | `grok-4.3` |
| `:mistral` | `mistral-large-latest` |
| `:moonshot` | `kimi-k2.6` |
Override any of these per provider with `:default-model`, globally via `SEMA_CHAT_MODEL`, or per call with `:model`.
## Environment Variables
| Variable | Description |
| -------------------- | ----------------------------------------------------- |
| `ANTHROPIC_API_KEY` | Anthropic API key |
| `OPENAI_API_KEY` | OpenAI API key |
| `GROQ_API_KEY` | Groq API key |
| `XAI_API_KEY` | xAI/Grok API key |
| `MISTRAL_API_KEY` | Mistral API key |
| `MOONSHOT_API_KEY` | Moonshot API key |
| `GOOGLE_API_KEY` | Google Gemini API key |
| `OLLAMA_HOST` | Ollama server URL (default: `http://localhost:11434`) |
| `JINA_API_KEY` | Jina embeddings API key |
| `VOYAGE_API_KEY` | Voyage embeddings API key |
| `COHERE_API_KEY` | Cohere embeddings API key |
| `SEMA_CHAT_MODEL` | Default chat model name |
| `SEMA_CHAT_PROVIDER` | Preferred chat provider |
| `SEMA_EMBEDDING_MODEL` | Default embedding model name |
| `SEMA_EMBEDDING_PROVIDER` | Preferred embedding provider |
---
---
url: 'https://sema-lang.com/docs/llm/cost.md'
---
# Cost Tracking & Budgets
## Usage Tracking
### `llm/last-usage`
Get token usage from the most recent LLM call.
```sema
(llm/last-usage)
; => {:prompt-tokens 42 :completion-tokens 15 :total-tokens 57
; :cache-read-tokens 0 :cache-creation-tokens 0
; :model "..." :cost-usd 0.0003}
```
### `llm/session-usage`
Get cumulative usage across all LLM calls in the current session.
```sema
(llm/session-usage)
; => {:prompt-tokens 1280 :completion-tokens 410 :total-tokens 1690
; :cache-read-tokens 1024 :cache-creation-tokens 0 :cost-usd 0.012}
```
#### Prompt-cache tokens
`:cache-read-tokens` and `:cache-creation-tokens` report how many input tokens
were served from (or written to) the provider's **prompt cache** — large savings
when you repeat a stable prefix across calls.
* **OpenAI** and **Gemini** (2.5+) cache *implicitly*: send the same long prefix
twice and the second call reports `:cache-read-tokens` automatically. Reads are
a subset of `:prompt-tokens`.
* **Anthropic** reports `:cache-read-tokens` and `:cache-creation-tokens`
*separately* from `:prompt-tokens` (caching there is opt-in via `cache_control`).
* Providers that don't report cache counts leave these at `0`.
> Cost is currently priced at the standard input rate; cached reads are reported
> for visibility but not yet discounted in `:cost-usd`.
### `llm/reset-usage`
Reset session usage counters.
```sema
(llm/reset-usage)
```
## Pricing Sources
Sema tracks LLM costs using pricing data from these sources, checked in this order:
1. **Custom pricing** — set via `(llm/set-pricing "model" input output)`, always wins
2. **Bundled price list** — a [models.dev](https://models.dev) snapshot (2,400+ models) that ships with Sema, so cost tracking works fully offline with no network calls
3. **Unknown** — if no source matches, cost tracking returns `nil` and budget enforcement is best-effort
The embedded snapshot is refreshed by maintainers with `make update-pricing` and shipped in patch releases. Prices are matched by model id, preferring the canonical first-party listing; when the serving provider is known (e.g. inside an `llm/with-fallback` chain), a reseller/gateway that lists the same model at a different rate is priced correctly.
### `llm/pricing-status`
Check the pricing source and the snapshot date.
```sema
(llm/pricing-status)
; => {:source "embedded" :updated-at "2026-06-18"}
```
## Budget Enforcement
> **Note:** If pricing is unknown for a model (not in any source), budget enforcement operates in best-effort mode — the call proceeds with a one-time warning. Use `(llm/set-pricing)` to set pricing for unlisted models.
### `llm/set-budget`
Set a spending limit (in dollars) for the session. LLM calls that would exceed the budget will fail.
```sema
(llm/set-budget 1.00) ; set $1.00 spending limit
```
### `llm/budget-remaining`
Check current budget status.
```sema
(llm/budget-remaining) ; => {:limit 1.0 :spent 0.05 :remaining 0.95}
```
### `llm/with-budget`
Scoped budget — sets spending limits for the duration of a thunk, then restores the previous budget when done. At least one of `:max-cost-usd` or `:max-tokens` is required. When both are provided, **whichever limit is hit first** triggers the error.
```sema
;; Cost-based budget
(llm/with-budget {:max-cost-usd 0.50} (lambda ()
(llm/complete "Expensive operation")))
;; Token-based budget (useful when pricing is unknown or stale)
(llm/with-budget {:max-tokens 10000} (lambda ()
(llm/complete "Limited tokens")))
;; Both limits — whichever is reached first stops execution
(llm/with-budget {:max-cost-usd 1.00 :max-tokens 50000} (lambda ()
(llm/complete "Double-capped")
(println (format "Budget: ~a" (llm/budget-remaining)))))
```
When a token budget is active, `llm/budget-remaining` includes `:token-limit`, `:tokens-spent`, and `:tokens-remaining` in addition to the cost fields.
#### Streaming and the budget
By default, budgets enforce on **non-streaming** calls (the spend is known after each call completes). A stream's cost isn't known until it ends, so streams aren't budget-gated unless you opt in with `:on-stream :pre-gate` — which refuses to **open** a stream once the scope's spend is already at the cap:
```sema
(llm/with-budget {:max-cost-usd 0.50 :on-stream :pre-gate} (lambda ()
(llm/stream "..." on-token))) ; blocked at open once $0.50 is spent
```
A single in-flight stream can still push *past* the cap (you only learn its cost when it finishes), but the next call is blocked. Usage is tracked either way.
### `llm/clear-budget`
Remove the spending limit.
```sema
(llm/clear-budget)
```
### `llm/set-pricing`
Set custom pricing for a model (overrides both dynamic and built-in pricing). Costs are per million tokens.
```sema
(llm/set-pricing "my-model" 1.0 3.0) ; $1.00/M input, $3.00/M output
```
## Batch & Parallel
### `llm/batch`
Send multiple prompts concurrently and collect all results.
```sema
(llm/batch (list "Translate 'hello' to French"
"Translate 'hello' to Spanish"
"Translate 'hello' to German"))
```
### `llm/pmap`
Map a function over items, sending all resulting prompts in parallel.
```sema
(llm/pmap
(fn (word) (format "Define: ~a" word))
'("serendipity" "ephemeral" "ubiquitous")
{:max-tokens 50})
```
---
---
url: 'https://sema-lang.com/docs/llm/caching.md'
---
# Response Caching
Sema caches LLM responses so identical calls don't hit the API twice. The cache is
**persistent**: responses are written to `~/.sema/cache/llm/` (one JSON file per entry,
named by a SHA-256 key), so a re-run of a script — even in a new process — serves the
answer recorded by an earlier run. An in-memory layer sits on top for the current session.
A call is a cache hit when its **model, temperature, system prompt, and full message
list** all match a stored entry. `:max-tokens` and `:tools` are *not* part of the key.
Caching is **off by default** — turn it on for a block with `llm/with-cache`.
> For replay that you **commit and share** (deterministic tests, offline demos), see
> [Cassettes](./cassettes) instead. They're a different tool: a cassette stores a tape
> next to your code rather than in your personal cache dir, and the response cache is
> turned off inside `llm/with-cassette`.
## Cache scope
### `llm/with-cache`
Run a thunk with caching enabled for every LLM call inside it. The **options map comes
first** when you pass one; with a single argument it's just the thunk. `:ttl` sets the
time-to-live in seconds (default 3600). Previous cache settings are restored on exit.
```sema
;; thunk only
(llm/with-cache (lambda () (llm/complete "hello")))
;; with options — opts FIRST, then the thunk
(llm/with-cache {:ttl 7200} (lambda () (llm/complete "hello")))
```
A cache hit costs nothing: it makes no provider call, so it reports **zero** token usage
and spends nothing against a [budget](./cost). The two calls below show a miss then a hit:
```sema
(llm/with-cache (lambda ()
(llm/complete "what is 2+2?") ; miss — calls the model, stores the answer
(llm/complete "what is 2+2?") ; hit — served from the cache, no API call
(llm/cache-stats))) ; => {:hits 1 :misses 1 :size 1}
```
## Inspection & debugging
### `llm/cache-key`
Generate the SHA-256 cache key for a prompt and options — handy for debugging why two
calls do or don't share a cache entry. Takes a prompt string and an optional options map.
```sema
(llm/cache-key "hello" {:model "gpt-4" :temperature 0.5})
```
### `llm/cache-stats`
Returns `{:hits :misses :size}`. Note that `:size` counts only the entries loaded into
memory **this session** — a cold start can serve hits from disk before `:size` reflects
them.
```sema
(llm/cache-stats) ; => {:hits 0 :misses 0 :size 0}
```
## Cache management
### `llm/cache-clear`
Clear cached responses — both the in-memory entries and the files in `~/.sema/cache/llm/`.
Returns the number of entries cleared.
```sema
(llm/cache-clear) ; => 0
```
---
---
url: 'https://sema-lang.com/docs/llm/resilience.md'
---
# Resilience & Retry
## Fallback Provider Chains
### `llm/with-fallback`
Wraps a thunk with a fallback chain of providers. If the LLM call fails with one provider, automatically tries the next provider in the list.
```sema
(llm/with-fallback [:anthropic :openai :groq]
(lambda () (llm/complete "Hello")))
```
#### Model selection across the chain
Model ids are provider-specific (a Claude id is meaningless to OpenAI), so each chain entry resolves its own model:
* A **bare provider keyword** (e.g. `:anthropic`) uses that provider's [default model](./providers#default-models), or whatever you set via `(llm/configure :anthropic {:default-model "..."})`. This is the recommended form — leave the body's `(llm/complete ...)` **unpinned** so every provider gets a model id valid for itself.
* If the body pins a `:model`, that exact string is sent to **every** provider in the chain. That's fine for a homogeneous chain, but pinning a provider-specific id (e.g. a Claude model) will fail on any other provider it falls back to.
#### Per-provider model overrides
To target a different model per provider within a single chain, give chain entries as `[provider model]` pairs or `{:provider :model}` maps. A per-provider override **wins over any `:model` pinned in the body**:
```sema
;; Anthropic uses Opus, OpenAI uses GPT-5.5, Groq uses its default
(llm/with-fallback [[:anthropic "claude-opus-4-8"]
[:openai "gpt-5.5"]
:groq]
(lambda () (llm/complete "Hello")))
;; Map form is equivalent and lets you omit :model to use the provider default
(llm/with-fallback [{:provider :anthropic :model "claude-opus-4-8"}
{:provider :openai}]
(lambda () (llm/complete "Hello")))
```
## Automatic Retry on Transient Errors
LLM calls (`llm/complete`, `llm/chat`, `agent/run`, and the fallback-chain path)
**automatically retry transient failures** — no configuration needed:
* Retried: HTTP 429 (rate limited), 5xx server errors, and network/timeout errors.
* Not retried: 4xx client errors other than 429 (e.g. 400 bad request), and parse
errors — these won't succeed on a retry, so they fail fast.
* Backoff: capped **exponential backoff with full jitter** (base 500ms, doubling
per attempt, capped at 30s), up to 3 retries. A 429 honors the provider's
`retry-after` hint when present.
This is distinct from [`llm/with-fallback`](#fallback-provider-chains) (which
switches *providers* on failure) and the generic [`retry`](#generic-retry) (which
wraps *any* thunk). They compose: each provider in a fallback chain does its own
transient-error retry before the chain moves on.
### Streaming and resilience
`llm/stream` applies these guarantees **at stream-open** — before the first token:
* **Fallback** — if a provider fails to *open* the stream, the chain fails over to the next, just like non-streaming. Once the first token has been delivered, a **mid-stream** failure is **not** failed over (switching providers mid-answer would re-emit the partial you already received); the error surfaces and the partial text is kept.
* **Rate-limiting** — `llm/with-rate-limit` gates the stream-open call the same as a non-streaming one.
* **Budget** — opt in with `llm/with-budget {... :on-stream :pre-gate}`: the stream is refused at open if the scope's spend is already at the cap. By default streams are **not** budget-gated (a stream's cost is unknown until it ends), though usage is still tracked afterward.
Two things still **don't** apply to streams: the **response cache** (a live stream isn't cached — for deterministic replay use [cassettes](/docs/llm/cassettes)) and **mid-stream retry** (a retry would duplicate already-emitted output — see above).
## Rate Limiting
### `llm/with-rate-limit`
Wraps a thunk with token-bucket rate limiting. Takes a rate (requests per second) and a thunk. Useful to avoid hitting API rate limits.
```sema
(llm/with-rate-limit 5 (lambda () (llm/complete "Hello")))
```
## Generic Retry
### `retry`
Retries a thunk on failure with exponential backoff. Takes a thunk and an optional options map.
```sema
;; Default: 3 attempts, 100ms base delay, 2.0 backoff
(retry (lambda () (http/get "https://example.com")))
;; Custom options
(retry (lambda () (http/get "https://example.com"))
{:max-attempts 5 :base-delay-ms 200 :backoff 1.5})
```
Options:
| Key | Type | Default | Description |
| ---------------- | ------- | ------- | ---------------------------------- |
| `:max-attempts` | integer | 3 | Maximum number of attempts |
| `:base-delay-ms` | integer | 100 | Initial delay between retries (ms) |
| `:backoff` | float | 2.0 | Backoff multiplier |
> **Note:** `retry` is in the stdlib (not LLM-specific) — it works with any function.
## LLM Convenience Functions
### `llm/summarize`
Summarize text using an LLM. Takes text and an optional options map.
```sema
(llm/summarize "Long article text here...")
(llm/summarize "Long text" {:model "claude-haiku-4-5-20251001" :max-tokens 200})
```
### `llm/compare`
Compare two texts using an LLM. Takes two strings and an optional options map.
```sema
(llm/compare "Text A" "Text B")
(llm/compare "Text A" "Text B" {:model "claude-haiku-4-5-20251001"})
```
---
---
url: 'https://sema-lang.com/docs/llm/embeddings.md'
---
# Embeddings & Similarity
Generate vector embeddings from text and compute similarity between them. On startup
`(llm/auto-configure)` picks an embedding provider by **precedence** — `JINA_API_KEY`,
then `VOYAGE_API_KEY`, then `COHERE_API_KEY`; if none is set it falls back to
`OPENAI_API_KEY` (`text-embedding-3-small`). The first key present wins.
## Configuration
### `llm/configure-embeddings`
Configure a dedicated embedding provider separately from the chat provider — so you can use
one provider for chat and another for embeddings. Pass `:default-model` to pick the model
(otherwise each provider uses its default: `jina-embeddings-v3`, `voyage-3`, or
`text-embedding-3-small`):
```sema
(llm/configure-embeddings :voyage
{:api-key (env "VOYAGE_API_KEY") :default-model "voyage-3-large"})
;; OpenAI-compatible embedding provider, with a model and optional base URL
(llm/configure-embeddings :openai
{:api-key (env "OPENAI_API_KEY") :default-model "text-embedding-3-large"})
```
## Generating Embeddings
### `llm/embed`
Generate an embedding for a string or a list of strings. Returns a **bytevector** containing densely-packed f64 values in little-endian format. This representation is 2× more memory efficient and 4× faster for similarity computations compared to a list of floats.
```sema
;; Single embedding (returns a bytevector)
(define v1 (llm/embed "hello world"))
;; Pick the model per call with an options map
(llm/embed "hello world" {:model "text-embedding-3-small"})
;; Batch embeddings
(llm/embed ["cat" "dog" "fish"]) ; => list of bytevectors
```
## Embedding Accessors
### `embedding/length`
Returns the number of dimensions (f64 elements) in an embedding bytevector.
```sema
(define v (llm/embed "hello"))
(embedding/length v) ; => 1024 (depends on provider)
```
### `embedding/ref`
Access a specific dimension by index.
```sema
(define v (llm/embed "hello"))
(embedding/ref v 0) ; => 0.0123 (first dimension)
```
### `embedding/->list`
Convert an embedding bytevector to a list of floats (useful for interop).
```sema
(define v (llm/embed "hello"))
(embedding/->list v) ; => (0.0123 -0.0456 ...)
```
### `embedding/list->embedding`
Convert a list of numbers to an embedding bytevector.
```sema
(define v (embedding/list->embedding '(0.1 0.2 0.3)))
(embedding/length v) ; => 3
```
## Computing Similarity
### `llm/similarity`
Compute cosine similarity between two embedding vectors. Returns a value between -1.0 and 1.0. Accepts both bytevectors (fast path) and lists of floats (backward compatible).
```sema
(define v1 (llm/embed "hello world"))
(define v2 (llm/embed "hi there"))
(llm/similarity v1 v2) ; => 0.87 (cosine similarity)
;; Also works with plain lists
(llm/similarity '(0.1 0.2 0.3) '(0.4 0.5 0.6))
```
## Reranking
### `llm/rerank`
Reorder a list of candidate documents by their relevance to a query using a hosted **cross-encoder** reranker (Cohere, Jina, or Voyage — the same **API key** you already use for embeddings, e.g. `COHERE_API_KEY` / `JINA_API_KEY` / `VOYAGE_API_KEY`; see [Supported Embedding Providers](#supported-embedding-providers) below for setup). Where `llm/similarity` / `vector-store/search` embed the query and documents *independently* (a bi-encoder), a reranker reads the query and each document *together*, so it's far more precise. The standard pattern is to retrieve a generous shortlist by vector search, then rerank it to the best few.
```sema
(llm/rerank "how do I read a file?"
(list "vectors are cool" "use file/read to read a file" "unrelated trivia")
{:top-k 2})
;; => ({:index 1 :score 0.91 :document "use file/read to read a file"} ...)
```
Returns `{:index :score :document}` maps, highest relevance first; `:index` points back into the input list. Options: `:top-k`, `:model`, and `:provider` (`:cohere` / `:jina` / `:voyage`). See the **[RAG guide](/docs/llm/rag)** for the full retrieve → rerank → answer pipeline.
## Token Counting
### `llm/token-count`
Estimate the number of tokens in a string or list of strings. Uses a heuristic (chars/4) — no tokenizer dependency required.
```sema
(llm/token-count "hello world") ; => 3
(llm/token-count '("hello" "world")) ; => sum of individual counts
```
### `llm/token-estimate`
Returns a detailed estimate map with the token count and the estimation method used.
```sema
(llm/token-estimate "hello world")
; => {:method "chars/4" :tokens 3}
```
## Supported Embedding Providers
| Provider | Env Variable |
| -------- | ---------------- |
| Jina | `JINA_API_KEY` |
| Voyage | `VOYAGE_API_KEY` |
| Cohere | `COHERE_API_KEY` |
| OpenAI | `OPENAI_API_KEY` |
See [Provider Management](./providers.md) for the full provider capability table.
---
---
url: 'https://sema-lang.com/docs/llm/vector-store.md'
---
# Vector Store & Math
## In-Memory Vector Store
Sema includes an in-memory vector store for semantic search over embeddings. Create named stores, add documents with embeddings and metadata, and search by cosine similarity. Stores can optionally be persisted to disk as JSON.
### `vector-store/create`
Create a named in-memory vector store. Returns the store name.
```sema
(vector-store/create "my-store")
```
### `vector-store/open`
Open a named store backed by a file. If the file exists, its contents are loaded; otherwise an empty store is created. The path is remembered for subsequent `vector-store/save` calls.
```sema
(vector-store/open "my-store" "embeddings.json")
```
### `vector-store/add`
Add a document with an ID, embedding (bytevector), and metadata map.
```sema
(vector-store/add "my-store" "doc-1"
(llm/embed "Hello world")
{:source "greeting.txt" :page 1})
```
If a document with the same ID exists, it is replaced.
### `vector-store/search`
Search by cosine similarity. Takes store name, query embedding, and k (number of results). Returns a list of maps with `:id`, `:score`, and `:metadata`.
```sema
(vector-store/search "my-store" (llm/embed "Hi there") 5)
;; => ({:id "doc-1" :score 0.92 :metadata {:source "greeting.txt" :page 1}} ...)
```
### `vector-store/delete`
Delete a document by ID. Returns `#t` if found, `#f` otherwise.
```sema
(vector-store/delete "my-store" "doc-1") ; => #t
```
### `vector-store/count`
Return the number of documents in a store.
```sema
(vector-store/count "my-store") ; => 42
```
### `vector-store/save`
Save a store to disk as JSON. If the store was opened with `vector-store/open`, the path is used automatically. Otherwise, pass a path explicitly.
```sema
;; Explicit path
(vector-store/save "my-store" "embeddings.json")
;; Implicit path (if opened with vector-store/open)
(vector-store/save "my-store")
```
The file format is a JSON document with base64-encoded embeddings and full metadata, portable across platforms.
## Vector Math
These functions operate on embedding bytevectors (packed f64 arrays in little-endian format, as returned by `llm/embed` or `embedding/list->embedding`).
### `vector/cosine-similarity`
Cosine similarity between two embedding vectors. Returns a float between -1.0 and 1.0.
```sema
(vector/cosine-similarity
(embedding/list->embedding '(1.0 0.0))
(embedding/list->embedding '(0.0 1.0)))
; => 0.0
```
### `vector/dot-product`
Dot product of two embedding vectors.
```sema
(vector/dot-product
(embedding/list->embedding '(1.0 2.0 3.0))
(embedding/list->embedding '(4.0 5.0 6.0)))
; => 32.0
```
### `vector/normalize`
Return a unit-length copy of the vector.
```sema
(vector/normalize (embedding/list->embedding '(3.0 4.0)))
;; => embedding with values (0.6 0.8)
```
### `vector/distance`
Euclidean distance between two embedding vectors.
```sema
(vector/distance
(embedding/list->embedding '(0.0 0.0))
(embedding/list->embedding '(3.0 4.0)))
; => 5.0
```
## Full Example
A RAG-style workflow: embed documents, store them, search semantically, and persist to disk.
```sema
;; Open a persistent store (creates file if it doesn't exist)
(vector-store/open "docs" "my-docs.json")
(define texts '("Rust is a systems language"
"Python is great for ML"
"Lisp is homoiconic"))
(for-each
(lambda (text)
(vector-store/add "docs" text (llm/embed text) {:text text}))
texts)
;; Save to disk
(vector-store/save "docs")
;; Retrieve the most relevant chunks for a question...
(define question "Which language is homoiconic?")
(define hits (vector-store/search "docs" (llm/embed question) 2))
;; ...then generate an answer grounded in only that context (the "G" in RAG)
(define context (string/join (map (lambda (h) (:text (:metadata h))) hits) "\n"))
(llm/complete
(prompt (system "Answer using only the provided context. Be concise.")
(user (format "Context:\n~a\n\nQuestion: ~a" context question)))
{:max-tokens 120})
;; => "Lisp — it is homoiconic."
```
Next time you run, `(vector-store/open "docs" "my-docs.json")` will load the saved embeddings instantly — no re-embedding needed.
::: tip Sharpen results with a reranker
Cosine `vector-store/search` has high recall but coarse ordering. For better precision, retrieve a larger shortlist and reorder it with the cross-encoder [`llm/rerank`](/docs/llm/embeddings#reranking) — the standard *retrieve-many → rerank-to-a-few* RAG move. See the **[RAG guide](/docs/llm/rag)** for the full pipeline.
:::
::: warning Use one embedding model per store
Every document and the query must share the same embedding dimensions. Mixing embedding
models (or providers) in one store raises a *dimension-mismatch* error at search time — so
pick one embedding model per store.
:::
---
---
url: 'https://sema-lang.com/docs/llm/cassettes.md'
---
# Cassettes (Record & Replay)
A **cassette** saves the answers from real LLM calls to a file the first time you run,
then plays them back on every run after — no API key, no network, the same output every
time. It's like recording a conversation once and replaying the tape.
Two things this makes easy:
* **Tests that don't need a key.** Record a run once, commit the file, and your
`llm/complete` and `agent/run` tests run offline and give the same result forever — so
they pass reliably in CI with no secrets and no flakiness.
* **Demos and docs that always work.** A playground example or a notebook can ship its
tape and render the exact same output every time, offline, with no model drift.
Because the saved answer keeps its real token counts, cost and budget tracking keep
working on replay too — so even cost tests become repeatable.
## Quick start
```sema
;; First run: calls the real model and saves the answer to the file.
;; Every run after: plays the saved answer back — offline, identical.
(llm/with-cassette "tapes/greeting.jsonl" {:mode :auto}
(fn ()
(llm/complete "Say hello in one word." {:model "gpt-5-mini"})))
;; => "Hello"
```
Run it once with an API key set to capture the tape, commit `tapes/greeting.jsonl`, and
from then on the call is offline and deterministic. That's the whole idea.
## The three modes
`:mode` decides what happens on each call:
| Mode | If the call is on the tape | If it's a new call |
| --- | --- | --- |
| `:auto` *(default)* | play it back | call the model and record it |
| `:replay` | play it back | **error** — the call wasn't recorded |
| `:record` | call the model and record it | call the model and record it |
`:auto` is the friendly default for writing tapes: it records what's missing and replays
what it already has. `:replay` is what you want in CI — it never touches the network, and
a call that isn't on the tape is a **hard error** that names the request. That error is a
feature: if you change a prompt, the matching recording disappears, and the failure tells
you exactly which call drifted instead of silently hitting a live model.
## What you can record
Cassettes cover the everyday LLM calls. Each is matched and replayed independently:
| Call | Works? | Notes |
| --- | --- | --- |
| `llm/complete`, `llm/chat` | ✅ | the answer, model, tokens, and finish reason |
| `llm/extract` and structured calls | ✅ | the structured result is rebuilt from the saved answer |
| `agent/run` and tool loops | ✅ | **each turn is saved separately**, so a full multi-turn run (model → tool call → result → final answer) replays exactly — your tool handlers still run on replay |
| `llm/stream` (streaming) | ✅ | the text chunks are saved and replayed in order — see [Streaming](#streaming-in-detail) |
| `llm/embed` (embeddings) | ✅ | the vectors are saved and replayed byte-for-byte |
A note on **agents**: because each model turn is recorded on its own, your tools execute
normally during replay — the cassette only stands in for the *model's* responses, not for
your tool code. That's usually what you want: deterministic model output, real tool logic.
## Using cassettes
### `llm/with-cassette` — record/replay for a block
The usual way: wrap the calls you want recorded in a function. The tape is saved when the
block finishes, and everything goes back to normal afterwards.
```sema
(llm/with-cassette "tapes/weather-agent.jsonl" {:mode :auto}
(fn ()
(define bot (agent {:model "gpt-5-mini" :tools [get-weather]}))
(agent/run bot "What's the weather in Oslo?")))
```
The options map is optional and currently takes `:mode` (`:auto`, `:record`, or
`:replay`, default `:auto`). The file — and any missing folders — is created when the tape
is written.
### Turning it on by hand
If your setup and teardown aren't a single block — for example in a test harness or a
notebook — use the imperative trio:
```sema
(llm/cassette-load "tapes/suite.jsonl" {:mode :replay}) ; turn it on
;; ... run many calls ...
(llm/cassette-save) ; write the tape to disk (returns #t if a cassette is active)
(llm/cassette-eject) ; write the tape and turn it back off
```
`llm/cassette-load` installs the cassette for everything that follows, until you eject it.
### Forcing replay across a whole run (CI)
Two environment variables turn on a cassette for an entire process, so a whole suite — or
a whole notebook — runs offline without changing any code:
```bash
SEMA_LLM_CASSETTE=tapes/suite.jsonl \
SEMA_LLM_CASSETTE_MODE=replay \
sema test/agents.sema
```
`SEMA_LLM_CASSETTE_MODE` is `replay`, `record`, or `auto` (default `auto`). This is
ignored under `--sandbox`, since it reads and writes a file.
A common CI pattern: record tapes locally once with a key, commit them, and run the suite
with `SEMA_LLM_CASSETTE_MODE=replay` so any un-recorded call fails loudly.
## Streaming in detail
Streaming hands you the answer in pieces — *chunks* — as the model generates them, by
calling a function you pass for each piece (a typing effect, a progress bar, live output).
A cassette records **the exact sequence of chunks**, then on replay feeds those same
chunks to your callback in the same order. So a streaming UI behaves identically offline:
```sema
;; Record once, then replay forever — the chunks arrive the same way both times.
(llm/with-cassette "tapes/story.jsonl" {:mode :auto}
(fn ()
(llm/stream "Tell me a two-line story."
(fn (chunk) (display chunk)) ; called once per recorded chunk, in order
{:model "gpt-5-mini"})))
```
Things worth knowing about streamed replay:
* **Boundaries are preserved.** If the recording arrived as `"Hel" "lo"`, replay calls
your function with `"Hel"` then `"lo"` — not one combined `"Hello"`. Code that depends on
chunking sees the same shape.
* **Replay is instant.** The chunks are delivered as fast as your callback accepts them;
the original network timing between chunks is *not* reproduced. Replay is for
determinism, not for re-simulating latency.
* **The full answer is saved too.** Alongside the chunks, the complete text, model, and
token counts are recorded — so cost tracking and `llm/last-usage` work on a replayed
stream just like a normal call.
If you only print the chunks (no callback), `llm/stream` writes to stdout; recording and
replay work the same way.
## Embeddings in detail
`llm/embed` returns vectors (as bytevectors). A cassette saves those vectors and returns
them verbatim on replay — so similarity scores, vector-store contents, and any math built
on them are exactly reproducible offline:
```sema
(llm/with-cassette "tapes/embeddings.jsonl" {:mode :auto}
(fn ()
(define v (llm/embed "semantic search query" {:model "text-embedding-3-small"}))
(vector/cosine-similarity v (llm/embed "another phrase"))))
```
Both `llm/embed` calls are recorded (keyed by their text), so the similarity number is
identical every run. Batch embeddings — passing a list of strings — are saved as a set of
vectors and replayed in order.
## Using cassettes in notebooks
Cassettes are a great fit for [notebooks](../notebook): record the LLM cells once with a
key, commit the tape next to the `.sema-nb` file, and the notebook re-runs the same way
forever — offline, for anyone, in CI.
There are two clean patterns.
### A setup cell that turns it on
Put one cell near the top of the notebook that loads a cassette; every LLM cell after it
records or replays automatically (cells in a notebook share one environment):
```sema
;; Cell 1 — setup
(llm/cassette-load "tapes/notebook.jsonl" {:mode :auto})
```
```sema
;; Cell 2 — a normal LLM cell; recorded on first run, replayed after
(llm/complete "Summarize the Sema language in one sentence." {:model "gpt-5-mini"})
```
```sema
;; Last cell — flush the tape so the recording is written
(llm/cassette-save)
```
Run the notebook once with a key to capture `tapes/notebook.jsonl`, commit it alongside
the notebook, and every later run (including a headless `sema notebook run`) replays it.
### Force replay for the whole notebook
To guarantee a notebook never calls a model — say when you publish it or run it in CI —
run it with the environment variable set, no edits required:
```bash
SEMA_LLM_CASSETTE=tapes/notebook.jsonl \
SEMA_LLM_CASSETTE_MODE=replay \
sema notebook run my-notebook.sema-nb
```
Any cell that makes a call not on the tape fails with a clear "cassette miss", so a stale
notebook can't quietly reach for a live model.
> **Tip:** keep tapes next to what they belong to — `tapes/` beside a test, or beside the
> `.sema-nb` — and commit them. They're plain text and diff cleanly, so a reviewer can see
> exactly how the recorded model output changed when you re-record.
## How it works with the rest of Sema
A cassette slots in just above the real model and below everything else, so it composes
instead of conflicting:
* **Cost & budgets.** A replayed answer keeps its real token counts, so
`llm/last-usage`, `llm/session-usage`, and budget limits all behave as if the call really
happened. This is different from a [cache](./caching) hit, which reports **zero** usage
(no call was made); a replay stands in for a real call, so it reports the real spend.
* **Tracing.** A replayed call still produces its [OpenTelemetry](./observability) trace,
with the recorded model and token counts — so replayed runs show up in your traces just
like live ones.
* **The response cache.** `llm/with-cassette` turns the in-memory response
[cache](./caching) off for its block, so the cache can't answer before the tape does.
You generally want one or the other, not both.
* **Retries & fallback.** While *recording*, the normal [retry and fallback](./resilience)
logic wraps the real call, so the tape captures the final successful answer. On replay
there's nothing to retry.
## What's in the file
A tape is plain text — **NDJSON**, one JSON object per line — so it's diffable,
appendable, and reviewable in a pull request. There's one line per saved call, and the
`kind` field says what it is:
```jsonl
{"v":1,"kind":"complete","key":"a1b2…","content":"Hello","model":"gpt-5-mini","prompt_tokens":12,"completion_tokens":1}
{"v":1,"kind":"stream","key":"c3d4…","content":"Hi there","model":"gpt-5-mini","chunks":["Hi"," there"],"completion_tokens":2}
{"v":1,"kind":"embed","key":"e5f6…","model":"text-embedding-3-small","embeddings":[[0.01,-0.02,0.03]]}
```
Only the **answer** is saved, looked up by a fingerprint (`key`) of the request. The
prompt text, your API key, and any headers are **never written to the file** — they
simply aren't part of what gets saved, so a tape is safe to commit. The `v` field is a
format version, there so old tapes can be migrated if the shape ever changes.
### What counts as "the same call"
Two calls match if their meaningful inputs are the same — the model, the system prompt,
the temperature, and the messages. Change any of those and it's a different call: in
`:replay` mode you get a clear "not recorded" error, which is exactly what flags a prompt
or model change. Things that don't affect the answer — request IDs, timing, your API key —
are not part of the fingerprint.
## Recipes
* **Record once, replay in CI.** Run the suite locally with a key and
`:mode :auto` (or `:record`) to capture tapes, commit them, then run CI with
`SEMA_LLM_CASSETTE_MODE=replay`. New or changed calls fail loudly.
* **Update a tape after a prompt change.** Delete the tape (or the affected line) and
re-run in `:auto`, or run that block in `:record` once. Commit the new tape; the diff
shows how the model's answer changed.
* **A reproducible demo.** Wrap the demo's LLM calls in `llm/with-cassette … {:mode
:replay}` and ship the tape, so it runs for anyone with no key.
## Good to know
* **Re-record after changes.** Change a prompt, model, or temperature and the old tape no
longer matches — re-record it (`:record`, or delete the file and run `:auto`).
* **One answer per call.** The first recorded answer for a given call is the one replayed.
* **Replay needs no provider.** In `:replay` mode nothing calls a model, so a cassette
works with no API key configured at all.
* **Cassette miss?** A "cassette miss in :replay mode" error means this exact call wasn't
recorded. Either the request changed (re-record it) or you're replaying a call you never
captured — switch that block to `:auto` to record it, then commit the updated tape.
---
---
url: 'https://sema-lang.com/docs/llm/observability.md'
---
# Tracing & Metrics
Sema can record what happens inside every LLM and agent run — each model call, tool
execution, retry, and notebook cell — as [OpenTelemetry](https://opentelemetry.io/)
traces and metrics, and send them to a tool where you can browse them. You don't write
any instrumentation: switch it on with one environment variable and `llm/complete`,
`agent/run`, `llm/embed`, and the rest are recorded automatically.
If OpenTelemetry is new to you, the terms used below:
* **OpenTelemetry (OTel)** is an open, vendor-neutral standard for traces and metrics.
* A **trace** is one run. It is made of **spans** — individual timed operations such as a
single LLM call or a tool execution. Spans nest, so an agent run appears as a tree.
* **OTLP** is the network protocol OTel uses. Sema speaks OTLP, so it works with any tool
that accepts it — a free local viewer like [Jaeger](https://www.jaegertracing.io/), or
a hosted service like [Langfuse](https://langfuse.com/), Grafana, or Datadog.
* Sema follows the OTel
[GenAI semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai)
— the agreed attribute names for LLM telemetry (token counts, model, cost, …) — so
these tools understand the data with no per-tool glue. Grafana, Jaeger, SigNoz,
OpenObserve, Datadog, Honeycomb, Logfire, MLflow, and others read it as-is; a few
LLM-specific tools (Arize Phoenix, Langfuse, …) need one extra setting — see
[Backend Compatibility](./otel-compat).
Tracing is **off by default** — if you don't point Sema at a backend or a file, it
records nothing. And once it's on, a slow or unreachable backend can never block, delay,
or crash your script: telemetry is sent in the background, out of the way of your run.
## How to turn it on
Sema reads its tracing settings from **environment variables** — values you set in your
shell. You can set them inline for a single command, or `export` them for the whole
session:
```bash
# Inline — applies to this one run only:
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 sema myscript.sema
# Or exported — applies to every command in this shell session:
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
sema myscript.sema
```
Two variables decide *where* the data goes, and **setting either one turns tracing on**:
* `OTEL_EXPORTER_OTLP_ENDPOINT` — send to a backend over the network (Jaeger, Langfuse, …).
* `SEMA_OTEL_FILE` — write to a local file instead (handy with no backend at all).
Set neither and tracing stays off. The full list of variables is in
[Configuration reference](#configuration-reference) below.
## Quick start: see a trace in one minute
[Jaeger](https://www.jaegertracing.io/) is a free trace viewer that runs in a single
container — a good way to see your first trace.
```bash
# 1. Start Jaeger. The UI is on port 16686; it accepts traces on 4318.
docker run --rm -d --name jaeger -p 4318:4318 -p 16686:16686 \
-e COLLECTOR_OTLP_ENABLED=true jaegertracing/all-in-one
# 2. Point Sema at it and run something. No model is pinned here, so this uses
# your default provider and its default model — just make sure an API key is
# set (ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / …).
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
sema -e '(llm/complete "say hi" {:max-tokens 16})'
```
Open `http://localhost:16686` in your browser, pick the **sema** service, and you'll see
one trace whose `chat` span carries the provider, model, input/output token counts,
cost, and finish reason.
> **Choosing a specific model.** The example uses whichever provider is active. To pick
> one, select it first: `(llm/set-default :openai)` then `{:model "gpt-5-mini"}`, or
> `(llm/set-default :anthropic)` then `{:model "claude-haiku-4-5-20251001"}`. A model id
> only works with the provider that offers it — sending an OpenAI model id to Anthropic
> returns a 404.
## Configuration reference
Every setting is an environment variable (see [How to turn it on](#how-to-turn-it-on)
for how to set them). The `OTEL_*` names come from OpenTelemetry itself; the
`SEMA_OTEL_*` names are Sema conveniences.
| Variable | What it does |
| --- | --- |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | The address of your tracing backend, e.g. `http://localhost:4318`. **Setting this turns tracing on.** |
| `OTEL_EXPORTER_OTLP_PROTOCOL` | How to talk to it: `http/protobuf` (default) · `http/json` · `grpc`. Keep the default unless your backend only accepts gRPC. |
| `OTEL_EXPORTER_OTLP_HEADERS` | Extra HTTP headers, usually authentication — e.g. `Authorization=Bearer `. Comma-separated `name=value` pairs; see [Authentication headers](#authentication-headers). |
| `OTEL_EXPORTER_OTLP_TIMEOUT` | Per-export timeout in milliseconds. Keep it short (e.g. `3000`) so a dead backend never holds things up. |
| `OTEL_SERVICE_NAME` | The name your runs appear under in the backend (default `sema`). |
| `SEMA_OTEL_FILE` | Write traces to this file path, one JSON object per line, instead of sending them over the network. Also turns tracing on. |
| `SEMA_OTEL_ENVIRONMENT` | A label such as `prod` or `staging` for filtering (recorded as `deployment.environment.name`). |
| `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | Set to `true` to also record the **prompt and response text** (off by default — see [Privacy](#privacy)). Sema also accepts the shorter alias `SEMA_OTEL_CAPTURE_CONTENT`. |
| `OTEL_BSP_MAX_QUEUE_SIZE`, `OTEL_BSP_MAX_EXPORT_BATCH_SIZE`, `OTEL_BSP_SCHEDULE_DELAY` | Advanced: tune the background export batching. The defaults are fine for most uses. |
Sema can send over HTTP or gRPC; choose with `OTEL_EXPORTER_OTLP_PROTOCOL`. HTTP (the
default) is what most backends accept — only switch to gRPC if yours requires it.
### Writing to a file instead of a backend
No backend running? Set `SEMA_OTEL_FILE` and Sema writes each finished span to a file as
one JSON object per line:
```bash
SEMA_OTEL_FILE=/tmp/sema-trace.jsonl \
sema -e '(llm/complete "ping" {:max-tokens 16})'
cat /tmp/sema-trace.jsonl | jq .
```
The file is written synchronously, so even a one-line script captures its spans.
## Authentication headers
Almost every **hosted** backend needs an API key, and you pass it as an HTTP header through
`OTEL_EXPORTER_OTLP_HEADERS`. (This is separate from `SEMA_OTEL_COMPAT`, which only relabels
attribute names — see [Backend Compatibility](./otel-compat).) The header **name** and the
key are dictated by the backend, not by Sema; always check the tool's own OTLP page for the
exact names.
### The format
`OTEL_EXPORTER_OTLP_HEADERS` is a comma-separated list of `name=value` pairs — the
[W3C Baggage](https://www.w3.org/TR/baggage/) format the OpenTelemetry spec mandates:
```bash
# one header
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer sk-abc123"
# two headers — separate with a comma
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer sk-abc123,x-project=my-app"
```
Rules worth knowing:
* **Separate multiple headers with commas**, not semicolons — semicolons are not supported.
* **The first `=` splits the name from the value**, so the value itself may contain `=`.
base64 strings with `=` padding (common in Basic auth) work fine.
* **Avoid literal commas or spaces inside a value** — a comma starts a new header. If a token
genuinely needs one, percent-encode it (`,` → `%2C`). Bearer tokens and base64 never
contain commas, so this rarely comes up.
* **Quote the whole value in your shell** so `$(...)` substitutions and special characters
survive.
### Common patterns
| Auth style | `OTEL_EXPORTER_OTLP_HEADERS` value | Example tools |
| --- | --- | --- |
| Bearer token | `Authorization=Bearer ` | Braintrust, Lunary, LangSmith |
| Basic auth | `Authorization=Basic ` | Langfuse, W\&B Weave |
| Vendor key header | `x-portkey-api-key=` · `dd-api-key=` | Portkey, Datadog |
### Building a Basic-auth header
Basic auth wants base64 of `id:secret`. Build it with `base64` and read the keys from
environment variables rather than hard-coding them. For [Langfuse](https://langfuse.com/):
```bash
export OTEL_EXPORTER_OTLP_ENDPOINT="https://cloud.langfuse.com/api/public/otel"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic $(echo -n "$LANGFUSE_PUBLIC_KEY:$LANGFUSE_SECRET_KEY" | base64)"
sema myagent.sema
```
### Tools that need more than one header
Some backends need a second (or third) header to route the trace to the right project or
workspace — the auth key alone isn't enough. The exact names come from each tool's OTLP docs:
| Tool | `OTEL_EXPORTER_OTLP_HEADERS` value |
| --- | --- |
| HoneyHive | `Authorization=Bearer ,x-honeyhive=project:` |
| W\&B Weave | `Authorization=Basic ,project_id=/` |
| Maxim | `x-maxim-api-key=,x-maxim-repo-id=` |
| Opik | `Authorization=,projectName=,Comet-Workspace=` |
Several LLM-focused backends can also show **richer** detail with one extra compatibility
setting on top of the auth header — see [Backend Compatibility](./otel-compat).
## What gets traced
| Span | Kind | Name | When |
| --- | --- | --- | --- |
| LLM call | `CLIENT` | `chat {model}` | every non-streaming completion (including cache hits) |
| Embeddings | `CLIENT` | `embeddings {model}` | every `llm/embed` |
| Tool call | `INTERNAL` | `execute_tool {name}` | every tool dispatch in an agent loop |
| Agent run | `INTERNAL` | `invoke_agent {name}` | every `agent/run` / tools-enabled completion |
| Notebook run | `INTERNAL` | `notebook.run_all` → `notebook.cell {id}` | a notebook "Run All" (one trace, one child span per cell) |
| Retry | `INTERNAL` | `llm.retry_attempt` | each HTTP retry (429 / 5xx / network), nested under the LLM span |
Each LLM span carries the standard GenAI attributes: `gen_ai.operation.name`,
`gen_ai.provider.name`, `gen_ai.request.model` / `gen_ai.response.model`,
`gen_ai.usage.input_tokens` / `output_tokens`, prompt-cache token counts,
`gen_ai.response.finish_reasons`, and the computed cost (`gen_ai.usage.cost`, plus
`gen_ai.usage.cost_usd`). Cache hits are flagged with `sema.gen_ai.cache.hit`. Tool spans
carry `gen_ai.tool.name` / `gen_ai.tool.call.id` / `gen_ai.tool.type`.
### Sessions and users (grouping multi-turn runs)
Every span carries a `gen_ai.conversation.id`, generated per run or supplied by you. For
tools that group by session (such as Langfuse), Sema also emits `session.id` and
`user.id`, so the turns of one conversation appear together:
```sema
(agent/run bot "what is 2 + 3?" {:session-id "chat-42" :user-id "alice"})
(agent/run bot "now add 10" {:session-id "chat-42" :user-id "alice"})
;; both runs appear under one session "chat-42", attributed to alice
```
`agent/run`, `llm/chat`, and `llm/complete` accept `:conversation-id`, `:session-id`, and
`:user-id`. If you omit `:session-id` it defaults to the conversation id; a standalone
completion gets a fresh conversation id automatically.
### Metrics
When you export over a network endpoint, Sema also records two standard GenAI metric
histograms:
* `gen_ai.client.token.usage` — token counts (dimension `gen_ai.token.type` = `input` or
`output`).
* `gen_ai.client.operation.duration` — call latency in seconds.
> Cache hits report zero usage by design (no provider call was made), so token metrics
> undercount real spend when caching is in play.
## Adding your own spans
The `llm/*` and `agent/*` calls are traced for you. When you build your *own* abstraction —
a custom RAG loop, a batch job, a provider Sema doesn't ship — these builtins let it emit
first-class spans too. Every one is a **no-op when tracing is off**, so they are safe to
leave in, and they never change your program's return value.
### Generic spans
```sema
;; with-span runs the body inside a named span carrying an attribute map, ends it on exit
;; (Error status if the body throws), and returns the body's value. Use {} for no attrs.
(with-span "ingest-batch" {:batch.size 100}
(otel/event "started" {})
(process-batch))
```
The underlying builtin is `(otel/span name thunk attrs)`; `with-span` is the ergonomic
macro over it. Any LLM/tool spans created inside nest beneath it.
### Annotate the current span
```sema
(otel/set-attribute :http.status 200) ; one attribute on the innermost span
(otel/set-attributes {:rows 42 :cache.hit true})
(otel/set-status :ok) ; or (otel/set-status :error "upstream timeout")
(otel/event "cache-miss" {:key "user:42"}) ; a point-in-time event
```
Attribute values keep their type — integers, floats, and booleans render as numbers/bools
in the backend, not strings.
### Typed spans (render like the built-ins)
For work that *is* an LLM call, tool, or retrieval — but that you implement yourself — use
the typed helpers. They set `gen_ai.operation.name` and, when `SEMA_OTEL_COMPAT` is set,
the backend-native span-kind, so a custom pipeline classifies in Phoenix/Traceloop/Langfuse
exactly like the built-in `llm/*` spans.
```sema
;; A custom LLM/generation call (a provider Sema doesn't natively support):
(otel/llm-span {:model "custom-model" :provider "myco" :operation "chat"}
(lambda ()
(let ((resp (my-http-llm-call prompt)))
;; Account tokens + cost on the span — same gen_ai.usage.* keys as the built-ins.
(otel/llm-usage {:input-tokens 120 :output-tokens 30 :cost-usd 0.001})
resp)))
;; A user-built retrieval step (first-class RETRIEVER span):
(otel/retrieval-span "vector-search" (lambda () (search index query)) {:top-k 5})
;; A user tool:
(otel/tool-span "lookup-weather" (lambda () (weather city)))
```
### Grouping into sessions
`with-session` groups every span started in its body under a session id (and optional
user), filling Langfuse **Sessions/Users** for non-agent code:
```sema
(with-session "chat-42" {:user "alice"}
(llm/complete "...") ; inherits session chat-42, user alice
(my-custom-pipeline))
```
| Form | What it does |
| --- | --- |
| `(with-span name attrs body…)` / `(otel/span name thunk attrs)` | Generic span around a block. |
| `(otel/set-attribute key value)` / `(otel/set-attributes map)` | Set attribute(s) on the innermost active span. |
| `(otel/set-status :ok)` / `(otel/set-status :error msg)` | Set the innermost span's status. |
| `(otel/event name attrs-map)` | Point-in-time event on the current span. |
| `(otel/llm-span config thunk)` + `(otel/llm-usage usage-map)` | Typed LLM/generation span + token/cost accounting. |
| `(otel/tool-span name thunk [attrs])` | Typed TOOL span. |
| `(otel/retrieval-span name thunk [attrs])` | Typed RETRIEVER span. |
| `(with-session id config body…)` / `(otel/with-session id [config] thunk)` | Group spans into a session/user. |
## Privacy
Prompt and response **text** is never recorded unless you explicitly set
`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`. Token counts, model names,
cost, and timing carry no message text and are always exported. When content capture is
on, long messages are truncated to keep span sizes reasonable.
## Embedding Sema in a Rust application
When Sema runs as a library inside your own program, it **never installs a global tracer
provider on its own** — that is the host application's job. You choose how it connects to
telemetry with `InterpreterBuilder::with_telemetry(mode)`:
```rust
use sema::InterpreterBuilder;
use sema_otel::TelemetryMode;
// Emit against the provider your application already installed in `opentelemetry::global`.
let interp = InterpreterBuilder::new()
.with_telemetry(TelemetryMode::UseHostGlobal)
.build();
```
| `TelemetryMode` | Behavior |
| --- | --- |
| `Off` (default) | No telemetry; never touches any global state. |
| `UseHostGlobal` | Emit against the global provider your app already installed (silent no-op if there is none). |
| `OwnProvider(p)` | Emit against a provider you hand to Sema; installs **no** global provider. |
| `FromEnv` | Self-install from the `OTEL_*` / `SEMA_OTEL_FILE` variables. The provider is owned by the built `Interpreter` and flushes when it is dropped. If your app already runs OpenTelemetry, prefer `UseHostGlobal` or `OwnProvider`. |
Sema's spans automatically nest under whatever span is current
(`opentelemetry::Context::current()`), so a host request span becomes the parent of
Sema's `invoke_agent → chat / execute_tool` tree. `Interpreter::new()` and `build()` with
the default `Off` never touch global OpenTelemetry state.
---
---
url: 'https://sema-lang.com/docs/llm/otel-compat.md'
---
# Backend Compatibility
By default Sema labels its telemetry with the
[OpenTelemetry GenAI semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai)
— the standard `gen_ai.*` attribute names. Tools that follow that standard understand
Sema's traces with no extra configuration.
A handful of popular LLM-observability tools don't read `gen_ai.*` — they look for their
own attribute names instead, so a Sema span can show up in them as "unknown" or with
blank fields. For those tools, set the `SEMA_OTEL_COMPAT` environment variable to a
**compatibility mode** — a short name such as `openinference` or `langfuse` that tells
Sema which extra attribute names to write *alongside* the standard ones. Nothing about
your program changes — it's still the same automatic tracing, just labelled so more tools
can read it.
This is purely additive: the standard `gen_ai.*` attributes are always present;
`SEMA_OTEL_COMPAT` only adds extra copies under other names. Read the
[Tracing & Metrics](./observability) page first for how tracing works and how to point
Sema at a backend — this page only covers the per-tool labelling.
## Which tools need a compatibility mode
This section covers the tools that can **receive** OpenTelemetry traces over OTLP. Most of
them read the standard `gen_ai.*` attributes and need **no** compatibility mode; only a few
key off their own attribute names and need a `SEMA_OTEL_COMPAT` mode. (Tools that ingest
only through their own SDK, sit in front of your calls as a proxy, or run offline
evaluations can't receive an OTLP push at all — see
[Tools you can't send traces to](#tools-you-can-t-send-traces-to).)
::: tip "No compatibility mode" is not the same as "no setup."
Almost every **hosted** service still needs its own authentication header — an API key
passed through `OTEL_EXPORTER_OTLP_HEADERS`, exactly as shown for Langfuse on the
[Tracing & Metrics](./observability#sending-to-hosted-langfuse) page. That auth header is a
property of the *backend*, not a Sema compatibility mode. The tables below say only whether
a tool needs a `SEMA_OTEL_COMPAT` mode to *understand* Sema's attributes — the column for
"does it need an API key" is "almost always, if it's hosted".
:::
### Reads `gen_ai.*` — no compatibility mode needed
**General trace viewers and APM platforms.** These store and display `gen_ai.*` as ordinary
span attributes (the LLM-specific ones also build GenAI dashboards from them):
| Tool | Self-hostable? | Notes |
| --- | --- | --- |
| Grafana / Tempo, [Jaeger](https://www.jaegertracing.io/) | yes | plain OpenTelemetry trace viewers |
| [SigNoz](https://signoz.io/) | yes | OTLP on 4317 / 4318 |
| [OpenObserve](https://openobserve.ai/) | yes | OTLP `/api/{org}/v1/traces` *(verified live)* |
| Honeycomb, Elastic | partly | general OTel APM |
| [Logfire](https://pydantic.dev/logfire) | no | Pydantic's OTel platform |
| [Datadog](https://www.datadoghq.com/) LLM Observability | no | maps `gen_ai.*` (semconv 1.37+) natively; needs a Datadog API-key header |
| [Dynatrace](https://www.dynatrace.com/) | no | maps `gen_ai.*` natively; needs a Grail (DPS) licence + an ingest token |
| [Coralogix](https://coralogix.com/) AI Center | no | maps `gen_ai.*`, but needs account-side setup (S3-archive routing + the experimental-semconv opt-in) |
| [New Relic](https://newrelic.com/) | no | accepts OTLP and stores `gen_ai.*` as raw attributes; native GenAI dashboards are not documented |
**LLM-native platforms.** These parse `gen_ai.*` into structured LLM records on their own
OTLP endpoint (all hosted ones need an API key/header):
| Tool | Self-hostable? | OTLP endpoint / notes |
| --- | --- | --- |
| [OpenLIT](https://openlit.io/) | yes | OTel-native; `docker compose up -d`; OTLP on 4318, no auth by default |
| [MLflow](https://mlflow.org/) | yes | tracking server exposes an OTLP `/v1/traces` endpoint |
| [Braintrust](https://www.braintrust.dev/) | no | maps `gen_ai.*` to structured fields; API key required (see the optional `braintrust` mode below) |
| [W\&B Weave](https://wandb.ai/) | no | `…/otel/v1/traces`; parses `gen_ai.*`; Basic-auth + `project_id` header *(verified in docs)* |
| [Portkey](https://portkey.ai/) | no | `/v1/otel/v1/traces`; reads `gen_ai.*`; `x-portkey-api-key` header |
| [HoneyHive](https://honeyhive.ai/) | no | `/v1/traces`; reads `gen_ai.*`; Bearer + `x-honeyhive` project header |
| [Opik](https://www.comet.com/opik) (Comet) | yes | `/api/v1/private/otel` (HTTP only); API key + project/workspace headers |
| [Lunary](https://lunary.ai/) | yes | `/v1/otel`; reads `gen_ai.*`; Bearer token |
| [Maxim AI](https://www.getmaxim.ai/) | no | `/v1/otel`; reads `gen_ai.*` / `llm.*` / `ai.*`; `x-maxim-*` headers |
| [PostHog](https://posthog.com/) | yes | `/i/v0/ai/otel`; maps `gen_ai.*` → `$ai_*` events; project token |
| [FutureAGI](https://futureagi.com/) | no | native convention is `gen_ai.*` (+ `fi.span.kind`); OpenInference is only an optional output mode |
| [Laminar](https://www.lmnr.ai/) | yes | parses `gen_ai.*` (+ its own `lmnr.*`); HTTP + gRPC; API key |
| [Agenta](https://agenta.ai/) | yes | translates `gen_ai.*` into its own `ag.*`; HTTP/protobuf only; API key |
| [Confident AI](https://www.confident-ai.com/) | no | Observatory endpoint reads `gen_ai.*` (+ `confident.*`); API key — this is DeepEval's backend |
| [Patronus AI](https://docs.patronus.ai/) | no | OTLP gRPC; ingests standard OTel spans; `x-api-key` header |
| [Promptfoo](https://www.promptfoo.dev/) | local | built-in OTLP receiver (port 4318) **while `promptfoo eval` runs**; no token |
### Needs a compatibility mode
These ingest OTLP but key off their **own** attribute names, so without the matching
`SEMA_OTEL_COMPAT` mode a Sema span shows up with blank or "unknown" fields:
| Tool | `SEMA_OTEL_COMPAT` mode | What it adds |
| --- | --- | --- |
| [Arize Phoenix](https://phoenix.arize.com/), [Arize AX](https://arize.com/) | `openinference` | span types, model/provider, tokens, cost, message I/O, tool args + schemas |
| [Langfuse](https://langfuse.com/) | `langfuse` | observation type/model, usage + cost detail, trace-level input/output, tags |
| [Traceloop](https://www.traceloop.com/) / OpenLLMetry | `traceloop` | span types, entity input/output, indexed message keys, tool functions |
| [LangSmith](https://www.langchain.com/langsmith) | `langsmith` | run types, session threading, tags/metadata |
| [Braintrust](https://www.braintrust.dev/) | `braintrust` *(optional)* | adds the richer `braintrust.*` tags/metadata/scores (it already reads `gen_ai.*` without it) |
> **Often grouped with OpenLLMetry, but actually `gen_ai.*`-native:** Laminar, LangWatch,
> Agenta and FutureAGI are sometimes listed as "Traceloop-compatible". In practice they read
> `gen_ai.*` directly (Agenta and FutureAGI translate it into their own namespace), so they
> need **no** compatibility mode — they're in the table above. The OpenLLMetry SDK works with
> them because *it too* emits `gen_ai.*`, not because they parse the `traceloop.*` namespace.
> **Advertise OTel but unconfirmed:** Galileo, PromptLayer, Keywords AI, Arthur AI, and
> [LangWatch](https://langwatch.ai/) accept OTLP or claim OpenTelemetry support, but their
> docs don't pin down which attributes they surface from a raw push. They may well work —
> send a trace with the standard setup and check whether your spans appear.
## Setting `SEMA_OTEL_COMPAT`
It's an environment variable like the others (see
[How to turn it on](./observability#how-to-turn-it-on)). Its value is a comma-separated
list of compatibility modes — the lower-case names from the table above:
```bash
# Just Phoenix:
SEMA_OTEL_COMPAT=openinference sema myagent.sema
# Phoenix and Langfuse at once:
SEMA_OTEL_COMPAT=openinference,langfuse sema myagent.sema
# Every mode at once — useful if you're not sure which backend you'll use:
SEMA_OTEL_COMPAT=all sema myagent.sema
```
Accepted modes: `openinference` (also `phoenix`, `arize`), `traceloop` (also
`openllmetry`), `langsmith`, `langfuse`, `braintrust`, and `all`. Names you don't
recognise are ignored, so a typo won't break anything.
Some of the added detail — message text, tool arguments and results, and the trace-level
input/output summary — is **content**, so it only appears when you also turn on content
capture with `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` (see
[Privacy](./observability#privacy)). Token counts, models, cost, and span types are
always added.
When `SEMA_OTEL_COMPAT` is unset, no extra attributes are written — the traces are exactly
what you get on the [Tracing & Metrics](./observability) page.
## Per-tool setup
### Arize Phoenix (OpenInference)
Phoenix is an open-source LLM trace viewer that runs in one container:
```bash
# Start Phoenix. UI on 6006; it accepts traces on 6006 (HTTP) and 4317 (gRPC).
docker run -d --name phoenix -p 6006:6006 -p 4317:4317 arizephoenix/phoenix:latest
SEMA_OTEL_COMPAT=openinference \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:6006 \
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true \
sema -e '(llm/complete "say hi" {:max-tokens 16})'
```
Open `http://localhost:6006`. Each Sema span is typed (`LLM` / `TOOL` / `AGENT` /
`EMBEDDING`) and shows the model, provider, token counts, cost, the message I/O, and —
for agent runs — tool arguments, results, and the tool schemas offered to the model.
### Langfuse
Langfuse already reads several of Sema's standard attributes (cost and message I/O). The
`langfuse` value fills in the rest — the observation type and model, the usage/cost detail
objects, and the trace-level input/output summary:
```bash
SEMA_OTEL_COMPAT=langfuse \
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:3000/api/public/otel" \
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic " \
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true \
sema myagent.sema
```
(See the [Langfuse example](./observability#sending-to-hosted-langfuse) for how to build
the auth header.) Multi-turn runs group into
[Sessions](./observability#sessions-and-users-grouping-multi-turn-runs) via the
`:session-id` and `:user-id` options.
### Traceloop (OpenLLMetry)
Traceloop is mainly a hosted product, but it reads plain OTLP, so you can also view the
output in any OTLP backend (such as SigNoz). `SEMA_OTEL_COMPAT=traceloop` adds the
`traceloop.span.kind` and `traceloop.entity.*` attributes, the indexed per-message keys,
and the advertised tool functions. (You only need this for Traceloop's own platform — the
look-alikes Laminar, LangWatch and Agenta read `gen_ai.*` directly.)
### LangSmith
Point Sema at LangSmith's OTLP endpoint with your API key and `SEMA_OTEL_COMPAT=langsmith`;
this adds LangSmith's run types, session threading, and tags/metadata, which are needed for
those features (`gen_ai.*` alone can't populate them). LangSmith is primarily hosted, but
Enterprise self-hosted deployments expose their own OTLP endpoint too.
### Braintrust
Braintrust reads the standard attributes, so it works with no value set. Add `braintrust`
only if you want its native `braintrust.tags` and `braintrust.metadata` fields.
## Span-type mapping
How each Sema span is labelled for each tool when its compat value is on:
| Sema span | OpenInference | Traceloop | LangSmith | Langfuse |
| --- | --- | --- | --- | --- |
| `chat` | `LLM` | `task` | `llm` | `generation` |
| `embeddings` | `EMBEDDING` | `task` | `embedding` | `generation` |
| `execute_tool` | `TOOL` | `tool` | `tool` | `span` |
| `invoke_agent` | `AGENT` | `agent` | `chain` | `span` |
| `retrieve` (vector search) | `RETRIEVER` | `workflow` | `retriever` | `span` |
| `rerank` | `RERANKER` | `workflow` | `chain` | `span` |
| notebook cell / retry | `CHAIN` | `workflow` | `chain` | `span` |
## Tags, metadata & streaming TTFT
With a compat mode on, three more things are filled in automatically — all behind the
same `SEMA_OTEL_COMPAT` switch, so a plain OTel backend stays lean.
**Auto-tags.** Every LLM span is tagged with its `operation:…`, `provider:…`, and
`model:…`, plus `cache-hit` on a cache-served response. These land on
`langfuse.trace.tags`, `braintrust.tags`, and `langsmith.span.tags`.
**Your own tags & metadata.** Pass `:tags` (a list) and `:metadata` (a map) to
`llm/complete`, `llm/chat`, `llm/stream`, or `agent/run`. Your tags are merged with the
auto-tags; metadata fans out to each backend's native field
(`langfuse.trace.metadata.*`, `langsmith.metadata.*`, `traceloop.association.properties.*`,
`braintrust.metadata`).
```sema
(llm/complete "Summarize this." {:max-tokens 100
:tags ["prod" "summarizer"]
:metadata {:env "prod" :feature "digest"}})
```
**Streaming time-to-first-token.** A streamed call records how long the first token took.
It's always on the span as `sema.gen_ai.server.time_to_first_token` (seconds) +
`sema.gen_ai.is_streaming`, and with compat on it also fills Langfuse's
`completion_start_time` (which drives its TTFT column). Traceloop/OpenLLMetry gets the
boolean `gen_ai.is_streaming` (OpenLLMetry has no per-span TTFT attribute — it tracks
streaming latency as a histogram metric instead).
Two more identity fields ride along when their backend is active: LangSmith's
`langsmith.trace.session_id` (it ignores the standard `session.id`), and Langfuse's
`langfuse.release` from `SEMA_OTEL_RELEASE`.
**Per-direction cost split (OpenInference).** Alongside the combined `llm.cost.total`,
chat spans also carry `llm.cost.prompt` and `llm.cost.completion`, so Phoenix/Arize show
the prompt-vs-completion cost breakdown. Derived from Sema's in-SDK cost computation.
**Embedding detail (OpenInference).** Embeddings spans carry `embedding.model_name`, and —
when [content capture](#limitations) is enabled — the input texts at
`embedding.embeddings.{i}.embedding.text` (capped per call). Raw vectors are never emitted.
## Tools you can't send traces to
Some LLM tools collect data a different way — through their own client SDK, by sitting in
front of your API calls as a proxy, or by running offline evaluations — rather than by
receiving OpenTelemetry traces. Sema's OTLP export can't feed those; to use one, follow
its own integration guide instead. The main categories:
* **Proxies / gateways** — capture by routing your model calls through them, not by
accepting traces: [Helicone](https://www.helicone.ai/), [LiteLLM](https://litellm.ai/),
[Pezzo](https://pezzo.ai/). (Portkey is *not* here — its observability endpoint accepts
OTLP and reads `gen_ai.*`; see the table above.)
* **SDK-only platforms** — ingest only through their own Python/JS library, with no OTLP
trace endpoint: [Vellum](https://www.vellum.ai/), [Athina AI](https://athina.ai/),
[Parea AI](https://www.parea.ai/), [Nebuly](https://www.nebuly.com/).
* **Evaluation-only** — offline scoring/testing, not a runtime trace receiver:
[RAGAS](https://docs.ragas.io/), [UpTrain](https://uptrain.ai/),
[Evidently AI](https://www.evidentlyai.com/), [Giskard](https://www.giskard.ai/),
[TruLens](https://www.trulens.org/).
* **Guardrails libraries** that *emit* telemetry rather than receive it:
[NVIDIA NeMo Guardrails](https://github.com/NVIDIA/NeMo-Guardrails),
[Guardrails AI](https://www.guardrailsai.com/).
* **Has an OTLP endpoint, but needs attributes Sema doesn't emit** —
[Fiddler AI](https://www.fiddler.ai/) accepts OTLP/HTTP, but requires its own
`fiddler.span.type` and `application.id` on every span; without them spans are dropped,
and Sema has no Fiddler compatibility mode to add them.
> Several tools that *used* to be SDK-only or eval-only now run an OTLP endpoint — Opik,
> Lunary, PostHog, Maxim, Promptfoo, Patronus and Confident AI are all in the supported
> tables above. [Humanloop](https://humanloop.com/) is gone the other way: its team joined
> Anthropic and the platform was sunset in September 2025, so it's no longer an integration
> target. If a tool below later adds an OTLP endpoint that reads the GenAI conventions, Sema
> works with it the same as the others — no change needed on Sema's side.
## Limitations
* **Message content requires the opt-in flag.** The message I/O, tool arguments and
results, and the trace-level input/output only appear when
`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`. Token counts, models, cost,
and span types are always added.
* **OpenInference has no separate tool-result field** — the result appears in the tool
span's `output.value` rather than a dedicated attribute.
* **A backend may re-derive cost** from the token counts on its side rather than reading
Sema's `gen_ai.usage.cost`, so the figure it shows can differ from Sema's exact per-call
cost (which accounts for cache pricing).
* **Proxies and gateways can't receive traces.** Helicone, LiteLLM and Pezzo capture data
by routing your model calls through them, not by accepting an OTLP push — use their own
integration instead.
* **Not yet implemented:** the per-message *indexed* attribute form some older
Traceloop/LangSmith parsers expect (Sema emits the structured and entity forms today).
* **More attributes per span.** Compat adds extra copies of each value. If you only use a
plain OTel backend, leave `SEMA_OTEL_COMPAT` unset to keep spans lean.
---
---
url: 'https://sema-lang.com/docs/llm/rag.md'
description: >-
Build a retrieval-augmented generation pipeline in Sema — embeddings, vector
search, cross-encoder reranking, and a grounded answer.
---
# RAG: retrieve, rerank, answer
Retrieval-Augmented Generation (RAG) answers a question by first **finding the relevant documents** and then asking the model to answer **using only those documents**. It's how you get grounded, citable answers over a corpus the model was never trained on — your docs, your codebase, your knowledge base.
Sema has the whole pipeline as first-class primitives:
| Step | Primitive | What it does |
| --- | --- | --- |
| **Embed** | `llm/embed` | Turn text into vectors (a *bi-encoder* — query and document embedded independently) |
| **Retrieve** | `vector-store/*` | Cosine nearest-neighbour search over those vectors |
| **Rerank** | `llm/rerank` | A *cross-encoder* reorders the candidates by reading query + document together |
| **Answer** | `llm/complete` | Generate an answer grounded in the top reranked documents |
The recipe everyone converges on is **retrieve many, rerank to a few**. Vector search has high recall but coarse ordering — because the query and each document are embedded *separately*, the score can't model how they interact. A reranker reads them *together*, so it's far more precise. You retrieve a generous shortlist by cosine (say top 12), then let the reranker pick the best 4.
This guide builds a working example that indexes Sema's **own builtin documentation** and answers "which function do I use?" questions. The full file is [`examples/llm/rag-docs-search.sema`](https://github.com/HelgeSverre/sema/blob/main/examples/llm/rag-docs-search.sema).
## Setup
You need two kinds of key in your environment:
* An **embedding + rerank** provider: `JINA_API_KEY`, `VOYAGE_API_KEY`, or `COHERE_API_KEY`. All three offer both embeddings and reranking from the same key, and Sema auto-configures them on startup.
* A **chat** provider for the final answer: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, …
## 1. Index the corpus
Read each document, embed it, and add the vector to a store. Two things make this fast and cheap:
* **Batch the embeddings.** `llm/embed` takes a *list* and returns a list of vectors, so you embed many documents per network call.
* **Cache the store to disk.** `vector-store/open` loads a saved store if the file exists (and starts empty otherwise), so you only pay to index once.
```sema
(define (build-index!)
(let* ((files (file/glob "crates/sema-docs/entries/stdlib/**/*.md"))
(docs (map (lambda (p)
{:name (path/stem p) :path p
:text (string/take (file/read p) 900)}) ; bounds-safe truncate
files))
;; Embed in batches: list/chunk splits into 64s, flat-map runs one call per
;; batch and flattens the per-batch vector lists back into one.
(vecs (flat-map (lambda (b) (llm/embed b))
(list/chunk 64 (map (lambda (d) (:text d)) docs)))))
;; map walks docs + vecs in lockstep — Scheme-style multi-list map.
(map (lambda (doc vec) (vector-store/add "docs" (:name doc) vec doc))
docs vecs)
(vector-store/save "docs")))
(vector-store/open "docs" "/tmp/sema-docs.vec")
(when (= (vector-store/count "docs") 0) (build-index!))
```
This leans entirely on stdlib: `string/take` truncates safely (no manual length check), `list/chunk` batches a list into fixed-size groups, `flat-map` maps-then-flattens, and `map` walks several lists in lockstep. We store the whole document map as the vector's metadata (the 4th argument to `vector-store/add`) — that's how we get the text back at query time.
## 2. Retrieve
Embed the question and pull a generous shortlist by cosine similarity. The query embedding must come from the same model as the stored vectors — which it does, since we use the same configured provider.
```sema
(define question "How do I read a file from disk and split it into lines?")
(define query-vec (llm/embed question))
(define candidates (vector-store/search "docs" query-vec 12))
```
Each candidate is a map `{:id :score :metadata}`, sorted by cosine score.
## 3. Rerank
Pull the document text out of each candidate's metadata and let the cross-encoder reorder them to the best 4:
```sema
(define candidate-texts (map (lambda (c) (:text (:metadata c))) candidates))
(define reranked (llm/rerank question candidate-texts {:top-k 4}))
```
`llm/rerank` returns `{:index :score :document}` maps, highest relevance first. `:index` points back into the list you passed in, so you can recover the original candidate (and its id/metadata):
```sema
(for-each (lambda (r)
(let ((name (:id (nth candidates (:index r))))
(score (math/round-to (:score r) 3)))
(println f" ${score} ${name}")))
reranked)
```
```
0.467 file-read-lines
0.304 read-line
0.293 file-for-each-line
0.239 io-read-line
```
The reranker pushed `file-read-lines` to the top — exactly the function the question is about. Treat the scores as an *ordering*, not a calibrated probability: they're query-dependent, so 0.47 isn't "twice as relevant" as 0.24.
## 4. Answer
Concatenate the top documents and instruct the model to answer using only them:
```sema
(define context
(string/join (map (lambda (r) (nth candidate-texts (:index r))) reranked)
"\n\n---\n\n"))
(define prompt
f"Using ONLY the Sema documentation below, answer the question and name the exact functions to call.\n\nDOCS:\n${context}\n\nQUESTION: ${question}")
(println (llm/complete prompt {:max-tokens 400}))
```
> **Reading a File as Lines** — Use `file/read-lines` to read a file from disk and get back a list of lines directly. For large files, use `file/for-each-line` to iterate without loading everything into memory.
Grounded, correct, and citable — the model only saw the four documents the reranker chose.
## Choosing a reranker
`llm/rerank` uses your configured rerank provider; override per call with `:provider` (a keyword like `:cohere` or the string `"cohere"`) and `:model`.
| Provider | `:provider` | Default model | Billing |
| --- | --- | --- | --- |
| Cohere | `:cohere` | `rerank-v3.5` | per search (flat per call) |
| Jina | `:jina` | `jina-reranker-v2-base-multilingual` | per token |
| Voyage | `:voyage` | `rerank-2.5` | per token |
```sema
(llm/rerank query docs {:top-k 5 :provider :voyage :model "rerank-2.5"})
```
Override `:model` for a newer version, multilingual support, or to trade cost for quality — check the provider's docs for current model names.
## Scores, top-k, and cost
**Scores rank; they don't threshold.** The `:score` is the provider's raw relevance score — query-dependent, *not* a calibrated probability, and on a different scale for each of Cohere, Jina, and Voyage. Use scores to order results *within a single rerank call*; don't compare them across providers or read a fixed cutoff as meaningful. If everything comes back with uniformly low scores, that's a signal the query and corpus don't match — not that the reranker failed.
**Choosing top-k.** `:top-k` is how many of the best documents to keep — typically **3–10** for RAG, sized so the kept documents fit comfortably in the answer prompt's context window. Omit `:top-k` to rerank and return *all* documents in relevance order.
**Cost and latency scale with the candidate set.** A reranker scores *every* candidate against the query, so cost and latency grow with the number of candidates and their length (Cohere bills per search, Jina/Voyage per token). That's why you **retrieve-then-rerank** — pull a shortlist with cheap vector search (say top-20), then rerank to the top-k — instead of reranking the whole corpus. Reranking is a *refinement* on a shortlist, never a standalone search over everything.
## Error handling
```sema
(try
(llm/rerank query candidates {:top-k 5})
(catch e (println "rerank failed:" (:message e))))
```
* An **empty document list** returns `()` immediately, with no API call.
* **API / network / rate-limit / invalid-model** failures raise a `SemaError` (catch with `try`).
* An **unknown `:provider`** — or no rerank provider configured at all — raises a "rerank provider not found" error. Set `COHERE_API_KEY` / `JINA_API_KEY` / `VOYAGE_API_KEY`, or pass `:provider` explicitly.
## Observability
With [OpenTelemetry](/docs/llm/observability) on and a compat backend selected, the retrieve and rerank steps emit OpenInference `RETRIEVER` and `RERANKER` spans — the reranker span carries the model name, `top-k`, and (with [content capture](/docs/llm/otel-compat) enabled) the reordered documents and their scores. A full RAG trace renders natively in Phoenix/Arize alongside the embedding and chat spans, which makes "why did this answer cite the wrong doc?" debuggable end to end.
## When do you actually need a reranker?
Reranking is the highest-leverage, lowest-effort quality lever in RAG — but it isn't free, so it's worth knowing when it pays off. The honest test is to **A/B it**: measure answer quality (and added latency) with retrieve-only vs. retrieve-then-rerank on your own queries.
**Reach for it when** cosine top-k returns *roughly* relevant results but the *ordering* is off, your documents are long or ambiguously worded (where a bi-encoder's single vector blurs detail a cross-encoder recovers), or you retrieve a large shortlist and need to trim it to what fits the prompt.
**You can skip it when** the corpus is small, your embedding model already nails ordering for your queries, or context-window space isn't a constraint — `vector-store/search` alone is a complete retriever.
---
---
url: 'https://sema-lang.com/docs/internals/architecture.md'
---
# Architecture Overview
Sema is a Lisp with first-class LLM primitives, implemented in Rust. All code runs on a single evaluator: a [bytecode VM](./bytecode-vm.md). The runtime is single-threaded (`Rc`, not `Arc`), with deterministic destruction via reference counting instead of a garbage collector.
The entire implementation is ~116k lines of Rust across 15 crates, each with a clear responsibility and strict dependency ordering.
## Crate Map
```
┌──────────────────────────────────────┐
│ sema │
│ (binary: CLI, REPL, embedding API) │
└──┬─────────────────┬─────────────────┘
│ │
┌─────────────▼──┐ ┌────▼─────┐
│ sema-notebook │ │ sema-eval│
│ notebook UI + ├────────►│ macros + │
│ server │ │ modules │
└────────────────┘ └─┬───┬──┬─┘
│ │ │
┌────────────────▼┐ │ ┌▼──────────────┐
│ sema-stdlib │ │ │ sema-llm │
│ native fns │ │ │ LLM providers │
└────────┬────────┘ │ │ + embeddings │
│ │ └───────┬───────┘
│ ┌────▼─────┐ │
│ │ sema-vm │ │
│ │ bytecode │ │
│ │ VM │ │
│ │(evaluator)│ │
│ └────┬─────┘ │
│ │ │
┌────▼───────────▼──┐ │
│ sema-reader │ │
│ lexer/parser │ │
└────────┬──────────┘ │
│ │
┌────────▼───────┐ │
│ sema-core │◄────────┘
│ Value, Env, │
│ SemaError │
└────────────────┘
```
**Dependency flow:** `sema-core ← sema-reader ← sema-vm ← sema-eval ← sema` — with `sema-eval` also pulling in `sema-stdlib` (to register builtins) and `sema-llm`, both of which depend only on `sema-core` (plus `sema-reader` for stdlib).
The critical constraint: **sema-stdlib and sema-llm depend on sema-core, not on sema-eval.** This avoids circular dependencies but creates a problem — both crates sometimes need to evaluate user code. They solve it via dependency inversion:
* **sema-stdlib** invokes the real evaluator via callbacks (`call_callback`/`eval_callback`) registered by `sema-eval` at startup — stored on the `EvalContext` and a shared thread-local stdlib context
* **sema-llm** mostly uses the same core callbacks, but still carries a redundant second eval callback of its own (tech debt — see [Solution 2](#solution-2-eval-callback-sema-llm-redundant-slated-for-removal))
This is discussed in detail in [The Circular Dependency Problem](#the-circular-dependency-problem).
### Crate Responsibilities
| Crate | Role | Key types |
| --------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **sema-core** | Shared types | `Value` (NaN-boxed 8-byte), `Env`, `SemaError`, string interner, `NativeFn`, `Lambda`, `Macro`, `Record`, LLM types |
| **sema-reader** | Parsing | `Lexer` (24 token types) + recursive descent `Parser` → `Value` AST + `SpanMap` |
| **sema-vm** | Bytecode VM (the evaluator) | `CoreExpr`, `ResolvedExpr`, `Op`, `Chunk`, `Emitter` — lowering, resolution, compilation, VM dispatch |
| **sema-eval** | Macro expansion + module loading | Macro expander (VM-native), module system (`import`/`load`), prelude, eval/call callback wiring; drives the VM. *Not* a standalone evaluator — the VM is the sole evaluator |
| **sema-stdlib** | Standard library | Native functions across a comprehensive standard library |
| **sema-llm** | LLM integration | `LlmProvider` trait, native providers (Anthropic, OpenAI, Gemini, Ollama), OpenAI-compatible shim, embedding providers, cost tracking |
| **sema-otel** | Observability | OpenTelemetry facade (spans/metrics for LLM, agent, tool, and notebook runs); depends only on `sema-core`; native-only (no-op on wasm32) |
| **sema-lsp** | Language Server | LSP via tower-lsp: completions, hover, go-to-definition, references, rename, semantic tokens, diagnostics |
| **sema-dap** | Debug Adapter | DAP server: breakpoints, stepping, stack traces, variable inspection via VM debug hooks |
| **sema-fmt** | Formatter | Code formatter for `.sema` files (`sema fmt`) |
| **sema-notebook** | Notebook interface | `.sema-nb` JSON format, evaluation engine, HTTP server with REST API, embedded browser UI, Markdown export |
| **sema-wasm** | WASM bindings | Browser playground bindings, JS interop via `wasm-bindgen` |
| **sema-mcp** | MCP server | Model Context Protocol server exposing Sema eval/build/notebook tools (`sema mcp`) |
| **sema-docs** | Doc generation (internal) | Builtin-docs index generator (`make docs`); not shipped as a binary |
| **sema** | Binary | clap CLI, reedline REPL (highlighter / hinter / inspector live in `crates/sema/src/repl/`), `InterpreterBuilder` embedding API |
## The Value Type
All Sema data is represented by a single NaN-boxed `Value` — an 8-byte `struct Value(u64)` that encodes every type in IEEE 754 quiet NaN payload space:
```rust
// crates/sema-core/src/value.rs
#[repr(transparent)]
pub struct Value(u64);
// Encoding: floats stored as raw f64 bits.
// All other types packed into quiet NaN payloads:
// sign=1 | exponent=0x7FF | quiet=1 | TAG(6 bits) | PAYLOAD(45 bits)
//
// Immediate types (no heap allocation):
// Nil, Bool, Char, Symbol(Spur), Keyword(Spur), IntSmall(±2^44)
//
// Heap types (Rc pointer in 45-bit payload):
// IntBig, String, List, Vector, Map, HashMap, Lambda, Macro, NativeFn,
// Prompt, Message, Conversation, ToolDef, Agent, Thunk, Record, Bytevector,
// MultiMethod, Stream, F64Array, I64Array, AsyncPromise, Channel
//
// Pattern matching via val.view() → ValueView enum
```
::: details The IBM 704 connection (1955)
The idea of packing type information and data into a single machine word goes back to the [IBM 704](http://bitsavers.informatik.uni-stuttgart.de/pdf/ibm/704/24-6661-2_704_Manual_1955.pdf) — the machine Lisp was born on. The 704's 36-bit word was divided into sub-fields: a 3-bit **prefix** (opcode), a 15-bit **decrement**, a 3-bit **tag** (register selector), and a 15-bit **address**. The same word could be an instruction, a fixed-point number, a floating-point number, or six BCD characters — depending entirely on context. Sema's NaN-boxing is the same fundamental idea scaled to 64 bits: 6 tag bits + 45 payload bits, where the tag determines how to interpret the payload. The 704 also pioneered the biased-exponent floating-point format (sign + 8-bit characteristic biased by +128 + 27-bit fraction) that would eventually become IEEE 754 thirty years later — the very standard whose NaN space we now exploit for type tagging.
:::
Several design choices here are worth examining.
### Why `Rc`, Not `Arc`
Sema is single-threaded. `Arc` adds an atomic increment/decrement on every clone/drop — unnecessary overhead when there's no cross-thread sharing. `Rc` uses ordinary (non-atomic) reference counting, which is cheaper and also means the compiler can catch accidental `Send`/`Sync` usage at compile time.
The trade-off versus a tracing garbage collector: reference counting gives deterministic destruction (values are freed the instant their last reference drops), but cannot collect cycles. In practice this is rarely a problem — Lisp closures tend to create tree-shaped reference graphs, not cycles. A lambda captures its enclosing environment, which may capture its own enclosing environment, forming a chain. Cycles are theoretically possible (e.g., named lambdas bind themselves in their own environment, and `Thunk` uses `RefCell` which could close over itself), but they don't arise in typical Sema programs. If they did, the leaked memory would be bounded by the closure's captured environment — not a growing leak.
Sema uses NaN-boxing — encoding values in the unused bits of IEEE 754 NaN representations to fit a tagged value in 8 bytes, the same technique used by Janet. This makes `Value` the same size as a `f64` or a pointer, meaning the value stack and constant pool have excellent cache locality. Heap types like `List`, `Map`, and `Lambda` add one level of `Rc` pointer indirection, with the pointer stored in the 45-bit payload field (using the 8-byte alignment guarantee to shift the pointer right by 3 bits). Small integers (±17.5 trillion), symbols, keywords, characters, booleans, and nil are all stored entirely within the 8-byte NaN-box with zero heap allocation.
### Why Vector-Backed Lists
`Value::List(Rc>)` stores list elements in a contiguous `Vec`, not a linked list of cons cells. This is a deliberate departure from traditional Lisp.
::: details Why `car` and `cdr` have those names
McCarthy's original Lisp (1958) ran on the [IBM 704](http://bitsavers.informatik.uni-stuttgart.de/pdf/ibm/704/24-6661-2_704_Manual_1955.pdf), which packed cons cells into a single 36-bit machine word. The **address** field (bits 21-35) held a pointer to the first element; the **decrement** field (bits 3-17) held a pointer to the rest of the list. The 704 had hardware instructions to extract these fields directly — `car` is literally "Contents of the Address Register" and `cdr` is "Contents of the Decrement Register." They were single machine instructions, not function calls. Sema keeps the names for Scheme compatibility but the implementation is completely different — `car` is a Vec index (`v[0]`) and `cdr` is a slice copy (`v[1..]`).
:::
The performance trade-offs:
| Operation | Vec-backed | Cons cells |
| ------------------------------ | -------------- | --------------- |
| Random access (`nth`) | O(1) | O(n) |
| `length` | O(1) | O(n) |
| Cache locality | Contiguous | Pointer-chasing |
| `cons` (prepend) | O(n) copy | O(1) |
| `append` | O(n) copy | O(n) |
| Pattern matching (`car`/`cdr`) | Slice indexing | Natural |
The performance win comes from cache locality — modern CPUs prefetch sequential memory, so iterating a `Vec` is dramatically faster than chasing pointers through a cons list. Random access and length are constant-time bonuses.
The cost is O(n) `cons` and `append`. Sema mitigates this with copy-on-write optimization (see [Performance Internals](./performance.md#_1-copy-on-write-map-mutation)): when the `Rc` refcount is 1, mutations happen in place instead of copying. In practice, most list construction uses `list`, `map`, `filter`, or `fold` — which build a new `Vec` directly — rather than repeated `cons`.
Clojure takes a third approach: persistent vectors backed by wide (32-way branching) array-mapped tries, giving effectively O(1) indexed access (O(log₃₂ n), which is ≤ 7 for any practical size) with structural sharing. Sema's approach is simpler and faster for small to medium lists, at the cost of no structural sharing.
### Why `BTreeMap` for Maps, `hashbrown` Opt-In
`Value::Map` uses `BTreeMap` (sorted, deterministic iteration order) rather than `HashMap`. This matters for:
* **Deterministic equality:** Two maps with the same entries compare identically via `PartialEq`, and iteration order is independent of insertion order — important for consistent hashing and display
* **Printing:** `{:a 1 :b 2}` always prints in the same order, making test assertions reliable
* **Usable as keys:** Maps can be keys in other `BTreeMap`s because `Value` implements `Ord`. Since `Map` variants compare by sorted content, two maps with the same entries are always equal under `Ord`, regardless of construction order
For performance-critical code, `Value::HashMap` wraps `hashbrown::HashMap` (the SwissTable implementation used inside Rust's standard library). It's opt-in via `(hashmap/new)` — see the [Performance Internals](./performance.md#_5-hashbrown-hashmap) for benchmarks.
### Why `Spur` for Symbols and Keywords
`Symbol(Spur)` and `Keyword(Spur)` store interned `u32` handles rather than strings. A thread-local `lasso::Rodeo` interner maps strings to `Spur` values and back:
```rust
thread_local! {
static INTERNER: RefCell = RefCell::new(Rodeo::default());
}
pub fn intern(s: &str) -> Spur {
INTERNER.with(|r| r.borrow_mut().get_or_intern(s))
}
pub fn with_resolved(spur: Spur, f: F) -> R
where
F: FnOnce(&str) -> R,
{
INTERNER.with(|r| {
let interner = r.borrow();
f(interner.resolve(&spur))
})
}
```
This makes symbol equality O(1) (integer comparison instead of string comparison) and environment lookup faster (integer keys in the env's hash map). It also means special form dispatch — the hottest path in the evaluator — compares `u32` values against pre-cached constants rather than resolving strings.
String interning is as old as Lisp itself. McCarthy's original LISP 1.5 (1962) interned atoms in the "object list" (oblist). The key difference: Sema uses a separate interner rather than pointer identity, so interning is explicit via `intern()` rather than implicit.
### LLM Types as First-Class Values
`Prompt`, `Message`, `Conversation`, `ToolDef`, and `Agent` sit in the `Value` type at the same level as `List` and `Map`. They're not encoded as maps-with-conventions — they're distinct types with their own constructors, pattern matching, and display representations:
```sema
;; These are values, not strings or maps
(define msg (message :user "Hello")) ; =>
(define p (prompt msg)) ; =>
(define conv (conversation p :model "claude-sonnet-4-6")) ; =>
```
This means the type system catches errors like passing a string where a message is expected, and tools like `complete` can dispatch on the actual type rather than checking for the presence of magic keys in a map.
## Environment Model
The environment is a linked list of scopes, each holding a `SpurMap` (a `hashbrown::HashMap`):
```rust
pub struct Env {
pub bindings: Rc>>,
pub parent: Option>,
pub version: Cell,
}
```
The `version` counter is bumped on every mutation; the bytecode VM's per-instruction inline caches use it to detect stale global lookups. Variable lookup walks the parent chain until it finds a binding or reaches the root. This is the standard lexical scoping model — a closure captures a reference to its defining environment, and lookups resolve outward through enclosing scopes.
### Operations
| Operation | Behavior | Used by |
| ------------------------- | --------------------------------------------- | --------------------------- |
| `get(spur)` | Walk parent chain, return first match | Variable lookup |
| `set(spur, val)` | Insert in current scope | `define`, parameter binding |
| `set_existing(spur, val)` | Walk chain, update where found | `set!` (mutation) |
| `update(spur, val)` | Overwrite in current scope | Hot-path env reuse |
| `take(spur)` | Remove from current scope, return value | COW optimization |
| `take_anywhere(spur)` | Remove from any scope in chain | COW optimization |
`take` and `take_anywhere` exist for the copy-on-write optimization: by *removing* a value from the environment before passing it to a function, the `Rc` refcount drops to 1, enabling in-place mutation. See [Performance Internals](./performance.md#_1-copy-on-write-map-mutation).
`update` exists for the lambda environment reuse optimization: when reusing an environment across iterations of a hot loop, `update` overwrites an existing binding in place instead of going through the full insert path. See [Performance Internals](./performance.md#_2-lambda-environment-reuse).
## Error Handling
`SemaError` is a `thiserror`-derived enum with 12 variants including `WithTrace` and `WithContext` wrappers:
```rust
#[derive(Debug, Clone, thiserror::Error)]
pub enum SemaError {
Reader { message: String, span: Span },
Eval(String),
Type { expected: String, got: String, got_value: Option },
Arity { name: String, expected: String, got: usize },
Unbound(String),
Llm(String),
Io(String),
PermissionDenied { function: String, capability: String },
PathDenied { function: String, path: String },
UserException(Value),
WithTrace { inner: Box, trace: StackTrace },
WithContext { inner: Box, ... },
}
```
### Constructor Helpers
Errors are created via constructor methods, never raw enum variants:
```rust
SemaError::eval("division by zero")
SemaError::type_error("int", val.type_name())
SemaError::arity("map", "2", args.len())
```
This keeps error construction concise across all native functions and special forms.
### Lazy Stack Traces
Stack traces are not captured at error creation time. Instead, the `WithTrace` wrapper is attached during error *propagation* — as an error unwinds out through a function call, it is wrapped with the current call stack:
```rust
pub fn with_stack_trace(self, trace: StackTrace) -> Self {
if trace.0.is_empty() {
return self;
}
match self {
SemaError::WithTrace { .. } => self, // already wrapped, don't double-wrap
SemaError::WithContext { inner, hint, note } => SemaError::WithContext {
inner: Box::new(inner.with_stack_trace(trace)), // wrap inside the context
hint,
note,
},
other => SemaError::WithTrace {
inner: Box::new(other),
trace,
},
}
}
```
This avoids the cost of capturing a stack trace for errors that are caught by `try`/`catch` — only errors that propagate to the top level pay the trace cost. The idempotence check (`WithTrace { .. } => self`) prevents double-wrapping when an error passes through multiple call frames.
## Interpreter State
Sema's evaluator state is held in an explicit `EvalContext` struct, defined in `sema-core/src/context.rs` and threaded through the evaluator as `ctx: &EvalContext`. Each `Interpreter` instance owns its own `EvalContext`, enabling multiple independent interpreters per thread with fully isolated state.
### EvalContext Fields
| Field | Type | Purpose |
| ------------------- | ----------------------------------- | -------------------------------------------- |
| `module_cache` | `RefCell>` | Loaded modules (path → exports) |
| `current_file` | `RefCell>` | Stack of file paths being executed |
| `module_exports` | `RefCell>>>` | Exports declared by currently-loading module |
| `module_load_stack` | `RefCell>` | Cycle detection during module loading |
| `call_stack` | `RefCell>` | Call frames for error traces |
| `span_table` | `RefCell>` | Rc pointer address → source span |
| `eval_depth` | `Cell` | Recursion depth counter |
| `max_eval_depth` | `Cell` | High-water mark of eval depth |
| `eval_step_limit` | `Cell` | Step limit for fuzz targets |
| `eval_steps` | `Cell` | Current step counter |
| `eval_deadline` | `Cell