Native Erlang Integration

August 1, 2026 · View on GitHub

Beamtalk compiles to BEAM via Core Erlang, which means it runs on the same virtual machine as Erlang and Elixir. While most Beamtalk code is written in .bt files, some use cases require hand-written Erlang — OTP gen_server lifecycle management, port handling, NIF wrappers, or direct access to hex.pm packages. Beamtalk provides a structured integration path for these cases.

This guide covers the complete native Erlang workflow: project layout, FFI calls, native-backed actors, hex dependencies, the stub generator, and practical examples.

Overview

There are three mechanisms for integrating Erlang code with Beamtalk, each suited to different scenarios:

MechanismUse caseDeclared in
(Erlang module) FFICall any Erlang function from a method bodyPer-method
native: + self delegateA class wholesale-backed by a hand-written Erlang module (a stateless Object or an Actor gen_server)Class declaration
[native.dependencies]Use hex.pm packages from native Erlang codebeamtalk.toml

These mechanisms are complementary. A single package might use all three — FFI for simple utility calls, native: for classes that delegate wholesale to one Erlang module, and hex dependencies for ecosystem libraries like gun or cowboy.

Unified model (ADR 0101). native: is the universal "this class is backed by a named Erlang module" declaration; the class kind selects the lowering — a native: Object delegates with an in-process synchronous call, while a native: Actor delegates across a process boundary via gen_server:call. Both share the same boundary, so all FFI is safe by default: error:* exceptions become structured #beamtalk_error{} values (a user never sees a raw Erlang tuple at the REPL), while exit:*/throw:* still propagate. See Native Stateless Objects and Wrap-by-Default Error Handling.

Directory Layout

Native Erlang source files live in a native/ directory, separate from src/:

my_package/
  beamtalk.toml                     # Package manifest
  src/                              # Beamtalk sources
    MyClass.bt
    MyActor.bt
  native/                           # Erlang sources
    my_backing_module.erl
  native/include/                   # Erlang headers (optional)
    my_records.hrl
  native/test/                      # Erlang tests (EUnit/Common Test)
    my_backing_module_tests.erl
  test/                             # Beamtalk tests (BUnit)
    MyClassTest.bt
    MyActorTest.bt

The native/ directory name is deliberate — it matches the native: keyword on actor classes and signals that the contents are a "native escape hatch" rather than primary Beamtalk source.

Convention: Prefix native Erlang module names with the package name to avoid collisions (e.g., my_package_pool.erl for a package named my_package). BEAM has a flat module namespace — only one version of any module can be loaded at a time.

Erlang FFI — (Erlang module) Calls

The simplest integration mechanism. Any Beamtalk method can call an Erlang function using the (Erlang module) syntax:

// Call a function on the native module
Object subclass: NativeCalc

  class add: a with: b =>
    (Erlang native_calc) add: a with: b

  class multiply: a with: b =>
    (Erlang native_calc) multiply: a with: b

  class version =>
    (Erlang native_calc) version

The corresponding Erlang module in native/:

%% native/native_calc.erl
-module(native_calc).
-export([add/2, multiply/2, version/0]).

add(A, B) -> A + B.
multiply(A, B) -> A * B.
version() -> 1.

Keyword mapping: In Erlang FFI calls, the function name is taken from the first keyword (with the colon removed), and all arguments are passed positionally. (Erlang my_mod) do: x with: y calls my_mod:do(X, Y). Unary selectors map directly: (Erlang my_mod) status calls my_mod:status().

Instance methods pass self explicitly when delegating to Erlang:

sealed retryWith: opts :: Dictionary -> HTTPResponse =>
  (Erlang beamtalk_http) retry: self options: opts

Class-side utility objects (no instances, just namespace wrappers) delegate each class method:

sealed Object subclass: System

  class getEnv: name :: String -> String | Nil =>
    (Erlang beamtalk_system) getEnv: name

  class osPlatform -> String =>
    (Erlang beamtalk_system) osPlatform

FFI is the right choice when:

  • You need to call a handful of Erlang functions
  • The Erlang module is stateless or manages its own state externally
  • You want explicit control over which Erlang function each method calls

