Lua.ex: Sandboxed Lua 5.3 on the BEAM, built for AI agents · Lua.ex
GitHub
Try it
*]:min-h-11">
Playground
Tour
Opcodes
About
Docs
GitHub
Theme
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,<br>user-supplied formulas, per-tenant plugins. Zero NIFs,<br>zero shelling out, every opcode auditable.
Open the Playground
Take the Tour
Sandboxed by default
Register-based VM
Zero NIFs, zero C
Built at
script.exs
▶ 4 µs
# Embed Lua in your Elixir app<br># with a single function call.
defmodule MyApp.Rules do<br>use Lua.API, scope: "rules"
deflua double(n), do: n * 2<br>end
# Now your Elixir function is<br># callable from any Lua script:
lua = Lua.new() |> Lua.load_api(MyApp.Rules)
{[10], _lua} = Lua.eval!(lua, "return rules.double(5)")<br># Compile Lua at compile-time with the<br># ~LUA sigil. `c` returns a compiled chunk,<br># ready to run on any state.
import Lua, only: [sigil_LUA: 2]
chunk = ~LUA"""<br>local total = 0<br>for i = 1, 100 do total = total + i end<br>return total<br>"""c
{[5050], _state} = Lua.run(Lua.new(), chunk)<br># Sandboxed by default. No file system,<br># no os.execute, no surprise side-effects.
lua = Lua.new()
{:error, err} =<br>Lua.eval(lua, "return os.execute('rm -rf /')")
# err.message =~ "attempted to call"<br># Give an LLM a Lua VM with your tools<br># bound. It can only call what you expose.
defmodule Agent.Tools do<br>use Lua.API, scope: "tools"
deflua search(q), do: MyApp.Search.run(q)<br>end
lua = Lua.new() |> Lua.load_api(Agent.Tools)
# The model emits Lua. You run it. Done.<br>{:ok, {results, _}} = Lua.eval(lua, llm_script)
~LUA sigil
~LUA[return 2 + 2]c
Why Lua?
The scripting language the world already trusts.
Lua is small, fast to learn, and built to be embedded. That's<br>why Neovim, Roblox, World of Warcraft, Redis, Nginx, and<br>Adobe Lightroom all chose it. Now you get the same language,<br>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<br>Roblox<br>WoW<br>Redis<br>Nginx<br>Lightroom<br>Wireshark
Why this exists
Everything you'd want in a scripting layer
Built to let your users write code inside your product, without ever<br>leaving the safety of the BEAM.
Pure Elixir VM
Lexer, parser, register-based VM, and stdlib, all written in idiomatic<br>Elixir. Drops into any Phoenix or OTP release.
Sandboxed by default
io, os, require, and friends are<br>disabled. Expose exactly the surface area you want, nothing more.
Elixir ↔ Lua interop
Use deflua to expose any Elixir function. Call Lua back<br>from Elixir with Lua.call_function!/3.
Compile-time sigil
~LUA validates syntax at compile time.<br>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.<br>Inspect every instruction in the playground.
Beautiful errors
Real stack traces, real source lines, useful messages. Errors blame the<br>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,<br>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<br>loaded. Run whatever Lua the model emits. It can only do what you<br>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<br>require<br>by default
Replay any opcode the agent ran. Full audit trail in the<br>playground
agent_tools.exs
defmodule MyAgent.Tools do<br>use Lua.API, scope: "tools"
deflua search(query), state do<br>results = MyApp.Search.run(query)<br>{[results], state}<br>end
deflua send_email(to, body), state do<br>MyApp.Mailer.deliver(to, body)<br>{[:ok], state}<br>end<br>end
# One VM per agent conversation.<br>lua = Lua.new() |> Lua.load_api(MyAgent.Tools)
# The agent emits Lua. You run it. It can only<br># do what you exposed -- nothing else.<br>{: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<br>the opcodes appear on the right, instruction by instruction,<br>register by register. Toggle prototypes for nested closures and follow<br>every closure, call,<br>and return.
Explore fib(15)<br>bytecode
-- main.lua
local function fib(n)<br>if n 2 then return n end<br>return fib(n - 1)<br>+ fib(n - 2)<br>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...