Guide

Agentry keeps the API of the browser gem, predicate for predicate. Installing it is swapping a gem; this page covers the rest.

Installation

gem "agentry"

Agentry requires Ruby 3.3 or later. It is tested on Rails 8.0 and 8.1, and works without Rails.

Migrating from browser

Change one line of the Gemfile, nothing else:

- gem "browser"
+ gem "agentry"

The gem still loads with require "browser", still defines Browser, and require "browser/aliases" or require: "browser/browser" keep the meaning they had. Both gems define Browser, so Agentry refuses to load next to browser, with an error saying so: remove the old line, don’t add a second one. Agentry is forked from browser 6.2.0 (dd88933), and every pull request diffs its answers against that version on the 20,328 user agents of the corpus.

What changes on its own

  • More bots, from the day you switch. 600 user agents of the corpus that browser took for humans are bots for Agentry: AI crawlers and assistants (meta-externalagent, Perplexity-User, Claude-User), ad and SEO crawlers, monitors, feed readers, scanners. Analytics that leave bots out count fewer visitors from that day, and anything gated by bot? (a session, a captcha, a cache) treats them as bots. Note the date of the switch next to your traffic charts. Coverage per category.
  • Names for bots. 792 user agents that browser named Generic Bot get the name of their bot. No name browser gave changes.
  • Client Hints in Rails. The browser helper and the middleware also read the Client Hints Chromium sends with every request. They only move an answer when they contradict the user agent: Chrome for Android asking for the desktop site becomes an Android tablet instead of a Linux desktop. Without the hints (Browser.new(user_agent, accept_language:), Safari, Firefox), nothing moves. See Client Hints.
  • Nothing else. No bot stops being one, and no browser, platform or device answer moves for a user agent alone. Test drivers (HeadlessChrome, Playwright, Puppeteer, Selenium) stay out of bot?, so the system tests of an app see what they saw.
  • A cache, on by default. Each process keeps the detection of its last 1,000 distinct user agents, about 1 MB. It empties itself when a matcher list or the bot list changes, so code that edits them keeps working.

What you can add

  • Browser.cache_size = 5_000 to keep more user agents, 0 to turn the cache off. See Options.
  • browser.bot.category, bot.ai? and bot.user_triggered?, to tell an AI crawler from a search engine, a scanner or an assistant fetching a page for a person. See Categories.
  • browser.bot.verified?(request.remote_ip), to tell a real Googlebot (or Bing, OpenAI, Apple, Perplexity, DuckDuckGo crawler) from one that only claims to be. See Verified crawlers.
  • browser.cloudflare, to read the verified bot flag, category, bot score and country Cloudflare knows, through a Transform Rule. See Cloudflare.
  • An Accept-CH: Sec-CH-UA-Platform-Version response header, so that browser.platform.windows11? and the macOS and Android versions come from Chromium’s Client Hints. See Client Hints.

Rolling back

Put gem "browser" back in place of gem "agentry". The bots of the weekly sync go back to being humans, so the analytics shift reverses on that day. browser has none of the additions above: remove the calls to them first, or they raise NoMethodError.

Usage

require "browser"

browser = Browser.new("Some User Agent", accept_language: "en-us")

Browser

browser.name                 # readable browser name
browser.version              # major version number
browser.full_version
browser.to_s                 # the meta info joined by space
browser.meta                 # an array with several attributes
browser.known?               # has the browser been successfully detected?
browser.unknown?             # the browser wasn't detected
browser.webkit?
browser.webkit_full_version
browser.chromium_based?
browser.chrome?
browser.edge?                # newest MS browser
browser.electron?            # Electron Framework
browser.firefox?
browser.ie?
browser.ie?(6)               # detect specific IE version
browser.ie?([">8", "<10"])   # detect specific IE (IE9)
browser.opera?
browser.opera_mini?
browser.safari?
browser.safari_webapp_mode?
browser.samsung_browser?
browser.core_media?
browser.duck_duck_go?
browser.epiphany?
browser.nokia?
browser.phantom_js?
browser.qq?
browser.quicktime?
browser.sougou_browser?
browser.sputnik?
browser.uc_browser?
browser.wechat?
browser.weibo?
browser.yandex?

