Lua, on the
BEAM.
Scriptable, sandboxed, stupid easy.
An Elixir-native Lua 5.3 VM, built for embedding untrusted code — AI agent tools, user-supplied formulas, per-tenant plugins. Zero NIFs, zero shelling out, every opcode auditable.
The scripting language the world already trusts.
Lua is small, fast to learn, and built to be embedded — that's why Neovim, Roblox, World of Warcraft, Redis, Nginx, and Adobe Lightroom all chose it. Now you get the same language, but the host runtime is the BEAM, not a C extension.
Everything you'd want in a scripting layer
Built to let your users write code inside your product, without ever leaving the safety of the BEAM.
Pure Elixir VM
Sandboxed by default
io, os, require, and friends are
disabled. Expose exactly the surface area you want — nothing more.
Elixir ↔ Lua interop
deflua to expose any Elixir function. Call Lua back
from Elixir with Lua.call_function!/3.
Compile-time sigil
~LUA validates syntax at compile time.
Add the c modifier and ship a pre-compiled chunk in your release.
See the bytecode
Beautiful errors
Drop a VM in every agent loop.
Each Lua VM is an immutable Elixir value. Spawn one per conversation, per tool call, per user — throw it away when you're done.
The canonical agent-tool pattern
Define the tools the agent can call. Hand it a VM with those tools loaded. Run whatever Lua the model emits — it can only do what you exposed, nothing else. No subprocess. No NIF. No surprises.
- One VM per agent conversation — cheap to spawn, garbage collected
- Tools are plain Elixir functions, arguments and returns marshal automatically
-
No
io,os, orrequireby default - Replay any opcode the agent ran — full audit trail in the playground
defmodule MyAgent.Tools do
use Lua.API, scope: "tools"
deflua search(query), state do
results = MyApp.Search.run(query)
{[results], state}
end
deflua send_email(to, body), state do
MyApp.Mailer.deliver(to, body)
{[:ok], state}
end
end
# One VM per agent conversation.
lua = Lua.new() |> Lua.load_api(MyAgent.Tools)
# The agent emits Lua. You run it. It can only
# do what you exposed -- nothing else.
{:ok, {result, _lua}} = Lua.eval(lua, agent_script)
See your Lua, as the VM sees it.
Like Godbolt, but for Lua bytecode. Type a snippet on the left, watch
the opcodes appear on the right — instruction by instruction,
register by register. Toggle prototypes for nested closures and follow
every closure, call,
and return.
local function fib(n)
if n < 2 then return n end
return fib(n - 1)
+ fib(n - 2)
end
return fib(15)
Drop in. Wire up. Ship.
Define an API module with use Lua.API. Annotate functions
with deflua. Load it into a Lua VM. That's it — your
users can now write Lua scripts that call back into your Elixir code,
safely.
- Reactive workflows, rules engines, plug-in systems
-
AI agent sandboxes — one Lua VM per conversation,
tools exposed via
deflua - Game logic, automation, embedded DSLs
Why this VM, not the others?
Lua on the BEAM isn't new — but the developer experience is. Here's the honest breakdown.
| Lua.ex this library | Luerl Erlang-native | C Lua via NIF/port native | |
|---|---|---|---|
| Runtime | Pure Elixir on the BEAM | Erlang on the BEAM | C, called over a port or NIF |
| Sandboxed by default | manual | ||
| Crash isolation | Supervised, immutable state | Supervised, immutable state | NIF crash = VM crash |
| Elixir interop API |
deflua macro, ~LUA sigil, Lua.API
|
Manual function registration | Port marshalling |
| Compile-time validation |
~LUAc
|
||
| Bytecode inspection | First-class | via tracing | via luac -l |
| Error messages | Source line, blame the callee by name | Functional but terse | Standard Lua |
| fib(30) throughput* | 0.71 ips | 0.88 ips | 143 ips |
*Benchee on Apple M1 Pro, Elixir 1.20.0-rc.4 / Erlang 29. Lua.ex is in the same ballpark as Luerl on tight CPU loops, both ~200× slower than raw C Lua. For the embedded scripting use case — AI tools, formulas, plugins — that gap rarely matters.
you want ergonomic Elixir interop, compile-time Lua, and beautiful errors — and you control the host application.
you're on the Erlang side and need a mature, battle-tested Lua 5.3 VM with the longest track record on the BEAM.
raw CPU throughput dominates and you can give up BEAM-level isolation, GC, and supervision in exchange.
Go play. Then read the docs.
The fastest way to understand this VM is to write Lua and watch the opcodes flow.
{:lua, "~> 1.0.0-rc.1"}