Schooner is a sandboxed Scheme interpreter you embed in an Elixir application. The host hands a script source to one of the entry points, gets back an Elixir term, and resource-bounds the work with the standard process tools. This guide covers a first script, the choice of entry point, and the value model that results come back in.
Hello, Schooner
{:ok, value} = Schooner.eval("(import (scheme base)) (+ 1 2)", Schooner.Env.new())
value
# => 3The pieces:
"(import (scheme base)) (+ 1 2)"— the Scheme source. The(import ...)declaration brings+into scope; without it even the basics are unbound.Schooner.Env.new()— a fresh environment. Top-leveldefines the script makes will land in this env's globals slot and persist if you re-use the env for anothereval.Schooner.eval/2— read, expand, evaluate. Returns{:ok, value}on success,{:error, exception}for any script-level failure.
For the raising variant, use the bang form:
3 = Schooner.eval!("(import (scheme base)) (+ 1 2)", Schooner.Env.new())Choosing an entry point
Schooner's eval entry points come in two flavours, each with a bang and a tagged-tuple form:
| Family | Auto-imports | Trust posture | Use for |
|---|---|---|---|
Schooner.run/1 and Schooner.run!/1 | injects every shipped standard library when the script declares none | Not sandbox-safe. Every shipped primitive is in scope. | tests, REPL-style use, your own scripts |
Schooner.eval/2,3 and Schooner.eval!/2,3 | none — bindings come exclusively from the env and the script's own (import ...) | Sandbox-safe. The embedder controls the surface. | embedding scripts you do not control |
Running untrusted input through the run family gives it every
shipped primitive. Use eval/2 whenever the script source isn't
yours.
The Schooner.Environment shortcut
Schooner.Env.new() is the low-level path. For typical embedding
work — registering host libraries, controlling which standard
libraries are reachable — use Schooner.Environment.new/1:
env =
Schooner.Environment.new(
standard_libraries: [:base, :char], # only these are importable
libraries: [my_log_lib], # custom host library
pre_imports: [["scheme", "base"], ["myapp", "log"]] # imported without a script-side import
)
{:ok, value} = Schooner.eval("(info \"hello\") (+ 1 2)", env)See Host Functions for the host-library
authoring side. The %Schooner.Environment{} struct is opaque —
treat it as a handle, not a record to pattern-match against.
The value model
Scheme values cross the boundary as tagged Elixir terms.
Schooner.eval/2 returns whatever the script's last form
produced, in this representation:
| Scheme value | Elixir representation |
|---|---|
#t / #f | true / false |
() (empty list) | [] |
(a . b) (pair) | [a | b] |
42 (exact integer) | 42 |
1.5 (inexact real) | 1.5 |
1/2 (exact rational) | {:rational, 1, 2} |
+inf.0 / -inf.0 / +nan.0 | {:float_special, :pos_inf | :neg_inf | :nan} |
1+2i (complex) | {:complex, 1, 2} |
#\a (character) | {:char, ?a} |
"hello" (string) | "hello" |
'foo (symbol) | {:sym, "foo"} |
#(1 2 3) (vector) | {:vector, {1, 2, 3}} |
#u8(0 1 255) (bytevector) | {:bytevector, <<0, 1, 255>>} |
| Procedure / closure | {:closure, ...} or {:primitive, name, arity, fun} |
| Record instance | {:record, type_id, fields_tuple} |
| Promise | {:promise, :lazy, thunk} or {:promise, :forced, value} |
| Parameter object | {:parameter, id, init, converter} |
| Error object | {:error_obj, kind, message, irritants} |
| EOF | :eof |
| Unspecified | :unspecified |
| Foreign (host opaque) | {:foreign, term} |
Integers, floats, strings, lists, and booleans use Elixir's native representation. Tags appear where Scheme distinguishes types that the BEAM does not: symbols from strings (both binaries), characters from integers, and vectors from records and closures (all tuples).
Don't pattern-match Schooner.Value shapes directly in host
code. Use the conversion helpers in Schooner.Host — see
Host Functions — so that a future change to
the representation does not break your host code.
Calling Scheme procedures from Elixir
A script can return a procedure (a closure or a primitive); the
host stashes it and invokes it later via Schooner.apply/2:
env = Schooner.Environment.new()
{:ok, double} = Schooner.eval("(import (scheme base)) (lambda (x) (* x 2))", env)
{:ok, 42} = Schooner.apply(double, [21])The same call invokes callbacks that a script passes to a host function; see Host Functions.
Compile once, run many times
For scripts you'll evaluate repeatedly, Schooner.compile/2
does the reading, macro expansion, and analysis once:
env = Schooner.Environment.new()
{:ok, compiled} = Schooner.compile("(import (scheme base)) (+ 1 2)", env)
{:ok, 3} = Schooner.run_compiled(compiled, env)
{:ok, 3} = Schooner.run_compiled(compiled, env)The compiled artifact is opaque (%Schooner.Compiled{}) — its
internals belong to the evaluator. You can run it against any
%Schooner.Environment{} whose macro environment is compatible
with the one passed to compile. Variable bindings from the
script's (import ...) declarations are resolved at compile
time and stored in the artifact, so they are in scope at run time
whatever the runtime environment's registry contains.
Error handling
The bare-name entry points return {:ok, _} | {:error, exception}.
The exception families that surface as {:error, _}:
Schooner.Error— uncaught Scheme(raise ...)in the scriptSchooner.Eval.Error— runtime evaluation failureSchooner.Primitive.Error— primitive type / arity / domain errorSchooner.Library.NotFoundError—(import ...)of a missing librarySchooner.Lexer.Error,Schooner.Reader.Error,Schooner.Expander.Error— source-level failures
ArgumentError raised by malformed options is not caught —
that's an embedder bug, not a script-level failure, and
silencing it would hide configuration mistakes.
The bang forms raise the same exceptions directly.
Next steps
- Host Functions — exposing Elixir as Scheme.
- Sandbox — running untrusted Scheme safely.
- Deviations from r7rs-small — every gap, with runnable examples.