ETS-Backed Self-Registering Registries
Engineering

ETS-Backed Registries: The Performance Trick Behind 552 Self-Registering Agents

Self-registration sounds like metaprogramming magic. It's actually just ETS + @after_compile + discipline. Here's exactly how Prismatic keeps 1,110 agent definitions (~70 runtime) and 128 OSINT tools discoverable in sub-millisecond time.

Apr 09, 2026 Β· 7 min read Β· TomΓ‘Ε‘ Korcak (korczis)

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:

  1. Zero manual registration. Add a new module, recompile, it appears in the registry.
  2. O(1) lookup. No scanning, no filtering at query time.
  3. 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)
end

Step 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
end

Step 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)
end

That’s it. Three small pieces. No YAML, no DSL compiler, no magic.

#Why ETS specifically

  • Concurrent reads. read_concurrency: true lets 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 :set table gives O(1) by key. A :ordered_set gives 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

Three small pieces. 1,110 agent definitions (~70 runtime). Sub-microsecond lookups. That is the trick.

Browse all β†’