Browser automation & web crawling for AI agents. Written in Zig. Zero Node.js.
CDP automation · A11y snapshots · HAR recording · Standalone fetcher · Interactive terminal browser · Agentic CLI · Security testing
Quick Start · Benchmarks · kuri-agent · Security Testing · API · Changelog
Why teams switch to Kuri: 464 KB binary, ~3 ms cold start. On Google Flights, a full agent loop (go→snap→click→snap→eval) costs 4,110 tokens vs 4,880 for agent-browser — 16% less per cycle, compounding across multi-step tasks.
Most browser tooling was built for QA engineers. Kuri is built for agent loops: read the page, keep token cost low, act on stable refs, and move on.
- The product story is not "most commands." It is "useful state from real pages at the lowest model cost."
- A tiny output only counts if the page actually rendered. Empty-shell output is a failure mode, not a win.
- The best proof is same-page, same-session, same-tokenizer comparisons.
Same Chrome session, measured with tiktoken cl100k_base. Run ./bench/token_benchmark.sh to reproduce.
| Tool / Mode | Bytes | Tokens | vs kuri |
Note |
|---|---|---|---|---|
kuri snap (compact) |
13,479 | 4,328 | baseline | |
kuri snap --interactive |
7,024 | 1,927 | 0.4x | Best for agent loops |
kuri snap --json |
102,124 | 31,280 | 7.2x | Old default |
agent-browser snapshot |
17,103 | 4,641 | 1.1x | |
agent-browser snapshot -i |
8,704 | 2,425 | 0.6x | |
lightpanda semantic_tree |
67,830 | 26,244 | 6.1x | ⚠ no JS — raw DOM |
lightpanda semantic_tree_text |
1,909 | 507 | 0.1x | ⚠ no JS — empty shell |
| Tool | Tokens per cycle |
|---|---|
| kuri-agent | 4,110 |
| agent-browser | 4,880 |
kuri saves 16% tokens per workflow cycle — compounding across multi-step tasks.
Action responses are flat JSON ({"ok":true}) instead of nested CDP, which adds up:
click = 9 tokens, back = 5 tokens, scroll = 5 tokens.
Why lightpanda scores low: Lightpanda can't execute JS-heavy SPAs. Google Flights renders via client-side
fetch()— lightpanda returns a 507-token empty nav shell with zero flight data. The low token count is a failed render, not efficiency.
Measured on Apple M3 Pro, macOS 15.3. kuri built with -Doptimize=ReleaseFast. agent-browser v0.20.0.
agent-browser kuri delta
(v0.20) (v0.2)
─────────────────────────────────────────────────────────────────────
CLI binary 6.0 MB 464 KB 13× smaller
Cold start (--version) 3.4 ms 3.0 ms ~same
Install (npm) 33 MB 3.3 MB (3 bins) 10× smaller
Commands 140+ 40+ endpoints different focus
Standalone fetcher ❌ ✅ kuri-fetch no Chrome needed
Terminal browser ❌ ✅ kuri-browse interactive REPL
JS engine (no Chrome) ❌ ✅ QuickJS SSR-style DOM
HTTP API server ❌ (CLI only) ✅ kuri thread-per-conn
agent-browser exposes a broader browser-control surface. Kuri is intentionally narrower: a lightweight HTTP API and CLI stack optimized for agent integration, token economy, and deployment simplicity.
Every browser automation tool drags in Playwright (~300 MB), a Node.js runtime, and a cascade of npm dependencies. Your AI agent just wants to read a page, click a button, and move on. Kuri is a single Zig binary. Four modes, zero runtime:
kuri → CDP server (Chrome automation, a11y snapshots, HAR)
kuri-fetch → standalone fetcher (no Chrome, QuickJS for JS, ~2 MB)
kuri-browse → interactive terminal browser (navigate, follow links, search)
kuri-agent → agentic CLI (scriptable Chrome automation + security testing)
curl -fsSL https://raw.githubusercontent.com/justrach/kuri/main/install.sh | shDetects your platform, downloads the right binary, installs to ~/.local/bin.
macOS binaries are notarized — no Gatekeeper prompt.
bun install -g kuri-agent
# or: npm install -g kuri-agentDownloads the correct native binary for your platform at install time.
Download the tarball for your platform from GitHub Releases and unpack it to your $PATH.
Requires Zig ≥ 0.15.0.
git clone https://github.com/justrach/kuri.git
cd kuri
zig build -Doptimize=ReleaseFast
# Binaries in zig-out/bin/: kuri kuri-agent kuri-fetch kuri-browseRequirements: Zig ≥ 0.15.1 · Chrome/Chromium (for CDP mode)
git clone https://github.com/justrach/kuri.git
cd kuri
zig build # build everything
zig build test # run 230+ tests
# CDP mode — launches Chrome automatically
./zig-out/bin/kuri
# Standalone mode — no Chrome needed
./zig-out/bin/kuri-fetch https://example.com
# Interactive browser — browse from your terminal
./zig-out/bin/kuri-browse https://example.com# start the server; if CDP_URL is unset, kuri launches managed Chrome for you
./zig-out/bin/kuri
# discover tabs from that managed browser
curl -s http://127.0.0.1:8080/discover
# inspect the discovered tab list
curl -s http://127.0.0.1:8080/tabsIf you already have Chrome running with remote debugging, set CDP_URL to either the WebSocket or HTTP endpoint:
CDP_URL=ws://127.0.0.1:9222/devtools/browser/... ./zig-out/bin/kuri
# or
CDP_URL=http://127.0.0.1:9222 ./zig-out/bin/kuri# 1. Discover Chrome tabs
curl -s http://localhost:8080/discover
# → {"discovered":1,"total_tabs":1}
# 2. Get tab ID
curl -s http://localhost:8080/tabs
# → [{"id":"ABC123","url":"chrome://newtab/","title":"New Tab"}]
# 3. Navigate
curl -s "http://localhost:8080/navigate?tab_id=ABC123&url=https://vercel.com"
# 4. Get accessibility snapshot (token-optimized for LLMs)
curl -s "http://localhost:8080/snapshot?tab_id=ABC123&filter=interactive"
# → [{"ref":"e0","role":"link","name":"VercelLogotype"},
# {"ref":"e1","role":"button","name":"Ask AI"}, ...]All endpoints return JSON. Optional auth via KURI_SECRET env var.
| Path | Description |
|---|---|
GET /health |
Server status, tab count, version |
GET /tabs |
List all registered tabs |
GET /discover |
Auto-discover Chrome tabs via CDP |
GET /browdie |
🌰 (easter egg) |
| Path | Params | Description |
|---|---|---|
GET /navigate |
tab_id, url |
Navigate tab to URL |
GET /tab/new |
url |
Create a new tab |
GET /window/new |
url |
Create a new window/tab target |
GET /snapshot |
tab_id, filter, format |
A11y tree snapshot with @eN refs |
GET /text |
tab_id |
Extract page text |
GET /screenshot |
tab_id, format, quality |
Capture screenshot (base64) |
GET /action |
tab_id, ref, kind |
Click/type/scroll by ref |
GET /evaluate |
tab_id, expression |
Execute JavaScript |
GET /close |
tab_id |
Close tab + cleanup |
| Path | Description |
|---|---|
GET /markdown |
Convert page to Markdown |
GET /links |
Extract all links |
GET /dom/query |
CSS selector query |
GET /dom/html |
Get element HTML |
GET /pdf |
Print page to PDF |
| Path | Description |
|---|---|
GET /har/start?tab_id= |
Start recording network traffic |
GET /har/stop?tab_id= |
Stop + return HAR 1.2 JSON |
GET /har/status?tab_id= |
Recording state + entry count |
| Path | Description |
|---|---|
GET /back |
Browser back |
GET /forward |
Browser forward |
GET /reload |
Reload page |
GET /cookies |
Get cookies |
GET /cookies/delete |
Delete cookies |
GET /cookies/clear |
Clear all cookies |
GET /storage/local |
Get localStorage |
GET /storage/session |
Get sessionStorage |
GET /storage/local/clear |
Clear localStorage |
GET /storage/session/clear |
Clear sessionStorage |
GET /session/save |
Save browser session |
GET /session/load |
Restore browser session |
GET /session/list |
List saved browser sessions |
GET /auth/profile/save |
Save cookies + storage as a named auth profile |
GET /auth/profile/load |
Restore a named auth profile into a tab |
GET /auth/profile/list |
List saved auth profiles |
GET /auth/profile/delete |
Delete a saved auth profile |
GET /debug/enable |
Enable in-page debug HUD and optional freeze mode |
GET /debug/disable |
Disable in-page debug HUD |
GET /headers |
Set custom request headers |
GET /perf/lcp |
Capture Largest Contentful Paint timing, optionally after navigation |
On macOS, auth profile secrets are stored in the user Keychain. On other platforms, Kuri falls back to .kuri/auth-profiles/.
url and expression query params are percent-decoded by the server, so encoded values like https%3A%2F%2Fexample.com are accepted.
| Path | Description |
|---|---|
GET /diff/snapshot |
Delta diff between snapshots |
GET /emulate |
Device emulation |
GET /geolocation |
Set geolocation |
POST /upload |
File upload |
GET /script/inject |
Inject JavaScript |
GET /intercept/start |
Start request interception |
GET /intercept/stop |
Stop interception |
GET /screenshot/annotated |
Screenshot with element annotations |
GET /screenshot/diff |
Visual diff between screenshots |
GET /screencast/start |
Start screencast |
GET /screencast/stop |
Stop screencast |
GET /video/start |
Start video recording |
GET /video/stop |
Stop video recording |
GET /console |
Get console messages |
GET /stop |
Stop page loading |
GET /get |
Direct HTTP fetch (server-side) |
GET /scrollintoview |
Scroll a referenced element into view |
GET /drag |
Drag from one ref to another |
GET /keyboard/type |
Type text with key events |
GET /keyboard/inserttext |
Insert text directly |
GET /keydown |
Dispatch a keydown event |
GET /keyup |
Dispatch a keyup event |
GET /wait |
Wait for ready state or element conditions |
GET /tab/close |
Close a tab |
GET /highlight |
Highlight an element by ref or selector |
GET /errors |
Get page/runtime errors |
GET /set/offline |
Toggle offline network emulation |
GET /set/media |
Set emulated media features |
GET /set/credentials |
Set HTTP basic auth credentials |
GET /find |
Find text matches in the current page |
GET /trace/start |
Start Chrome tracing |
GET /trace/stop |
Stop tracing and return trace data |
GET /profiler/start |
Start JS profiler |
GET /profiler/stop |
Stop JS profiler |
GET /inspect |
Inspect an element or page state |
GET /set/viewport |
Set viewport size |
GET /set/useragent |
Override user agent |
GET /dom/attributes |
Get element attributes |
GET /frames |
List frame tree |
GET /network |
Inspect network state/requests |
Standalone HTTP fetcher — no Chrome, no Playwright, no npm. Ships as a ~2 MB binary with built-in QuickJS for JS execution.
zig build fetch # build + run
# Default: convert to Markdown
kuri-fetch https://example.com
# Extract links
kuri-fetch -d links https://news.ycombinator.com
# Structured JSON output
kuri-fetch --json https://example.com
# Execute inline scripts via QuickJS
kuri-fetch --js https://example.com
# Write to file, quiet mode
kuri-fetch -o page.md -q https://example.com
# Pipe-friendly: content → stdout, status → stderr
kuri-fetch -d text https://example.com | wc -w- 5 output modes —
markdown,html,links,text,json - QuickJS JS engine —
--jsexecutes inline<script>tags - DOM stubs —
document.querySelector,getElementById,window.location,document.title,console.log,setTimeout(SSR-style) - SSRF defense — blocks private IPs, metadata endpoints, non-HTTP schemes
- Colored output — respects
NO_COLOR,TERM=dumb,--no-color, TTY detection - File output —
-o/--outputwith byte count + timing summary - Custom UA —
--user-agentflag - Quiet mode —
-qsuppresses stderr status
Interactive terminal browser — browse the web from your terminal. No Chrome needed.
zig build browse # build + run
kuri-browse https://example.com🌰 kuri-browse — terminal browser
→ loading https://example.com
# Example Domain
This domain is for use in documentation examples...
Learn more [1]
───── Links ─────
[1] https://iana.org/domains/example
✓ 528 bytes, 1 links (133ms)
[nav] https://example.com> 1 ← type 1 to follow the link
| Command | Action |
|---|---|
<number> |
Follow link [N] |
<url> |
Navigate (if contains .) |
:go <url> |
Navigate to URL |
:back, :b |
Go back in history |
:forward, :f |
Go forward |
:reload, :r |
Re-fetch current page |
:links, :l |
Show link index |
/<term> |
Search in page (highlights matches) |
:search <t> |
Search in page |
:n, :next |
Re-highlight search |
:history |
Show navigation history |
:help, :h |
Show all commands |
:quit, :q |
Exit |
- Colored markdown rendering — headings, links, code blocks, bold, blockquotes
- Numbered links — every link gets
[N], type the number to follow it - Navigation history — back/forward like a real browser
- In-page search —
/termhighlights all matches - Relative URL resolution — follows links naturally across pages
- Smart filtering — skips
javascript:andmailto:hrefs
Scriptable CLI for Chrome automation — drives the browser command-by-command from your terminal or shell scripts. Shares session state across invocations via ~/.kuri/session.json.
zig build agent # build kuri-agent
# 1. Find a Chrome tab
kuri-agent tabs
# → ws://127.0.0.1:9222/devtools/page/ABC123 https://example.com
# 2. Attach to it
kuri-agent use ws://127.0.0.1:9222/devtools/page/ABC123
# 3. Navigate + interact
kuri-agent go https://example.com
kuri-agent snap --interactive # → [{"ref":"e0","role":"link","name":"More info"}]
kuri-agent click e0
kuri-agent shot # saves ~/.kuri/screenshots/<ts>.png| Command | Description |
|---|---|
tabs [--port N] |
List Chrome tabs |
use <ws_url> |
Attach to a tab (saves session) |
status |
Show current session |
go <url> |
Navigate to URL |
snap [--interactive] [--text] [--depth N] |
A11y snapshot, saves @eN refs |
click <ref> |
Click element by ref |
type <ref> <text> |
Type into element |
fill <ref> <text> |
Fill input value |
select <ref> <value> |
Select dropdown option |
eval <js> |
Evaluate JavaScript |
text [selector] |
Get page text |
shot [--out file.png] |
Screenshot |
cookies |
List cookies with security flags |
headers |
Check security response headers |
audit |
Full security audit |
kuri-agent supports browser-native security trajectories — log in once, then run reconnaissance and header/cookie audits without leaving the terminal.
Enumerate → Inspect — after authenticating, dump auth cookies and check security flags:
kuri-agent go https://target.example.com/login
kuri-agent snap --interactive
kuri-agent fill e2 myuser
kuri-agent fill e3 mypassword
kuri-agent click e4 # submit login
kuri-agent cookies
# cookies (3):
# session_id domain=.example.com path=/ [Secure] [HttpOnly] [SameSite=Strict]
# csrf_token domain=.example.com path=/ [Secure] [!HttpOnly]
# tracking domain=.example.com path=/ [!Secure] [!HttpOnly]Header audit — check what security headers the target sends:
kuri-agent go https://target.example.com
kuri-agent headers
# → {"url":"https://...","status":200,"headers":{
# "content-security-policy":"default-src 'self'",
# "strict-transport-security":"max-age=31536000",
# "x-frame-options":"(missing)",
# "x-content-type-options":"nosniff", ...}}Full audit — HTTPS, missing headers, JS-visible cookies in one shot:
kuri-agent audit
# → {"protocol":"https:","url":"https://...","score":6,
# "issues":["MISSING:x-frame-options","COOKIES_EXPOSED_TO_JS:2"],
# "headers":{"content-security-policy":"default-src 'self'", ...}}Cross-account trajectory — use eval to replay API calls with different tokens:
# After login, grab the auth token from localStorage
kuri-agent eval "localStorage.getItem('token')"
# Probe a resource ID with the current session
kuri-agent eval "fetch('/api/assessments/42').then(r=>r.status)"
# Check for IDOR: does a different user's resource return 200 or 403?
kuri-agent eval "fetch('/api/assessments/99').then(r=>r.status)"kuri-agent outputs JSON suitable for pipeline integration. Each security command emits a single JSON line — pipe through jq for triage:
kuri-agent audit | jq '.issues[]'
kuri-agent cookies | head -20
kuri-agent headers | jq '.headers | to_entries[] | select(.value == "(missing)") | .key'┌──────────────────────────────────────────────────────────┐
│ HTTP API Layer │
│ (std.http.Server, thread-per-connection) │
├──────────────┬──────────────────┬────────────────────────┤
│ Browser │ Crawler Engine │ kuri-fetch / browse │
│ Bridge │ │ (standalone CLIs) │
├──────────────┼──────────────────┼────────────────────────┤
│ CDP Client │ URL Validator │ std.http.Client │
│ Tab Registry │ HTML→Markdown │ QuickJS JS Engine │
│ A11y Snapshot│ Link Extractor │ DOM Stubs (Layer 3) │
│ Ref Cache │ Text Extractor │ SSRF Validator │
│ HAR Recorder │ │ Colored Renderer │
│ Stealth JS │ │ History + REPL │
├──────────────┴──────────────────┴────────────────────────┤
│ Chrome Lifecycle Manager │
│ (launch, health-check, auto-restart, port detection) │
└──────────────────────────────────────────────────────────┘
- Arena-per-request — all per-request memory freed in one
deinit()call - No GC —
GeneralPurposeAllocatorin debug mode catches every leak - Proper cleanup chains —
Launcher → Bridge → CdpClients → HarRecorders → Snapshots → Tabs errdeferguards — partial failures roll back cleanly
| Mode | Behavior |
|---|---|
Managed (no CDP_URL) |
Launches Chrome headless, finds free CDP port, supervises, auto-restarts on crash (max 3 retries), kills on shutdown |
External (CDP_URL set) |
Connects to existing Chrome, health-checks via /json/version, does NOT kill on shutdown |
kuri/
├── build.zig # Build system (Zig 0.15.2)
├── build.zig.zon # Package manifest + QuickJS dep
├── src/
│ ├── main.zig # CDP server entry point
│ ├── fetch_main.zig # kuri-fetch CLI entry point
│ ├── browse_main.zig # kuri-browse CLI entry point
│ ├── js_engine.zig # QuickJS wrapper + DOM stubs
│ ├── bench.zig # Benchmark harness
│ ├── chrome/
│ │ └── launcher.zig # Chrome lifecycle manager
│ ├── server/
│ │ ├── router.zig # HTTP route dispatch (40+ endpoints)
│ │ ├── middleware.zig # Auth (constant-time comparison)
│ │ └── response.zig # JSON response helpers
│ ├── bridge/
│ │ ├── bridge.zig # Central state (tabs, CDP, HAR, snapshots)
│ │ └── config.zig # Env var configuration
│ ├── cdp/
│ │ ├── client.zig # CDP WebSocket client
│ │ ├── websocket.zig # WebSocket frame codec
│ │ ├── protocol.zig # CDP method constants
│ │ ├── actions.zig # High-level CDP actions
│ │ ├── stealth.zig # Bot detection bypass
│ │ └── har.zig # HAR 1.2 recorder
│ ├── snapshot/
│ │ ├── a11y.zig # A11y tree with interactive filter
│ │ ├── diff.zig # Snapshot delta diffing
│ │ └── ref_cache.zig # @eN ref → node ID cache
│ ├── crawler/
│ │ ├── validator.zig # SSRF defense, URL validation
│ │ ├── markdown.zig # HTML → Markdown (SIMD tag counting)
│ │ ├── fetcher.zig # Page fetching
│ │ ├── extractor.zig # Readability extraction
│ │ └── pipeline.zig # Parallel crawl pipeline
│ ├── storage/
│ │ ├── local.zig # Local file writer
│ │ └── r2.zig # R2/S3 uploader
│ ├── util/
│ │ └── json.zig # JSON helpers
│ └── test/
│ ├── harness.zig # Test HTTP client
│ ├── integration.zig # Integration tests
│ └── merjs_e2e.zig # E2E tests
└── js/
├── stealth.js # Bot detection bypass
└── readability.js # Content extraction
| Env Var | Default | Description |
|---|---|---|
HOST |
127.0.0.1 |
Server bind address |
PORT |
8080 |
Server port |
CDP_URL |
(none) | Connect to existing Chrome (ws://... or http://127.0.0.1:9222) |
KURI_SECRET |
(none) | Auth secret for API requests |
STATE_DIR |
.kuri |
Session state directory |
REQUEST_TIMEOUT_MS |
30000 |
HTTP request timeout |
NAVIGATE_TIMEOUT_MS |
30000 |
Navigation timeout |
STALE_TAB_INTERVAL_S |
30 |
Stale tab cleanup interval |
NO_COLOR |
(none) | Disable colored CLI output |
For a 50-page monitoring task (from Pinchtab benchmarks):
| Method | Tokens | Cost ($) | Best For |
|---|---|---|---|
/text |
~40,000 | $0.20 | Read-heavy (13× cheaper than screenshots) |
/snapshot?filter=interactive |
~180,000 | $0.90 | Element interaction |
/snapshot (full) |
~525,000 | $2.63 | Full page understanding |
/screenshot |
~100,000 | $1.00 | Visual verification |
Open an issue before submitting a large PR so we can align on the approach.
git clone https://github.com/justrach/kuri.git
cd kuri
zig build test # 230+ tests must pass
zig build test-fetch # kuri-fetch tests (66 tests)
zig build test-browse # kuri-browse testsSee CONTRIBUTORS.md for guidelines.
| Project | What we borrowed |
|---|---|
| agent-browser | @eN ref system, snapshot diffing, HAR recording patterns |
| Pinchtab | Browser control architecture for AI agents |
| Pathik | High-performance crawling patterns |
| QuickJS-ng via mitchellh/zig-quickjs-ng | JS engine for kuri-fetch |
| Lightpanda | Zig-native headless browser pioneer, CDP compatibility patterns |
| Zig 0.15.2 | The whole stack |
Apache-2.0
