Lua.ex
Try it
Pure Elixir · Lua 5.3 · agent-ready

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.

Sandboxed by default
Register-based VM
Zero NIFs, zero C
script.exs
▶ 4 µs
Why Lua?

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.

Tiny surface
8 types, ~20 keywords, one core data structure. A weekend to learn.
Built to embed
Designed from day one to live inside a host application — not the other way around.
Battle-tested
Three decades shipping in everything from game engines to load balancers.
Neovim Roblox WoW Redis Nginx Lightroom Wireshark
Why this exists

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

Lexer, parser, register-based VM, and stdlib — all written in idiomatic Elixir. Drops into any Phoenix or OTP release.

Sandboxed by default

io, os, require, and friends are disabled. Expose exactly the surface area you want — nothing more.

Elixir ↔ Lua interop

Use 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

Every Lua chunk compiles to a register-based opcode stream. Inspect every instruction in the playground.

Beautiful errors

Real stack traces, real source lines, useful messages. Errors blame the callee by name — none of that "attempt to call a nil value" nonsense.
For AI agents

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, or require by default
  • Replay any opcode the agent ran — full audit trail in the playground
agent_tools.exs
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)
Compiler Explorer

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.

-- main.lua
local function fib(n)
  if n < 2 then return n end
  return fib(n - 1)
       + fib(n - 2)
end

return fib(15)
; bytecode
; main chunk
00 load_env r0
02 closure r2, proto[0]
03 move r1, r2
04 set_open_upvalue r1, r2
06 get_open_upvalue r2, r1
07 load_constant r4, 15
08 move r3, r4
09 call r2, args=1, results=-1
; function #1 (proto[0])
01 load_constant r1, 2
02 less_than r2, r0, r1
03 test r2
05 get_upvalue r1, up[1]
06 load_constant r3, 1
07 subtract r4, r0, r3
08 move r2, r4
09 call r1, args=1, results=1
10 get_upvalue r5, up[2]
11 load_constant r7, 2
12 subtract r8, r0, r7
13 move r6, r8
14 call r5, args=1, results=1
15 add r9, r1, r5
16 return r9, count=1
For your app

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
queue.exs
How it compares

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.

Pick Lua.ex when…

you want ergonomic Elixir interop, compile-time Lua, and beautiful errors — and you control the host application.

Pick Luerl when…

you're on the Erlang side and need a mature, battle-tested Lua 5.3 VM with the longest track record on the BEAM.

Pick C Lua when…

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"}