When Prismatic needs to answer βwhat agents exist?β it does not scan a directory, parse YAML, or query Postgres. It hits ETS and gets an answer in under a microsecond. The same pattern backs the 128 OSINT adapters, the 231 AIAD commands, and the glossary lookups. This is the self-registration pattern β and it is simpler than it looks.
#The goal
A subsystem that satisfies three properties:
- Zero manual registration. Add a new module, recompile, it appears in the registry.
- O(1) lookup. No scanning, no filtering at query time.
- Reload-safe. Restarting the registry GenServer rebuilds from the current BEAM, not a stale disk cache.
#The pattern
Step one: a registry GenServer that owns a named, read-concurrent ETS table.
defmodule PrismaticAgents.Registry do
use GenServer
def start_link(_), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
def init(:ok) do
:ets.new(:agent_registry, [:named_table, :set, :public, read_concurrency: true])
load_from_beam()
{:ok, %{}}
end
def get(slug), do: :ets.lookup(:agent_registry, slug) |> List.first()
def all, do: :ets.tab2list(:agent_registry)
endStep two: an Agent behaviour with an @after_compile hook that every concrete agent uses:
defmodule PrismaticAgents.Agent do
defmacro __using__(_opts) do
quote do
@behaviour PrismaticAgents.Agent.Behaviour
@after_compile PrismaticAgents.Agent
def __register__ do
PrismaticAgents.Registry.put(metadata().slug, metadata())
end
end
end
def __after_compile__(_env, _bytecode) do
# Deferred so registry GenServer is up at app start
:ok
end
endStep three: on registry boot, walk all loaded modules and call __register__/0 on anything implementing the behaviour:
defp load_from_beam do
:code.all_loaded()
|> Enum.filter(fn {m, _} -> function_exported?(m, :__register__, 0) end)
|> Enum.each(fn {m, _} -> m.__register__() end)
endThatβs it. Three small pieces. No YAML, no DSL compiler, no magic.
#Why ETS specifically
- Concurrent reads.
read_concurrency: truelets every LiveView query in parallel without serializing through a GenServer mailbox. - Survives ownership. Named public tables outlive the owning process if you set
heir. Good for hot reloads. - O(1) lookups. A
:settable gives O(1) by key. A:ordered_setgives O(log n) but keeps iteration cheap.
#The trap: hot reload and stale entries
In dev with iex -S mix, if a module is edited and the registry GenServer is not restarted, the ETS table still holds the old metadata. Fix: subscribe to :code_server events or just bind a dev-only PubSub that triggers a rebuild on phx.reload. Most teams do the second one.
#Where to go next
- Academy: {{ cross_link(path=β/academy/learn/first-agentβ, text=βFirst Agentβ) }} β build a self-registering agent
- Academy: Storage Patterns β when ETS is the right adapter
- Glossary: ETS, Registry, Self-Registration, Metaprogramming, Agent Registry
Three small pieces. 1,110 agent definitions (~70 runtime). Sub-microsecond lookups. That is the trick.