Headless browsing with Maho: scripts that read the web
Most browsers have a headless mode. Most users never touch it. The mode lives behind a CLI flag, the docs are sparse, and the use case is usually “a script that does what you would have done by hand if you had the patience”.
This post is the short version of how that looks in Maho. We will run three scripts. A single-page fetch that returns the title. A structured extract that returns a small JSON object per URL. A batch run that walks a file of URLs and writes one NDJSON line per visit.
The point is not to replicate Playwright. Playwright is a different tool with a different shape. We will get to where the line is.
For the broader CLI surface, see the CLI reference.

When headless is the right tool
Section titled “When headless is the right tool”Headless is right when you want your browser, scripted.
The phrase “your browser” is the load-bearing one. Your cookies. Your logged-in sessions. Your saved passwords if you have approved their use. Your installed extensions. Your privacy defaults. The thing you have already configured to be how you like it. You want a script to use that browser, not a fresh stripped-down one.
Headless is wrong when you want a clean, isolated browser context for testing. That is what Playwright is built for. Each Playwright run starts cold, with a brand-new profile, no cookies, no extensions. You write tests against it. The whole point is determinism.
Headless Maho is the opposite. The whole point is that the script benefits from the state your browser already has. A summary of the page you are logged into. A scrape of a private dashboard. A check of a paywalled article you subscribe to. A quick batch fetch of pages that detect bots, where your real browser already has the right cookies to be considered human.
If your script needs a clean profile, use Playwright. If your script needs your profile, headless Maho is the right tool.

Setup: enable the headless mode
Section titled “Setup: enable the headless mode”Headless mode is off by default. Turn it on once.
maho config set headless.enabled trueYou will be asked to confirm in the GUI the first time. Headless mode is the kind of permission that should not be silent: a script with access to your profile is a script that can see anything you can see logged in. Approve it once, and the CLI is unlocked from then on.
Check that it works:
maho headless --versionYou should see the binary version and a one-line confirmation that headless is enabled. If you get “headless mode is disabled”, the GUI permission did not take. Open Maho, go to settings, enable headless mode under Developer, and retry.
A note about credentials: headless Maho uses the same Keychain entries as the GUI. There is no separate password store and no flag for “give the script all my passwords”. Each script run inherits the cookies your browser already has. If a site logs you out in the GUI, the script will see the logged-out state on the next run. This is the right default. We did not want a CLI mode that quietly outlived your real session.
Step 1: a single-page fetch
Section titled “Step 1: a single-page fetch”The simplest thing the CLI does is open a URL, render it, and return a single field.
maho headless --url https://example.com --extract titleOutput:
Example DomainBehind the scenes, Maho launches a headless instance with your profile, navigates to the URL, waits for the page to be considered loaded, runs the equivalent of document.title, and writes the result to stdout. The whole call usually finishes in under two seconds on a fresh page.
The --extract flag accepts a small set of named fields: title, description, canonical, body-text, links, meta-tags. Each one returns a string or a list. For anything beyond these, you write a query, which is the next section.
A note about wait conditions: by default Maho waits for DOMContentLoaded plus a short network idle window. For sites that load content lazily, you can pass --wait selector:#main or --wait timeout:5s to wait for a specific element or a duration. The defaults are tuned to be fast on simple pages and patient enough on most real ones.
Step 2: structured extraction
Section titled “Step 2: structured extraction”Most real scripts want more than a title.
maho headless --url https://example.com/blog/x \ --query 'title=document.title, h1=document.querySelector("h1")?.innerText, words=document.body.innerText.split(/\s+/).length' \ --format jsonOutput:
{ "title": "X: a blog post", "h1": "X: a blog post", "words": 1247}The --query flag is a comma-separated list of named expressions. Each expression runs in the page context and the result is collected under the name. The query language is JavaScript. There is no DSL on top, on purpose. If you can write the expression in DevTools, you can write it in --query.
Two practical patterns are worth naming.
First, prefer optional chaining and short fallbacks. Pages that do not have an h1 should not crash the whole batch. document.querySelector("h1")?.innerText ?? null is two characters longer and saves you debugging on the run after.
Second, return small objects. The result of --query is JSON, and JSON streams over a pipe well only if each record is compact. A query that returns the whole page text is going to make your batch file large and your downstream tools unhappy. Pull the fields you need, leave the rest.
For a similar shape on the local data side, see a CLI for your browser history.
Step 3: a list run
Section titled “Step 3: a list run”The batch case is the one most people end up here for. You have a file of URLs. You want one structured record per URL. You want it to stream so a partial failure does not lose the whole run.
maho headless --batch urls.txt --query 'title=document.title, words=document.body.innerText.split(/\s+/).length' --out results.ndjsonurls.txt:
https://example.com/ahttps://example.com/bhttps://example.com/cresults.ndjson:
{"url":"https://example.com/a","title":"A","words":812}{"url":"https://example.com/b","title":"B","words":1024}{"url":"https://example.com/c","title":"C","words":612}NDJSON, not a JSON array. One record per line. If the batch crashes halfway through, the file is still valid for the URLs that completed. You can resume by passing --skip-completed results.ndjson urls.txt on the next run.
The default concurrency is two. You can raise it with --concurrency 6, but read the next section first.
Resource and rate-limit notes
Section titled “Resource and rate-limit notes”A headless browser is a real browser. Each tab is a real renderer process. Each process holds memory and CPU. On a 16 GB Mac, a sensible upper bound is six concurrent tabs. Above that, swap kicks in and the run gets slower, not faster.
A second consideration is the target site. A batch of 200 URLs against the same domain at concurrency 6 is going to look like a small DDoS to the server. Maho’s default per-domain concurrency is two, with a small jitter between requests. You can override it, and we will not stop you, but if a site bans your IP you will be the one cleaning up the cookies and dealing with the rate-limit page in the GUI.
A third consideration is your own profile. If your batch run logs into a site, it might trigger a “new device” alert in the account. The headless instance is the same browser as your GUI, but the timing pattern is different. If you have a job that runs at 3am and you wonder why the account flagged it, this is why.
For long-running batches, prefer to run them in a session you can detach from (tmux, nohup, or a launchd job). The headless process holds memory until it exits. A 10,000-URL run at concurrency 4 takes hours.
Where this is not Playwright (and that is fine)
Section titled “Where this is not Playwright (and that is fine)”A short list of where the line is.
Headless Maho uses your profile. Playwright uses a clean one.
Headless Maho is for one-off scripts and personal automation. Playwright is for repeatable test suites.
Headless Maho exposes a small CLI surface (URL, query, batch, format). Playwright exposes a full browser API. If you need to click a button, fill a form, wait for a selector, then click another button, that is Playwright territory and we will not pretend otherwise.
Headless Maho returns structured data from pages you visit. Playwright drives a browser through a flow.
Both tools are useful. They are not the same tool.
If your script is “summarize the article at this URL into a JSON record”, that is headless Maho. If your script is “log into the staging environment, run through the signup flow, assert the welcome modal appears”, that is Playwright. We use both in development and recommend the same.

Get early access
Section titled “Get early access”If you want to script the browser you already use, with the cookies and extensions and config you already have, get early access to Maho.