Two illustrations of why you might reach for AshLua. The first is something you
can ship today; the second sketches what an ash_ai-driven MCP server might
look like once it lands.
1. User-configurable scripts: a custom dashboard tile
A common pattern in admin tools and internal SaaS dashboards is letting users build their own dashboard — pick what they want to see, with whatever logic they want behind it. The hard part is letting that logic touch real data without giving users a Phoenix shell or rebuilding queries in a config UI.
With AshLua, each dashboard tile can be a small Lua snippet, evaluated with the viewing user's actor and tenant. The script can list, filter, and aggregate — nothing else.
The persistence side
Persist tile definitions as a resource of yours, e.g. MyApp.Dashboards.Tile,
with at least name, description, script (string), and an owner.
defmodule MyApp.Dashboards.Tile do
use Ash.Resource, domain: MyApp.Dashboards
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
attribute :script, :string, allow_nil?: false, public?: true
end
# ... owner relationship, actions, policies ...
endRendering the tile
To render a tile for the current user, evaluate its script with that user as the actor:
defmodule MyAppWeb.TileLive do
use MyAppWeb, :live_view
def render_tile(tile, current_user) do
case AshLua.eval!(tile.script,
otp_app: :my_app,
actor: current_user,
tenant: current_user.org_id
) do
{[value], _lua} -> {:ok, value}
end
rescue
e in [Lua.RuntimeException, Lua.CompilerException] ->
{:error, Exception.message(e)}
end
endWhat the user writes
The script body is just Lua. The user references the operations exposed by your domains — same surface AshLua's documentation describes. Three flavors of useful tile:
Count of overdue items in my queue
return assert(work.todo.read({
filter = { assigned_to_id = my_id, completed = false,
due_date = { less_than = today() } },
operation = "count"
}))Average rating of my last 10 reviews
return assert(reviews.review.read({
filter = { reviewer_id = my_id },
sort = "-created_at",
limit = 10,
operation = { "avg", "rating" }
}))Top 5 best-selling products this week
return assert(catalog.product.read({
filter = { sold_at = { greater_than = ago(7, "day") } },
sort = "-units_sold",
limit = 5,
fields = { "name", "units_sold" }
}))Why this is safe
The script can only:
- Call operations on domains the host has exposed via
AshLua.Domain. - Pass inputs that Ash already knows how to validate.
- See data the actor is authorized to see — your existing authorization policies are the gate, exactly as they would be for any other Ash call.
The script cannot:
- Read or change the actor, tenant, or context.
- Reach into Elixir, the filesystem, the network, or other processes.
- Spend more than the work an explicit query already would.
For free, you get a UI/UX where users wire up real, authorized data into their own widgets without you building a query-builder UI for every combination of filter + sort + fields + aggregate.
2. An AshLua-backed MCP server (upcoming, via ash_ai)
ash_ai already exposes Ash actions
to LLMs over the Model Context Protocol,
one tool per action. That's a great fit when the LLM's job is "create a Todo"
or "search for posts". It's less good when the job is "summarize all overdue
todos by category", "diff this user's activity week-over-week", or anything
else that needs composition — many calls combined and arithmetic between
them.
The natural fit: hand the LLM the Lua surface AshLua already publishes, and let it write the composition itself.
The shape of that server isn't shipped yet, but the pieces are all in place.
A future ash_ai MCP server would advertise three tools:
{
"tools": [
{
"name": "ash_lua_list_callables",
"description": "List every operation (callable Lua function) exposed by this application's Ash domains.",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": false}
},
{
"name": "ash_lua_get_docs",
"description": "Fetch the markdown documentation for one operation (e.g. \"posts.post.create\") or one record/named type (e.g. \"posts.post\", \"Status\").",
"inputSchema": {
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"],
"additionalProperties": false
}
},
{
"name": "ash_lua_eval",
"description": "Evaluate a Lua script against the application's Ash domains. The script is run with the current user's actor / tenant / context.",
"inputSchema": {
"type": "object",
"properties": { "script": { "type": "string" } },
"required": ["script"],
"additionalProperties": false
}
}
]
}All three back directly onto the public AshLua API:
| MCP tool | Maps to |
|---|---|
ash_lua_list_callables | AshLua.Docs.list_callables(otp_app: app) + list_types/1 |
ash_lua_get_docs | AshLua.Docs.callable_doc/2 or AshLua.Docs.type_doc/2 |
ash_lua_eval | AshLua.eval!(script, otp_app: app, actor: ..., tenant: ...) |
An example LLM workflow
A user asks: "How many overdue, high-priority todos do I have, and what's the average age of the oldest five?"
The model probes the surface, then writes the script:
LLM → ash_lua_list_callables()
← ["work.todo.read", "work.todo.create", ...]
LLM → ash_lua_get_docs("work.todo.read")
← # `work.todo.read` ... (the markdown ash_lua generates today)
LLM → ash_lua_get_docs("work.todo")
← # Record type `work.todo` — fields include `priority`, `due_date`, `created_at`, ...
LLM → ash_lua_eval(script = """
local overdue = assert(work.todo.read({
filter = { priority = "high", completed = false,
due_date = { less_than = today() } },
operation = "count"
}))
local sample = assert(work.todo.read({
filter = { priority = "high", completed = false,
due_date = { less_than = today() } },
sort = "created_at",
limit = 5,
fields = { "created_at" }
}))
return overdue, sample
""")
← { result = { 12, [<5 todo records>] }, err = nil }The model then takes the structured result and produces the answer. Crucially, every Ash call still went through that user's actor and tenant — the LLM never sees credentials, never bypasses authorization, and never makes up a field name (the docs gave it the real surface up front).
Why route LLMs through Lua instead of one-tool-per-action
When the LLM has one tool per action, it composes by making N tool calls and doing arithmetic in its head between them. That's slow, expensive, and error-prone — especially for any answer that requires combining results.
When the LLM has one tool that takes Lua, it composes inside the script. One
round-trip, one set of host-supplied identities, and the result is a real
value computed against the real database. The model still uses get_docs to
discover the surface — but instead of stitching results together itself, it
writes the stitching as code.
This pattern is what AshLua was built for.