Skip to main content

guides/ash-jido.md

# AshJido Resources

This guide explains how to expose an Ash resource as a source of model-callable
Jidoka operations through the `ash_resource` DSL entity. AshJido generates one
action module per Ash action, and Jidoka picks those up as ordinary `:ash_resource`
operations on the agent spec. By the end you will be able to register a resource,
filter which actions reach the model, understand the safety implications of
exposing `:create`, `:update`, and `:destroy`, and import the same shape from
JSON or YAML.

## When To Use This

- Use this guide when an Ash resource is the source of truth for data your agent
  should read or mutate, and you want each Ash action to surface as one tool.
- Use this guide when you want generated parameter schemas and consistent
  errors across read, create, update, and destroy paths without writing one
  `Jidoka.Action` per Ash action.
- Do **not** use this guide for one-off business logic that does not belong on
  the resource. Reach for a `Jidoka.Workflow` instead. See
  [Skill, Workflow, And Subagent Tools](skill-workflow-subagent-tools.md).

## Prerequisites

- A working Jidoka DSL agent. See [Getting Started](getting-started.md).
- An Ash domain and at least one resource that includes the AshJido extension,
  for example:

```elixir
defmodule MyApp.Support.Ticket do
  use Ash.Resource,
    domain: MyApp.Support,
    extensions: [AshJido]

  ash_jido do
    expose [:read, :create]
  end

  actions do
    defaults [:read, :create, :update]
  end
end
```

- `AshJido.Tools` is available at compile time. Jidoka resolves it through
  `Application.get_env(:jidoka, :ash_jido_tools, AshJido.Tools)` so tests may
  inject a double.

## Quick Example

The smallest agent backed by an Ash resource is one resource plus one DSL
module.

```elixir
defmodule MyApp.SupportAgent do
  use Jidoka.Agent

  agent :support_agent do
    model "openai:gpt-4o-mini"
    instructions "Look up tickets before answering. Use create_ticket only when asked."
  end

  tools do
    ash_resource MyApp.Support.Ticket, actions: [:read, :create]
  end
end
```

That spec exposes one operation per filtered Ash action. The model sees
`read_ticket` and `create_ticket`; the resource owns persistence and
authorization. No process is started by this declaration.

## Concepts

```diagram
╭───────────────────────────╮
│ Ash resource              │
│  + AshJido extension      │
╰─────────────┬─────────────╯
              │ AshJido.Tools.actions/1
╭───────────────────────────╮     ╭──────────────────────────╮
│ Generated Jido action     │────▶│ Jidoka.Agent.Spec.Operation │
│ modules (one per action)  │     │ metadata.source = "ash_resource" │
╰─────────────┬─────────────╯     ╰──────────────────────────╯
              │ JidoActions.operations/2
╭───────────────────────────╮
│ Jidoka turn loop          │
│ same effect path as       │
│ deterministic actions     │
╰───────────────────────────╯
```

Three concepts cover this integration:

1. **AshJido generation.** AshJido inspects the resource's exposed actions and
   emits one Jido action module per action. Each module exports `to_tool/0` and
   `run/2`, which is everything Jidoka needs.
2. **Filtering.** The DSL `actions: [...]` list limits which generated modules
   become operations. The default empty list means "every generated action".
3. **Metadata tagging.** Each compiled operation carries `metadata.source =
   "ash_resource"` and `metadata.resource = inspect(MyApp.Support.Ticket)`. The
   spec also records a `tool_sources` entry summarizing what was registered.

### Security / Trust Boundaries

- The DSL trusts the resource module. Never derive `ash_resource MyResource`
  from user input; gate registrations behind an internal allowlist.
- `actions:` is the only place in the DSL that limits *which* actions reach the
  model. Treat it as the production allowlist for write actions. A bare
  `ash_resource MyResource` exposes every AshJido-generated action.
- AshJido does not bypass resource policies. Authorization runs through Ash as
  normal; the runtime context propagated to the action carries actor and tenant.
- Generated parameter schemas come from the resource. If the resource has a
  sensitive attribute that should not be exposed, mark it private at the
  resource level, not at the agent level.
- The runtime never serializes credentials into `metadata`. Resource modules,
  resource names, and action names are the only identifiers surfaced.

## How To

### Step 1: Register A Read-Only Resource

Read paths are the safest starting point. They are pure with respect to your
data and idempotent for caching.

```elixir
defmodule MyApp.ReadAgent do
  use Jidoka.Agent

  agent :read_agent do
    instructions "Use read_ticket when asked about ticket status."
  end

  tools do
    ash_resource MyApp.Support.Ticket, actions: [:read]
  end
end
```

Confirm with `Jidoka.inspect(MyApp.ReadAgent)`. The `operations` list should
contain one `:ash_resource` operation per filtered action.

### Step 2: Add A Mutating Action With An Approval Control

When you allow write actions, pair them with a control that gates execution.

```elixir
defmodule MyApp.RequireApproval do
  use Jidoka.Control, name: "require_ash_approval"

  @impl true
  def call(_operation), do: {:interrupt, :approval_required}
end

defmodule MyApp.SupportAgent do
  use Jidoka.Agent

  agent :support_agent do
    instructions "Use create_ticket only after the user confirms."
  end

  tools do
    ash_resource MyApp.Support.Ticket, actions: [:read, :create]
  end

  controls do
    operation MyApp.RequireApproval, when: [source: "ash_resource", name: "create_ticket"]
  end
end
```

Controls match against `Jidoka.Agent.Spec.Operation` metadata, which is why
`source: "ash_resource"` is a stable filter.

### Step 3: Pass A Tenant And Actor Through Context

