← All posts

Letting AI agents run code safely in Elixir with tv-labs/lua

How tv-labs/lua gives AI agents a sandboxed Lua runtime inside Elixir: exposing safe functions, setting limits and wiring code execution into the agent loop.

By 7 min readLeggi in italiano

If you build AI agents in Elixir and want them to run code or call tools without being able to damage your platform, use tv-labs/lua. It’s a Lua 5.3 runtime that runs entirely on the BEAM, sandboxed by default, with a small and pleasant API for exposing exactly the Elixir functions you choose. At Turn.io, where I own the AI features, I connected our Lua app engine to the Agent block so that agents execute tools and custom code inside a Lua sandbox. That’s how customers connect agents to medical records and external APIs without being able to break anything. We use this library, it works wonderfully, and this post is mostly a thank you to the tv-labs team, with the code to show why.

TL;DR

  • tv-labs/lua is a Lua 5.3 VM written in Elixir: no NIFs, no C, nothing to compile.
  • Lua.new/1 is sandboxed by default: no io, no os.execute, no require or load.
  • You expose capabilities with use Lua.API and deflua, and pass secrets through private storage the script can’t read.
  • :max_instructions, :max_call_depth and :max_string_bytes bound the work a script can do; a monitored process adds a wall-clock timeout and a memory cap.
  • For agents, “run code” becomes one tool: the model writes Lua, you run it with limits, and you return results or a readable error to the model.

Why Lua is a good fit for agent tools

When an agent needs to do more than call one fixed endpoint, for example fetch a record, filter a list and compute a date, you have two options. You can define a dozen narrow tools and hope the model chains them well, or you can give it one tool that runs a short script against an API you control. The second option is often simpler and cheaper in tokens, but only if the runtime is safe to hand to a model.

Lua is a good match for that:

  • It’s small. Models write it well, the standard library is compact, and there isn’t much surface to reason about.
  • It’s easy to restrict. A script has no filesystem, network or environment access unless you give it some. Every capability is a function you put into the state yourself.
  • It’s predictable. If you also sandbox the clock and randomness (os.time, os.clock, os.date, math.random), a script’s output depends only on its input and the functions you expose. That makes tool runs reproducible, which you’ll appreciate when writing evals.
  • It runs inside the BEAM. With tv-labs/lua, the VM is plain Elixir, so a script runs in an ordinary process that you can monitor, cap and kill. Each Lua value is immutable state that you thread explicitly, so nothing leaks between two tool runs unless you pass the state along yourself.

The library started as an Elixir wrapper around Robert Virding’s Luerl, and the README credits it generously as prior art. Since 1.0 it has been a full reimplementation of the Lua 5.3 lexer, parser and VM in Elixir, with better error messages and features aimed squarely at running untrusted code. The README even names AI-agent-authored code as a primary use case.

A sandboxed state and a first eval

Add {:lua, "~> 1.0"} to your deps. Lua.new/1 gives you a sandboxed VM, and Lua.eval!/3 returns the list of returned values plus the updated state:

lua = Lua.new(max_instructions: 1_000_000, max_call_depth: 200)

{[4], _lua} = Lua.eval!(lua, "return 2 + 2")

Lua.eval!(lua, ~S[os.execute("ls")])
# ** (Lua.RuntimeException) Lua runtime error: os.execute(_) is sandboxed

The default deny-list covers the io library, file, os.execute, os.exit, os.getenv, os.remove, os.rename, os.tmpname, package, require, load, loadfile, loadstring and dofile. A sandboxed function still exists, but calling it raises. You can punch specific holes with exclude:, or add more paths with Lua.sandbox/2, for example Lua.sandbox(lua, [:os, :time]).

The limits are options on Lua.new/1. :max_instructions is an instruction budget per evaluation, and running out raises "instruction budget exceeded". :max_call_depth turns runaway recursion into "stack overflow". The VM also refuses allocation bombs like string.rep("x", 1e15) before allocating, with a ceiling you can lower through :max_string_bytes. All of these are ordinary Lua errors, so a script can catch them with pcall and your code can rescue them from eval!.