Wrap-by-Default Error Handling

All Erlang calls — inline (Erlang …) FFI, native: Object delegation, and native: Actor delegation — share one error boundary (ADR 0101 Part 2). The boundary handles the two channels a BEAM function can use independently:

Outcome of the Erlang callChannelBeamtalk resultHow you handle it
returns {ok, V} / {error, R}return valuea Result(V, R) value (ADR 0076)isOk / value / andThen:
raises error:Reasonexceptiona raised #beamtalk_error{}on:do: / ensure:, or it bubbles to the REPL
raises exit:Reasonexceptionpropagates unwrapped (caught by an enclosing on:do: as erlang_exit, else process death)supervision / let-it-crash
raises throw:Termexceptionpasses through unchanged^ and Beamtalk exceptions are throw/catch

The key guarantee: a user never sees a raw Erlang error tuple at the REPL. A badarg, {badkey, K}, function_clause, etc. is converted to a structured #beamtalk_error{} with a kind, a hint, and a Class/selector breadcrumb — not a bare {badarg, [...]}.

exit:/throw: are deliberately not wrapped: process exit and foreign throws are semantically meaningful (intentional erlang:exit/1, {noproc, _} from a dead actor, control-flow throws) and must not be flattened into #beamtalk_error{}. So (Erlang erlang) exit: #killed still terminates the process with reason killed.

Same function, both channels

