Skip to content

Erlang/OTP primer

This page is for readers who don’t know Erlang. It explains the concepts that show up constantly in the Soma docs: Erlang, BEAM, process, mailbox, OTP, supervisor, gen_server, gen_statem, application, and release.

The one-line version:

Erlang gives you lightweight processes, mailboxes, and message passing.
OTP gives you the framework, behaviours, and supervision patterns for building reliable concurrent systems.
Soma uses Erlang/OTP to implement the agent entity and the run/tool/LLM calls it starts.

Erlang is a language designed for highly concurrent, highly available systems. It grew up in telecoms, and its core strength is letting a large number of independent tasks run for a long time, talk to each other, and keep going when one part fails — without dragging the whole system down.

The Erlang features Soma cares about:

  • lightweight processes;
  • a private mailbox per process;
  • communication between processes by message passing;
  • no shared process state by default;
  • processes can be monitored, linked, and supervised;
  • one process crashing can be treated as ordinary system behavior.

These fit an agent runtime well, because an agent system naturally runs into:

  • an LLM call that hangs;
  • a tool call that times out;
  • an external process that crashes;
  • a user cancelling a task;
  • one task failing while the actor/session must stay alive;
  • a need for every action to be auditable.

BEAM is Erlang’s virtual machine. Erlang code runs on BEAM.

Roughly:

Erlang source
-> compile
-> BEAM bytecode
-> run on the BEAM VM

BEAM provides:

  • a large number of lightweight processes;
  • preemptive scheduling;
  • process mailboxes;
  • message passing;
  • fault isolation;
  • timers;
  • ports;
  • hot code loading, among other low-level capabilities.

When the Soma docs say “in-BEAM tool,” they mean a tool that runs directly inside BEAM as an Erlang module — for example echo, sleep, file_read, file_write.

An Erlang process is not an OS process. It’s a lightweight process living inside the BEAM VM.

The difference:

OS process
- an operating-system-level process
- relatively expensive to create
- its own address space
Erlang process
- a lightweight process inside the BEAM VM
- cheap to create
- has its own mailbox and state
- talks to other Erlang processes by message

In Soma:

soma_actor can be a long-lived Erlang process
soma_run is the Erlang process for one run
soma_tool_call is the short-lived Erlang process for one tool invocation

The core of the actor model is:

an actor owns its own state;
an actor has its own mailbox;
an actor communicates by message;
an actor does not share internal state directly.

Erlang processes implement the actor model naturally:

Erlang process = actor
mailbox = actor inbox
Pid ! Message = send message
receive = process message handling

Soma’s design has two layers of actor semantics:

Erlang actor model
-> the foundation: process, mailbox, message, monitor, supervisor
soma_actor
-> the domain abstraction: a long-lived agent entity capable of LLM calls

So soma_actor is not simply “wrap one LLM call in a process.” It’s a long-lived agent entity with identity, state, memory/context, model config, tool policy, and active tasks.

Every Erlang process has a mailbox. Other processes can send messages to it.

For example:

ActorPid ! {actor_message, Envelope}

This is not a function call. The sender drops the message into the target process’s mailbox; the target decides when to handle it, driven by its own event loop / state machine.

This matters for Soma:

  • soma_actor is triggered by messages;
  • soma_actor instances talk to each other by message;
  • the result of soma_llm_call comes back to soma_actor as a message;
  • the result of soma_run comes back to the session/actor as a message;
  • the result of soma_tool_call comes back to soma_run as a message.

This boundary guarantees that a sub-operation can’t mutate the parent’s state directly — it can only send a result. The parent receives the message and decides its own state transition.

OTP stands for Open Telecom Platform. Today, OTP usually means the standard library, frameworks, and design patterns the Erlang ecosystem provides for building reliable concurrent systems.

One way to see it:

Erlang = language + VM + lightweight processes + mailbox + message passing
OTP = supervisor + gen_server + gen_statem + application + release, the engineering frameworks

OTP’s job is to organize Erlang’s concurrency into maintainable, restartable, releasable production systems.

A behaviour is a kind of “callback protocol” in Erlang/OTP.

A behaviour defines:

which callbacks you have to implement;
when the OTP runtime will call them;
how the standard framework runs your module.

Common OTP behaviours:

  • gen_server
  • gen_statem
  • supervisor
  • application

Soma also defines its own tool behaviour:

-callback describe() -> soma_tool:spec().
-callback invoke(soma_tool:input(), soma_tool:ctx()) ->
{ok, soma_tool:output()} | {error, soma_tool:error()}.

This gives every tool the same shape.

gen_server is OTP’s generic server-process pattern.

It suits processes that are long-lived, hold internal state, and handle requests.

Typical capabilities:

  • initialize state;
  • synchronous requests;
  • asynchronous messages;
  • plain process messages;
  • lifecycle callbacks such as terminate / code change.

Objects in Soma that fit gen_server:

soma_agent_session long-lived session process
soma_event_store in-memory event store
soma_tool_registry tool registry

A future simple soma_actor could also start out as a gen_server, but once the actor’s state transitions get complicated, gen_statem is the better fit.

gen_statem is OTP’s state-machine process pattern.

It suits processes with clear state transitions.

For example, soma_run:

executing
-> waiting_tool
-> completed | failed | timeout | cancelled

A future soma_actor is also a good fit for gen_statem:

idle
-> thinking
-> waiting_llm
-> running
-> replying
-> idle