Version predicates take a version or a list of constraints, so a “modern browser” check is a method of yours:

def modern_browser?(browser)
  [
    browser.chrome?(">= 65"),
    browser.safari?(">= 10"),
    browser.firefox?(">= 52"),
    browser.ie?(">= 11") && !browser.compatibility_view?,
    browser.edge?(">= 15"),
    browser.opera?(">= 50"),
    browser.facebook? &&
      browser.safari_webapp_mode? &&
      browser.webkit_full_version.to_i >= 602
  ].any?
end

Bots

browser.bot?
browser.bot.name
browser.bot.search_engine?
browser.bot.why?             # which matcher detected this user agent as a bot
Browser::Bot.why?(user_agent)

How a bot is detected, and how to change the lists, is on the Bots page.

Device

browser.device
browser.device.id
browser.device.name
browser.device.unknown?
browser.device.mobile?
browser.device.tablet?
browser.device.tv?
browser.device.console?
browser.device.ipad?
browser.device.iphone?
browser.device.ipod_touch?
browser.device.kindle?
browser.device.kindle_fire?
browser.device.silk?
browser.device.surface?
browser.device.samsung?
browser.device.blackberry_playbook?
browser.device.nintendo?
browser.device.switch?
browser.device.wii?
browser.device.wiiu?
browser.device.playstation?
browser.device.ps3?
browser.device.ps4?
browser.device.psp?
browser.device.vita?
browser.device.xbox?
browser.device.xbox_360?
browser.device.xbox_one?

Platform

browser.platform
browser.platform.id
browser.platform.name
browser.platform.version          # e.g. 9 (for iOS 9)
browser.platform.unknown?
browser.platform.adobe_air?
browser.platform.android?
browser.platform.android?(4.2)    # detect Android Jelly Bean 4.2
browser.platform.android_app?     # detect webview in an Android app
browser.platform.android_webview? # alias for android_app?
browser.platform.blackberry?
browser.platform.blackberry?(10)  # detect specific BlackBerry version
browser.platform.chrome_os?
browser.platform.firefox_os?
browser.platform.ios?
browser.platform.ios?(9)          # detect specific iOS version
browser.platform.ios_app?         # detect webview in an iOS app
browser.platform.ios_webview?     # alias for ios_app?
browser.platform.kai_os?
browser.platform.linux?
browser.platform.mac?
browser.platform.windows?
browser.platform.windows_xp?
browser.platform.windows_vista?
browser.platform.windows7?
browser.platform.windows8?
browser.platform.windows8_1?
browser.platform.windows10?
browser.platform.windows11?        # from Client Hints only, see below
browser.platform.windows_rt?
browser.platform.windows_mobile?
browser.platform.windows_phone?
browser.platform.windows_touchscreen_desktop?
browser.platform.windows_wow64?
browser.platform.windows_x64?
browser.platform.windows_x64_inclusive?

The full lists live in lib/browser/platform.rb and lib/browser/device.rb.

Aliases

To call browser.mobile? and browser.tablet? on the base object, include the aliases:

require "browser/aliases"
Browser::Base.include(Browser::Aliases)

Browser.new("Some user agent").mobile? #=> false

Options

