Bots

Bot detection is deliberately aggressive: a user agent is a bot as soon as one matcher says so, unless it is a known exception.

The lists

1,566named botsbots.yml, keyed by a lowercase substring of the user agent
16categoriesbot_categories.yml: ai_crawler, search_engine_crawler, scanner…
6search enginessearch_engines.yml: answer bot.search_engine?
4exceptionsbot_exceptions.yml: never a bot, whatever a matcher says

browser.bot.name is the name from bots.yml, or Generic Bot when another matcher caught it. The lists are in the repository.

Every week, script/sync_bots.rb rewrites the part of bots.yml below its marker line, and bot_categories.yml, from crawler-user-agents and ai.robots.txt, and a pull request shows the answers that change. Above the marker, the list inherited from browser stays as it was.

Coverage

How many of the bots of crawler-user-agents and ai.robots.txt each gem calls a bot, per category: every user agent crawler-user-agents gives as an instance, and, for each name of ai.robots.txt, which gives none, the user agent an AI agent of 2026 sends. script/bot_coverage.rb counts them into docs/bot_coverage.json and the weekly sync regenerates it, with this table.

Of the 882 crawler user agents added to crawler-user-agents since 2024 (test drivers and scanners left out), browser misses 353 and Agentry 11. Against fnando/browser dd88933.

CategoryUser agentsbrowser: bot?Agentry: bot?Agentry: a category
academic_research35243534
advertising_and_marketing97789494
ai_agent35122128
ai_assistant35193434
ai_crawler186150183183
ai_search45364443
archiver68506868
browser_automation232221
feed_fetcher92409191
http_library10368102102
monitoring_and_analytics260141258256
page_preview136110135133
scanner10846106106
search_engine_crawler418384417406
search_engine_optimization685481674671
Total2,3261,6412,2642,270

browser_automation is never bot? on purpose (below). ai_agent misses its plain-word names (Operator, Cursor, Devin, Codex): as substrings they would match human user agents.

Categories

browser.bot.category names what a bot declares it is, with the names of Cloudflare’s verified bot categories where one fits, so an app can compare it with what Cloudflare verified. It is nil for a browser, a bot exception, and a bot no list names (caught by a keyword).

browser = Browser.new("Mozilla/5.0 (compatible; Claude-User/1.0)")
browser.bot.category        # => :ai_assistant
browser.bot.ai?             # => true: ai_crawler, ai_search, ai_assistant or ai_agent
browser.bot.user_triggered? # => true: ai_assistant or ai_agent, acting for a person right now
Categorybot?
search_engine_crawler, search_engine_optimization, ai_crawler, ai_search, ai_assistant, ai_agent, page_preview, feed_fetcher, advertising_and_marketing, monitoring_and_analytics, archiver, academic_research, securitytrue
http_library: curl, got, python-requests…true, as curl and python-requests already were in browser: system tests don’t send them.
scanner: sqlmap, Nikto, Censys…true
browser_automation: HeadlessChrome, Playwright, Puppeteer, Selenium…false: the system tests of an app drive a real browser through them, and Playwright’s and Selenium’s headless Chrome send HeadlessChrome. PhantomJS stays a bot, as in browser.

To treat test drivers as bots in production: browser.bot? || browser.bot.category == :browser_automation.

Matchers

MatcherA bot when
Browser::Bot::EmptyUserAgentMatcherThe user agent is empty.
Browser::Bot::KnownBotsMatcherThe user agent contains a key of bots.yml.
Browser::Bot::KeywordMatcherThe user agent matches /crawl|fetch|search|monitoring|spider|bot/.

browser.bot.why? returns the matcher that fired. A matcher is any callable taking the lowercased user agent and a browser:

Browser::Bot.matchers << ->(ua, _browser) { ua.match?(/externalhit/i) }

To start from nothing, Browser::Bot.matchers.clear; to get the built-in ones back, Browser::Bot.matchers += Browser::Bot.default_matchers. To restore browser 2’s detection, remove the keyword and empty user agent matchers:

Browser::Bot.matchers.delete(Browser::Bot::KeywordMatcher)
Browser::Bot.matchers.delete(Browser::Bot::EmptyUserAgentMatcher)

Extending the lists

Browser::Bot.bots.merge!(new_bots_hash)
Browser::Bot.bot_exceptions += new_exceptions
Browser::Bot.search_engines.merge!(new_search_engines_hash)

The prefix tries behind each list are rebuilt when the list grows or is replaced. Browser::Bot.category_by_keyword takes new categories the same way. To add a bot to Agentry itself, add its user agent to test/ua_bots.yml and its name to bots.yml; a search engine also goes in test/ua_search_engines.yml and search_engines.yml.

A trap. Public crawler lists call “bot” anything that is not a human browser: crawlers, but also test drivers (Playwright, Puppeteer, Selenium), scripts and scanners. Adding them all to bot? would flag the system tests of the apps using it, so test drivers get a category of their own and no bot?.

The corpus

corpus/user_agents.txt holds 20,328 distinct user agents. A change that is not meant to change an answer proves it there: one script writes every answer of a gem as JSON lines, another diffs two snapshots.

ruby -I path/to/upstream/lib script/corpus_snapshot.rb > /tmp/upstream.jsonl
ruby -I lib script/corpus_snapshot.rb > /tmp/agentry.jsonl
ruby script/corpus_diff.rb /tmp/upstream.jsonl /tmp/agentry.jsonl

path/to/upstream is a plain clone of fnando/browser at dd88933. A change meant to change answers, like a fresher bot list, lists the differences it expects in corpus/expected_differences.jsonl (corpus_diff.rb --write), and the diff must show exactly those. No browser of uap-core turns into a bot: the sync leaves out any keyword that would flag one.

Data sources

The corpus gathers, deduplicated, the user agents of:

The bot list and its categories are synced from permissively licensed sources only: crawler-user-agents and ai.robots.txt, both MIT. script/bot_coverage.rb counts, per category, the bots of both lists that browser and Agentry detect, into docs/bot_coverage.json. Details in corpus/SOURCES.md.