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 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
Built at
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 4, 0, 3, {:local, "n"}, nil
08 move r2, r4
09 call r1, args=1, results=1
10 get_upvalue r5, up[2]
11 load_constant r7, 2
12 subtract 8, 0, 7, {:local, "n"}, nil
13 move r6, r8
14 call r5, args=1, results=1
15 add 9, 1, 5, nil, nil
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. 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"}