Getting Started with AshJido

Copy Markdown View Source

AshJido bridges Ash Framework resources with Jido agents by automatically generating Jido.Action modules from your Ash actions. Every Ash action becomes a tool in an agent's toolbox while maintaining type safety and respecting Ash authorization policies.

Installation

Add ash_jido to your dependencies in mix.exs:

def deps do
  [
    {:ash_jido, "~> 0.2"}
  ]
end

Then fetch dependencies:

mix deps.get

Walkthrough Guides

For focused end-to-end examples, use these guides alongside this reference:

Core

Operations

Agent Integration

Basic Usage

Add the AshJido extension to your Ash resource and define which actions to expose in the jido section:

defmodule MyApp.Accounts.User do
  use Ash.Resource,
    domain: MyApp.Accounts,
    extensions: [AshJido]

  attributes do
    uuid_primary_key :id
    attribute :name, :string, allow_nil?: false
    attribute :email, :string, allow_nil?: false
    attribute :role, :atom, default: :user
  end

  actions do
    defaults [:read, :destroy]

    create :register do
      accept [:name, :email]
    end

    update :update_profile do
      accept [:name]
    end

    update :promote do
      accept []
      change set_attribute(:role, :admin)
    end
  end

  jido do
    action :register, name: "create_user", description: "Creates a new user account"
    action :read, name: "list_users"
    action :update_profile
    action :destroy
  end
end

This generates Jido.Action modules for each exposed action:

  • MyApp.Accounts.User.Jido.Register
  • MyApp.Accounts.User.Jido.Read
  • MyApp.Accounts.User.Jido.UpdateProfile
  • MyApp.Accounts.User.Jido.Destroy

Exposing All Actions

Use all_actions to quickly expose all public Ash actions on a resource with smart defaults:

jido do
  all_actions
end

all_actions uses Ash's public API boundary and skips actions with public?: false by default. Use explicit action :name entries for deliberate per-action exposure, or opt into a trusted/internal catalog:

jido do
  all_actions include_private?: true
end

Generated schemas also use Ash's public input boundary by default. Accepted attributes and action arguments marked public?: false are omitted from the Jido schema unless include_private?: true is set for a trusted/internal tool. This controls tool exposure; Ash authorization, policies, and runtime validation remain authoritative when the action executes.

You can filter which actions to expose:

jido do
  # Exclude specific actions
  all_actions except: [:destroy, :internal_update]
end
jido do
  # Only expose specific actions
  all_actions only: [:register, :read, :update_profile]
end

You can also add tags to all generated actions:

jido do
  all_actions tags: ["user-management", "public-api"]
end

And apply static relationship loads to all generated read actions:

jido do
  all_actions only: [:read], read_load: [:profile, :roles]
end

Using Generated Actions

Call the generated modules using run/2 with params and a context map. context[:domain] overrides the Ash resource's static domain: configuration; if neither is available, AshJido raises an ArgumentError.

# Create a user
{:ok, user} = MyApp.Accounts.User.Jido.Register.run(
  %{name: "John Doe", email: "john@example.com"},
  %{domain: MyApp.Accounts}
)

# List users (returns list of maps when output_map?: true)
{:ok, users} = MyApp.Accounts.User.Jido.Read.run(
  %{},
  %{domain: MyApp.Accounts}
)

# Update a user (requires the resource primary key; id for the default primary key)
{:ok, updated_user} = MyApp.Accounts.User.Jido.UpdateProfile.run(
  %{id: user[:id], name: "Jane Doe"},
  %{domain: MyApp.Accounts}
)

# Delete a user (requires the resource primary key; id for the default primary key)
{:ok, _} = MyApp.Accounts.User.Jido.Destroy.run(
  %{id: user[:id]},
  %{domain: MyApp.Accounts}
)

Context Options

The context map supports additional options for authorization and multi-tenancy:

context = %{
  domain: MyApp.Accounts,       # Required only when no static resource domain is configured or you need an override
  actor: current_user,          # Optional: for authorization policies
  tenant: "org_123",            # Optional: for multi-tenant apps
  authorize?: true,             # Optional: explicit authorization mode
  tracer: [MyApp.Tracer],       # Optional: Ash tracer modules
  scope: MyApp.Scope.for(user), # Optional: Ash scope
  context: %{request_id: "1"},  # Optional: Ash action context
  timeout: 15_000,              # Optional: Ash operation timeout
  signal_dispatch: {:pid, target: self()} # Optional: override signal dispatch
}

MyApp.Accounts.User.Jido.Register.run(params, context)

Configuration Options

Each action in the jido section supports these options:

OptionTypeDefaultDescription
namestring"resource_action"Custom name for the Jido action
module_nameatomResource.Jido.ActionNameCustom module name for the generated action
descriptionstringAsh action descriptionDescription for AI discovery and documentation
categorystringnilCategory for discovery/tool organization
tagslist(string)[]Tags for categorization and AI discovery
vsnstringnilOptional semantic version metadata
output_map?booleantrueConvert output structs to public-field maps
include_private?booleanfalseInclude inputs with public?: false in generated schemas for trusted/internal tools
loadtermnilStatic Ash.Query.load/2 statement for read actions
allowed_loadstermnilAllowlisted runtime load entries for read actions
query_params?booleantrueEnable query parameters (filter, sort, limit, offset, and allowlisted load) for read actions
max_page_sizepos_integernilMaximum limit value for read actions (clamps the limit parameter)
emit_signals?booleanfalseEmit Jido signals from Ash notifications (create/update/destroy)
signal_dispatchtermnilDefault signal dispatch config (overridable via context)
signal_typestringderivedOverride emitted signal type
signal_sourcestringderivedOverride emitted signal source

| signal_include | atom | list(atom) | :pkey_only | Data inclusion mode for generated-action signals | | telemetry? | boolean | false | Emit Jido-namespaced telemetry for generated action execution |

all_actions additionally supports:

  • read_load for static read relationship loading
  • read_query_params? to enable/disable query parameters for read actions
  • read_max_page_size to set maximum page size for read actions
  • include_private? to include Ash actions and inputs with public?: false in trusted/internal catalogs
  • category (default ash.<action_type>)
  • tags
  • vsn
  • emit_signals?, signal_dispatch, signal_type, signal_source, and telemetry?

Signals

Use AshJido.Notifier for Ash-native lifecycle publications to a Jido signal bus:

defmodule MyApp.Blog.Post do
  use Ash.Resource,
    domain: MyApp.Blog,
    extensions: [AshJido],
    notifiers: [AshJido.Notifier]

  jido do
    signal_bus MyApp.SignalBus
    signal_prefix "blog"

    publish :create, "blog.post.created", include: [:id, :title]
    publish_all :update, include: :changes_only
  end
end

Generated actions can also emit signals when a tool run needs runtime dispatch overrides or telemetry signal counters:

jido do
  action :create,
    emit_signals?: true,
    signal_dispatch: {:pid, target: self()},
    telemetry?: true
end

Both paths use AshJido.SignalFactory. Generated-action signals include primary key data in signal.data by default; use signal_include to widen the payload intentionally. Notifier publications use the configured include mode.

Telemetry

Telemetry is opt-in:

jido do
  action :create, telemetry?: true
end

When enabled, generated actions emit:

  • [:jido, :action, :ash_jido, :start]
  • [:jido, :action, :ash_jido, :stop]
  • [:jido, :action, :ash_jido, :exception]

Examples

jido do
  # Simple exposure with defaults
  action :create

  # Custom name for better AI discoverability
  action :read,
    name: "search_users",
    description: "Search for users by criteria",
    load: [:profile]

  # Add tags for categorization
  action :update,
    category: "ash.update",
    tags: ["user-management", "data-modification"],
    vsn: "1.0.0"

  # Custom module name
  action :promote, module_name: MyApp.Actions.PromoteUser

  # Disable output map conversion (keep Ash structs)
  action :special, output_map?: false
end

Tool Export Helpers

Use AshJido.Tools when integrating generated actions with tool-oriented agent systems:

AshJido.Tools.actions(MyApp.Accounts.User)
AshJido.Tools.actions(MyApp.Accounts)
AshJido.Tools.tools(MyApp.Accounts.User)

Sensor Bridge Helpers

If you use signal dispatch targets that should also feed sensor runtimes, use AshJido.SensorDispatchBridge:

AshJido.SensorDispatchBridge.forward(signal_or_message, sensor_runtime)
AshJido.SensorDispatchBridge.forward_many(messages, sensor_runtime)
AshJido.SensorDispatchBridge.forward_or_ignore(message, sensor_runtime)

Output Formats

By default (output_map?: true), Ash structs are converted to plain maps for easier consumption by agents and JSON serialization.

Set output_map?: false to preserve the original Ash resource structs in the output.

Policy Enforcement

AshJido respects Ash authorization policies. When you define policies on your resources, they are automatically enforced when actions are executed through the generated Jido modules.

defmodule MyApp.Accounts.SecureDocument do
  use Ash.Resource,
    domain: MyApp.Accounts,
    extensions: [AshJido],
    authorizers: [Ash.Policy.Authorizer]

  policies do
    policy action_type(:create) do
      authorize_if actor_present()
    end

    policy action_type(:read) do
      authorize_if always()
    end
  end

  jido do
    action :create
    action :read
  end