The return channel (Result) and the exception channel (#beamtalk_error{}) are orthogonal and mutually exclusive per call — a function either returns or raises — so the same function can use both:

// File readAll: -> Result(String, Error)

// Modeled, recoverable failure → a RETURNED Result error: value
File readAll: "missing.txt"        // => Result error: File 'readAll:': file not found

// Misuse / fault (a non-String path) → a RAISED #beamtalk_error{}
File readAll: 12345                 // raises: Type error in 'readAll:' on File

The split is principled: Result for expected/recoverable outcomes the API models, exceptions for misuse/faults. Wrap-by-default changes no return type — a raised #beamtalk_error{} is invisible to the type system, so Result remains the only type-visible error channel.

Error context: Class + selector vs Erlang MFA

A wrapped native: error carries the Beamtalk Class and selector, so it reads Stream / take: rather than the backing beamtalk_stream:take:

(Stream from: 1) take: -1          // raises Type error in 'take:' on Stream

Inline (Erlang …) FFI has a documented limitation: the proxy knows the Erlang MFA, not the Beamtalk class, so an inline-FFI error carries the ErlangModule context (e.g. Type error in 'atom_to_list' on ErlangModule). It is still structured — just less specific than a native: method's breadcrumb. Prefer native: for whole-class delegation precisely so errors name your class.

Native Stateless Objects — native: for Object

native: is not limited to actors. A stateless Object that delegates wholesale to a single Erlang module declares the module on subclass: and uses => self delegate bodies — identical surface to a native Actor, but the lowering is an in-process synchronous call rather than a gen_server round-trip (the class kind selects the lowering). Stream is the worked example:

sealed typed Object subclass: Stream(E) native: beamtalk_stream

  class sealed from: start :: Integer -> Stream(Integer) => self delegate   // ⇒ beamtalk_stream:from(Start)
  select: predicate :: Block -> Stream(E)                => self delegate   // ⇒ beamtalk_stream:select(Self, Predicate)
  take: count :: Integer -> List(E)                      => self delegate   // ⇒ beamtalk_stream:take(Self, Count)
  asList -> List(E)                                       => self delegate   // ⇒ beamtalk_stream:asList(Self)

A class may freely mix self delegate methods with full-bodied Beamtalk methods.

Object vs Actor delegation

native: Objectnative: Actor
self delegate lowers tobeamtalk_erlang_proxy:native_call(Mod, Fn, [Self|Args], {Class, Sel})beamtalk_actor:sync_send(Pid, Sel, Args)
Receiver (self)first positional argthe actor pid
Call shapein-process, synchronous — cannot block, time out, or raise noproccrosses a process boundary — can block, time out, raise noproc, cross nodes
Statenone (state: is for Actors)lives in the gen_server

Caution (leaky abstraction). Because both kinds read self delegate, switching a delegation class between Object subclass: and Actor subclass: silently flips the lowering (in-process call ⇄ cross-process sync_send) with no type error. The class kind one line above is the only signal of the failure/performance profile.

The naming rule (normative)

The Erlang function name is the first keyword with its colon removed; the remaining keyword values follow as positional args. Self-threading is inferred from the declaration side:

  • Instance methods prepend self: take: countmod:take(Self, Count); inject: i into: bmod:inject(Self, I, B); unary asListmod:asList(Self).
  • Class methods omit self: class from: startmod:from(Start).

This is the same first-keyword convention as inline FFI ((Erlang mod) do: x with: ymod:do(X, Y)).

The delegate sentinel

self delegate typechecks because the Object/Value base (and Object class, for class-side constructors) defines a delegate sentinel — exactly mirroring the delegate sentinel ADR 0056 added to Actor. On a native: class, codegen rewrites self delegate to the real call before the sentinel runs; on a non-native: class the sentinel raises, catching the misuse. delegate is therefore a reserved selector on the Object protocol.

Inheritance

native: delegation follows the class declaration, not inheritance. A subclass that does not redeclare native: inherits no backing module and cannot add self delegate methods of its own; a subclass that redeclares native: other_mod uses other_mod only for the self delegate methods it declares.

Reserved-word constraint

If a method's first keyword is an Erlang reserved word (after and andalso band begin bnot bor bsl bsr bxor case catch cond div end fun if let not of or orelse receive rem try when xor), codegen emits a compile error rather than producing invalid output like mod:receive(Self, X). Use inline FFI with an explicitly-named function for those (rare) cases.

Instantiating a native: Class (BT-2998)

A native: class keeps its instances in whatever shape its backing module defines — beamtalk_datetime's calendar tuple, beamtalk_uuid's 16 raw bytes, beamtalk_stream's generator closure — and normally declares no fields of its own. The inherited Value class>>new builds an instance out of $beamtalk_class plus the declared field defaults, so on such a class it can only produce a correctly-tagged but empty map: dispatch accepts it, and the first real message then fails deep inside the Erlang module.

So new/new: on a native: class with no declared fields is a compile error, and the compiled new/0 raises instantiation_error for the dynamic paths (REPL, perform:) where no static receiver class is known. Both messages name the class's actual constructors:

DateTime new
// error: `native:` class `DateTime` cannot be instantiated with `new` — its
//        instances live in its backing Erlang module, not in declared fields
// help:  Use one of: DateTime now, DateTime year:month:day:, DateTime fromString:

Two escapes, both of which say "this class really can build an instance":

  • Declare your own class-side new. Random and Queue do — class sealed new -> Random => self delegate — and it is a real constructor, so nothing is refused.
  • Declare fields. A native: class carrying field: declarations (Package, SupervisionNode) has a genuine default instance, so new keeps building it.

Native Actors — native: and self delegate

When an Actor needs a hand-written gen_server (for port management, deferred replies, handle_info/2, or complex OTP patterns), declare it with native::

Actor subclass: DatabasePool native: my_db_pool

  class connect: config => self spawnWith: config

  query: sql -> List => self delegate
  query: sql params: params -> List => self delegate
  transaction: block -> Object => self delegate
  close -> Nil => self delegate

How It Works

The native: keyword on subclass: tells the compiler to generate a facade module instead of a full gen_server. Methods with => self delegate bodies are compiled into dispatch calls that forward through beamtalk_actor:sync_send/3 to the backing gen_server. Methods with full Beamtalk bodies (like connect: above) compile normally.

The result is that callers interact with DatabasePool using standard Beamtalk message sends — they never see the Erlang gen_server behind it:

pool := DatabasePool connect: #{"host" => "localhost", "port" => 5432}
rows := pool query: "SELECT * FROM users"
pool close

Writing the Backing Gen_Server

The hand-written Erlang module must implement the standard gen_server protocol. Messages arrive in the wire format {Selector, Args, PropCtx} where PropCtx is a propagated context map (ADR 0069). Add a guard clause to strip it, then match on {Selector, Args}:

%% native/my_db_pool.erl
-module(my_db_pool).
-behaviour(gen_server).
-export([start_link/1, init/1, handle_call/3, handle_cast/2,
         handle_info/2, terminate/2]).

start_link(Config) ->
    gen_server:start_link(?MODULE, Config, []).

init(Config) ->
    {ok, Conn} = connect_db(Config),
    {ok, #{conn => Conn}}.

%% Strip propagated context (ADR 0069 Phase 2b) — required boilerplate
handle_call({Selector, Args, PropCtx}, From, State) when is_map(PropCtx) ->
    beamtalk_actor:restore_propagated_ctx(PropCtx),
    handle_call({Selector, Args}, From, State);
handle_call({'query:', [SQL]}, _From, #{conn := Conn} = State) ->
    {reply, {ok, execute(Conn, SQL, [])}, State};
handle_call({'query:params:', [SQL, Params]}, _From, #{conn := Conn} = State) ->
    {reply, {ok, execute(Conn, SQL, Params)}, State};
handle_call({'transaction:', [Block]}, _From, #{conn := Conn} = State) ->
    Result = run_transaction(Conn, Block),
    {reply, {ok, Result}, State};
handle_call({close, []}, _From, #{conn := Conn} = State) ->
    close_db(Conn),
    {reply, {ok, nil}, State};
handle_call(_Request, _From, State) ->
    {reply, {error, not_implemented}, State}.

handle_cast(_Msg, State) ->
    {noreply, State}.

handle_info(_Info, State) ->
    {noreply, State}.

terminate(_Reason, #{conn := Conn}) ->
    close_db(Conn),
    ok.

Key Rules

start_link/1 is required. The facade always calls BackingModule:start_link(Config) where Config is the map passed to spawnWith:. When spawn is called without arguments, Config is #{} (empty map).

Use {ok, Result} reply wrapping. Replies must be {ok, Value} or {error, Reason}. The DirectValue fallback exists for legacy modules but new native actors must use wrapped replies to avoid ambiguity with error tuples.

No state: declarations. Native actors cannot declare state: fields — all instance state lives in the gen_server. classState: is permitted for class-level state (e.g., a singleton reference).

// This is a compile error:
Actor subclass: Broken native: some_module
  state: count = 0   // => error: native actor cannot declare state fields

Type annotations are recommended. Since self delegate method bodies are opaque, the type annotation is the only type information available to the compiler and LSP:

query: sql :: String -> List => self delegate

The compiler emits a warning if a self delegate method has no return type annotation.

Sync and Async Dispatch

The call site determines the dispatch mode — no annotation needed in the .bt file:

  • pool query: sql -- sync via gen_server:call (handle_call/3)
  • pool query: sql! -- async cast via gen_server:cast (handle_cast/2)

Backing gen_servers that support fire-and-forget should implement both handle_call and handle_cast clauses for the relevant selectors.

gen_statem Support

native: also works with gen_statem backing modules. Both gen_server and gen_statem expose the same gen:call/4 API internally. Synchronous calls arrive as {call, From} events in state callbacks:

%% gen_statem handle_event_function mode
%% Strip propagated context (ADR 0069 Phase 2b)
handle_event({call, From}, {Selector, Args, PropCtx}, State, Data) when is_map(PropCtx) ->
    beamtalk_actor:restore_propagated_ctx(PropCtx),
    handle_event({call, From}, {Selector, Args}, State, Data);
handle_event({call, From}, {'readLine', []}, _State, Data) ->
    {keep_state, Data, [{reply, From, {ok, read_line(Data)}}]}.

The generate native Stub Generator

Rather than writing the gen_server boilerplate by hand, use the generate native CLI command to generate a skeleton from the .bt file:

beamtalk generate native DatabasePool

This reads src/DatabasePool.bt (or DatabasePool.bt in the current directory), extracts the native: module name and all self delegate methods, and generates a skeleton Erlang file:

%% Generated from DatabasePool.bt — fill in implementations
%% @doc Backing gen_server for the DatabasePool native Actor.

-module(my_db_pool).
-behaviour(gen_server).

-export([start_link/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).

-spec start_link(map()) -> {ok, pid()} | {error, term()}.
start_link(Config) ->
    gen_server:start_link(?MODULE, Config, []).

-spec init(map()) -> {ok, map()}.
init(_Config) ->
    %% TODO: initialise state from Config
    {ok, #{}}.

%% Strip propagated context (ADR 0069 Phase 2b) — required boilerplate
handle_call({Selector, Args, PropCtx}, From, State) when is_map(PropCtx) ->
    beamtalk_actor:restore_propagated_ctx(PropCtx),
    handle_call({Selector, Args}, From, State);

%% --- Delegate methods from DatabasePool.bt ---

%% query: -> List
handle_call({'query:', [Sql]}, _From, State) ->
    %% TODO: implement query:
    {reply, {ok, todo}, State};
%% query:params: -> List
handle_call({'query:params:', [Sql, Params]}, _From, State) ->
    %% TODO: implement query:params:
    {reply, {ok, todo}, State};
%% transaction: -> Object
handle_call({'transaction:', [Block]}, _From, State) ->
    %% TODO: implement transaction:
    {reply, {ok, todo}, State};
%% close -> Nil
handle_call({close, []}, _From, State) ->
    %% TODO: implement close
    {reply, {ok, todo}, State};

handle_call(_Request, _From, State) ->
    {reply, {error, not_implemented}, State}.

handle_cast(_Msg, State) ->
    {noreply, State}.

handle_info(_Info, State) ->
    {noreply, State}.

terminate(_Reason, _State) ->
    ok.

The generated file is a starting point — fill in the TODO implementations, then move the file to native/. The generator produces {ok, todo} reply wrapping by default, matching the required {ok, Result} protocol.

File search order: The command looks for {ClassName}.bt in the current directory, then src/{ClassName}.bt, then lib/{ClassName}.bt.

Error cases:

  • Class without native: declaration: error with guidance to add native: to the subclass: declaration
  • Missing .bt file: error listing the searched paths
  • Class with no self delegate methods: warning (generates a skeleton with only the catch-all clause)

Hex Dependencies

Packages that need hex.pm libraries (e.g., gun for HTTP, cowboy for servers) declare them in beamtalk.toml:

[package]
name = "http"
version = "0.1.0"
description = "HTTP client and server for Beamtalk"

[dependencies]
# Beamtalk package dependencies (if any)

[native.dependencies]
# Hex dependencies for native Erlang code
gun = "~> 2.1"
cowboy = "~> 2.12"

Version Constraint Syntax

Constraints follow hex.pm conventions:

ConstraintMeaning
"~> 2.1">= 2.1.0 and < 3.0.0
"~> 2.1.0">= 2.1.0 and < 2.2.0
">= 1.0.0 and < 2.0.0"Explicit range
"2.12.0"Exact version

How the Build Works

beamtalk build handles native Erlang compilation automatically using one of two paths, selected by whether [native.dependencies] is present:

Without hex deps.erl files in native/ are compiled directly via compile:file/2. This is fast (~200ms) and requires no external tooling.

With hex deps — a rebar.config is generated from beamtalk.toml and rebar3 compiles both hex deps and native/*.erl files in a single invocation. Beamtalk bundles a pinned copy of rebar3, so no separate installation is needed.

The compilation order is always:

  1. Hex dependencies (if any)
  2. Native Erlang files in native/
  3. Beamtalk .bt files in src/

This ensures that native modules are available when .bt files reference them via FFI or native:.

Packages Without Native Erlang

A package can declare [native.dependencies] without a native/ directory — for example, to call a hex package directly via (Erlang module) FFI without any hand-written Erlang glue:

[native.dependencies]
jiffy = "~> 1.1"
Object subclass: FastJson
  class parse: str => (Erlang jiffy) decode: str
  class stringify: obj => (Erlang jiffy) encode: obj

Dependency Resolution

All native dependencies across the entire Beamtalk dependency graph are resolved together. beamtalk build collects [native.dependencies] from every transitive package and passes them to a single rebar3 invocation. Resolved versions are pinned in beamtalk.lock for reproducible builds.

This top-level resolution is required by BEAM's flat module namespace — only one version of any module can be loaded at a time. If two packages declare different constraints for the same hex package, rebar3's constraint solver finds a version satisfying both (or reports a conflict).

Compilation and Build Integration

Build Pipeline

beamtalk build
  |
  +-- Phase 1: Native Erlang
  |     Path A (no hex deps): compile:file/2 for each native/*.erl
  |     Path B (with hex deps): rebar3 compile (hex deps + native/*.erl)
  |
  +-- Phase 2: Beamtalk .bt files
        Compile .bt -> .core -> .beam
        Code path includes Phase 1 output

Testing Native Code

Native Erlang tests live in native/test/ and follow standard Erlang conventions:

beamtalk test
  -> rebar3 eunit (native/test/*_tests.erl)
  -> rebar3 ct    (native/test/*_SUITE.erl, if present)
  -> BUnit        (test/*.bt)

EUnit modules should be named *_tests.erl and Common Test suites *_SUITE.erl. BUnit tests exercise the Beamtalk-facing API, while native tests cover the Erlang implementation directly.

REPL Hot-Loading

Native Erlang modules participate in workspace hot-loading:

  • :load_project scans native/ alongside src/, recompiling changed .erl files before .bt files
  • :reload ClassName demand-compiles the referenced native .erl file if it is newer than its .beam

Both paths use compile:file/2 directly — no rebar3 invocation for interactive reloading.

Runtime Dependencies

All native Erlang modules in a Beamtalk package have an implicit dependency on beamtalk_runtime. Common runtime modules available to native code:

ModulePurpose
beamtalk_errorStructured error creation (#beamtalk_error{})
beamtalk_actorsync_send/3 for actor dispatch
beamtalk_object_opsObject protocol operations
beamtalk_resultok/1, error/1 result wrapping

This dependency does not need to be declared in beamtalk.toml — the build tool ensures the runtime is on the code path.

Practical Example: Creating a Native-Backed Class

This walkthrough creates a key-value store backed by an ETS table. ETS tables require Erlang for lifecycle management, and the actor owns the table (ETS tables die with their owner process), so Actor supervision gives table durability for free.

Step 1: Define the Beamtalk API

Create src/KeyValueStore.bt:

// Copyright 2026 James Casey
// SPDX-License-Identifier: Apache-2.0

/// A persistent key-value store backed by an ETS table.
///
/// ## Examples
///
/// ```beamtalk
/// store := KeyValueStore create
/// store put: #name value: "Alice"
/// store get: #name            // => "Alice"
/// store keys                  // => #(#name)
/// store size                  // => 1
/// ```
Actor subclass: KeyValueStore native: kv_store

  class create => self spawn
  class create: name => self spawnWith: #{"name" => name}

  get: key -> Object => self delegate
  put: key value: value -> Nil => self delegate
  delete: key -> Nil => self delegate
  keys -> List => self delegate
  size -> Integer => self delegate

Step 2: Generate the Erlang Skeleton

beamtalk generate native KeyValueStore

This produces kv_store.erl with handle_call/3 clauses for each delegate method. Move it to the native/ directory:

mkdir -p native
mv kv_store.erl native/

Step 3: Implement the Gen_Server

Edit native/kv_store.erl:

%% Copyright 2026 James Casey
%% SPDX-License-Identifier: Apache-2.0

%% @doc Backing gen_server for the KeyValueStore native Actor.
-module(kv_store).
-behaviour(gen_server).

-export([start_link/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).

start_link(Config) ->
    gen_server:start_link(?MODULE, Config, []).

init(Config) ->
    Name = maps:get(<<"name">>, Config, undefined),
    Tid = case Name of
        undefined -> ets:new(?MODULE, [set, private]);
        _         -> ets:new(binary_to_atom(Name), [set, private, named_table])
    end,
    logger:set_process_metadata(#{domain => [beamtalk, runtime]}),
    {ok, #{tid => Tid}}.

%% Strip propagated context (ADR 0069 Phase 2b) — required boilerplate
handle_call({Selector, Args, PropCtx}, From, State) when is_map(PropCtx) ->
    beamtalk_actor:restore_propagated_ctx(PropCtx),
    handle_call({Selector, Args}, From, State);
handle_call({'get:', [Key]}, _From, #{tid := Tid} = State) ->
    Result = case ets:lookup(Tid, Key) of
        [{_, Value}] -> Value;
        []           -> nil
    end,
    {reply, {ok, Result}, State};
handle_call({'put:value:', [Key, Value]}, _From, #{tid := Tid} = State) ->
    true = ets:insert(Tid, {Key, Value}),
    {reply, {ok, nil}, State};
handle_call({'delete:', [Key]}, _From, #{tid := Tid} = State) ->
    true = ets:delete(Tid, Key),
    {reply, {ok, nil}, State};
handle_call({keys, []}, _From, #{tid := Tid} = State) ->
    Keys = [K || {K, _V} <- ets:tab2list(Tid)],
    {reply, {ok, Keys}, State};
handle_call({size, []}, _From, #{tid := Tid} = State) ->
    {reply, {ok, ets:info(Tid, size)}, State};
handle_call(_Request, _From, State) ->
    {reply, {error, not_implemented}, State}.

handle_cast(_Msg, State) ->
    {noreply, State}.

handle_info(_Info, State) ->
    {noreply, State}.

terminate(_Reason, #{tid := Tid}) ->
    ets:delete(Tid),
    ok.

Step 4: Write Tests

Create native/test/kv_store_tests.erl for EUnit tests of the Erlang implementation:

%% Copyright 2026 James Casey
%% SPDX-License-Identifier: Apache-2.0

-module(kv_store_tests).
-include_lib("eunit/include/eunit.hrl").

basic_test() ->
    {ok, Pid} = kv_store:start_link(#{}),
    {ok, nil} = gen_server:call(Pid, {'put:value:', [key, <<"hello">>]}),
    {ok, <<"hello">>} = gen_server:call(Pid, {'get:', [key]}),
    gen_server:stop(Pid).

Create test/KeyValueStoreTest.bt for BUnit tests of the Beamtalk API:

// Copyright 2026 James Casey
// SPDX-License-Identifier: Apache-2.0

TestCase subclass: KeyValueStoreTest

  testPutAndGet =>
    store := KeyValueStore create
    store put: #name value: "Alice"
    self assert: (store get: #name) equals: "Alice"

  testSize =>
    store := KeyValueStore create
    self assert: (store size) equals: 0
    store put: #a value: 1
    self assert: (store size) equals: 1

Step 5: Build and Run

beamtalk build
beamtalk test

Quick Reference

When to Use Each Mechanism

NeedUse
Call a stateless Erlang function(Erlang module) FFI in method body
Stateless Object delegating wholesale to one moduleObject subclass: … native: module + self delegate
Wrap a few utility functions as class methodsObject subclass: with inline FFI calls
Actor with OTP gen_server controlActor subclass: … native: module + self delegate
Actor with Beamtalk-only logicStandard Actor subclass: (no native:)
Hex.pm package dependency[native.dependencies] in beamtalk.toml
Port, NIF, or raw processPer-method (Erlang module) FFI

self delegate vs FFI in Native Actors

A native: class can mix both patterns:

Actor subclass: MyActor native: my_backing_module

  // Delegates through gen_server — lifecycle-aware, timeout-aware
  getData -> Object => self delegate

  // Direct FFI call — bypasses gen_server for concurrent reads
  cachedValue -> Object =>
    (Erlang my_backing_module) directLookup: self

Use self delegate for operations that modify state or need gen_server guarantees. Use FFI for read-only operations that can safely bypass the gen_server mailbox (e.g., concurrent ETS reads).

  • Erlang FFI -- FFI syntax reference
  • ADR 0055 -- Erlang-backed class authoring protocol
  • ADR 0056 -- Native actors design decisions
  • ADR 0072 -- User Erlang sources in packages
  • ADR 0101 -- Unified interop: native: for stateless Objects, wrap-by-default FFI