The advantage of gen_statem is that the state names are part of the design: timeout/cancel/result all land clearly on state transitions.

A supervisor is OTP’s supervising process.

It doesn’t carry business logic; it starts, monitors, and restarts child processes.

A typical supervision tree:

top_sup
├── event_store
├── registry
├── actor_sup
└── run_sup

A supervisor lets the system treat failure as an ordinary event:

child process crashed
-> supervisor observes exit
-> restart or leave stopped according to policy

In Soma:

  • soma_sup is the top-level supervisor;
  • soma_session_sup manages sessions;
  • soma_run_sup manages runs;
  • there can be a soma_actor_sup for actors;
  • there can be a soma_llm_call_sup for one-shot LLM-call workers.

Link and monitor are both Erlang mechanisms for observing a process’s lifecycle.

Simplified:

link
- establishes a failure-propagation relationship between processes
- when a linked process crashes, the other usually receives an exit signal
monitor
- one-way observation
- when the monitored process exits, the monitor receives a 'DOWN' message
- it does not automatically take the monitor down with it

Soma uses monitors heavily:

soma_run monitors soma_tool_call
soma_actor monitors soma_run / soma_llm_call

This way, when a sub-operation crashes, the parent receives it as data:

{'DOWN', MRef, process, Pid, Reason}

The parent can record it as run.failed, task.failed, or llm.failed instead of crashing along with it.

An OTP application is a startable unit in an Erlang system.

It usually contains:

  • .app.src metadata;
  • an application callback module;
  • a supervision tree;
  • dependency declarations;
  • source, tests, priv files, and so on.

Soma today is a rebar3 umbrella containing several OTP applications:

apps/soma_runtime
apps/soma_tools
apps/soma_event_store

application:ensure_all_started(soma_runtime) starts soma_runtime and its dependencies.

A release is a publishable, runnable Erlang system package.

It bundles the applications it needs, their dependencies, configuration, and optionally ERTS.

Soma’s release goal is:

users don't need Erlang installed on their machine;
unpacking the release tarball is enough to run soma.

A self-contained release — the term used in the docs — is a release that bundles the Erlang runtime.

A port is BEAM’s mechanism for talking to an external OS process.

Soma’s CLI tool adapter uses a port to launch an external executable:

soma_tool_call
-> open_port({spawn_executable, Executable}, Args)
-> external OS process

This is different from an Erlang process:

Erlang process
- a lightweight process inside BEAM
External OS process
- an operating-system process
- managed and communicated with by BEAM through a port

Soma’s constraints on CLI tools:

  • use executable + argv;
  • never use a shell command string;
  • on timeout/cancel, kill the worker and the external OS process;
  • bound the size of stdout/stderr output;
  • normalize a non-zero exit into {error, Reason}.

rebar3 is Erlang’s build tool.

An umbrella repo is a project structure containing several OTP applications:

soma/
rebar.config
apps/
soma_runtime/
soma_tools/
soma_event_store/

This fits Soma, because runtime, tools, and event store are distinct boundaries that still need to be built and tested together as one system.

Soma conceptErlang/OTP counterpart
soma_actorlong-lived Erlang process, ideally gen_statem
actor mailboxErlang process mailbox
actor messageErlang message envelope
actor stategen_server / gen_statem state
actor policyactor state + validation module
soma_llm_callone-shot worker process
soma_runper-run gen_statem
soma_tool_callone-shot worker process
tool crashmonitor 'DOWN' message
timeouttimer / state_timeout
cancellationmessage + child teardown
event storegen_server
tool registrygen_server
external CLI toolOS process via port
releaseOTP release tarball

The hard part of an agent runtime isn’t “call an LLM once” or “call one tool.” It’s:

  • long-lived entities;
  • multiple concurrent tasks;
  • sub-operations timing out;
  • user cancellation;
  • isolating local failures;
  • event auditing;
  • actor-to-actor messages;
  • not letting the LLM directly hold execution rights.

Erlang/OTP provides exactly this set of low-level semantics:

process -> the isolation boundary for entity / run / call
mailbox -> the message inbox
supervisor -> lifecycle and recovery
monitor -> observing sub-operation results and crashes
gen_statem -> explicit state transitions
timer -> timeouts
event store -> an auditable trail
port -> the boundary to external programs

Soma’s design puts these mechanisms to work in an agent runtime:

soma_actor is the agent entity;
soma_run is the path that executes a fixed list of steps;
soma_tool_call / soma_llm_call are supervised sub-operations;
events are the source of truth.
TermShort explanation
Erlanga language designed for highly concurrent, highly available systems
BEAMErlang’s virtual machine
Erlang processa lightweight process inside BEAM, not an OS process
mailboxthe message queue of each Erlang process
message passingprocesses communicating by sending messages
actor modela concurrency model of independent entities communicating by message
OTPOpen Telecom Platform — frameworks and patterns for reliable Erlang systems
behavioura callback protocol, e.g. gen_server, gen_statem
gen_serverthe long-lived server-process pattern
gen_statemthe state-machine process pattern
supervisora supervisor that starts, monitors, and restarts child processes
monitorone-way observation of a process exit, receiving a 'DOWN' message
linka failure-propagation relationship between processes
applicationa startable OTP application unit
releasea publishable, runnable Erlang system package
portBEAM’s mechanism for talking to an external OS process
rebar3Erlang’s build tool
umbrellaa structure with several OTP applications in one repo