end

When calling actions, pass the actor in the context:

# This will fail with :forbidden - no actor provided
{:error, error} = SecureDocument.Jido.Create.run(
  %{title: "Secret"},
  %{domain: MyApp.Accounts, actor: nil}
)
error.details.reason  # => :forbidden

# This succeeds - actor is present
{:ok, doc} = SecureDocument.Jido.Create.run(
  %{title: "Secret"},
  %{domain: MyApp.Accounts, actor: current_user}
)

Error Handling

Ash errors are automatically converted to Jido's Splode-based error system:

Ash Error TypeJido Error Type
Ash.Error.InvalidJido.Action.Error.InvalidInputError (validation error)
Ash.Error.ForbiddenJido.Action.Error.ExecutionFailureError (with reason :forbidden)
Ash.Error.FrameworkJido.Action.Error.InternalError
Ash.Error.UnknownJido.Action.Error.InternalError

Field-level validation errors are preserved and accessible:

case MyApp.Accounts.User.Jido.Register.run(%{name: ""}, %{domain: MyApp.Accounts}) do
  {:ok, user} ->
    # Success
    user

  {:error, %Jido.Action.Error.InvalidInputError{} = error} ->
    # Access field-specific errors
    error.details.fields
    # => %{name: ["is required"]}
    
  {:error, %Jido.Action.Error.ExecutionFailureError{details: %{reason: :forbidden}}} ->
    # Authorization failed
    :unauthorized
end

Naming Conventions

Default Action Names

Auto-generated names follow verb-first patterns:

  • :create"create_<resource>" (e.g. "create_user")
  • :read with name :read"list_<resources>" (e.g. "list_users")
  • :read with name :by_id"get_<resource>_by_id" (e.g. "get_user_by_id")
  • :update"update_<resource>" (e.g. "update_user")
  • :destroy"delete_<resource>" (e.g. "delete_user")
  • custom :action"<resource>_<action_name>" or "<verb>_<resource>" for common verbs

Default Module Names

Modules are generated under the resource namespace:

  • MyApp.Accounts.User with :register action → MyApp.Accounts.User.Jido.Register
  • MyApp.Blog.Post with :publish action → MyApp.Blog.Post.Jido.Publish

Action Types

Each Ash action type maps to corresponding behavior:

Ash Action TypeBehavior
:createCreates a new record via Ash.create!
:readQueries records via Ash.read!
:updateUpdates a record using the resource primary key fields via Ash.update!
:destroyDeletes a record using the resource primary key fields and declared destroy arguments via Ash.destroy!
:actionRuns custom logic via Ash.run_action!

Complete Example

Here's a complete example with a domain, resource, and usage:

# lib/my_app/blog/domain.ex
defmodule MyApp.Blog do
  use Ash.Domain

  resources do
    resource MyApp.Blog.Post
  end
end

# lib/my_app/blog/post.ex
defmodule MyApp.Blog.Post do
  use Ash.Resource,
    domain: MyApp.Blog,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshJido]

  postgres do
    table "posts"
    repo MyApp.Repo
  end

  attributes do
    uuid_primary_key :id
    attribute :title, :string, allow_nil?: false
    attribute :body, :string
    attribute :status, :atom, default: :draft
    timestamps()
  end

  actions do
    defaults [:read, :destroy]

    create :create do
      accept [:title, :body]
    end

    update :update do
      accept [:title, :body]
    end

    update :publish do
      accept []
      change set_attribute(:status, :published)
    end
  end

  jido do
    action :create, 
      name: "create_post",
      description: "Create a new blog post draft",
      tags: ["content-management", "authoring"]

    action :read,
      name: "list_posts",
      description: "List and search blog posts"

    action :update,
      name: "edit_post",
      tags: ["content-management"]

    action :publish,
      name: "publish_post",
      description: "Publish a draft post",
      tags: ["content-management", "publishing"]

    action :destroy,
      name: "delete_post",
      tags: ["content-management", "destructive"]
  end
end

Using the generated actions:

alias MyApp.Blog.Post

# Create a post
{:ok, post} = Post.Jido.Create.run(
  %{title: "Hello World", body: "My first post"},
  %{domain: MyApp.Blog}
)

# List all posts
{:ok, posts} = Post.Jido.Read.run(%{}, %{domain: MyApp.Blog})

# Publish the post
{:ok, published} = Post.Jido.Publish.run(
  %{id: post.id},
  %{domain: MyApp.Blog}
)

Querying and Filtering