SettingDefaultWhat it does
accept_language:nilThe Accept-Language header, passed to Browser.new; parsed into browser.accept_language.
headers:nilThe Rack env of the request, passed to Browser.new; read for the Client Hints and the Cloudflare headers. The Rails helper and the middleware pass it.
Browser.user_agent_size_limit2048Largest user agent accepted, in bytes. A larger one raises Browser::Error.
Browser.accept_language_size_limit2048Largest Accept-Language header accepted, in bytes. A larger one raises Browser::Error.
Browser.cache_size1000Distinct user agents (and Accept-Language headers) whose detection each process keeps, about 1 KB each; emptied when full or when a matcher list or the bot list changes. 0 turns it off.
Browser::Bot.matchersthree built-in matchersCallables deciding whether a user agent is a bot. See Bots.
Browser::Bot.botsbots.ymlKnown bots, keyed by a lowercase substring of their user agent.
Browser::Bot.search_enginessearch_engines.ymlBots answering bot.search_engine?.
Browser::Bot.bot_exceptionsbot_exceptions.ymlSubstrings that are never a bot, even when a matcher says so.
Browser::Bot::IPRangesbot_ip_ranges.ymlThe IP ranges crawler owners publish; .refresh fetches them again. See Verified crawlers.
Browser::Cloudflare.header_namesX-Bot-Verified, X-Bot-Category, X-Bot-Score, CF-IPCountryThe headers browser.cloudflare reads; set a hash of the ones to change. See Cloudflare.
Browser.user_agent_size_limit = 4096
Browser.accept_language_size_limit = 4096

Rails

The Gemfile line is enough: a railtie adds a browser helper to controllers and views, built from the request’s User-Agent and Accept-Language headers, its Client Hints and Cloudflare headers.

<% if browser.ie?(6) %>
  <p class="disclaimer">You're running an older IE version. Please update it!</p>
<% end %>

To use Agentry in a Rails app without adding the helper to controllers, load only the library:

gem "agentry", require: "browser/browser"

Middleware

Browser::Middleware redirects user agents from a Rack stack:

use Browser::Middleware do
  redirect_to "/upgrade" if browser.ie?
end

In Rails, route helpers work from an initializer (config/initializers/browser.rb), and request gives the Rack::Request:

Rails.configuration.middleware.use Browser::Middleware do
  redirect_to upgrade_path if browser.ie? && request.env["PATH_INFO"] != "/exclude_me"
end

Accept-Language

browser.accept_language is an array of languages, sorted by quality from highest to lowest. A missing quality means 1.0; a quality of zero means the language is not accepted.

language = Browser.new(user_agent, accept_language: "en-us").accept_language.first

language.code    #=> "en"
language.region  #=> "US"
language.full    #=> "en-US"
language.quality #=> 1.0
language.name    #=> "English/United States"

Verified crawlers

A user agent is a claim: anyone can send Googlebot. Google, Bing, OpenAI, Apple, Perplexity and DuckDuckGo publish the IP ranges of their crawlers, and Agentry ships them. bot.owner names the owner of the crawler a user agent claims to be, bot.verified?(ip) says whether the IP belongs to that owner’s ranges.

browser.bot.owner                         # => :google, :bing, :openai, :apple, :perplexity, :duckduckgo or nil
browser.bot.verified?(request.remote_ip)  # => true when a Googlebot comes from Google

if browser.bot.owner && !browser.bot.verified?(request.remote_ip)
  head :forbidden # claims to be a known crawler, comes from elsewhere
end

The lookup is a binary search over sorted integer ranges, IPv4 and IPv6, with no network call: bench/signals.rb measures it. An IP that does not parse (a forged X-Forwarded-For) answers false, never raises. Pass the client’s IP as your proxies resolve it (request.remote_ip in Rails, with trusted_proxies set).

Reverse DNS. Google, Bing and Apple also document a DNS check: the IP’s reverse lookup must name one of their domains, and that name must resolve back to the IP. It costs two DNS queries, so it is off unless asked, and only tried for an IP outside the published ranges:

browser.bot.verified?(request.remote_ip, reverse_dns: true)

Fresh ranges. The weekly bot sync rewrites bot_ip_ranges.yml from the owners’ JSON files, and each release ships them. A process that runs for weeks can fetch them again. Each process holds its own copy; a failed source raises Browser::Error and keeps the current ranges:

Browser::Bot::IPRanges.refresh # fetches every owner's JSON, swaps the ranges at once

# or fetch once, share them through a cache, and load them in every process
prefixes_by_owner = Rails.cache.fetch("bot_ip_ranges", expires_in: 1.week) { Browser::Bot::IPRanges.fetch }
Browser::Bot::IPRanges.load(prefixes_by_owner)

Cloudflare

