ash_ai exposes Ash actions to LLMs
over the Model Context Protocol. The natural unit of work there is "call one
action" — perfect for "create a Todo" or "search for posts", less perfect for
"summarize all overdue todos by category".
AshLua plugs into the same pipeline with two compact actions on a resource of
yours, declared via the AshLua.EvalActions extension. Once exposed through
ash_ai, an LLM gets exactly two new tools — one to read the API surface, one
to execute composed Lua against it — instead of one tool per Ash action. The
LLM does the composition inside Lua, in one round-trip, with the host's actor
and tenant attached.
The shape
Define one resource per "agent surface" you want to expose:
defmodule MyApp.Agents.MCPActions do
use Ash.Resource,
domain: MyApp.Agents,
extensions: [AshLua.EvalActions]
eval_actions do
resource MyApp.Posts.Post, actions: [:read, :get_statistics]
resource MyApp.Posts.Comment, actions: [:read]
resource MyApp.Accounts.User, actions: [:read, :create]
end
endThat's the whole declaration. The extension synthesizes two generic actions on
MCPActions:
:eval— takes a Luascriptand runs it throughAshLua.eval!/2, scoped to only the listed(resource, action)pairs. Returns%{result, error}(mirroring the in-script(result, err)convention).:docs— returns markdown documentation for the same scoped surface, in one of three modes:- no arguments → the full rendered page (
AshLua.Docs.full_doc/1); name: "..."→ the focused page for that callable, type, or topic;search: "..."→ a ranked list of matching ids, intended as a discovery aid (then follow up with the same action usingname).
nameandsearchare mutually exclusive.- no arguments → the full rendered page (
Both actions inherit the standard Ash machinery: the calling actor, tenant, and context are passed straight through to every Ash call the Lua script performs. There is no way for a script to escalate, switch tenants, or call operations outside the scoped set.
The synthesized action names are configurable. Use this when you want multiple agent surfaces co-existing under different tool names, or when the defaults collide with action names you've already defined:
eval_actions do
eval_action_name :run_lua
docs_action_name :describe_lua
resource MyApp.Posts.Post, actions: [:read]
endWhy one resource, two actions
The pair maps onto exactly what an LLM client needs to drive itself:
| LLM intent | Action call |
|---|---|
| "What can I do here?" | MCPActions.docs(%{}) |
| "Find anything about overdue todos." | MCPActions.docs(%{search: "..."}) |
| "Tell me more about this one operation." | MCPActions.docs(%{name: "..."}) |
| "Do this composed thing for me." | MCPActions.eval(%{script: "..."}) |
Because the actions live on a regular Ash resource, they reuse everything else
you already have — policies, code-interface generation, logging, telemetry,
ash_ai tool-exposure. From ash_ai's perspective, these are two ordinary
actions to advertise as MCP tools.
Scoping the surface
eval_actions is the source of truth for which operations the script (and
generated docs) can see. The script can only call <domain>.<resource>.<action>
paths that correspond to a listed (resource, action) pair; everything else
is invisible (no entry in the docs, no callable in the Lua environment).
This is the natural place to apply "principle of least privilege" — expose only the actions that are safe and useful for the agent you're building, and omit anything destructive or expensive. You can run multiple agent resources side-by-side, each with its own scope:
defmodule MyApp.Agents.ReadOnlyMCP do
use Ash.Resource,
domain: MyApp.Agents,
extensions: [AshLua.EvalActions]
eval_actions do
resource MyApp.Posts.Post, actions: [:read]
resource MyApp.Posts.Comment, actions: [:read]
end
end
defmodule MyApp.Agents.SupportMCP do
use Ash.Resource,
domain: MyApp.Agents,
extensions: [AshLua.EvalActions]
eval_actions do
resource MyApp.Support.Ticket, actions: [:read, :create, :reassign]
resource MyApp.Accounts.User, actions: [:read]
end
endWiring it to ash_ai
ash_ai exposes Ash actions as MCP tools through its own tools do ... end
block on the domain. Register both synthesized actions there — the LLM will
then see them under whatever names you give the tool declarations:
defmodule MyApp.Agents do
use Ash.Domain, otp_app: :my_app, extensions: [AshAi]
resources do
resource MyApp.Agents.MCPActions
end
tools do
tool :ash_lua_docs, MyApp.Agents.MCPActions, :docs
tool :ash_lua_eval, MyApp.Agents.MCPActions, :eval
end
endIf you customised the synthesized action names via eval_action_name /
docs_action_name, pass the same names to the third argument of tool:
tools do
tool :read_lua_surface, MyApp.Agents.MCPActions, :describe_lua
tool :run_lua, MyApp.Agents.MCPActions, :run_lua
endThe LLM sees the two MCP tool names you declared (ash_lua_docs,
ash_lua_eval, or your custom ones). Both share the actor and tenant
configured for the MCP session — ash_ai threads those through into the
generic action's context, and from there into every Ash call the Lua script
performs.
A walkthrough
The user asks: "How many overdue, high-priority todos do I have, and what's the average age of the oldest five?"
LLM → ash_lua_docs({ search = "overdue todo" })
← # Search results for `overdue todo`
- `work.todo.read` (operation) — list todos with filtering
- `work.todo` (record type) — record type
...
LLM → ash_lua_docs({ name = "work.todo.read" })
← # `work.todo.read` … (the per-operation page)
LLM → ash_lua_docs({ name = "work.todo" })
← # Record type `work.todo` … (fields, filterable predicates, …)
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
""" })
← { 12, [<5 records>] }One round-trip's worth of eval covers what would otherwise be many tool
calls plus arithmetic the model has to do itself. Every Ash call inside the
script still flowed through the user's actor, tenant, and policies.
What :eval returns
The action's return is whatever the script returns from Lua. Because Ash
actions need a stable return shape, the synthesized :eval action's
returns is structured along the same lines as the Lua-side
(result, err) convention:
%{
result: <encoded Lua value, or nil on error>,
error: <%{ message, errors: [...] } or nil>
}A successful script run populates result and leaves error nil; a failed
script run does the opposite. This mirrors the in-script (result, err)
convention so the LLM's reasoning about success/failure looks the same at
both layers.
What :docs returns
:docs operates in three modes depending on its arguments:
No arguments — returns the full markdown page from
AshLua.Docs.full_doc/1, restricted to the scoped surface.name: "..."— returns a single focused page:- a callable path like
"work.todo.read"→ the per-operation page fromAshLua.Docs.callable_doc/2; - a record-type path like
"work.todo"or a named type like"Status"→ the page fromAshLua.Docs.type_doc/2; - a topic id like
"filters"→ the topic page fromAshLua.Docs.topic_doc/2.
Unknown names return an
:invalid_argumenterror.- a callable path like
search: "..."— returns markdown listing up to 20 matching ids ranked by relevance (AshLua.Docs.search/2). Each line shows the id, its kind (operation / record type / type / topic), and a one-line summary. The LLM then picks one and follows up withname.
name and search are mutually exclusive — passing both returns an
:invalid_argument error on :search.
Why not just expose every action directly?
You can — and ash_ai does this well for direct, single-action workflows
("create this Todo", "look up that User"). The trade-off shows up the moment
the user asks something compositional:
- One tool per action: the LLM makes N round-trips, holds intermediate results in its context window, and does arithmetic between them. More tokens, more error surface, no transactionality across the steps.
eval+docs: the LLM writes the composition as a Lua script. Everything runs in one round-trip against the real database, with one consistent actor / tenant / context, and the model only has to reason about the final value.
Both approaches coexist — wire them up alongside each other and let the model pick the right tool for the request.