Embed a sandboxed Lua 5.3 scripting runtime in your Elixir application — no NIFs, no C, no Erlang runtime dependency.
Lua is a Lua 5.3 virtual machine implemented entirely in Elixir. The lexer,
parser, register-based VM, and standard library all run directly on the BEAM,
so there is nothing to compile and no foreign code in your release. It exists
to let you safely run untrusted scripts — AI-agent–authored code, game logic,
user-defined rules, configuration, plugins — with a small, idiomatic Elixir API
for passing data and functions across the boundary. Giving an AI agent a
sandboxed runtime where it can only call the Elixir functions you expose is a
primary use case. Scripts are sandboxed by default, errors carry source and
line information, and each Lua value is plain immutable Elixir state with no
shared mutable globals.
Add lua to your dependencies in mix.exs:
def deps do
[
{:lua, "~> 1.0.0-rc"}
]
endEvaluate Lua with Lua.eval!/2. It returns {results, lua} where results
is the list of returned values and lua is the updated state:
iex> {[4], _lua} = Lua.eval!("return 2 + 2")
You can thread state across multiple evaluations, set globals from Elixir, and read them back:
iex> lua = Lua.set!(Lua.new(), [:name], "world")
iex> {[greeting], _lua} = Lua.eval!(lua, ~S[return "hello, " .. name])
iex> greeting
"hello, world"
Runtime errors raise Lua.RuntimeException, which carries the failing
:source and :line so you can report exactly where a script broke:
try do
Lua.eval!(~LUA"""
local x = 1
error("something went wrong")
""")
rescue
e in Lua.RuntimeException ->
e.line # => 2
e.source # => "<eval>" (chunk name)
# e.message is a formatted, colorized frame (ANSI codes elided here):
#
# Lua runtime error: Runtime Error
#
# at <eval>:2:
#
# runtime error: something went wrong
e.message
endLua-level error handling works too — pcall catches the error and returns it
as a value:
iex> {[false, "nope"], _lua} = Lua.eval!(~S[return pcall(function() error("nope") end)])
The quickest way to expose an Elixir function is Lua.set!/3:
iex> lua = Lua.set!(Lua.new(), [:sum], fn args -> [Enum.sum(args)] end)
iex> {[10], _lua} = Lua.eval!(lua, "return sum(1, 2, 3, 4)")
For richer APIs, define a module with use Lua.API and the deflua macro,
then load it with Lua.load_api/2:
defmodule MyAPI do
use Lua.API
deflua double(v), do: 2 * v
end
lua = Lua.new() |> Lua.load_api(MyAPI)
{[10], _lua} = Lua.eval!(lua, "return double(5)")Pass an arbitrary Elixir term across the boundary as a {:userdata, term}
tuple. It round-trips opaquely — Lua can hold the reference and hand it back,
but cannot inspect or dereference it:
iex> lua = Lua.set!(Lua.new(), [:thing], {:userdata, %{secret: 42}})
iex> {[{:userdata, %{secret: 42}}], _lua} = Lua.eval!(lua, "return thing")
Lua.new/1 sandboxes dangerous stdlib paths by default, including
os.execute, os.exit, os.getenv, file I/O (io.*), require, load, and
dofile. Calling a sandboxed function raises rather than touching the host:
Lua.eval!(~S[os.execute("rm -rf /")])
# ** (Lua.RuntimeException) Lua runtime error: os.execute(_) is sandboxedTo allow a specific operation, exclude it from the sandbox explicitly:
iex> lua = Lua.new(exclude: [[:os, :getenv]])
iex> {[value], _lua} = Lua.eval!(lua, ~S[return os.getenv("HOME")])
iex> is_binary(value)
true
Full metamethod dispatch is supported (__index, __newindex, __call,
arithmetic, comparison, length, concatenation, and __tostring), so idiomatic
Lua object patterns work as written:
iex> {[result], _lua} = Lua.eval!(~LUA"""
...> local Vec = {}
...> Vec.__index = Vec
...> Vec.__add = function(a, b) return setmetatable({x = a.x + b.x}, Vec) end
...> local a = setmetatable({x = 1}, Vec)
...> local b = setmetatable({x = 2}, Vec)
...> return (a + b).x
...> """)
iex> result
3
Lua targets Lua 5.3. The lexer, parser, register-based VM, value
encoding/decoding, varargs, multiple returns, _G/_ENV, metatables, the
string-pattern engine (find/match/gmatch/gsub), and the string,
table, math, os, and debug standard libraries are implemented.
As a sandboxed embedded VM, some standalone-interpreter behavior is a deliberate non-goal rather than a missing feature:
- Standalone interpreter /
os.execute— there is no shell-out to the host. - Host filesystem access —
Luadoes not read your host filesystem. Theio.*library andrequire/dofileare sandboxed by default and raise rather than touching disk; there is no host-OS file or module resolution. - Coroutines, garbage collection / weak tables, and the full
debuglibrary.
For the live Lua 5.3 official test-suite pass count and the rationale behind
each deferral, see the
ROADMAP.md. This
release is 1.0.0-rc.0.
Runnable, end-to-end scripts live in
examples/. Run
any of them with mix run examples/<name>.exs:
examples/01_quickstart.exs— eval some Lua and get the result.examples/02_userdata.exs— pass an Elixir struct as userdata and call methods on it from Lua.examples/03_custom_stdlib.exs— add an Elixir-defined function to the state and call it from Lua.examples/04_sandboxing.exs— the default sandbox plus allowing specificos.*ops explicitly.examples/05_chunks.exs— compile once, eval many times.examples/06_error_handling.exs—pcall, structured exception fields, source/line attribution.
- Full API reference on HexDocs.
- The Working with Lua guide is a Livebook walkthrough of the embedding patterns.
- The
~LUAsigil and Mix tasks guide covers compile-time validation and tooling. - The Security and sandboxing guide covers the sandbox, allocation guards, recursion limits, and bounding CPU and memory.
When referring to this library,
Luais stylized as a link. References to Lua the language are in plaintext and not linked.
Lua is built to run untrusted scripts. By default, Lua.new/1 installs
a sandbox that blocks the dangerous standard-library paths (io, file,
os.execute/exit/getenv, package, require, load, …), and the VM
guards against allocation-bomb denial-of-service by refusing oversized
string.rep, table.unpack/concat/move, and string concatenations
before they allocate.
# os.exit is sandboxed by default — calling it raises (catchable)
iex> {[false, message], _} = Lua.eval!(Lua.new(), "return pcall(os.exit)")
iex> message =~ "sandboxed"
trueCapability sandboxing (:sandboxed, :exclude, Lua.sandbox/2),
recursion limits (:max_call_depth), the built-in allocation guards, and
the host-level pattern for bounding CPU time and total memory are all
covered in the Security and sandboxing guide.
Lua started as an ergonomic Elixir wrapper around Robert Virding's
Luerl project. As of 1.0.0 it is a full
Elixir-native reimplementation of the Lua 5.3 lexer, parser, and virtual
machine, with a public API designed to feel idiomatic from Elixir.
Compared to Luerl: Lua is pure Elixir with no shared mutable state (each
Lua value is plain immutable state you thread explicitly), ships richer error
messages with source and line attribution, and benchmarks competitively.
Luerl deserves credit as the prior art that made this possible — its design
informed many decisions in the new VM, and we benchmark against it.
Released under the Apache-2.0 license. See
LICENSE.