Skip to main content

documentation/tutorials/getting-started-with-ash-lua.md

<!--
SPDX-FileCopyrightText: 2026 ash_lua contributors <https://github.com/ash-project/ash_lua/graphs/contributors>

SPDX-License-Identifier: MIT
-->

# Getting started with AshLua

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:

```elixir
def deps do
  [
    {:ash_lua, "~> 0.1.0"}
  ]
end
```

Run the installer to wire the formatter and any extension defaults:

```sh
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.

```elixir
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 ...
end
```

By 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:

```elixir
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

  # ...
end
```

With these defaults, every public action on `MyApp.Accounts.User` becomes
callable as `accounts.user.<action>(input)` from Lua.

## 3. Evaluate a Lua script

```elixir
{[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)`.

```lua
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)
end
```

If you'd rather have errors raise, wrap the call in Lua's built-in `assert`:

```lua
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:

```lua
local user = assert(accounts.user.read({
  fields = { "id", "name", "email" }
}))
```

`fields` accepts a tree:

```lua
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:

```lua
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`:

```lua
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:

```lua
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`).

```elixir
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!/2` into wherever scripts come from in your application
    (admin UI, scheduled jobs, MCP tools, etc.).