Generated Jido read actions support query parameters for filtering, sorting, pagination, and allowlisted relationship loading. These parameters are optional and provide powerful querying capabilities while respecting Ash's authorization policies.

Filter Syntax

Use the filter parameter to query records using Ash's filter input syntax:

# Simple equality filter
{:ok, users} = MyApp.Accounts.User.Jido.Read.run(
  %{filter: %{name: "John Doe"}},
  %{domain: MyApp.Accounts}
)

# Filter with operators
{:ok, adults} = MyApp.Accounts.User.Jido.Read.run(
  %{filter: %{age: %{greater_than: 18}}},
  %{domain: MyApp.Accounts}
)

# Multiple conditions (all must match)
{:ok, active_admins} = MyApp.Accounts.User.Jido.Read.run(
  %{filter: %{status: "active", role: "admin"}},
  %{domain: MyApp.Accounts}
)

# IN operator for multiple values
{:ok, users} = MyApp.Accounts.User.Jido.Read.run(
  %{filter: %{status: %{in: ["active", "pending"]}}},
  %{domain: MyApp.Accounts}
)

Common Filter Operators:

  • %{field: value} — Equality
  • %{field: %{greater_than: value}} — Greater than
  • %{field: %{less_than: value}} — Less than
  • %{field: %{greater_than_or_equal: value}} — Greater than or equal
  • %{field: %{less_than_or_equal: value}} — Less than or equal
  • %{field: %{in: [value1, value2]}} — Match any value in list
  • %{field: %{contains: "substring"}} — String contains (case-sensitive)

Sorting

Use the sort parameter to order results. You can specify sorting as JSON-style entries, a keyword list, or a string:

# JSON-style entries (tool-call friendly)
{:ok, users} = MyApp.Blog.Post.Jido.Read.run(
  %{sort: [%{"field" => "created_at", "direction" => "desc"}]},
  %{domain: MyApp.Blog}
)

# Keyword list syntax
{:ok, users} = MyApp.Blog.Post.Jido.Read.run(
  %{sort: [created_at: :desc, title: :asc]},
  %{domain: MyApp.Blog}
)

# String syntax (- prefix for descending)
{:ok, users} = MyApp.Blog.Post.Jido.Read.run(
  %{sort: "-created_at,title"},
  %{domain: MyApp.Blog}
)

Pagination

Use limit and offset for pagination:

# First page (20 items)
{:ok, page1} = MyApp.Accounts.User.Jido.Read.run(
  %{limit: 20, offset: 0},
  %{domain: MyApp.Accounts}
)

# Second page
{:ok, page2} = MyApp.Accounts.User.Jido.Read.run(
  %{limit: 20, offset: 20},
  %{domain: MyApp.Accounts}
)

# Combine with filtering and sorting
{:ok, active_users_page} = MyApp.Accounts.User.Jido.Read.run(
  %{
    filter: %{status: "active"},
    sort: [name: :asc],
    limit: 50,
    offset: 100
  },
  %{domain: MyApp.Accounts}
)

Dynamic Relationship Loading

Use the load parameter to dynamically load relationships at query time:

# Load a single relationship
{:ok, posts} = MyApp.Blog.Post.Jido.Read.run(
  %{load: :author},
  %{domain: MyApp.Blog}
)

# Load multiple relationships
{:ok, posts} = MyApp.Blog.Post.Jido.Read.run(
  %{load: [:author, :comments, :tags]},
  %{domain: MyApp.Blog}
)

# Load nested relationships
{:ok, posts} = MyApp.Blog.Post.Jido.Read.run(
  %{load: [author: [:profile, :roles]]},
  %{domain: MyApp.Blog}
)

# Combine with other query parameters
{:ok, posts} = MyApp.Blog.Post.Jido.Read.run(
  %{
    filter: %{status: "published"},
    sort: [published_at: :desc],
    limit: 10,
    load: [author: :profile, comments: :author]
  },
  %{domain: MyApp.Blog}
)

Configuration

Query parameters are enabled by default for read actions. You can configure this behavior:

jido do
  # Query params enabled by default
  action :read

  # Disable query params for a specific action
  action :read, query_params?: false

  # Set maximum page size (clamps limit parameter)
  action :read, max_page_size: 100

  # Combine with static load
  action :read, load: :profile, max_page_size: 50

  # Configure defaults for all read actions
  all_actions only: [:read], read_query_params?: true
  all_actions only: [:read], read_max_page_size: 100
end

Security Note: Query parameters use Ash's safe filter_input and sort_input variants, which:

  • Only allow filtering and sorting on public attributes
  • Honor field policies and authorization rules
  • Prevent access to private or sensitive fields
  • Validate all input before executing queries

Next Steps