AIToolsBlocklist

Elixir client for AI Tools Blocklist, the data behind allowlist and blocklist management tools. Ask about a hostname and get back whether it belongs to an AI tool, the tool's categories, and what its vendor says about training on customer data. It is built on Req, so it fits naturally into Phoenix apps, Oban jobs and plain scripts.

Installation

def deps do
[{:aitoolsblocklist, "~> 1.0"}]
end

First lookup

client = AIToolsBlocklist.Client.new(System.fetch_env!("AQ_API_KEY"))
case AIToolsBlocklist.Client.check(client, "chat.mistral.ai") do
{:ok, %{"blocked" => true} = tool} ->
IO.puts("#{tool["domain"]} is an AI tool: #{tool["primary_category"]}")
{:ok, %{"blocked" => false}} ->
IO.puts("not in the register")
{:error, {:api_error, status, body}} ->
IO.puts("API answered #{status}: #{inspect(body)}")
{:error, reason} ->
IO.puts("request failed: #{inspect(reason)}")
end

check/2 returns tagged tuples, never raises for HTTP problems, and hands you the decoded JSON as a map with string keys.

The map you get back

For an AI tool:

For anything else, "blocked" is false and "categories" is an empty list.

Pattern matching makes policy code short and readable:

def decide({:ok, %{"blocked" => false}}), do: :allow
def decide({:ok, %{"trains_on_data" => "no"}}), do: :allow_and_log
def decide({:ok, %{"trains_on_data" => t}}) when t in ["yes", "unstated"], do: :block
def decide({:ok, _}), do: :warn
def decide({:error, _}), do: :unknown

Retries you get for free

The client uses Req.get/2, and Req retries safe requests by default. Timeouts, connection errors and responses of 408, 429 and common 5xx statuses are retried a few times with exponential backoff before you see an error. For most callers that means a single check/2 call already copes with brief hiccups. Errors that reach you are either persistent or not retryable, such as a 401 for a bad key.

A Plug for Phoenix

Internal tools sometimes need a quick "is this AI?" endpoint that does not expose the key. A small plug and an ETS cache do the job:

defmodule MyAppWeb.AiCheck do
import Plug.Conn
@ttl :timer.hours(12)
def init(opts), do: opts
def call(%{path_params: %{"host" => host}} = conn, _opts) do
result = cached(String.downcase(host))
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(result))
end
defp cached(host) do
now = System.monotonic_time(:millisecond)
case :ets.lookup(:ai_check_cache, host) do
[{^host, value, exp}] when exp > now -> value
_ ->
value =
case AIToolsBlocklist.Client.check(client(), host) do
{:ok, map} -> Map.take(map, ["domain", "blocked", "primary_category", "trains_on_data"])
{:error, _} -> %{"domain" => host, "error" => true}
end
:ets.insert(:ai_check_cache, {host, value, now + @ttl})
value
end
end
defp client, do: AIToolsBlocklist.Client.new(Application.fetch_env!(:my_app, :atb_key))
end

Create the table once at startup with :ets.new(:ai_check_cache, [:named_table, :public, read_concurrency: true]). Cache negative answers too, since most hosts are not AI tools.

Checking many hosts at once

Task.async_stream/3 gives controlled concurrency with almost no code:

hosts
|> Task.async_stream(&AIToolsBlocklist.Client.check(client, &1), max_concurrency: 4, timeout: 30_000)
|> Enum.map(fn {:ok, result} -> result end)

Four concurrent lookups keep a batch quick without leaning on rate limits.

Configuration

new/2 takes the API key and an optional :base_url:

AIToolsBlocklist.Client.new(key, base_url: "http://localhost:4001/stub")

The base URL is handy for tests against a local stub or for routing through an internal gateway. new/2 only accepts a non-empty binary key. Anything else raises a FunctionClauseError right away, which catches configuration mistakes at boot. The same guard applies to the host passed to check/2.

Keep the key in runtime configuration, loaded in config/runtime.exs from an environment variable, not in compiled config.

Error shapes

Result Meaning
{:error, {:api_error, 401, body}} Key not recognised
{:error, {:api_error, 403, body}} Plan inactive or monthly quota used
{:error, {:api_error, 429, body}} Still rate limited after Req's retries
{:error, %Req.TransportError{}} Network problem after retries

When lookups are not enough

Resolvers and firewalls that see every DNS query should not call an API per query. Plans with the downloadable list let you load all classified domains into ETS at boot and refresh nightly. Keep check/2 for new hosts and admin tooling.

Start with evidence: a shadow AI detection tool shows which tools people already use. If your system runs LLM agents, add an AI agent allow list for LLM agents. For the non-AI part of the web, including web filtering for remote employees and branch offices, use the general database.

The register is also on pub.dev for Flutter, on crates.io for Rust, and as a Go module.

License

MIT