<!--
SPDX-FileCopyrightText: 2026 ash_lua contributors <https://github.com/ash-project/ash_lua/graphs/contributors>
SPDX-License-Identifier: MIT
-->
# Example use cases
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.
```elixir
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 ...
end
```
### Rendering the tile
To render a tile for the current user, evaluate its script with that user as
the actor:
```elixir
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
end
```
### What 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**
```lua
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**
```lua
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**
```lua
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`](https://github.com/ash-project/ash_ai) already exposes Ash actions
to LLMs over the [Model Context Protocol](https://modelcontextprotocol.io/),
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:
```json
{
"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:
```text
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.