Notes for AI assistants (Claude Code, Cursor, Continue, etc.) and humans pairing with them.
Each Ash domain in this repo (DiffoExample.Access, DiffoExample.Nbn) declares two parallel lists:
resources do ... resource X do define :name, action: :foo end end— the code-interface surface, generatingMyDomain.name/...functions for Elixir callers.tools do tool :name, X, :foo end— the MCP surface, exposing the same actions to AI agents viaash_ai.
When you add a new action to a resource, add it to BOTH places. Forgetting to add the tool entry leaves the action invisible to MCP clients — the test suite won't catch it, the code compiles, the action works for Elixir callers, and the AI silently can't reach it.
The convention is:
- One
toolentry perdefine-d action. - Tool name matches the code-interface name where possible (e.g.
define :build_cable↔tool :build_cable, Cable, :build). Disambiguate with a suffix where two resources have actions of the same name (e.g.tool :assign_port_on_cardandtool :assign_port_on_ntd). - Read actions exposed as their natural read names (
tool :get_cable_by_id, Cable, :read).
Tool-via-existing-action is the WWZD-shaped pattern: authorisation, validation, after-action choreography, multi-tenancy, polymorphism — all inherited from the action without writing AI-specific logic. The AI gets the same access an RSP-actor consumer has, no more and no less. That property only holds if every action the AI should be able to reach is declared as a tool.
- Internal-only actions that no consumer (Elixir, HTTP, MCP) should call directly. These typically don't have a
defineeither. - Provider primitives (
Diffo.Provider.create_party,Diffo.Provider.create_place) — deliberately not exposed via Access/Nbn MCP. When Access/Nbn grow their own party/place relationship-management actions, expose those instead, and let authorisation gate what each actor can do. - Duplicate
defines wrapping the same action — e.g.define :get_rsp_by_epid, action: :read, get_by: :idanddefine :get_rsp_by_short_name, action: :read, get_by: :short_nameboth wrap the:readaction with different filters. One tool (tool :get_rsp_by_epid, Rsp, :read) covers both — the AI can supply the filter shape it needs via tool arguments. The code-interface defines are an Elixir convenience; the tool exposes the underlying action.
If you're modifying:
- A
*.exfile underlib/access/resources/,lib/access/services/, orlib/nbn/resources/that adds/renames adefinein its domain — also touchlib/access/access.exorlib/nbn/nbn.exand update thetools doblock. - The
tools doblock itself — make sure the resource module is aliased at the top of the domain file.
After changes, count alignment:
# tools declared
grep -c "tool :" lib/access/access.ex lib/nbn/nbn.ex
# actions code-interface-defined
grep -c "define :" lib/access/access.ex lib/nbn/nbn.exThe two should match (modulo intentional exclusions).
Two checks before any commit, every commit:
mix format # auto-format every changed file to project style
reuse lint # ensure every file has SPDX-FileCopyrightText + SPDX-License-IdentifierNew .ex / .exs files start with a comment header matching the existing
files (see lib/diffo_example/util.ex for the canonical form). New markdown
files use an HTML-comment variant (see README.md). reuse lint will tell
you which files are missing copyright/license info; if you've created a
new file and haven't added the header, this is the place to catch it.
Forgetting either is the easiest way to introduce CI noise the reviewer has to clean up. Save them both the time.