ash-framework
Skill4 starsChanged 4 months agoMITView on GitHub
MANDATORY when considering, adopting, or working with Ash Framework for Elixir applications. Invoke before starting a new Ash project or major refactor. Guides defining Ash resources with attributes and relationships, configuring actions and policies, using Ash extensions (AshPostgres, AshPhoenix, AshJsonApi), and migrating from Phoenix contexts to Ash DSL patterns. Trigger words: Ash Framework, Ash resource, Ash action, resource-oriented, DSL, alternative to contexts,
1 other project uses a version of this.
Adopted by
Most recently taken up by
- T0ha/camelottheir own version, first seen Nov 8, 2026
Other versions1 project uses a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 1
- T0ha/camelot★ 17same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository34 other files it gives its agents.
- AGENTS.mdAGENTS.md
- apply-phoenix-controller-conventionsSkill
- benchee-profilingSkill
- broadway-data-pipelinesSkill
- cachex-cachingSkill
- code-qualitySkill
- code-reviewSkill
- deployment-gotchasSkill
- ecto-changeset-patternsSkill
- ecto-essentialsSkill
- ecto-migrationSkill
- ecto-nested-associationsSkill
- elixir-essentialsSkill
- gettext-i18nSkill
- liveview-streamsSkill
- mix-tasks-generatorsSkill
- oban-essentialsSkill
- otp-essentialsSkill
- phoenix-authenticationSkill
- phoenix-authorization-patternsSkill
- phoenix-channels-essentialsSkill
- phoenix-json-apiSkill
- phoenix-liveview-essentialsSkill
- phoenix-pubsub-patternsSkill
- phoenix-uploadsSkill
- property-based-testingSkill
- refactor-codeSkill
- req-http-clientSkill
- respond-to-reviewSkill
- security-essentialsSkill
- swoosh-emailsSkill
- telemetry-essentialsSkill
- testing-essentialsSkill
- typespec-dialyzerSkill
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 1 is distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 36
Follow this sequence when starting a new Ash project:
- Very specific: line 165
Debugging authorization failures: If a call raises Ash.Error.Forbidden, enable policy breakdown logging:
- Very specific: line 175
Add {:ash_phoenix, "~> 2.0"} to deps.
- Very specific: line 195
Add {:ash_json_api, "~> 1.0"} to deps.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Ash Framework
RULES — Follow these with no exceptions
1. Use use Ash.Resource for domain resources — never manually implement protocols
2. Define actions explicitly — don't rely on defaults [:read, :create] without understanding what they expose
3. Add policies for authorization — every resource with sensitive data must have explicit policy blocks
4. Use Ash.Changeset.for_create/3 and Ash.Changeset.for_update/3 — not bare struct manipulation
5. Run mix ash_postgres.generate_migrations before manual migration — let Ash generate the schema
6. Verify resource loads — run mix compile and confirm no Spark.Error.DslError before proceeding
End-to-End Workflow
Follow this sequence when starting a new Ash project:
- Add dependencies — add
{:ash, "~> 3.0"}and{:ash_postgres, "~> 2.0"}tomix.exs - Configure Repo — change
use Ecto.Repotouse AshPostgres.Repo, otp_app: :my_app - Define Domain module — create a domain with
use Ash.Domainandresources do ... end - Define Resource — use
use Ash.Resource, domain: MyApp.Domain, data_layer: AshPostgres.DataLayer - Configure postgres — add
tableandrepoin thepostgres doblock - Define attributes — use
uuid_primary_key,attribute,timestamps()in theattributes doblock - Define relationships — use
belongs_to,has_many,many_to_manyinrelationships doblock - Define actions — use
actions dowithdefaults,create,update,readblocks - Add policies — use
policies doblock withauthorize_iforforbid_ifrules - Generate migrations — run
mix ash_postgres.generate_migrationsthenmix ash_postgres.migrate - Test with Ash API — use
Domain.create!(resource, attributes)to verify the resource works
Core Concepts
Resource Definition
defmodule MyApp.Blog.Post do
use Ash.Resource,
domain: MyApp.Blog,
data_layer: AshPostgres.DataLayer
postgres do
table "posts"
repo MyApp.Repo
end
attributes do
uuid_primary_key :id
attribute :title, :string do
allow_nil? false
constraints [max_length: 255]
end
attribute :body, :string do
allow_nil? false
end
attribute :status, :atom do
constraints [one_of: [:draft, :published, :archived]]
default :draft
end
timestamps()
end
relationships do
belongs_to :author, MyApp.Accounts.User do
allow_nil? false
end
has_many :comments, MyApp.Blog.Comment
end
actions do
defaults [:read, :destroy]
create :create do
primary? true
accept [:title, :body, :status, :author_id]
end
update :publish do
accept []
change set_attribute(:status, :published)
end
read :published do
filter expr(status == :published)
end
end
endUsing Actions
# Create a post
post =
MyApp.Blog.Post
|> Ash.Changeset.for_create(:create, %{
title: "Hello World",
body: "This is my first post",
author_id: user.id
})
|> MyApp.Blog.create!()
# Read posts
posts =
MyApp.Blog.Post
|> Ash.Query.for_read(:published)
|> Ash.Query.filter(author_id == ^user.id)
|> MyApp.Blog.read!()
# Update post
post
|> Ash.Changeset.for_update(:publish)
|> MyApp.Blog.update!()Policies (Authorization)
policies do
policy action_type(:read) do
authorize_if relates_to_actor_via(:author)
authorize_if expr(status == :published)
end
policy action_type(:create) do
authorize_if actor_present()
end
policy action(:update) do
authorize_if relates_to_actor_via(:author)
end
policy action(:destroy) do
authorize_if relates_to_actor_via(:author)
end
endDebugging authorization failures: If a call raises Ash.Error.Forbidden, enable policy breakdown logging:
# config/dev.exs
config :ash, :policies, log_policy_breakdowns: :errorAshPhoenix LiveView Integration
Add {:ash_phoenix, "~> 2.0"} to deps. See AshPhoenix docs for full LiveView and form component examples.
# Build form from changeset in mount
form =
post
|> Ash.Changeset.for_update(:update, %{})
|> AshPhoenix.Form.for_update()
|> to_form()
# Handle save event — reassign form on error
case Blog.update(Ash.Changeset.for_update(post, :update, params)) do
{:ok, post} -> {:noreply, put_flash(socket, :info, "Saved.") |> assign(post: post)}
{:error, cs} -> {:noreply, assign(socket, form: cs |> AshPhoenix.Form.for_update() |> to_form())}
endAshJsonApi Integration
Add {:ash_json_api, "~> 1.0"} to deps. See AshJsonApi docs for pagination, includes, and error serialization.
# In your resource
use Ash.Resource,
domain: MyApp.Blog,
data_layer: AshPostgres.DataLayer,
extensions: [AshJsonApi.Resource]
json_api do
type "post"
routes do
base "/posts"
get :read
index :published
post :create
patch :publish
end
end# router.ex
scope "/api/json" do
pipe_through :api
forward "/", AshJsonApi.Router, domains: [MyApp.Blog]
endCalculations and Aggregates
aggregates do
count :comment_count, :comments
count :published_comment_count, :comments do
filter expr(status == :published)
end
end
# Use in queries
MyApp.Blog.Post
|> Ash.Query.filter(comment_count > 0)
|> MyApp.Blog.read!()Common Pitfalls
| ❌ Don't | ✅ Do |
Rely on defaults [:read, :create] without knowing what they expose | Define actions explicitly and accept only the intended attributes |
Build filters with string interpolation ("status == '#{s}'") | Use pinned expressions: Ash.Query.filter(status == ^status) |
| Alter the DB schema by hand before defining the resource | Define the resource first, then mix ash_postgres.generate_migrations |
| Skip policy blocks on resources with sensitive data | Add policies do ... end with explicit authorize_if/forbid_if |
| Manipulate structs directly for writes | Use Ash.Changeset.for_create/3 and Ash.Changeset.for_update/3 |
| Rescue a generic error and lose context | Match specific types: Ash.Error.Forbidden, Ash.Error.Query.NotFound |
| Offset-paginate large result sets | Use keyset pagination (Ash.Query.page(after: ...)) |
Custom Validations — use the action layer, not DB constraints
create :create do
accept [:title, :body, :author_id]
validate str_length(:title, min: 1, max: 255) do
message "Title must be between 1 and 255 characters"
end
endFor multi-field or conditional logic, implement a custom Ash.Resource.Validation module:
defmodule MyApp.Validations.TitleNotBlank do
use Ash.Resource.Validation
@impl true
def validate(changeset, _opts, _context) do
case Ash.Changeset.get_attribute(changeset, :title) do
nil -> {:error, field: :title, message: "can't be blank"}
"" -> {:error, field: :title, message: "can't be blank"}
_ -> :ok
end
end
endFiltering — use ^ for safe interpolation, never string interpolation
# NEVER: Ash.Query.filter("status == '#{params["status"]}'"}) -- injection risk
MyApp.Blog.Post
|> Ash.Query.filter(status == ^status and author_id == ^current_user.id)
|> Ash.Query.sort([inserted_at: :desc])Not Found — match on Ash.Error.Query.NotFound explicitly
case MyApp.Blog.Post |> Ash.get(id) do
{:ok, post} -> {:ok, post}
{:error, %Ash.Error.Query.NotFound{}} -> {:error, :not_found}
{:error, error} -> {:error, error}
endError Handling — match Ash error types specifically
case MyApp.Blog.Post
|> Ash.Changeset.for_create(params)
|> MyApp.Blog.create() do
{:ok, post} -> {:ok, post}
{:error, %Ash.Error.InvalidInput{fields: fields}} -> {:error, :validation, fields}
{:error, %Ash.Error.Forbidden{}} -> {:error, :unauthorized}
{:error, %Ash.Error.Changeset{errors: errors}} -> {:error, :invalid_changeset, errors}
{:error, error} ->
Logger.error("Unexpected error: #{inspect(error)}")
{:error, :internal_error}
endPagination — use keyset pagination for large result sets
MyApp.Blog.Post
|> Ash.Query.page(limit: 20, after: last_inserted_at)
|> MyApp.Blog.read!()Migrations from Ecto to Ash
Always create the Ash resource first, then let Ash generate migrations — never alter the DB schema before defining the resource.
# Step 1: Create Ash resource matching existing schema
defmodule MyApp.Blog.Post do
use Ash.Resource, domain: MyApp.Blog, data_layer: AshPostgres.DataLayer
postgres do
table "posts"
repo MyApp.Repo
end
end
# Step 2: Generate and run migration
# mix ash_postgres.generate_migrations
# mix ash_postgres.migrate
# Step 3: Update context to delegate to Ash
def get_post!(id) do
MyApp.Blog.Post |> Ash.get!(id)
endIntegration
| Predecessor | This Skill | Successor |
| elixir-essentials | ash-framework | phoenix-json-api |
| ecto-essentials | ash-framework | phoenix-authorization-patterns |
Companion skills:
- testing-essentials — test Ash actions and policies
- phoenix-liveview-essentials — wire AshPhoenix forms into LiveView
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

