Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ spark_locals_without_parens = [
expose?: 1,
field_names: 1,
forbidden_fields: 1,
labels: 1,
name: 1,
namespace: 1,
namespace: 2,
namespace: 3,
otp_app: 1,
resource: 1,
resource: 2
Expand Down
11 changes: 8 additions & 3 deletions documentation/dsls/DSL-AshLua.Domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ end

### lua.namespace
```elixir
namespace name
namespace name, labels \\ []
```


Expand All @@ -56,7 +56,7 @@ end

```
namespace "storefronts.pages" do
action :list, MyApp.StorefrontPage, :list_for_storefront
action :list, MyApp.StorefrontPage, :list_for_storefront, labels: [:public, :read_model]
end

```
Expand All @@ -68,6 +68,7 @@ end
| Name | Type | Default | Docs |
|------|------|---------|------|
| [`name`](#lua-namespace-name){: #lua-namespace-name .spark-required} | `String.t \| list(String.t)` | | The public Lua namespace. Dotted strings are split into nested Lua tables, so "storefronts.pages" exposes `storefronts.pages.*`. |
| [`labels`](#lua-namespace-labels){: #lua-namespace-labels } | `list(atom) \| [labels: list(atom)]` | `[]` | Labels inherited by every mapped action in this namespace. Prefer action-level labels when individual actions need different eval surfaces. |



Expand All @@ -84,7 +85,7 @@ Expose an Ash action at a public Lua function name inside a namespace.
### Examples
```
namespace "pages" do
action :list, MyApp.StorefrontPage, :list_for_storefront
action :list, MyApp.StorefrontPage, :list_for_storefront, labels: [:public, :read_model]
end

```
Expand All @@ -98,7 +99,11 @@ end
| [`name`](#lua-namespace-action-name){: #lua-namespace-action-name .spark-required} | `atom` | | The Lua function name inside the namespace. |
| [`resource`](#lua-namespace-action-resource){: #lua-namespace-action-resource .spark-required} | `module` | | The Ash resource that owns the action. |
| [`action`](#lua-namespace-action-action){: #lua-namespace-action-action .spark-required} | `atom` | | The internal Ash action to call. |
### Options

| Name | Type | Default | Docs |
|------|------|---------|------|
| [`labels`](#lua-namespace-action-labels){: #lua-namespace-action-labels } | `list(atom)` | `[]` | Labels that describe this mapped Lua action. `AshLua.EvalActions` can use these labels to expose individual actions. |



Expand Down
33 changes: 21 additions & 12 deletions documentation/dsls/DSL-AshLua.EvalActions.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,27 @@ defmodule MyApp.Agents.MCPActions do
use Ash.Resource, extensions: [AshLua.EvalActions]

eval_actions do
resource MyApp.Posts.Post, actions: [:read, :get_statistics]
resource MyApp.Posts.Comment, actions: [:read]
labels [:public]
end
end
```

The synthesized actions inherit the caller's actor / tenant / context — both
The synthesized actions inherit the caller's actor / tenant / context. Both
the script body and the documentation rendering are constrained to the
configured `(resource, action)` pairs, and every Ash call inside the Lua
script flows through the standard authorization machinery.
configured action labels, and every Ash call inside the Lua script flows
through the standard authorization machinery.


## eval_actions
Configures the Lua surface exposed to the synthesized `:eval` and `:docs` actions.

Each `resource` entry pairs a resource module with the set of action names
that the script (and the generated docs) is allowed to see. Use this to
apply principle-of-least-privilege per agent surface — only the listed
actions become callable from Lua and only those entrypoints appear in
`:docs` output.
Prefer `labels` to expose mapped Lua actions declared on the domain with
`lua do namespace ... action ..., labels: [...] end`. This keeps the eval
surface tied to the same public Lua surface used everywhere else.

The legacy `resource` entries remain supported for derived surfaces and
fine-grained compatibility. When both `labels` and `resource` entries are
configured, the resource/action list narrows the labelled action surface.


### Nested DSLs
Expand All @@ -40,8 +41,15 @@ actions become callable from Lua and only those entrypoints appear in
### Examples
```
eval_actions do
resource MyApp.Posts.Post, actions: [:read, :get_statistics]
resource MyApp.Posts.Comment, actions: [:read]
labels [:public]
end

```

```
eval_actions do
labels [:public]
resource MyApp.Posts.Post, actions: [:read]
end

```
Expand All @@ -53,6 +61,7 @@ end

| Name | Type | Default | Docs |
|------|------|---------|------|
| [`labels`](#eval_actions-labels){: #eval_actions-labels } | `list(atom)` | `[]` | Mapped action labels to expose to the eval/docs actions. An action is included only when it has all requested labels. |
| [`eval_action_name`](#eval_actions-eval_action_name){: #eval_actions-eval_action_name } | `atom` | `:eval` | Name of the synthesized eval action. Defaults to `:eval`. |
| [`docs_action_name`](#eval_actions-docs_action_name){: #eval_actions-docs_action_name } | `atom` | `:docs` | Name of the synthesized docs action. Defaults to `:docs`. |
| [`otp_app`](#eval_actions-otp_app){: #eval_actions-otp_app } | `atom` | | OTP app to scan when building the manifest. Defaults to the agent resource's domain's `:otp_app`. |
Expand Down
86 changes: 67 additions & 19 deletions documentation/how_to/integrate-with-ash-ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,39 @@ and tenant attached.

## The shape

Define one resource per "agent surface" you want to expose:
Declare the public Lua namespaces on your Ash domains, then define one
resource per "agent surface" you want to expose:

```elixir
defmodule MyApp.Content do
use Ash.Domain,
otp_app: :my_app,
extensions: [AshLua.Domain]

lua do
namespace "posts" do
action :list, MyApp.Posts.Post, :read, labels: [:public, :read_model]
action :stats, MyApp.Posts.Post, :get_statistics, labels: [:public, :read_model]
end

namespace "comments" do
action :list, MyApp.Posts.Comment, :read, labels: [:public, :read_model]
end

namespace "accounts" do
action :create_user, MyApp.Accounts.User, :create, labels: [:public, :writes]
action :list_users, MyApp.Accounts.User, :read, labels: [:public]
end
end
end

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]
labels [:public]
end
end
```
Expand All @@ -40,14 +61,16 @@ That's the whole declaration. The extension synthesizes two generic actions on
`MCPActions`:

* **`:eval`** — takes a Lua `script` and runs it through `AshLua.eval!/2`,
scoped to *only* the listed `(resource, action)` pairs. Returns
scoped to *only* mapped actions matching the configured labels. 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`);
* no arguments → the compact index (`AshLua.Docs.index_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 using `name`).
Use `name: "full"` to request the full rendered page
(`AshLua.Docs.full_doc/1`).

`name` and `search` are mutually exclusive.

Expand All @@ -65,7 +88,7 @@ eval_actions do
eval_action_name :run_lua
docs_action_name :describe_lua

resource MyApp.Posts.Post, actions: [:read]
labels [:public, :read_model]
end
```

Expand All @@ -87,14 +110,22 @@ 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).
Domain namespaces are the source of truth for the public Lua surface.
`eval_actions` selects which mapped actions the script and generated docs can
see. The script can only call public paths from actions whose labels match the
configured `labels`; everything else is invisible (no entry in the docs, no
callable in the Lua environment).

When multiple labels are listed, a mapped action must have all of them to be
included. A namespace can also declare `labels: [...]`; those labels are
inherited by every action inside it, but action-level labels are what give you
individual resolution. The legacy `resource ..., actions: ...` selector still
works for derived surfaces and can be combined with labels to narrow a labelled
surface by underlying Ash action.

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

```elixir
Expand All @@ -104,8 +135,7 @@ defmodule MyApp.Agents.ReadOnlyMCP do
extensions: [AshLua.EvalActions]

eval_actions do
resource MyApp.Posts.Post, actions: [:read]
resource MyApp.Posts.Comment, actions: [:read]
labels [:read_model]
end
end

Expand All @@ -115,8 +145,7 @@ defmodule MyApp.Agents.SupportMCP do
extensions: [AshLua.EvalActions]

eval_actions do
resource MyApp.Support.Ticket, actions: [:read, :create, :reassign]
resource MyApp.Accounts.User, actions: [:read]
labels [:support_agent]
end
end
```
Expand Down Expand Up @@ -158,6 +187,24 @@ 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.

## Optional: preload eval manifests

By default, the scoped eval manifest is built lazily when an `:eval` or `:docs`
action runs. For applications that need quicker first-invocation times in
production, preload every eval manifest during application boot:

```elixir
AshLua.preload_eval_manifests!(:my_app)
```

This stores the immutable scoped manifests in `:persistent_term`. Runtime eval
calls still build a fresh Lua VM and still receive the current actor, tenant,
and context; only the reusable surface specification is cached. Once preloaded,
checking the cache is a microsecond-level persistent-term read.

In development or test, lazy generation is usually simpler because code reloads
and changing DSL configuration require clearing or rebuilding the cache.

## A walkthrough

The user asks: _"How many overdue, high-priority todos do I have, and what's
Expand Down Expand Up @@ -229,9 +276,10 @@ for details.

`: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.
* **No arguments** — returns a compact index from `AshLua.Docs.index_doc/1`,
restricted to the scoped surface.
* **`name: "..."`** — returns a single focused page:
* `"full"` → the full markdown page from `AshLua.Docs.full_doc/1`;
* a callable path like `"work.todo.read"` → the per-operation page from
`AshLua.Docs.callable_doc/2`;
* a record-type path like `"work.todo"` or a named type like `"Status"`
Expand Down
Loading
Loading