This guide covers all configuration options available for rpc_action declarations, including load restrictions, query controls, identity lookups, and more.
Overview
Each rpc_action accepts two required arguments and optional configuration:
rpc_action :function_name, :ash_action_name, options| Argument | Description |
|---|---|
| First | Name of the generated TypeScript function |
| Second | Name of the Ash action to execute |
| Options | Keyword list of configuration options |
Load Restrictions
Control which relationships and calculations clients can request using allowed_loads and denied_loads.
Load restrictions shape the API surface of an action — they are not an authorization mechanism. Access control is handled by your Ash policies (including field policies), which apply to every load regardless of these options. Use load restrictions to keep an endpoint's loadable surface tight, most commonly to stop clients from requesting expensive loads (heavy aggregates, deep relationship trees) on actions that don't need them.
Both lists are validated at compile time: every entry must resolve to a loadable public field (relationship, calculation, aggregate, or embedded-resource attribute), with nested keywords checked against the relationship's destination resource. Typos fail compilation instead of silently never matching.
allowed_loads (Whitelist)
Only allow loading specific fields:
typescript_rpc do
resource MyApp.Todo do
# Only user and tags can be loaded
rpc_action :list_todos, :read, allowed_loads: [:user, :tags]
end
end// Allowed
const result = await listTodos({
fields: ["id", "title", { user: ["name"], tags: ["name"] }]
});
// Error: "comments" not in allowed_loads
const result = await listTodos({
fields: ["id", "title", { comments: ["text"] }]
});denied_loads (Blacklist)
Block specific fields while allowing all others:
typescript_rpc do
resource MyApp.Todo do
# Everything except the expensive aggregates can be loaded
rpc_action :list_todos, :read, denied_loads: [:activity_stats, :full_history]
end
end// Allowed (user is not denied)
const result = await listTodos({
fields: ["id", "title", { user: ["name"] }]
});
// Error: "activity_stats" is denied
const result = await listTodos({
fields: ["id", "title", { activity_stats: ["total_events"] }]
});Nested Load Restrictions
Restrict nested relationships using keyword list syntax:
typescript_rpc do
resource MyApp.Todo do
# Allow user, but only allow loading user's public_profile
rpc_action :list_todos, :read,
allowed_loads: [
:tags,
user: [:public_profile]
]
end
end// Allowed
const result = await listTodos({
fields: [
"id",
{ user: ["name", { public_profile: ["bio"] }] }
]
});
// Error: user.private_settings not allowed
const result = await listTodos({
fields: [
"id",
{ user: ["name", { private_settings: ["data"] }] }
]
});TypeScript Type Generation
Load restrictions affect the generated TypeScript types. With allowed_loads, only the allowed fields appear in the field selection types:
rpc_action :list_todos, :read, allowed_loads: [:user]// Generated type only includes "user" as a loadable field
// "comments", "tags", etc. are not available in autocomplete
const result = await listTodos({
fields: ["id", "title", { user: ["name"] }] // Only user is available
});Error Responses
When a client requests a restricted field:
// With allowed_loads: [:user]
const result = await listTodos({
fields: ["id", { comments: ["text"] }] // "comments" not allowed
});
// Returns:
// {
// success: false,
// errors: [{
// type: "load_not_allowed",
// message: "Field 'comments' is not in the allowed loads list",
// fields: ["comments"]
// }]
// }// With denied_loads: [:activity_stats]
const result = await listTodos({
fields: ["id", { activity_stats: ["total_events"] }]
});
// Returns:
// {
// success: false,
// errors: [{
// type: "load_denied",
// message: "Field 'activity_stats' is denied",
// fields: ["activity_stats"]
// }]
// }When to Use Each
| Option | Use When |
|---|---|
allowed_loads | An action only needs a small, known set of loadable fields |
denied_loads | Most fields are fine and you only need to block a few expensive ones |
Best practice: Use allowed_loads when you want a lean endpoint with an explicit loadable surface. Use denied_loads when you only need to keep a few expensive loads off an action. In both cases, rely on Ash policies — not load restrictions — for access control.
Query Controls
enable_filter?
Disable client-side filtering:
typescript_rpc do
resource MyApp.Todo do
# Standard action with filtering
rpc_action :list_todos, :read
# Server controls filtering via action arguments
rpc_action :list_recent_todos, :list_recent, enable_filter?: false
end
endWhen enable_filter?: false:
- The
filterparameter is not included in TypeScript types - Filter types for this action are not generated
- Any filter sent by client is silently ignored
// With enable_filter?: false
const result = await listRecentTodos({
fields: ["id", "title"],
input: { daysBack: 7 } // Use action arguments for filtering
// filter: { ... } // Not available in types
});enable_sort?
Disable client-side sorting:
typescript_rpc do
resource MyApp.Todo do
# Standard action with sorting
rpc_action :list_todos, :read
# Server controls ordering
rpc_action :list_ranked_todos, :read, enable_sort?: false
end
endWhen enable_sort?: false:
- The
sortparameter is not included in TypeScript types - Any sort sent by client is silently ignored
// With enable_sort?: false
const result = await listRankedTodos({
fields: ["id", "title", "rank"]
// sort: "-rank" // Not available in types
});Combining Controls
# Fully server-controlled action
rpc_action :list_curated_todos, :read,
enable_filter?: false,
enable_sort?: false,
allowed_loads: [:user]Get Actions
get?
Constrain a read action to return a single record:
typescript_rpc do
resource MyApp.User do
rpc_action :get_current_user, :read, get?: true
end
endUses Ash.read_one instead of Ash.read, returning a single record or error.
get_by
Look up a single record by specific fields:
typescript_rpc do
resource MyApp.User do
rpc_action :get_user_by_email, :read, get_by: [:email]
end
endconst result = await getUserByEmail({
getBy: { email: "user@example.com" },
fields: ["id", "name", "email"]
});not_found_error?
Control behavior when a get action finds no record:
typescript_rpc do
resource MyApp.User do
# Returns error when not found (default)
rpc_action :get_user, :read, get_by: [:id]
# Returns null when not found
rpc_action :find_user, :read, get_by: [:email], not_found_error?: false
end
end// With not_found_error?: false
const result = await findUser({
getBy: { email: "maybe@example.com" },
fields: ["id", "name"]
});
if (result.success) {
if (result.data) {
console.log("Found:", result.data.name);
} else {
console.log("User not found"); // No error, just null
}
}Identity Lookups
Control how records are located for update and destroy actions.
Primary Key (Default)
rpc_action :update_user, :update
# Equivalent to: identities: [:_primary_key]await updateUser({
identity: "550e8400-e29b-41d4-a716-446655440000",
input: { name: "New Name" },
fields: ["id", "name"]
});Named Identity
First define the identity on your resource:
defmodule MyApp.User do
use Ash.Resource
identities do
identity :unique_email, [:email]
end
endThen configure the RPC action:
rpc_action :update_user_by_email, :update, identities: [:unique_email]await updateUserByEmail({
identity: { email: "user@example.com" },
input: { name: "New Name" },
fields: ["id", "name"]
});Multiple Identities
Allow either primary key or named identity:
rpc_action :update_user, :update, identities: [:_primary_key, :unique_email]// By primary key
await updateUser({
identity: "550e8400-e29b-41d4-a716-446655440000",
input: { name: "Via PK" },
fields: ["id"]
});
// By email
await updateUser({
identity: { email: "user@example.com" },
input: { name: "Via Email" },
fields: ["id"]
});Actor-Scoped Actions
For actions that operate on the current actor:
# Action filters to current user
defmodule MyApp.User do
actions do
update :update_me do
change relate_actor(:id)
end
end
end
# No identity needed
rpc_action :update_me, :update_me, identities: []// No identity parameter - operates on authenticated user
await updateMe({
input: { name: "My New Name" },
fields: ["id", "name"]
});Composite Identities
Identities spanning multiple fields:
identities do
identity :by_tenant_user, [:tenant_id, :user_id]
end
rpc_action :update_subscription, :update, identities: [:by_tenant_user]await updateSubscription({
identity: {
tenantId: "tenant-uuid",
userId: "user-uuid"
},
input: { status: "active" },
fields: ["id", "status"]
});Metadata Fields
Expose action metadata to clients:
rpc_action :list_todos, :read, show_metadata: [:total_count, :has_more]See Action Metadata for details.
Quick Reference
| Option | Type | Default | Description |
|---|---|---|---|
allowed_loads | list(atom | keyword) | nil | Whitelist of loadable fields |
denied_loads | list(atom | keyword) | nil | Blacklist of loadable fields |
enable_filter? | boolean | true | Enable client-side filtering |
enable_sort? | boolean | true | Enable client-side sorting |
get? | boolean | false | Return single record |
get_by | list(atom) | nil | Fields for single-record lookup |
not_found_error? | boolean | true | Error vs null on not found |
identities | list(atom) | [:_primary_key] | Allowed identity lookups |
show_metadata | list(atom) | false | nil | nil | Metadata fields to expose |
metadata_field_names | keyword | nil | Metadata field name mappings |
namespace | atom | string | nil | Organize action into namespace |
description | string | nil | Custom JSDoc description |
deprecated | boolean | string | nil | Mark as deprecated |
see | list(atom) | nil | Related actions for @see tags |
Next Steps
- Querying Data - Filtering, sorting, pagination
- CRUD Operations - Update and destroy patterns
- Action Metadata - Exposing metadata to clients
- Field Selection - Field selection patterns
- Developer Experience - Namespaces, JSDoc, and manifest generation