Schooner ships only pure Scheme primitives by default. Anything
that touches the outside world — logging, reading config, looking
up data, talking to a database — comes from host functions:
Elixir functions you register as Scheme procedures via
Schooner.Host.library/1.
This guide covers building a host library, the conversion helpers that move between Scheme values and Elixir terms, foreign payloads for opaque host data, the callback pattern (Scheme → host → Scheme), and how to map errors between the two sides.
Worked reference:
Schooner.Time(inlib/schooner/time.ex) is the r7rs(scheme time)library shipped as an opt-in host library. Read it alongside this guide for a concrete, runnable example of the patterns described here — a single publiclibrary/0that returns a%Schooner.Library{}, a small set of primitive impls following the standard ABI, and a sandbox-safe canonical name (["scheme", "time"]).
A first host library
defmodule MyApp.SchemeLib do
alias Schooner.Host
def lib do
Host.library(
name: ["myapp", "log"],
primitives: [
{"info", 1, &info/1},
{"error", 1, &error_/1}
]
)
end
defp info([msg]) do
text = Host.to_string!(msg, op: "myapp/log/info")
Logger.info(text)
:unspecified
end
defp error_([msg]) do
text = Host.to_string!(msg, op: "myapp/log/error")
Logger.error(text)
:unspecified
end
endThe shape of a host primitive is {name :: binary, arity :: Schooner.Value.arity_spec, fun :: ([Schooner.Value.t] -> Schooner.Value.t)} — the same ABI used by every built-in
primitive Schooner ships with.
To use the library, list it on Schooner.Environment.new/1:
env = Schooner.Environment.new(libraries: [MyApp.SchemeLib.lib()])
Schooner.eval!(~s|(import (myapp log)) (info "starting")|, env)Because the library has a non-empty name, it is named — the
script must (import (myapp log)) to see its bindings. That's
the sandbox-safe default: the embedder controls the menu, the
script controls which dishes it orders.
Anonymous libraries (no import needed)
For trivial cases — a single helper, a constant — a host
library with name: [] is anonymous: its bindings are
applied directly to the runtime env at construction time, with
no (import ...) required:
inject =
Schooner.Host.library(
primitives: [
{"now-ms", 0, fn [] -> System.system_time(:millisecond) end}
]
)
env = Schooner.Environment.new(libraries: [inject])
Schooner.eval!("(now-ms)", env)
# => some integerThere is no Scheme syntax that produces an empty library-name datum, so anonymous libraries are unreachable from script code; the only path their bindings enter scope is the embedder listing them. This is sandbox-loosening — list anonymous libraries deliberately.
Conversion helpers
Schooner does not auto-marshal across the host boundary. Host
functions consume Schooner.Value.t/0 and return
Schooner.Value.t/0. To go between Scheme values and idiomatic
Elixir terms, use the helpers on Schooner.Host.
Naming convention
to_*!/2— asserting: raisesSchooner.Host.TypeErroron shape mismatch. Takes a keyword list with:opso the error points at the host call site.to_*/1— total: returns{:ok, term} | :error. Use for the "branch on shape" case.
# Asserting — the typical case in a host primitive body
text = Host.to_string!(msg, op: "myapp/log/info")
# Total — when a type mismatch is not an error
case Host.to_string(msg) do
{:ok, text} -> Logger.info(text)
:error -> Logger.info(Schooner.Value.write(msg))
endAvoid import Schooner.Host
Schooner.Host.to_string/1 conflicts with the auto-imported
Kernel.to_string/1. Use alias Schooner.Host and call the
helpers as Host.to_string!(value, op: "...") rather than
importing the module.
Available accessors
| Helper | Accepts | Returns |
|---|---|---|
Schooner.Host.to_integer!/2 | bare exact integer | Elixir integer |
Schooner.Host.to_float!/2 | bare Elixir float | Elixir float |
Schooner.Host.to_real!/2 | integer / float / rational | Elixir number (rationals coerced to float) |
Schooner.Host.to_string!/2 | bare binary (Scheme string) | Elixir binary |
Schooner.Host.to_symbol_name!/2 | {:sym, name} | Elixir binary (the name) |
Schooner.Host.to_char!/2 | {:char, cp} | non-negative integer codepoint |
Schooner.Host.to_bool!/2 | true | false | Elixir bool |
Schooner.Host.to_list!/2 | proper Scheme list | Elixir list |
Schooner.Host.to_vector!/2 | {:vector, tuple} | Elixir list |
Schooner.Host.to_bytevector!/2 | {:bytevector, binary} | Elixir binary |
Schooner.Host.to_foreign_ref!/2 | {:foreign, term} | the wrapped host term |
Schooner.Host.to_proc!/2 | closure / primitive / parameter | the value unchanged (callable assertion) |
The Schooner.Host.to_real!/2 vs Schooner.Host.to_float!/2
split is deliberate: to_float! rejects integers (the host
should say what it wants), to_real! is the lenient "give me
whatever number it is" form.
Constructors
The same module delegates to Schooner.Value's constructors:
Host.string/1, Host.symbol/1, Host.list/1, Host.vector/1,
Host.foreign/1, Host.primitive/3, etc. Use them instead of
naming the internal tag shape directly — that way future
representation changes only touch Schooner.Value, not your
host code.
Foreign payloads
When the host wants to expose an opaque handle — a pid, a
Reference, an internal struct — wrap it as a foreign value:
defmodule MyApp.DbLib do
alias Schooner.Host
def lib(db_pid) do
Host.library(
name: ["myapp", "db"],
values: [
{"connection", Host.foreign(db_pid)}
],
primitives: [
{"query", 2, &query/1}
]
)
end
defp query([conn, sql]) do
pid = Host.to_foreign_ref!(conn, op: "db/query")
sql_text = Host.to_string!(sql, op: "db/query")
rows = MyApp.DB.query(pid, sql_text)
Host.list(Enum.map(rows, &row_to_scheme/1))
end
defp row_to_scheme(row), do: # ...
endForeign values are opaque to Scheme: the script can pass
them around (bind them, return them, store them in pairs and
vectors) but cannot inspect or forge them. write redacts the
contents to #<foreign>. eq? / eqv? / equal? compare the
wrapped Elixir terms with ===, so two foreigns wrapping the
same pid are equal.
Callback pattern (Scheme → host → Scheme)
A host function can receive a Scheme procedure as an argument
and invoke it back via Schooner.apply!/2:
defp map_over_rows([fun, rows_value]) do
fun = Host.to_proc!(fun, op: "myapp/map-over-rows")
rows = Host.to_list!(rows_value, op: "myapp/map-over-rows")
rows
|> Enum.map(&Schooner.apply!(fun, [&1]))
|> Host.list()
endUsed from Scheme:
(import (myapp util))
(map-over-rows (lambda (row) (cons "got" row)) '(1 2 3))
;; => (("got" . 1) ("got" . 2) ("got" . 3))Schooner.apply!/2 works on any procedure value — closures,
primitives, parameters. The callback runs in the same Elixir
process as the outer eval, so it inherits the current
exception and parameter state. A with-exception-handler or
guard in the outer script therefore also handles a raise
from inside the callback, however many host calls lie between
them.
Continuation barrier (documentation-only in v1)
A Scheme callback that captures a call/cc continuation, then
escapes via that continuation after the host call has
returned, is unsupported. Schooner v1's call/cc is
escape-only; a continuation invoked outside its dynamic extent
already raises a structured error, and the host-boundary case
fires the same guard. So the rule is:
Capture-and-escape across the host boundary is unsupported and raises
Schooner.Eval.Error. Use(raise ...)andwith-exception-handlerfor callbacks that need long-lived non-local exit — exceptions cross the host boundary cleanly through the existing handler stack.
This is forward-compatible with v2.0's first-class call/cc,
which will turn the rule into a hard runtime barrier.
Error mapping
An error from host code either reaches the script's exception handlers or bypasses them and surfaces to the embedder, depending on how it is raised.
Host raises a Scheme-catchable error
When you want the script's with-exception-handler / guard to
see the error, use Schooner.Host.raise_error/2 with a string
message and a list of Scheme irritants. It builds a :user error
object and follows the same path as Scheme's own raise: it calls
the innermost handler. With no handler installed it surfaces to
the embedder as Schooner.Error, and Schooner.eval/2 returns
{:error, %Schooner.Error{}}. Raising Schooner.Error directly with
Kernel.raise/2 does not reach Scheme handlers; it unwinds
past them, and Schooner.eval/2 returns it as {:error, _}.
defp query([conn, sql]) do
pid = Host.to_foreign_ref!(conn, op: "db/query")
sql_text = Host.to_string!(sql, op: "db/query")
case MyApp.DB.query(pid, sql_text) do
{:ok, rows} ->
Host.list(Enum.map(rows, &row_to_scheme/1))
{:error, reason} ->
Host.raise_error("db query failed", [Host.foreign(reason)])
end
endTo raise an arbitrary Scheme value, such as a symbol or an error
object you built yourself, use Host.raise_value/1. Both helpers
raise non-continuably, like Scheme's raise. Invalid argument
types to Host.raise_error/2 raise ArgumentError as a host
programming error.
The script catches it like any other:
(guard (e ((error-object? e) (handle-failure e)))
(query connection "SELECT ..."))Host-side type errors are not script-catchable
Schooner.Host.TypeError (raised by to_*!/2 helpers) and
Schooner.Primitive.Error (raised by built-in primitive type /
arity / domain checks) both surface to the host, not to the
script. They bubble out of Schooner.eval!/2 as Elixir
exceptions, or land in the {:error, _} arm of Schooner.eval/2.
This is deliberate: a sandboxed script must not be able to paper
over its own type errors. If you want a host primitive to
produce a script-catchable failure, use Host.raise_error/2 or
Host.raise_value/1; if you want it to be a host-side bug report,
use Schooner.Host.TypeError (which is what the assertion
helpers produce automatically).
A complete example
defmodule MyApp.Embed do
alias Schooner.Host
def env(db_pid) do
Schooner.Environment.new(
standard_libraries: :default,
libraries: [
MyApp.SchemeLib.lib(),
MyApp.DbLib.lib(db_pid)
],
pre_imports: [
["scheme", "base"], # define, length, string-append, number->string ...
["myapp", "log"] # info, error
]
)
end
def run_rule(source, db_pid) do
Schooner.eval(source, env(db_pid))
end
endA script the host hands to run_rule/2:
(import (myapp db))
(define rows (query connection "SELECT id FROM users WHERE active = TRUE"))
(info (string-append "got " (number->string (length rows)) " rows"))
rowsdefine,string-append,number->string,lengthare in scope without import (pre-imported(scheme base)).infois in scope without import (pre-imported(myapp log)).queryandconnectionare in scope after the explicit(import (myapp db)).
Decide which surface to pre-import based on trust. For untrusted
input you might lock down the standard libraries to a tighter
set (standard_libraries: [:base, :char]) so the script can't
even attempt to import things you don't want to expose — see
Sandbox.