Ash needs an actor and tenant to enforce policies. Both flow through the turn
context.

```elixir
{:ok, result} =
  Jidoka.turn(MyApp.SupportAgent, "Open ticket for refund of order 42.",
    context: %{actor: current_user, tenant: tenant_id},
    llm: llm
  )
```

The `:ash_resource` capability forwards the public context (everything that is
not stripped by a `forward_context: {:except, ...}` policy) into the generated
Jido action's `context` argument.

### Step 4: Import The Same Agent From YAML

The DSL is one authoring path. JSON and YAML imports compile into the same
spec, but every module reference must be resolved through a registry the caller
supplies.

```elixir
yaml = """
agent:
  id: support_agent
  model: openai:gpt-4o-mini
  instructions: Look up tickets before answering.
tools:
  ash_resources:
    - resource: my_app.support.ticket
      actions: [read, create]
"""

{:ok, spec} =
  Jidoka.import(yaml,
    ash_resources: %{"my_app.support.ticket" => MyApp.Support.Ticket}
  )
```

Imports never call `String.to_atom/1` on input. Unknown resource names produce
an `Jidoka.Error.Invalid` with the offending key.

### Step 5: Inspect The Operation Metadata

The spec metadata records exactly what was registered, including whether
expansion succeeded.

```elixir
spec = MyApp.SupportAgent.spec()

spec.metadata["tool_sources"]
#=> [%{"source" => "ash_resource", "resource" => "MyApp.Support.Ticket",
#      "actions" => ["read", "create"], "expanded?" => true}]
```

`expanded?: false` means AshJido did not return generated modules. The most
common cause is a missing extension on the resource.

## Common Patterns

- **Pin `actions:` even for reads.** An explicit list makes future audits
  cheap and prevents a new action from silently reaching the model.
- **Keep write actions behind a control.** Use `operation MyControl, when: [source: "ash_resource", name: "create_ticket"]`
  to require approval, dry runs, or rate limiting.
- **Use separate agents for read and write.** A `ReadAgent` exposing only
  `:read` and a `WriteAgent` exposing `:create`/`:update` is easier to reason
  about than one agent with both.
- **Surface generated descriptions.** AshJido derives the action description
  from the resource. Improve it on the resource, not at the agent layer.

## Testing

Tests can drive ash_resource agents with the same deterministic capabilities
used elsewhere. The Ash action is real; only the LLM is faked.

```elixir
defmodule MyApp.ReadAgentTest do
  use ExUnit.Case, async: true

  test "agent calls read_ticket" do
    llm = fn _intent, journal ->
      llm_calls =
        Enum.count(journal.results, fn {_id, r} -> r.kind == :llm end)

      case llm_calls do
        0 ->
          {:ok, %{type: :operation, name: "read_ticket", arguments: %{"id" => "T-1"}}}

        1 ->
          {:ok, %{type: :final, content: "Ticket T-1 is open."}}
      end
    end

    assert {:ok, result} =
             Jidoka.turn(MyApp.ReadAgent, "Status of T-1?", llm: llm)

    assert result.content =~ "T-1"
  end
end
```

For unit tests of the registration step itself, swap `AshJido.Tools` with a
double:

```elixir
defmodule MyApp.FakeAshTools do
  def actions(MyApp.Support.Ticket), do: [MyApp.Support.Generated.Read]
end

Application.put_env(:jidoka, :ash_jido_tools, MyApp.FakeAshTools)
```

## Troubleshooting

| Symptom | Likely Cause | Fix |
| --- | --- | --- |
| `expanded?: false` in `tool_sources` | AshJido did not generate any actions; usually the extension is missing on the resource. | Add `extensions: [AshJido]` and at least one exposed action. |
| `{:error, {:duplicate_operation_source_name, name}}` | Two registrations expose the same action name. | Make `actions:` lists disjoint or rename through a custom Ash action name. |
| `Ash.Error.Forbidden` from a turn | The runtime context did not carry an actor or tenant. | Pass `context: %{actor: ..., tenant: ...}` to `Jidoka.turn/3`. |
| `to_tool/0` rescued internally and the action is missing | AshJido could not project the action. | Inspect with `AshJido.Tools.tools(MyApp.Support.Ticket)` and resolve the generation error on the resource. |
| Import fails with `:invalid` on `ash_resources` | A name was not in the supplied registry. | Add the name under `ash_resources: %{...}` in the `Jidoka.import/2` call. |

## Reference

Key modules touched in this guide:

- [`Jidoka.Agent`](`Jidoka.Agent`) - DSL entry point that hosts the `tools do
  ash_resource ... end` entity.
- Tool DSL section - DSL
  schema for `ash_resource`, including `:actions`, `:description`,
  `:idempotency`, and `:metadata` options.
- [`Jidoka.Agent.Spec.Operation`](`Jidoka.Agent.Spec.Operation`) - the compiled
  operation entry tagged with `metadata.source = "ash_resource"`.
- [`AshJido.Tools`](`AshJido.Tools`) - generator helper Jidoka uses to discover
  action modules for a resource.

## Related Guides

- [Getting Started](getting-started.md) - the smallest DSL agent end to end.
- [Skill, Workflow, And Subagent Tools](skill-workflow-subagent-tools.md) -
  the three other DSL-level operation sources that compile through
  `Jidoka.Operation.Source`.
- [Controls](controls.md) - how to gate `create`, `update`, and `destroy`
  operations with approvals or dry runs.
- [Browser Tools](browser-tools.md) - a sibling source for constrained
  external reads.
- [MCP Tools](mcp-tools.md) - a sibling source for remote MCP servers.