AshLua lets you expose your Ash domains and resources to Lua scripts, with a consistent actor / tenant / context attached to every call. Scripts can read data, create / update / delete records, and call generic actions — all using the same authorization, validation, and types your application already uses elsewhere.
This guide walks you through installing AshLua, exposing your first resource, evaluating a Lua script, and using the documentation surface so you (or an LLM-driven tool) can discover what's callable.
1. Install
Add the dependency:
def deps do
[
{:ash_lua, "~> 0.1.0"}
]
endRun the installer to wire the formatter and any extension defaults:
mix igniter.install ash_lua
2. Expose a domain and a resource
AshLua ships two Spark extensions: AshLua.Domain (for your domain modules)
and AshLua.Resource (for each resource you want to expose). Add them to
their respective extensions: lists.
defmodule MyApp.Accounts do
use Ash.Domain,
otp_app: :my_app,
extensions: [AshLua.Domain]
resources do
resource MyApp.Accounts.User
end
end
defmodule MyApp.Accounts.User do
use Ash.Resource,
domain: MyApp.Accounts,
extensions: [AshLua.Resource]
# ... attributes / actions ...
endBy default, the domain is exposed under a Lua table named after the domain module's last segment (snake_cased), and each resource under a similarly-derived key. You can override either name:
defmodule MyApp.Accounts do
use Ash.Domain, otp_app: :my_app, extensions: [AshLua.Domain]
lua do
name "accounts"
end
# ...
end
defmodule MyApp.Accounts.User do
use Ash.Resource, domain: MyApp.Accounts, extensions: [AshLua.Resource]
lua do
name "user"
end
# ...
endWith these defaults, every public action on MyApp.Accounts.User becomes
callable as accounts.user.<action>(input) from Lua.
3. Evaluate a Lua script
{[user_id], _lua} =
AshLua.eval!(
"""
local user, err = accounts.user.create({
name = "Zach",
email = "z@example.com",
fields = { "id" }
})
assert(err == nil)
return user.id
""",
otp_app: :my_app,
actor: current_user
)AshLua.eval!/2 accepts:
:otp_app— required; the OTP app whose Ash domains to expose.:actor,:tenant,:context— host-supplied; threaded through every Ash call the script makes. These are not readable from Lua and cannot be overridden from the script — the host is the sole source of authority.:manifest/:lua— optional pre-built artifacts (for advanced use).
4. The Lua API shape
Every operation in Lua is called as a function on a domain table and returns
two values: a result and an error. A successful call returns (result, nil);
a failed call returns (nil, err_table).
local user, err = accounts.user.create({ name = "Zach" })
if err then
-- err is a table: { message = "...", errors = { { code = "...", fields = {...}, ... }, ... } }
print("create failed:", err.message)
else
print("created user:", user.id)
endIf you'd rather have errors raise, wrap the call in Lua's built-in assert:
local user = assert(accounts.user.create({ name = "Zach" }))assert returns the first value when the second is nil, and raises with the
second value otherwise — so it works exactly like an Elixir ! variant for
free.
5. Choosing which fields come back
By default, operations that return records return only the primary key.
Pass a fields selection to opt into more:
local user = assert(accounts.user.read({
fields = { "id", "name", "email" }
}))fields accepts a tree:
fields = {
"id", "title", -- stored fields
"title_upper", -- computed fields
"comment_count", -- summary fields
{ author = { "id", "name" } }, -- linked one-record
{ comments = { "id", "body" } }, -- linked many-records
{ title_prefixed = { args = { prefix = ">> " } } }, -- computed with input
{ metadata = { "priority", "category" } }, -- structured-value sub-selection
{ coordinates = { "latitude" } }, -- tuple sub-selection
{ content = { text = { "body" } } }, -- one-of (union) member sub-selection
}Passing { author = {} } (an empty sub-selection) means "use the default for
the linked record" — primary key only. Passing an explicit list selects exactly
what you ask for and nothing else.
Unknown fields, unknown computed-field arguments, and other selection mistakes
surface as a structured (nil, err) with code = "invalid_fields".
6. Querying lists
List-style read operations accept a few reserved keys:
local results = assert(posts.post.read({
filter = { published = true }, -- narrow the result set
sort = "-created_at", -- `-` prefix for descending
limit = 10,
offset = 20,
fields = { "title", { author = { "name" } } },
}))To paginate with a cursor instead, use page = { limit = 10, after = "..." }.
The result becomes a table with results, count, limit, more?, and
offset or before / after depending on the pagination style.
To summarize a result set without retrieving the records, use operation:
local total = assert(posts.post.read({ operation = "count" }))
local any = assert(posts.post.read({ operation = "exists" }))
local avg_rating = assert(posts.comment.read({ operation = { "avg", "rating" } }))
local high_sum = assert(posts.comment.read({
filter = { rating = { greater_than_or_equal = 5 } },
operation = { "sum", "rating" }
}))Supported operations: "count", "exists", and { "sum" | "avg" | "min" | "max" | "count", "<field>" }.
7. Mutations
Create / update / delete behave like read, except update and delete take the primary key inline in the input:
local post = assert(posts.post.create({
title = "Hello", body = "World", fields = { "id", "title" }
}))
local updated = assert(posts.post.update({
id = post.id, title = "Hello again", fields = { "title" }
}))
assert(posts.post.destroy({ id = post.id }))Generic actions (defined with action :name, type do ... end) take their
declared arguments and return whatever the action returns — a scalar, a map,
a list, whatever. fields is honored when the return type is a record or a
structured value.
8. Discovering the API surface
AshLua.Docs produces per-operation and per-record-type markdown straight from
your domains — useful both as a reading aid and as a feed for an MCP
search_docs / get_docs tool (e.g. ash_ai).
AshLua.Docs.list_callables(otp_app: :my_app)
# => ["accounts.user.create", "accounts.user.read", ...]
{:ok, md} = AshLua.Docs.callable_doc([otp_app: :my_app], "accounts.user.create")
AshLua.Docs.list_types(otp_app: :my_app)
# => ["accounts.user", ...]
{:ok, md} = AshLua.Docs.type_doc([otp_app: :my_app], "accounts.user")
# Or get one rendered page covering everything:
AshLua.Docs.full_doc(otp_app: :my_app)The rendered pages deliberately avoid implementation vocabulary — they describe
operations as get / list / create / update / delete / call, and
treat stored, computed, and summary fields uniformly as "fields".
9. Authorization and multitenancy
The actor, tenant, and context you pass to AshLua.eval!/2 are merged into
every Ash call the script makes. That means:
- Authorization policies fire as they normally would for that actor.
- Tenant-scoped resources are scoped to the tenant you supplied.
- Custom context (audit metadata, request IDs, etc.) reaches your changes, preparations, and policies.
There is intentionally no Lua-side API to read or modify any of these — a script cannot escalate its actor, leak its actor's identity, or switch tenants.
Next steps
- Run
AshLua.Docs.full_doc(otp_app: :my_app)to see what's automatically exposed. - Look at the per-callable pages for operations you want to script against.
- Wire
AshLua.eval!/2into wherever scripts come from in your application (admin UI, scheduled jobs, MCP tools, etc.).