Unmagic::Browser
A real browser you can drive from Ruby — for one-off page grabs and multi-step automation. It runs locally in development and against Cloudflare Browser Rendering (or any remote Puppeteer endpoint) in production, behind one API. Built so an LLM agent — or plain Ruby code — can browse the web the way a person does.
browser = Unmagic::Browser.new
# one-off: grab a JS-rendered page as Markdown
browser.markdown("https://example.com")
# multi-step: log in, search, read the result
browser.open("https://news.ycombinator.com/login") do |session|
session.fill_in "acct", with: "pg"
session.fill_in "pw", with: ENV["HN_PASSWORD"]
session. "login"
session.wait_for text: "logout"
session.screenshot
end
Installation
gem "unmagic-browser"
Bundler requires it for you. Outside Bundler, the gem's name maps to its path in the usual way:
require "unmagic/browser"
Drivers
The same API runs against three drivers. Pick one when you construct the browser; nothing else in your code changes. A symbol builds the driver with its defaults — when a driver needs options, construct it yourself and pass the instance.
# A symbol builds the driver with no arguments…
Unmagic::Browser.new(driver: :local) # same as driver: Unmagic::Browser::Driver::Local.new
Unmagic::Browser.new(driver: :remote)
Unmagic::Browser.new(driver: :cloudflare)
# …pass an instance to hand a driver options.
# Local Chrome — great for development. headless: false opens a window you can watch.
Unmagic::Browser.new(driver: Unmagic::Browser::Driver::Local.new(headless: false))
# Any remote Puppeteer / CDP websocket — browserless, your own pool, etc.
Unmagic::Browser.new(driver: Unmagic::Browser::Driver::Remote.new(ws: "ws://browserless.internal:3000"))
# Cloudflare Browser Rendering — no browser runs on your servers.
Unmagic::Browser.new(driver: Unmagic::Browser::Driver::Cloudflare.new(
account_id: ENV["CF_ACCOUNT_ID"],
token: ENV["CF_BROWSER_TOKEN"],
))
With no arguments, Unmagic::Browser.new uses :local in development and the configured
default_driver everywhere else.
One-off page grabs
For "just get me this page", call straight on the browser. Each call takes the cheapest path its driver offers: the REST rendering API on Cloudflare (no browser to hold open), a throwaway page on local and remote.
browser.markdown("https://example.com") # => String (clean text)
browser.html("https://example.com") # => String (fully JS-rendered HTML)
browser.screenshot("https://example.com") # => PNG bytes
browser.screenshot("https://example.com",
full_page: true, # the whole scrollable page
selector: "#main") # or a single element
browser.pdf("https://example.com") # => PDF bytes
One-offs always render with default settings. To capture a page in dark mode, on a phone viewport, or in another locale, open a session and use emulation.
On local and remote the throwaway pages share one browser, started on first use and shut
down when the process exits. browser.close lets it go sooner; sessions are unaffected,
because each holds its own.
Sessions
A session is a live browser page you drive step by step. browser.open works like
File.open: give it a URL and a block, and you get a session — already on that page —
that's cleaned up when the block ends.
browser.open("https://store.example.com") do |session|
session.fill_in "Search", with: "espresso"
session. "Go"
session.wait_for text: "results"
session.all(".product").map { |el| el.text }
end
Unmagic::Browser.open(url) is shorthand for Unmagic::Browser.new.open(url).
Call open without a block and it returns the session for you to keep — see
sessions that outlive one call.
Verbs
The API reads like Capybara, so it's instantly familiar.
# navigate
session.visit(url)
session.reload
session.go_back
session.go_forward
session.current_url
session.title
# act
session.fill_in("Email", with: "me@example.com")
session.("Sign in")
session.click_link("Forgot password?")
session.click("Anything at all")
session.select("Australia", from: "Country")
session.check("Remember me")
session.uncheck("Send me email")
session.choose("Express shipping")
session.attach_file("Résumé", "/path/to/cv.pdf")
session.press(:enter)
session.hover("Profile")
# read
session.text
session.html
session.markdown
session.value("Email")
session.screenshot
session.pdf
session.evaluate("document.title")
# wait & ask
session.wait_for(text: "Welcome")
session.wait_for(selector: ".loaded")
session.has_text?("Welcome")
session.("Sign in")
session.has_link?("Log out")
session.has_field?("Email")
session.has_selector?(".row")
# scope
session.find(".cart")
session.all(".product")
session.within(".checkout") { session.("Pay") }
Verbs auto-wait: click_button "Sign in" keeps looking until the button appears and is
actionable, up to a timeout — so you rarely need an explicit wait_for.
Questions don't. has_button? answers about the page as it stands and returns straight
away, because a caller asking whether a challenge appeared wants "no" now, not in ten
seconds. When you mean to wait, say so with wait_for.
Smart locators
You don't have to hand-write CSS. A plain string is resolved by label, then visible text, then ARIA role, falling back to CSS — whichever matches. That's what keeps the API working when the caller (an LLM, say) doesn't know the page's markup.
session.fill_in "Email", with: "me@example.com" # finds <label>Email</label> → its input
session. "Sign in" # finds the button reading "Sign in"
Be explicit when you want to be:
session.fill_in css: "#user_email", with: "me@example.com"
session. text: "Sign in"
session.find label: "Date of birth"
Emulation (dark mode, viewport, devices, locale…)
Everything about how the page renders — colour scheme, viewport, device, locale, timezone —
lives in one emulate: config, set in exactly two places: passed to open, or changed live
with session.emulate.
browser.open("https://example.com",
emulate: { color_scheme: :dark,
viewport: { width: 1440, height: 900 } }) do |session|
session.screenshot # dark mode, at 1440×900
end
The full set of knobs — valid as keys in any emulate: hash, or as a reusable object:
emulation = Unmagic::Browser::Emulation.new(
color_scheme: :dark, # :light | :dark | :no_preference
reduced_motion: :reduce, # :reduce | :no_preference
viewport: { width: 390, height: 844,
scale: 2, mobile: true, touch: true }, # scale = device pixel ratio
device: "iPhone 13", # shortcut: sets viewport + UA + touch
user_agent: "MyBot/1.0",
locale: "en-AU", # Accept-Language
timezone: "Australia/Sydney",
geolocation: { latitude: -33.87, longitude: 151.21 },
media: :print, # :screen | :print (handy before #pdf)
)
# build it once, reuse it across sessions
browser.open(url, emulate: emulation) { |session| ... }
# change it live — applies to everything after
session.emulate(color_scheme: :light)
session.emulate(viewport: { width: 1280, height: 800 })
# or scope a change to a block — reverts when the block ends
session.emulate(color_scheme: :dark) do
session.screenshot # dark…
end
session.screenshot # …light again
Whatever you leave out renders at a fixed default — desktop viewport, light, screen media —
not at whatever the machine underneath prefers. Headless Chrome inherits the host's
appearance if you let it, which would mean the same page rendering dark on a laptop after
sunset and light on CI. Pass color_scheme: :no_preference if you actually want the host's.
Niche emulation (CPU/network throttling, offline, vision-deficiency simulation) isn't wrapped — reach it through the escape hatch.
Sessions that outlive one call
A block session is perfect inside a single method. An LLM agent, though, works turn by
turn — open a page in one call, act on it in the next, let a human step in, then continue.
So open without a block hands you the session to keep. It's a plain object; hold it
wherever your app keeps state.
session = browser.open("https://example.com/login")
session.fill_in "Email", with: "me@example.com"
session. "Continue"
# ...later, same object, same live browser...
session.fill_in "Password", with: secret
session. "Sign in"
session.text
session.close
A kept session holds a real browser — and on Cloudflare, one of a limited number of
concurrent slots — until you close it.
Operations on a session are serialized, so two concurrent callers can't interleave a keystroke. When a sequence must be atomic against other callers, wrap it:
session.lock do
session.fill_in "Amount", with: "100"
session. "Transfer"
end
Human in the loop (captcha / 2FA)
Because a kept session stays live between calls, a human can step in mid-flow: submit the login, detect a challenge, show the person a screenshot, and resume once they've answered — all against the same session.
session = browser.open("https://example.com/login")
session.fill_in "Email", with: "me@example.com"
session.fill_in "Password", with: secret
session. "Sign in"
if session.has_text?("Enter the code we texted you")
# ...show session.screenshot to a person, collect the code...
end
# later, once the human has answered — same session, still live:
session.fill_in "Code", with: human_supplied_code
session. "Verify"
For visual captchas, use a local driver with headless: false and let the person click the
real window, then keep driving.
Errors
Failures are typed, so you classify them by rescuing — never by parsing a message.
begin
browser.markdown(url)
rescue Unmagic::Browser::Error::TimedOut # the page took too long
rescue Unmagic::Browser::Error::Unreachable # couldn't reach the page or the rendering service
rescue Unmagic::Browser::Error::RenderFailed # the service answered but couldn't render
rescue Unmagic::Browser::Error::RateLimited # hit a concurrency / rate limit
rescue Unmagic::Browser::Error::Misconfigured # missing credentials / config
rescue Unmagic::Browser::Error::NotFound # nothing on the page matched a locator
rescue Unmagic::Browser::Error::SessionClosed # the session has been closed
rescue Unmagic::Browser::Error # base class for all of the above
end
NotFound is a TimedOut, because locators auto-wait: "not found" always means "still not
there when we ran out of patience".
Configuration
Unmagic::Browser.configure do |config|
# used when Unmagic::Browser.new is called without a driver — a symbol or an instance
config.default_driver = Unmagic::Browser::Driver::Cloudflare.new(
account_id: ENV["CF_ACCOUNT_ID"],
token: ENV["CF_BROWSER_TOKEN"],
)
# how long a locator keeps looking, and a `wait_for` waits, before giving up
config.timeout = 10
# how long a single navigation is given — longer, because a cold page on a
# slow network legitimately takes a while and there's nothing to retry against
config. = 30
end
Either timeout can be overridden per browser (Unmagic::Browser.new(timeout: 5)), per
session (browser.open(url, timeout: 5)), or per call (session.find(".row", wait: 2)).
The Cloudflare driver falls back to CLOUDFLARE_ACCOUNT_ID and, for the token,
CLOUDFLARE_BROWSER_RENDERING_TOKEN then CLOUDFLARE_API_TOKEN — so a machine already set
up for Cloudflare needs nothing, and a token scoped to just Browser Rendering still wins
over the account-wide one. The remote driver falls back to BROWSER_WS_ENDPOINT. In a
container you can usually skip the block entirely.
Escape hatch
The curated API stays small on purpose. When you need something it doesn't wrap (network
interception, tracing, the full Puppeteer surface), drop down to the object doing the real
work. It's the mirror of the driver: option: you pick a driver when you construct the
browser, and driver hands the underlying instance back.
browser.driver # => the driver instance (e.g. Unmagic::Browser::Driver::Cloudflare)
browser.open("https://example.com") do |session|
puppeteer_page = session.driver # => the live Puppeteer::Page this session is driving
puppeteer_page.request_interception = true
# ...full puppeteer-ruby API...
end
Every driver drives the browser over CDP through
puppeteer-ruby, so session.driver is
always a Puppeteer::Page. Session operations stay serialized as usual; wrap multi-step
native work in session.lock when it must be atomic against other callers.
License
MIT © Keith Pitt