Behind Cloudflare, the edge already knows whether a bot is verified, its category and the visitor’s country. A Transform Rule copies what the app needs into request headers, and browser.cloudflare reads them:

browser.cloudflare.verified_bot? # => true for a bot Cloudflare verified
browser.cloudflare.bot_category  # => :search_engine_crawler, :ai_crawler… (the names of bot.category)
browser.cloudflare.bot_score     # => 1 (automated) to 99 (human), Bot Management only
browser.cloudflare.country       # => "FR", "XX" when unknown, "T1" for Tor

The Rails helper and the middleware pass the request’s headers along. Elsewhere, pass the Rack env: Browser.new(user_agent, headers: env). Each site names its headers in its own rule; the defaults are generic, change any of them:

Browser::Cloudflare.header_names = {
  verified_bot: "X-Bot-Verified", # the defaults
  bot_category: "X-Bot-Category",
  bot_score: "X-Bot-Score",
  country: "CF-IPCountry"
}

The Transform Rule. In the Cloudflare dashboard, for your zone:

  1. Open Rules, then Overview, and create a Request Header Transform Rule. Name it, for example, Bot signals for the app.
  2. Under If incoming requests match…, choose All incoming requests: Set replaces whatever header the client sent, so a header the rule sets on every request is one a client can’t forge.
  3. Under Then… Modify request header, add one Set dynamic operation per header:
    • X-Bot-Verified, value cf.bot_management.verified_bot (Bot Management), or cf.client.bot on plans without it: both give true or false;
    • X-Bot-Category, value cf.verified_bot_category, such as Search Engine Crawler or AI Crawler;
    • X-Bot-Score, value cf.bot_management.score, only with Bot Management (Enterprise): leave it out otherwise.
  4. Deploy the rule.
  5. For the country, turn on IP Geolocation under Network: Cloudflare then adds CF-IPCountry itself, no rule needed.

The headers prove something only if every request goes through Cloudflare: accept connections on the origin from Cloudflare’s IPs only (or through a tunnel), or a client can reach it with forged headers.

Client Hints

Chromium browsers freeze their user agent: Windows 11 still says Windows NT 10.0, macOS stays at 10_15_7, Android at Android 10; K. They send the real values as User-Agent Client Hints, and Agentry uses them when present, falling back on the user agent otherwise (Safari and Firefox send none). The Rails helper and the middleware pass them along; elsewhere, pass the Rack env as headers:.

browser = Browser.new(user_agent, accept_language: language, headers: rack_env)

browser.platform.windows11?           # Sec-CH-UA-Platform-Version 13 or more
browser.platform.version              # => "11", "15.1.0" on macOS 15.1, "15.0.0" on Android 15
browser.device.mobile?                # Sec-CH-UA-Mobile, when sent
browser.client_hints.platform         # => "Windows"
browser.client_hints.version_by_brand # => {"Chromium" => "142", "Google Chrome" => "142"}

Sec-CH-UA, Sec-CH-UA-Mobile and Sec-CH-UA-Platform come with every request. Sec-CH-UA-Platform-Version, which tells Windows 11 from Windows 10, comes only once the site asks for it with a response header, from the next request on:

response.headers["Accept-CH"] = "Sec-CH-UA-Platform-Version"

Without hints, every answer is the user agent’s, as in browser: Browser.new(user_agent, accept_language:) answers exactly as before. Hints that only repeat the user agent reuse its cached detection; the others build a platform and a device for that request alone, never cached under the user agent.

Internet Explorer and Safari

Internet Explorer. In compatibility view, IE8 and later run as an older version. version and full_version return the navigator’s version; msie_version and msie_full_version return the engine’s.

browser.version             #=> 11
browser.full_version        #=> 11.0
browser.msie_version        #=> 7
browser.msie_full_version   #=> 7.0
browser.compatibility_view? #=> true

Safari. iOS webviews and web apps are not Safari. Combine platform and WebKit detection instead:

browser = Browser.new("Mozilla/5.0 (iPad; U; CPU OS 3_2_1 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Mobile/7B405")

browser.safari?        #=> false
browser.webkit?        #=> true
browser.platform.ios?  #=> true