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 (in lib/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 public library/0 that 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
end

The 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 integer

There 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: raises Schooner.Host.TypeError on shape mismatch. Takes a keyword list with :op so 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))
end

Avoid 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

HelperAcceptsReturns
Schooner.Host.to_integer!/2bare exact integerElixir integer
Schooner.Host.to_float!/2bare Elixir floatElixir float
Schooner.Host.to_real!/2integer / float / rationalElixir number (rationals coerced to float)
Schooner.Host.to_string!/2bare 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!/2true | falseElixir bool
Schooner.Host.to_list!/2proper Scheme listElixir 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!/2closure / primitive / parameterthe 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: # ...
end

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

Used 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 ...) and with-exception-handler for 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
end

To 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
end

A 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"))
rows
  • define, string-append, number->string, length are in scope without import (pre-imported (scheme base)).
  • info is in scope without import (pre-imported (myapp log)).
  • query and connection are 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.