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 walks the host-function authoring API: building a 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_*!/2asserting: raises Schooner.Host.TypeError on shape mismatch. Takes a keyword list with :op so the error points at the host call site. The bang in the name is the Elixir convention for "raises rather than returns a tagged result".
  • to_*/1total: 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

Several helper names — Schooner.Host.to_string/1 and Schooner.Host.to_string!/2 — shadow Kernel.to_string/1. Use alias Schooner.Host and call the helpers as Host.to_string!(value, op: "..."). Don't import 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 re-exports 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.

This is the right vehicle for any "host handle a script needs to reference but shouldn't see inside".

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/parameter state. A with-exception-handler installed in the outer script catches an error raised three frames deep across two host hops.

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 undefined 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

Two failure modes cross the boundary in opposite directions.

Host raises a Scheme-catchable error

When you want the script's with-exception-handler / guard to see the error, raise a Scheme exception via Schooner.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} ->
      raise Schooner.Error,
        value:
          Schooner.Value.error_object(
            :user,
            Host.string("db query failed"),
            [Host.foreign(reason)]
          )
  end
end

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 the Schooner.Error pattern above; if you want it to be a host-side bug report, use Schooner.Host.TypeError (which is what the assertion helpers produce automatically).

Tying it all together

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.