A daemon-based web browser for tiling window managers. Real WebKit rendering, terminal-emulator spawn times.
hwatu treats the browser window as a resource that other programs manage. For humans, that program is your tiling WM. For coding agents, it's the agent harness: see docs/agents.md.
curl -fsSL https://raw.githubusercontent.com/hongnoul/hwatu/main/scripts/install.sh | bash
Requires webkitgtk-6.0
at runtime (the installer checks and tells you the package for your distro). Or build from source:
cargo build --release # needs rust + webkitgtk-6.0 dev headers
Browsers conflate two things: the engine (slow to start, RAM-hungry) and the
window (what you actually ask for). hwatu splits them, the same way
emacsclient
/wezterm
do:
owns WebKitGTK 6, a prewarmed WebView pool, and all windows.hwatud
is a thin client: one Unix-socket roundtrip to open a window.hwatu
Measured: 13 ms median (p90 35 ms) from hwatu <url>
to a mapped,
window on a warm daemon; --background
and --headless
land at 16 ms and 14 ms medians. The first-ever window pays a one-time engine/GPU init (~200-400 ms). Full data and methodology: docs/benchmarks.md.
If you want a minimal browser for a tiling window manager (Hyprland, sway, i3, river), the usual suspects trade differently:
| hwatu | surf | qutebrowser | luakit | |
|---|---|---|---|---|
| Window spawn | 13 ms median (warm daemon) | full engine start per window | full engine start | full engine start |
| Engine | WebKitGTK 6 | WebKitGTK 2 | QtWebEngine (Chromium) | WebKitGTK 2 |
| Tabs | none, WM tiles are tabs | none | built-in | built-in |
| Keyboard-driven UI | your WM's binds | patches | first-class vim binds | lua config |
| Memory model | one shared engine, N views | one process per window | one big process | one process |
No tabs. A tab is a window. Your tiling WM is the tab manager.No chrome. The WebView is the whole window.Real rendering. Full WebKit: JS, CSS, media, WebGL, as the frontend intended. No custom half-engine.No ads. Content blocking is built in and on by default, evaluated natively in WebKit's network process — zero JS, zero UI, zero spawn cost.
hwatu # open the launcher (autostarts hwatud)
hwatu example.com # open a URL (https:// implied)
hwatu how to exit vim # anything that isn't a URL is a web search
hwatu --app-id mail url # per-window app_id for WM window rules
hwatu list # id, url, title of every window
hwatu list --json # same, as JSON (for wofi/rofi pipelines)
hwatu close 2 # close window 2
hwatu adblock # content-blocker status (rule count, source)
hwatu adblock off # disable blocking (persisted; `on` re-enables)
hwatu adblock update # fetch EasyList + EasyPrivacy, recompile
hwatu update # self-update to the latest release
hwatu quit # stop the daemon
The daemon speaks a small automation protocol, built for AI coding agents (jcode has a native hwatu backend) and scripts that need to verify web UIs. A full verification pass (open headless, wait for load, eval, screenshot, close) measures 216 ms median, ~75 ms without the screenshot (docs/benchmarks.md). Full guide: docs/agents.md.
hwatu --background localhost:3000 # open without stealing focus
hwatu --headless localhost:3000 # open with no window at all
hwatu snapshot # page text + clickable elements, as JSON
hwatu click a --contains Pricing # click (selector or --ref from snapshot)
hwatu type 'input[name=q]' hello --enter # fill an input, then submit
hwatu console # console output, JS errors, failed requests
hwatu eval 'document.title' # run JS in the page (async, JSON out)
hwatu eval --id 2 'location.href' # target a window by id
hwatu goto localhost:3000 # navigate + wait for the load
hwatu goto --no-wait example.com # navigate without waiting
hwatu shot /tmp/page.png # screenshot the viewport (PNG)
hwatu shot --full /tmp/page.png # screenshot the whole document
hwatu scroll h2 --contains Pricing # scroll an element into view
hwatu wait-load # block until the current load settles
hwatu upload 'input[type=file]' ./pic.png # set a file input's files from disk
hwatu focus 2 # raise/focus window 2 (materializes
eval
accepts a JavaScript expression (document.title
) or a
function body (const n = ...; return n
); await
works in both
and a returned Promise is awaited before the result comes back as
JSON. Without --id
, commands target the focused window, the window
your last automation command touched, or the only window. Everything
is one JSON request over the Unix socket
($XDG_RUNTIME_DIR/hwatu.sock
), so any language can drive it directly.
A --headless
window is a live session the WM never sees: an agent
can drive and screenshot it, and hwatu focus <id>
later materializes that exact session as a normal window for the human to inspect.
Agents get --headless
without asking for it: when hwatu
runs
inside a coding-agent environment (detected by markers like
CLAUDECODE
, JCODE_SOCKET
, CURSOR_AGENT
), opens default to
headless so a verification flow never appears in the WM at all.
Human entries (shells, WM keybinds) keep the normal focused open, and
an agent can pass --focus
to deliberately show the user a window.
The agent default is configurable: set HWATU_AGENT_MODE
or
"agent_mode": "normal" | "background" | "headless"
in
~/.config/hwatu/config.json
(e.g. background
for users who want
verification windows visible-but-unfocused in their tiler).
For the background mode, because most tilers focus new windows
regardless, the installer offers
(default yes) to add a no-initial-focus rule for app-id
hwatu-background
to your niri/Hyprland/sway config; preseed
HWATU_WM_RULE=no
to skip.
Ctrl+w
(or Ctrl+q
) closes the focused window. Ctrl+l
(or O
) opens the URL
bar prefilled with the current address, o
opens it blank; Enter
navigates, Esc
cancels. Ctrl+o
/ Ctrl+i
go back/forward in
history (vim jumplist style). Ctrl+r
/ F5
reload the page.
Ctrl+Shift+j
/ Ctrl+Shift+k
scroll the page down/up by half a viewport. The daemon and engine stay warm.
Every bind is remappable in ~/.config/hwatu/keys.conf
, one
action = chord[, chord...]
per line (none
unbinds):
back = ctrl+o, alt+Left
forward = ctrl+i, alt+Right
url_edit = ctrl+l, O
close = none
Chords are [ctrl+][alt+][shift+]key
; a key is a character (o
, /
)
or a GDK key name (slash
, Left
, Page_Down
). Uppercase implies
shift. Actions: close
, url_open
, url_edit
, find
, find_back
,
find_next
, find_prev
, scroll_down
, scroll_up
, back
,
forward
, reload
. Chords with ctrl/alt always win over the page; bare keys
reach the page first (an o
typed in a text box stays in the page).
On Wayland, --app-id
names the window for your compositor's rules:
windowrule = workspace 3, class:mail
assign [app_id="mail"] workspace 3
Unfocused windows are suspended after HWATU_DISCARD_SECS
(default
120): navigation history is serialized to ~/.cache/hwatu/discard/
,
the web process is killed, and the RAM comes back. Focusing the window
restores it from the prewarm pool, so resume feels instant. If a page's
web process crashes or is OOM-killed, the bar offers
page crashed, reload? [y/n]
instead of leaving a white window.
If the daemon dies uncleanly (crash, OOM kill, logout), the next
hwatud
reopens every window at its last URL: the open-window set is
snapshotted to ~/.local/state/hwatu/session.json
as you browse. A
clean hwatu quit
removes the snapshot, so intentional exits stay exits.
hwatu's one piece of chrome: a single-line vim-style bar at the bottom of the window, hidden until summoned. Everything interactive lives there, so the resting state stays chromeless.
The launcher: a barehwatu
opens a keybind cheat sheet with the URL bar already open — type a URL or search and hit Enter.Esc
closes an untouched launcher window, so a mis-fired keybind costs nothing. SetHWATU_HOME
to get a home page instead. -
Open a URL:Ctrl+l
/O
edit the current address,o
starts blank. Input is normalized like the CLI (example.com
getshttps://
, loopback hosts gethttp://
), and anything that doesn't look like a URL becomes a web search. -
Web search: input with spaces or without a dot (rust borrow checker
,vim
) searches with your configured engine. The installer asks which one (DuckDuckGo, Google, Bing, Brave, Startpage, Kagi, Ecosia — default DuckDuckGo); change it any time in~/.config/hwatu/search.conf
, either an engine name or a URL template likehttps://example.com/search?q=%s
. Applies without a daemon restart. -
Find in page:/
opens forward search,?
backward. Matches highlight incrementally with a live count.Enter
commits (focus returns to the page,n
/N
jump next/previous),Esc
cancels. A/
typed into a page's text box still goes to the page. -
Permission prompts: mic, camera, location, notifications, clipboard and friends appear asexample.com wants microphone [y/n]
. Decisions are remembered per site for the daemon's lifetime and apply across windows. Nothing is written to disk. -
TLS errors: failed certificate loads show the reason (expired, unknown issuer, hostname mismatch, ...).y
adds a session exception for that host and reloads;n
/Esc
leaves the load stopped. Exceptions reset when the daemon exits. - Download status: saved/failed notices flash briefly.
No dialogs, no download manager. Attachments and unrenderable MIME
types save straight to HWATU_DOWNLOAD_DIR
, or your xdg-user-dirs
download folder, or ~/Downloads
. Name collisions get a (n)
suffix. The bar flashes the destination when a download finishes.
A baseline filter list is embedded in the binary, so blocking works
offline on first run. hwatu adblock update
upgrades to full EasyList + EasyPrivacy (117,431 compiled rules); compiled rulesets are cached, so only the first start after a list change pays compile cost (~5 s), warm starts load in ~0.3 s. Rules run in WebKit's content-extension engine in the network process — the same machinery as Safari content blockers — so there is no JavaScript in the request path and no per-window cost.
- Toggle:
hwatu adblock on|off
applies live to every open window and persists in~/.config/hwatu/config.json
.HWATU_ADBLOCK=off
overrides at daemon startup. - Own filters: put ABP-syntax rules in
~/.config/hwatu/filters.txt
; they are appended to whatever lists are active. - Filter kinds the engine cannot express declaratively ($csp, $redirect, scriptlets, procedural cosmetics) are skipped, never approximated, so a filter-list update can't break page loads.
No config file for engine knobs. They are set to their correct values
in code (GPU compositing always on), and the only surfaces are
keys.conf
(above) and environment variables read by hwatud
:
HWATU_HOME
– page opened by a barehwatu
(default: the built-in launcher, a keybind cheat sheet with the URL bar pre-opened) (defaulthttps://hongnoul.github.io/hwatu/, useabout:blank
for none).HWATU_DISCARD_SECS
– seconds an unfocused window keeps its live WebView before being suspended to save RAM (default 120, 0 disables).HWATU_DOWNLOAD_DIR
– where downloads land (default: xdg-user-dirs download folder, falling back to~/Downloads
).HWATU_WEBKIT_FEATURES=Ident:on,Other:off
– flip individual WebKit runtime features on odd hardware. Unknown identifiers are ignored.- Standard
WEBKIT_*
/GSK_RENDERER
vars pass through untouched.
Scrolling smoothness scales with your distro's WebKitGTK: 2.46+ paints
with Skia on the GPU and is markedly smoother. hwatud
logs its WebKitGTK version, session type, and renderer at startup; include that line in any jank report.
hwatu <url> --unix socket--> hwatud (GTK main loop)
├── prewarmed WebView (adopted instantly,
│ next one warmed in idle time)
├── window registry (id -> WebView)
└── WebKit: shared network process,
per-site web processes
Crates:
crates/ipc
– newline-delimited JSON protocol (Request
/Response
)crates/hwatud
– daemon: GTK4 + webkit6, socket server on the GLib loopcrates/hwatu
– client: no GTK linkage, connects or spawns the daemon
- Background window suspension + discard-to-disk (RAM reclaim)
- Per-window
app_id
for WM window rules - URL bar (
Ctrl+l
,o
,O
) - Crash resilience: reopen windows after an unclean daemon death
- Automation protocol: eval / goto / shot / wait-load / upload / scroll / focus
--background
/--headless
window modes (no focus steal, no window) - Measured benchmarks in docs (spawn, verification loop, memory)
- Agent-ergonomic eval (expression or function body, probed server-side)
- Sticky automation target: id-less commands follow the last-driven window
scroll
with selector/nth/contains disambiguation + landing report - Full-document screenshots (
shot --full
) - Persistent cookies (logins survive daemon restarts)
- Text/a11y page snapshot (
hwatu snapshot
): token-cheap page state for agents - First-class interaction:
hwatu click
/hwatu type
(selector or snapshot ref) - Console + network capture for verification loops (
hwatu console
) - Link hints
- Profiles (separate cookie jars / web contexts); per-agent isolation
- Displayless operation (nested headless compositor) for CI