Portals runs code written in another language inside supervised OS processes ("workers") and lets Elixir call it like a function. Each worker is a normal program in its own language that imports a small Portals SDK; the BEAM launches it, talks to it over a private Unix-domain socket, and restarts or fails it like any other OTP process.
This page covers what is the same for every language. Each language has its own page with a complete walkthrough: Python, Ruby, JavaScript, PHP, Java and Go.
For a working app that uses all six languages at once, see
examples/hello_live,
a Phoenix LiveView page that calls a hello-world function in each.
Packages
Core portals contains the Elixir side only: pools, the connection state
machine, the wire protocol and the conformance kit. Each language is a
separate package that bundles its worker SDK, so you install only the
languages you use:
| Language | Package | Elixir module | Runtime needed on the host |
|---|---|---|---|
| Python | portals_python | Portals.Python | Python 3 |
| Ruby | portals_ruby | Portals.Ruby | Ruby 3.0+ |
| JavaScript | portals_javascript | Portals.JavaScript | Node.js 18+ (default), Bun, Deno 2 or QuickJS-ng |
| PHP | portals_php | Portals.PHP | PHP 8.1+ (64-bit) |
| Java | portals_java | Portals.Java | JDK 17+ |
| Go | portals_go | Portals.Go | Go 1.21+ to build |
Every package is published to Hex only. The SDKs are never installed
from PyPI, RubyGems, npm, Packagist, Maven or the Go module proxy: they live
in the package's priv/ directory and the language module puts them on
the worker's import path for you. All packages are released in lockstep,
at the same version as portals.
def deps do
[
{:portals_python, "~> 0.1.0"},
{:portals_javascript, "~> 0.1.0"}
]
endA language package depends on portals, so you don't list portals
itself.
The shape of every integration
- Handlers: ordinary functions in the other language.
- An entry script (or main class or binary) that creates a
Workerand callsrun(). Portals passes it the socket path as its last command-line argument; the SDK reads it from there. - A pool in your supervision tree, configured with the language
module's
worker_opts/1. - Calls from Elixir:
Portals.Pool.call(pool, module, function, args).
children = [
{Portals.Pool,
Portals.Python.worker_opts(
script: Application.app_dir(:my_app, "priv/python/worker.py"),
name: MyApp.Python,
size: 4
)}
]
{:ok, 2} = Portals.Pool.call(MyApp.Python, "text_tools", "word_count", ["hello world"])module and function are strings in the worker's naming scheme, for
example a Python module path or a name registered with the worker. The
arguments are a list of values; see "Values" below.
Pools
Portals.Pool keeps size workers running and can start up to
max_overflow temporary ones under load. Each worker serves many calls at
once, up to the concurrency it advertises (max_concurrency in its SDK).
The pool sends each call to the least-busy worker.
| Option | Default | Meaning |
|---|---|---|
:size | required | Workers kept running |
:max_overflow | 0 | Extra temporary workers when all are at capacity |
:checkout_timeout | 5_000 | How long a call waits for capacity before {:error, %Portals.Error{kind: :checkout_timeout}} |
:name | none | Register the pool, so it can be called by name |
:callback_allowlist | nil (any) | [{module, function, arity}] the workers may call back into |
:cd | BEAM's cwd | Working directory for the worker processes |
:env | [] | Extra environment variables |
Per-call options: :timeout (how long the caller waits, default 5_000)
and :deadline_ms (a deadline passed to the worker, which handlers can
read).
For a single unpooled worker, use Portals.start_worker/1 with the same
options and Portals.call/5. Streams currently need a single worker (see
"Streaming").
Errors
Every failure is {:error, %Portals.Error{}}, never a raise (except from
the call! variants). kind tells you what happened:
kind | Meaning |
|---|---|
:remote | The handler raised; message, remote["exception_type"] and stacktrace come from the worker |
:execution_timeout | No reply within :timeout |
:checkout_timeout | Every worker was at capacity for :checkout_timeout |
:worker_exit | The worker process died; every call in flight on it gets this error |
:protocol | The worker sent malformed data; only that worker is closed |
:cancelled | A stream was cancelled, or its owning process exited |
:transport | The socket to the worker failed |
:overload | The worker refused the call (for example, too many streams) |
Portals.cancel/1 is cooperative: it tells the worker the call was
cancelled, and the handler decides what to return (each SDK exposes a
"cancelled?" check). The caller still receives that terminal result.
Callbacks and messages
A handler can call back into Elixir while it runs, for example to report progress or to fetch data:
portals.callback("Elixir.MyApp.Progress", "report", [step])and can send a message to any PID it was given:
portals.send_message(pid, {"done": True})which arrives as {:portals_message, worker_pid, value} (or the bare
value with message_envelope: :raw).
Streaming
Portals.open_stream/5 opens a bidirectional stream on a single worker.
Both sides send chunks; each direction has a byte-credit window, so a slow
consumer slows the producer down instead of growing memory without bound.
{:ok, worker} = Portals.start_worker(Portals.Python.worker_opts(script: "priv/python/worker.py"))
{:ok, stream} = Portals.open_stream(worker, "text_tools", "upcase_stream", [])
:ok = Portals.Stream.send_enumerable(stream, ["a", "b"])
["A", "B"] = stream |> Portals.Stream.to_enumerable() |> Enum.to_list()
{:ok, 2} = Portals.Stream.await(stream)Values
Values are encoded with MessagePack plus Erlang extensions, so most Elixir terms survive a round trip:
| Elixir | Notes |
|---|---|
nil, booleans, integers, floats | Integers of any size. Floats stay floats. |
| strings (UTF-8 binaries) | Arrive as the language's string type |
%Portals.Codec.Bin{data: bytes} | Raw bytes. Wrap any binary that is not valid UTF-8. |
| lists, tuples, improper lists | Tuples and improper lists use SDK wrapper types |
| maps | Keys become strings: %{a: 1} and %{1 => 2} come back as %{"a" => 1} and %{"1" => 2} |
| atoms | SDK Atom wrapper types. Only atoms that already exist on the BEAM are decoded. |
| PIDs, references | Opaque. Pass them back, or use them as message targets. |
| charlists | Plain lists of integers |
Gotchas (all languages)
- Wrap raw bytes in
%Portals.Codec.Bin{}. Elixir binaries are sent as text. A binary that is not valid UTF-8 is corrupted or rejected depending on the language. In Python it closes the worker's connection, failing every call in flight on it. - Only return atoms the BEAM already knows. A worker that sends an atom which does not exist on the BEAM (to stop atom-table exhaustion, Portals never creates atoms from worker input) breaks the protocol: the worker's connection is closed and every call in flight on it fails. Send strings for open-ended values.
- Callback targets need the
Elixir.prefix and must exist. Name the module as"Elixir.MyApp.Progress", not"MyApp.Progress". The module and function atoms must already exist on the BEAM, so the module must be loaded (it is in a release; in tests, callCode.ensure_loaded!/1). :timeoutonly stops the caller waiting. The handler keeps running, the call keeps its slot in the worker's concurrency budget until it returns, and its late reply is dropped into the caller's mailbox as a{:portals_result, ...}message. Keep handlers bounded, honor:deadline_msinside long handlers, and for work you may abandon usePortals.async/5, thenPortals.cancel/1with a handler that checks its SDK's cancelled flag.- Worker stdout and stderr are discarded under the default Unix-socket
transport. A
printor a crash traceback written there never reaches your logs. Log to a file (PORTALS_LOG_FILEin the Ruby, JavaScript, PHP, Java and Go SDKs; theloggingmodule in Python). - Streams are per worker.
Portals.Poolhas no streaming API; open streams on a dedicatedPortals.start_worker/1worker. - Give each pool its own child id. Two
{Portals.Pool, ...}children in one supervisor needSupervisor.child_spec({Portals.Pool, opts}, id: MyApp.Python). - The working directory is the BEAM's. Use absolute paths
(
Application.app_dir/2) for scripts and data files, or pass:cd. - Unix socket paths are limited to about 100 bytes. Sockets live under
$PORTALS_RUNTIME_DIR(defaultSystem.tmp_dir!()/portals). If your temp directory has a long path, setPORTALS_RUNTIME_DIRto something short. - Portals owns only the direct worker process. Processes your handler spawns are not killed with the worker. Use a container or process group if you need that.
- Workers are trusted. Process isolation is not a sandbox. Never let
user input choose the
module/functionnames: in Python, for example, any importable module,osincluded, is callable.
Best practices (all languages)
- Build
worker_opts/1withApplication.app_dir(:my_app, "priv/...")paths so they work identically in development and in releases. - Size pools for CPU parallelism and
max_concurrencyfor I/O concurrency. Most runtimes have a global lock or a single event loop, so one worker rarely uses more than one core. - Set
:callback_allowlistin production so a worker can only call the functions it is meant to. - Pass plain data (maps, lists, strings, numbers) across the boundary and keep language objects on their own side.
- Treat the worker like a microservice: keep it stateless where possible so a restart loses nothing, and make module and function names constants in your Elixir code.