Exposing Elixir functions to Lua

For a one-off, Lua.set!/3 accepts a function that takes the argument list and returns a list of results:

lua = Lua.set!(Lua.new(), [:sum], fn args -> [Enum.sum(args)] end)
{[10], _lua} = Lua.eval!(lua, "return sum(1, 2, 3, 4)")

For an agent’s API, I prefer a module with use Lua.API and deflua. The scope option puts the functions under a namespace, and the state form gives the function the current Lua state:

defmodule MyApp.AgentTools.LuaAPI do
  use Lua.API, scope: "records"

  # Callable from Lua as records.get("123")
  deflua get(id), state do
    client = Lua.get_private!(state, :records_client)

    case MyApp.Records.fetch(client, id) do
      {:ok, record} -> Lua.encode!(state, record)
      {:error, :not_found} -> {:error, "record #{id} not found"}
    end
  end
end

defmodule MyApp.AgentTools.Log do
  use Lua.API

  # Replaces Lua's print/1 so output is collected instead of written to stdout
  @variadic true
  deflua print(args), state do
    line = Enum.map_join(args, "\t", &to_string/1)
    {[], Lua.put_private(state, :output, [line | Lua.get_private!(state, :output)])}
  end
end

A few details here are worth knowing, and all of them are the library being careful on your behalf:

  • Private storage (Lua.put_private/3, Lua.get_private!/2) holds values your Elixir functions can read but Lua code can’t. That’s where the API client and its credentials go. The model never sees them, and no script can print them.
  • Returns must be encoded. A deflua that returns a plain map raises and tells you to use Lua.encode!/2, which returns {encoded, state}. That’s exactly the shape deflua accepts, so Lua.encode!(state, record) can be the last expression.
  • {:error, reason} becomes a Lua error. The script can catch it with pcall, or let it propagate to your Elixir code.
  • Arity is checked. Calling records.get("1", "2") fails with “expected 1 arguments, got 2”, a message a model can act on.

Wiring it into the agent loop

In the agent, the whole thing is one tool. The definition tells the model what exists in the sandbox:

{
  "name": "run_lua",
  "description": "Run a short Lua 5.3 script in a sandbox. records.get(id) returns a record as a table. Use print() for notes. Return the values you need; they are sent back to you as JSON.",
  "input_schema": {
    "type": "object",
    "properties": { "code": { "type": "string" } },
    "required": ["code"]
  }
}

When the model calls it, the harness builds a fresh state for that call, loads the APIs, injects the per-conversation client, and evaluates the script in a separate process with a timeout and a heap cap. The process wrapper follows the Security & Sandboxing guide, which is one of the most useful pages in the docs:

defmodule MyApp.AgentTools.RunLua do
  @heap_words 8_000_000
  @timeout_ms 2_000

  def execute(%{"code" => code}, %{records_client: client}) do
    lua =
      Lua.new(max_instructions: 5_000_000, max_call_depth: 200, max_string_bytes: 1_000_000)
      |> Lua.load_api(MyApp.AgentTools.LuaAPI)
      |> Lua.load_api(MyApp.AgentTools.Log)
      |> Lua.put_private(:records_client, client)
      |> Lua.put_private(:output, [])

    case run_isolated(lua, code) do
      {:ok, results, output} -> {:ok, %{"result" => Enum.map(results, &to_json/1), "output" => output}}
      {:error, message} -> {:error, message}
    end
  end

  defp run_isolated(lua, code) do
    parent = self()
    prev_trap = Process.flag(:trap_exit, true)

    worker =
      spawn_link(fn ->
        # include_shared_binaries requires OTP 27+
        Process.flag(:max_heap_size, %{
          size: @heap_words,
          kill: true,
          error_logger: false,
          include_shared_binaries: true
        })

        result =
          try do
            {results, lua} = Lua.eval!(lua, code, source: "run_lua")
            {:ok, results, lua |> Lua.get_private!(:output) |> Enum.reverse()}
          rescue
            e in [Lua.CompilerException, Lua.RuntimeException] -> {:error, Exception.message(e)}
          end

        send(parent, {:result, result})
      end)

    try do
      receive do
        {:result, result} -> result
        {:EXIT, ^worker, :killed} -> {:error, "memory limit exceeded"}
        {:EXIT, ^worker, reason} -> {:error, "crashed: #{inspect(reason)}"}
      after
        @timeout_ms ->
          Process.exit(worker, :kill)
          {:error, "timed out after #{@timeout_ms}ms"}
      end
    after
      Process.flag(:trap_exit, prev_trap)
    end
  end

  # Decoded Lua tables arrive as lists of {key, value} pairs
  defp to_json(value) when is_list(value), do: Lua.Table.deep_cast(value)
  defp to_json(value), do: value
end

The loop then turns either branch into a tool result:

case MyApp.AgentTools.RunLua.execute(call.input, ctx) do
  {:ok, payload} ->
    %{type: "tool_result", tool_use_id: call.id, content: Jason.encode!(payload)}

  {:error, message} ->
    %{type: "tool_result", tool_use_id: call.id, content: message, is_error: true}
end

Given a script like this one from the model:

local r = records.get("123")
print("visits:", #r.visits)
return { name = r.name, last_visit = r.visits[#r.visits].date }

the model gets back {"output": ["visits:\t2"], "result": [{"last_visit": "2026-03-04", "name": "Test"}]} with my test data. If it writes return nope.x, it gets attempt to index a nil value (global 'nope') (at run_lua:1). If it loops forever, it gets instruction budget exceeded. All of those are messages a model can read and correct on the next step, which is exactly what you want from a tool.

What to watch out for

Set every limit explicitly. :max_instructions and :max_call_depth default to :infinity, and the string ceiling defaults to 256 MiB. For agent code you want small numbers, and you want :max_string_bytes comfortably below your heap cap, as the guide recommends, so string bombs are refused deterministically instead of depending on garbage collection timing.

The instruction budget isn’t a clock. It bounds the work the VM does, which is great because it’s deterministic. It doesn’t count time spent inside your own Elixir functions, though, and a slow external API call is where a real tool will spend most of its time. Keep the wall-clock timeout, and give your HTTP clients their own timeouts too.

Expose capabilities, not a toolbox. Every deflua is attack surface, because the script is written by a model that can be manipulated by whatever text ends up in its context. records.get(id), scoped to the client you injected for this conversation, is fine. http.request(url) isn’t. Validate arguments inside each function and treat everything that comes back from an external API as data. I wrote more about this in how agents hand over safely.

Return errors to the model, not to your logs only. Rescue Lua.CompilerException and Lua.RuntimeException and send Exception.message/1 back as the tool result. It’s short, free of ANSI codes and includes the source name and line number, which is usually enough for the model to fix its script. If you need structured errors for logging or a UI, Lua.RuntimeException.to_map/2 gives you a JSON-safe map.

Know what’s out of scope. The library targets Lua 5.3 without coroutines, weak tables or the full debug library, all listed as deliberate non-goals in the README. None of that has mattered for agent tools in my experience, but it’s good to know before you promise customers “full Lua”.

A small contribution

While hosting sandboxed Lua apps, we hit a bug: returning a table that contains itself, like the very common T.__index = T class idiom, made the eval boundary recurse forever. I sent a small fix that stops the walk when it meets a cycle, and it shipped in 1.0.2. It was reviewed and merged the same day, which says a lot about how the project is run.

Thank you, tv-labs

Running model-written code safely is one of those problems that looks simple until you list everything that can go wrong. tv-labs/lua takes care of most of that list: a sandbox by default, deterministic limits, errors with line numbers, and documentation that tells you honestly where the VM’s guarantees end and yours begin. Huge thanks to the tv-labs team for building it and sharing it. Start with the hexdocs, and read the Security & Sandboxing guide before you ship.

If you’re building agents that need to call real systems without putting those systems at risk, I help teams design AI agents, their tools and the evals that prove they work.