1. Project Overview & Quickstart (lightpanda-io/browser)
1. Project Overview & Quickstart (lightpanda-io/browser)
Technical Documentation: lightpanda-io/browser
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`README.md`)
Lightpanda Browser
Benchmarks
Requesting 933 real web pages over the network on a AWS EC2 m5.large instance.
See benchmark details.
| Metric | Lightpanda | Headless Chrome | Difference |
|---|---|---|---|
| Memory (peak, 100 pages) | 123MB | 2GB | ~16x less |
| Execution time (100 pages) | 5s | 46s | ~9x faster |
Quick start
Install
Package Managers
Latest nightly from Homebrew:
brew install lightpanda-io/browser/lightpandaLatest nightly from Arch Linux User Repository:
yay -S lightpanda-nightly-binDownload from the nightly builds
You can download the last binary from the nightly
builds for
Linux and MacOS for both x86_64 and aarch64.
For Linux
curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-x86_64-linux && \
chmod a+x ./lightpandaVerify the binary before running anything:
./lightpanda versionLinux aarch64 is also available
Note: The Linux release binaries are linked against glibc. On musl-based Linux distributions (Alpine, etc.) the binary fails with
cannot execute: required file not foundbecause the glibc dynamic linker is missing. Use a glibc-based base image (e.g.,FROM debian:bookworm-slimorFROM ubuntu:24.04) or build from sources.Android / Termux: there is no native Android build. The Linux aarch64 binary needs the glibc loader (
/lib/ld-linux-aarch64.so.1), which Android's Bionic libc does not provide, so it fails with the samecannot execute: required file not founderror.
For MacOS
curl -L -o lightpanda https://github.com/lightpanda-io/browser/releases/download/nightly/lightpanda-aarch64-macos && \
chmod a+x ./lightpandaMacOS x86_64 is also available
For Windows + WSL2
Lightpanda has no native Windows binary. Install it inside WSL following the Linux steps above.
WSL not installed? Run wsl --install from an administrator shell, restart, then open wsl.
See Microsoft's WSL install guide for details.
Your automation client (Puppeteer, Playwright, etc.) can run either inside WSL or on the Windows host. WSL forwards localhost:9222 automatically.
Install from Docker
Lightpanda provides official Docker
images for both Linux amd64 and
arm64 architectures.
The following command fetches the Docker image and starts a new container exposing Lightpanda's CDP server on port 9222.
docker run -d --name lightpanda -p 127.0.0.1:9222:9222 lightpanda/browser:latestDump a URL
./lightpanda fetch --obey-robots --dump html --log-format pretty --log-level info https://demo-browser.lightpanda.io/campfire-commerce/You can use --dump markdown to convert directly into markdown, or--dump png > page.png or --dump pdf > page.pdf for a text-only rendering
of the page.--wait-until, --wait-ms, --wait-selector and --wait-script are
available to adjust waiting time before dump.
Start a CDP server
./lightpanda serve --obey-robots --log-format pretty --log-level info --host 127.0.0.1 --port 9222Once the CDP server started, you can run a Puppeteer script by configuring thebrowserWSEndpoint.
Example Puppeteer script
import puppeteer from 'puppeteer-core';
// use browserWSEndpoint to pass the Lightpanda's CDP server address.
const browser = await puppeteer.connect({
browserWSEndpoint: "ws://127.0.0.1:9222",
});
// The rest of your script remains the same.
const context = await browser.createBrowserContext();
const frame = await context.newPage();
// Dump all the links from the frame.
await frame.goto('https://demo-browser.lightpanda.io/amiibo/', {waitUntil: "networkidle0"});
const links = await frame.evaluate(() => {
return Array.from(document.querySelectorAll('a')).map(row => {
return row.getAttribute('href');
});
});
console.log(links);
await frame.close();
await context.close();
await browser.disconnect();Start a webdriver Bidi server
Use --protocol webdriver to enable Bidi support.
You can start both, CDP and Bidi, with --protocol webdriver --protocol cdp
./lightpanda serve --obey-robots --log-format pretty --log-level info --host 127.0.0.1 --port 9222 --protocol webdriverAgent mode
lightpanda agent lets you drive the browser with a native agent. Describe what
you want in plain English or with slash commands, and it controls the browser:
navigating pages, clicking through flows, filling forms, extracting structured
data. Think of it as a robot you're directing to use the web, more than a
chatbot you're having a conversation with.
Because the agent runs inside the same process as the browser, every tool call
is a direct operation and you retain Lightpanda's speed and memory advantage.
The output of an agent session is a
PandaScript: vanilla JavaScript
with a small set of native browser primitives built directly into Lightpanda.
Run /save to export one from your current session, then replay it withlightpanda run <script>.js. Scripts are deterministic and token-free, so
you can prototype with the LLM and ship the output to production without a
model at runtime.
It supports Anthropic, OpenAI, Gemini, Google Vertex AI, Mistral, Hugging
Face, the multi-lab gateways Vercel AI Gateway
and OpenRouter (one key for hundreds of models), any OpenAI-compatible endpoint viaOPENAI_BASE_URL, and local models via Ollama or llama.cpp. You can also run
without an LLM using --no-llm, which drops you into the REPL. See the
agent documentation for the full
reference.
./lightpanda agent # auto-detects API key from env
./lightpanda agent --task "top story on news.ycombinator.com?"
./lightpanda agent --no-llm # basic REPL, no LLM
./lightpanda run session.js # run a recorded script
cat session.js | ./lightpanda run - # ...or pipe one in via stdin
./lightpanda agent --provider gemini --task "..." # force a specific provider
./lightpanda agent --list-models # models available for the detected provider
VERTEX_API_KEY=... ./lightpanda agent --provider vertex # Vertex AI, express mode
GOOGLE_CLOUD_PROJECT=my-proj ./lightpanda agent --provider vertex # Vertex AI, token via gcloud auth
AI_GATEWAY_API_KEY=... ./lightpanda agent --provider vercel --model moonshotai/kimi-k2 # any model behind Vercel AI Gateway
OPENROUTER_API_KEY=... ./lightpanda agent --provider openrouter --model anthropic/claude-sonnet-5 # any model behind OpenRouter
OPENAI_BASE_URL=https://my-gateway/v1 OPENAI_API_KEY=... ./lightpanda agent # any OpenAI-compatible serverNative MCP and skill
The MCP server communicates via MCP JSON-RPC 2.0 over stdio.
Add to your MCP configuration:
{
"mcpServers": {
"lightpanda": {
"command": "/path/to/lightpanda",
"args": ["mcp"]
}
}
}HTTP transport and independent sessions
For serving several agents from one process, start the MCP server over HTTP
instead of stdio by giving it a port (add --host x.x.x.x to specify the
interface to listen on):
lightpanda mcp --port 9223Clients POST JSON-RPC to http://host:9223/mcp. Each connection is routed to
its own browsing session — its own page, cookies and memory — so agents no
longer clobber each other's page:
- A client that
initializes without anMcp-Session-Idheader is assigned a
fresh session; the id comes back in the response'sMcp-Session-Idheader.
Send it on subsequent requests to stay on that session (isolation). - Two agents that send the same
Mcp-Session-Idshare one browsing context
(sharing — e.g. a workflow where several agents work the same page). - The
session_new,session_listandsession_closetools manage sessions
explicitly. SendingDELETE /mcpwith anMcp-Session-Idcloses that session.
A skill is available in lightpanda-io/agent-skill.
Shell completion
lightpanda completion prints a completion script for commands, flags and their values:
lightpanda completion fish > ~/.config/fish/completions/lightpanda.fish
lightpanda completion bash > ~/.local/share/bash-completion/completions/lightpanda
lightpanda completion zsh > ~/.zfunc/_lightpanda # with fpath=(~/.zfunc $fpath) before compinitTelemetry
By default, Lightpanda collects and sends usage telemetry. This can be disabled by setting an environment variable LIGHTPANDA_DISABLE_TELEMETRY=true. You can read Lightpanda's privacy policy at: https://lightpanda.io/privacy-policy.
Core dumps
Set LIGHTPANDA_DISABLE_CORE_DUMP (to any value) to suppress crash core dumps by zeroing the soft RLIMIT_CORE at startup.
Status
Here are the key features we have implemented.
For full details, see our Web Platform Tests results.
- CORS
- HTTP loader (Libcurl)
- HTML parser (html5ever)
- DOM tree
- Javascript support (v8)
- DOM APIs
- Ajax
- XHR API
- Fetch API
- DOM and Markdown dump
- CDP/websockets server
- Click
- Input form
- Cookies
- Custom HTTP headers
- Proxy support
- Network interception
- Respect
robots.txtwith option--obey-robots - CDP and Webdriver Bidi
- Adblocker
Build from sources
Prerequisites
Lightpanda is written with Zig 0.15.2. You have to
install it with the right version in order to build the project.
Lightpanda also depends on
v8,
Libcurl and html5ever.
To be able to build the v8 engine, you have to install some libs:
For Debian/Ubuntu based Linux:
sudo apt install xz-utils ca-certificates \
pkg-config libglib2.0-dev \
clang make curl gitYou also need to install Rust.
For systems with Nix, you can use the devShell:
nix developFor MacOS, you need cmake and Rust.
brew install cmakeBuild and run
You can build the entire browser with make build or make build-dev for debug
env.
But you can directly use the zig command: zig build run.
Embed v8 snapshot
Lighpanda uses v8 snapshot. By default, it is created on startup but you can
embed it by using the following commands:
Generate the snapshot.
zig build snapshot_creator -- src/snapshot.binBuild using the snapshot binary.
zig build -Dsnapshot_path=../../snapshot.binSee #1279 for more details.
Test
Unit Tests
You can test Lightpanda by running make test.
make test # Run all tests
make test F="server" # Filter by substring
TEST_FILTER="WebApi: #selector_all" make test # Filter main + subtest (separator: #)
TEST_VERBOSE=true make test
TEST_FAIL_FIRST=true make test
METRICS=true make test # Capture allocation/duration metrics as JSON
TEST_JOBS=1 make test # Run in one process (default: up to 4, or 1 when filtered)End to end tests
To run end to end tests, you need to clone the demo
repository into ../demo dir.
You have to install the demo's node
requirements
You also need to install Go > v1.24.
make end2endWeb Platform Tests
Lightpanda is tested against the standardized Web Platform
Tests.
We use a fork including a customtestharnessreport.js. Results are published daily.
For reference, you can execute a WPT test case with your browser via
wpt.live.
Configure WPT HTTP server
To run the test, you must clone the repository, configure the custom hosts and generate theMANIFEST.json file.
Clone the repository with the fork branch.
git clone -b fork --depth=1 [email protected]:lightpanda-io/wpt.gitEnter into the wpt/ dir.
Install custom domains in your /etc/hosts
./wpt make-hosts-file | sudo tee -a /etc/hostsGenerate MANIFEST.json
./wpt manifestUse the WPT's setup
guide for
details.
Run WPT test suite
An external Go runner is provided by
github.com/lightpanda-io/demo/
repository, located into wptrunner/ dir.
You need to clone the project first.
First start the WPT's HTTP server from your wpt/ clone dir.
./wpt serveRun a Lightpanda browser
zig build -Dwpt_extensions run -- serve \
--ws-max-concurrent 64 \
--insecure-disable-tls-host-verification \
--load-resources iframe \
--load-resources image \
--load-resources worker \
--load-resources stylesheetThen you can start the wptrunner from the demo's clone dir:
cd wptrunner && go run .Or one specific test:
cd wptrunner && go run . Node-childNodes.htmlwptrunner command accepts --summary and --json options modifying output.
Also --concurrency define the concurrency limit.
To check a single test case without the runner, fetch --dump wpt prints its
results as JSON: the harness status, a pass/fail summary and the cases that
did not pass. It relies on the fork's testharnessreport.js.
zig build -Dwpt_extensions run -- fetch \
--insecure-disable-tls-host-verification \
--load-resources worker \
--load-resources iframe \
--dump wpt \
--wait-script "window.report && report.complete" \
"http://web-platform.test:8000/dom/nodes/Node-childNodes.html":warning: Running the whole test suite will take a long time. In this case,
it's useful to build in releaseFast mode to make tests faster.
zig build -Dwpt_extensions -Doptimize=fast run -- serve ...Contributing
See CONTRIBUTING.md for guidelines.
You must sign our CLA during the pull request process.
Why Lightpanda?
Javascript execution is mandatory for the modern web
Simple HTTP requests used to be enough for web automation. That's no longer the case. Javascript now drives most of the web:
- Ajax, Single Page Apps, infinite loading, instant search
- JS frameworks: React, Vue, Angular, and others
Chrome is not the right tool
Running a full desktop browser on a server works, but it does not scale well. Chrome at hundreds or thousands of instances is expensive:
- Heavy on RAM and CPU
- Hard to package, deploy, and maintain at scale
- Many features are not necessary in headless made
Lightpanda is built for performance
Supporting Javascript with real performance meant building from scratch rather than forking Chromium:
- Not based on Chromium, Blink, or WebKit
- Written in Zig, a low-level language with explicit memory control
- No graphical rendering engine
Overview (`orderfile/README.md`)
Hot-code orderfile
lightpanda.ld is an LLD linker script that gathers the functions and
read-only data touched by a typical session into two contiguous output
sections, .text.hot and .rodata.hot, placed in front of the cold .text
and .rodata. It only affects layout; no code changes.
Why
The kernel maps file-backed pages in 64KB fault-around windows
(/sys/kernel/debug/fault_around_bytes). With the default link order the hot
code is scattered across the whole 40MB of text, so every window that holds a
single executed byte becomes resident: ~17MB of .text and ~4MB of .rodata
for a hot set that is really 7.5MB + 2MB. Packing the hot set contiguously
takes the CDP bench (demo/puppeteer/cdp.js, 100 runs) from VmHWM ~28.2MB to
~25.3MB with the stock V8 archive and ~21.2MB once V8 is addressable too (see
below), with no change in run duration.
How it is applied
- Opt-in via
-Dorderfile=orderfile/lightpanda.ld; CI passes it for the Linux
release artifact and the e2e bench build. Local release builds don't, so
they keep the sub-second link. Linux/LLD only (it is an ELF linker script). - The exe and every C library it links are built with
-ffunction-sections -fdata-sectionsso each function/datum has its own
input section for the script to address. - Patterns that match nothing are ignored by LLD, so a stale script degrades
gracefully: renamed or removed functions simply fall back into cold.text.
Zig's__anon_NNNnames (~7% of the patterns) renumber on unrelated
changes, so the profile decays a little with every commit; it is
regenerated nightly by CI (below). - Link-time cost: LLD name-checks every input section against every pattern
of every input-section description, so the script scopes patterns to their
object file (*api.o(...)). The shipped script covers Zig/Rust/C only
(~4k patterns, ~2s of link). Once V8's functions are addressable (below) the
script grows to ~26k patterns and the release link to ~26s; one description
per pattern instead of per file would make that 85s (the line breaks inside
a description are free, they exist for the diff). Debug builds pay nothing. - The prebuilt V8 archive is compiled with
-fno-unique-section-names
(Chromium hardcodes it), so all of V8's function sections are named.text
and the script cannot address them.mark_hot_sections.zig(run
automatically bybuild.zigwhen an orderfile is set) rewrites the archive,
renaming just the hot sections to.text.hot.<symbol>/.rodata.hot.<symbol>so the script gathers all of V8 with one.text.hot.*glob (which keeps the link at ~2s instead of ~26s). The
browser's own hot Zig/Rust/C sections are matched by explicit per-file
patterns. The rewritten archive is a build cache artifact (~132MB -> ~158MB;
LLVM objects share .shstrtab with .strtab, so each touched member grows);
the linked binary is byte-for-byte unaffected. - V8's embedded builtins blob (the
Builtins_*symbols, one ~2MB.text
section) is deliberately left cold. Pulling it into.text.hotshifts the
layout so that V8's runtime code range (a separate mmap) no longer reaches
the blob with a pc-relative call, and V8 then copies the whole 2MB blob into
an executable anonymous mapping at startup (+~1.8MB RSS, deterministic).mark_hot_sections.zigandgen_order.pyboth skipBuiltins_*.
Regenerating the profile
# root for /sys/kernel/debug/fault_around_bytes; ../demo checked out with
# `npm install` done; node, go, python3 and binutils on the PATH.
orderfile/tools/regen.sh -Doptimize=fast -Dsnapshot_path=../../snapshot.bin -Dcpu=x86_64The script
- builds with an empty linker script (the unordered binary; the
--verbose-linkline of that build is wheregen_order.pygets the
objects and archives it maps symbols to sections with); - runs the CDP bench (
demo/puppeteer/cdp.js, 100 runs) against it with
fault-around set to 4KB, dumps the pages that are resident
(pagemap.py), turns them into hot symbol lists in address order
(hotlist.py) and into the newlightpanda.ld/v8.txt
(gen_order.py); - re-runs that build's
ld.lldline with the new script to check that it
links (the script is a cache input of the whole exe compilation, so
building with it would be a second full compile; the relink takes
seconds and is the same link, bar V8's hot-marked archive coming from
the previousv8.txt, which does not affect whether the script parses).
Whether the new profile is any good is not measured here; the e2e bench'sMAX_VmHWM gate is, on the next push to main.
The profile is a list of resident pages, so every function sharing a 4KB page
with a hot one is counted as hot. Profiling the unordered binary keeps that
over-inclusion random: a profile of the ordered binary would re-include every
function that went cold but still sits in .text.hot next to a hot one, and
the hot set would only ever grow. Over-inclusion is the cheap side of the
trade anyway (a hot function left out costs a whole 64KB cold window; one
included needlessly costs its own size), and .text.hot is packed in
address order of the profile, so functions that run together stay together.
The binary is benched from tmpfs (RAMDIR, default /dev/shm): on ext4 with
a recent kernel, large page-cache folios are mapped whole and hide the
64KB-window behaviour that CI sees. The x86_64 profile is applied to theaarch64 release too; it carries over as far as the section and symbol
names coincide, nothing is measured on aarch64.
To look at what is still resident outside the hot sections, run the bench by
hand and use pagemap.py + nm on the live process.
--- METRICS ---
- Files Extracted: 2
- Estimated Token Budget: ~5790 tokens
- Canonical Reference: https://codewiki.google/github.com/lightpanda-io/browser
2. Official Technical Reference & Guides (lightpanda-io/docs)
File: src/content/guides/configure-a-proxy.mdx
title: Use a proxy
import { Tabs } from 'nextra/components'
Use a proxy
Lightpanda supports using your own proxy, both locally and on Lightpanda Cloud. On Lightpanda Cloud, you can also use a shared proxy we provide instead.
<Tabs items={['Local', 'Cloud']}>
<Tabs.Tab>
Use a proxy locally
To use a proxy, start Lightpanda from the command line with --http-proxy followed by an HTTP or HTTPS proxy address. All outgoing requests go through the proxy, regardless of the protocol your client uses to connect to Lightpanda.
The examples below use serve, but fetch, mcp, and agent accept these options too.
lightpanda serve --http-proxy http://127.0.0.1:3000Authenticate with basic auth
Add basic auth credentials to the proxy address with the username:password@ format.
lightpanda serve --http-proxy 'http://me:[email protected]:3000'Authenticate with a bearer token
--proxy-bearer-token authenticates with bearer auth. It adds a Proxy-Authorization header to all outgoing requests.
lightpanda serve --http-proxy 'http://127.0.0.1:3000' --proxy-bearer-token 'MY-TOKEN'Authenticate from your script
You can also pass the proxy credentials from your Puppeteer or Playwright script instead of the command line.
Selenium can't do this, because Lightpanda does not support proxy authentication over WebDriver BiDi. Put the credentials in the --http-proxy URL instead.
<Tabs items={['Puppeteer', 'Playwright']}>
<Tabs.Tab>
With Puppeteer, you have to configure the proxy address when starting
Lightpanda.
lightpanda serve --http-proxy 'http://127.0.0.1:3000'Then you can call page.authenticate function to inject your authentication
from your script.
const page = await context.newPage();
// Set credentials for HTTP Basic Auth
await page.authenticate({
username: 'my_username',
password: 'my_password',
});You can find the full example in our
demo repository.
</Tabs.Tab>
<Tabs.Tab>
With Playwright, configure the proxy when creating the browser's context.
const context = await browser.newContext({
baseURL: baseURL,
proxy: {
server: 'http://127.0.0.1:3000',
username: 'my_username',
password: 'my_password',
},
});
const page = await context.newPage();You can find the full example in our
demo repository.
</Tabs.Tab>
</Tabs.Tab>
<Tabs.Tab>
Use a proxy on Lightpanda Cloud
Add the proxy query parameter to the connection URL to route the session through a proxy. You have two options: use a shared proxy, or add your own.
Use a shared proxy
Every account can use two shared proxies.
fast_dc: the default. A single shared datacenter IP, optimized for speed. Each region has its own, so the IP depends on the endpoint you connect to.datacenter: a pool of shared datacenter IPs with automatic rotation. Accepts an optionalcountryparameter (two-letter country code) to target a specific country.
wss://euwest.cloud.lightpanda.io/ws?proxy=datacenter&country=de&token=TOKENOmit proxy and the session uses fast_dc. Contact us for additional proxy options.
Use your own proxy
Add your proxy on the proxies page in the console, then pass its identifier as the proxy parameter. The console generates the identifier and shows it on the proxy's card. It is not the name you gave the proxy.
wss://euwest.cloud.lightpanda.io/ws?proxy=xK4mP2qR&token=TOKENThe kind you set on the proxy, datacenter or residential, is a label. It does not change how the session connects.
</Tabs.Tab>
File: src/content/guides/interact-with-a-webpage.mdx
title: Interact with a webpage
import { Callout, Tabs } from 'nextra/components'
Interact with a webpage
In this guide, you'll use Lightpanda to run a search on HackerNews, wait for dynamic results to load, and extract structured data from the page.
This example requires Lightpanda because HackerNews uses XHR requests to display search results — raw HTML fetching wouldn't capture them.
Prerequisites
You'll need Node.js installed on your computer.
Create a hn-scraper directory and initialize a new Node.js project. You can accept all the default values in the npm init prompts.
mkdir hn-scraper && \
cd hn-scraper && \
npm initInstall the lightpanda and the puppeteer-core or playwright-core npm package.
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
npm install --save puppeteer-core @lightpanda/browser </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
npm install --save playwright-core @lightpanda/browser </Tabs.Tab>
This guide uses a local Lightpanda instance — see installation for setup details. The code can be adapted to connect to Lightpanda Cloud.
Create index.js
Create an index.js file. Import the packages, configure the connection, and set up the browser lifecycle:
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import puppeteer from 'puppeteer-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const puppeteeropts = {
browserWSEndpoint: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Puppeteer to the browser.
const browser = await puppeteer.connect(puppeteeropts);
const context = await browser.createBrowserContext();
const page = await context.newPage();
// Disconnect Puppeteer.
await page.close();
await context.close();
await browser.disconnect();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import { chromium } from 'playwright-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const playwrightopts = {
endpointURL: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Playwright's chromium driver to the browser.
const browser = await chromium.connectOverCDP(playwrightopts);
const context = await browser.newContext({});
const page = await context.newPage();
// Disconnect Playwright.
await page.close();
await context.close();
await browser.close();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
Navigate and search
Navigate to HackerNews, type a search term, and press Enter:
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
await page.goto("https://news.ycombinator.com/");
await page.type('input[name="q"]', 'lightpanda');
await page.keyboard.press('Enter'); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
await page.goto("https://news.ycombinator.com/");
await page.locator('input[name="q"]').fill('lightpanda');
await page.keyboard.press('Enter'); </Tabs.Tab>
Wait for results
Wait for the search results to appear, with a 5 second timeout:
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
await page.waitForFunction(() => {
return document.querySelector('.Story_container') != null;
}, { timeout: 5000 }); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
await page.waitForSelector('.Story_container', { timeout: 5000 }); </Tabs.Tab>
Extract the data
Loop over the results to extract the title, URL, and metadata for each story:
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
const res = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.Story_container')).map(row => {
return {
title: row.querySelector('.Story_title span').textContent,
url: row.querySelector('.Story_title a').getAttribute('href'),
meta: Array.from(row.querySelectorAll('.Story_meta > span:not(.Story_separator, .Story_comment)')).map(row => {
return row.textContent;
}),
}
});
});
console.log(res); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
const res = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.Story_container')).map(row => {
return {
title: row.querySelector('.Story_title span').textContent,
url: row.querySelector('.Story_title a').getAttribute('href'),
meta: Array.from(row.querySelectorAll('.Story_meta > span:not(.Story_separator, .Story_comment)')).map(row => {
return row.textContent;
}),
}
});
});
console.log(res); </Tabs.Tab>
Full script
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import puppeteer from 'puppeteer-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const puppeteeropts = {
browserWSEndpoint: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Puppeteer to the browser.
const browser = await puppeteer.connect(puppeteeropts);
const context = await browser.createBrowserContext();
const page = await context.newPage();
// Go to hackernews home page.
await page.goto("https://news.ycombinator.com/");
// Type the search term and submit.
await page.type('input[name="q"]', 'lightpanda');
await page.keyboard.press('Enter');
// Wait until the search results are loaded.
await page.waitForFunction(() => {
return document.querySelector('.Story_container') != null;
}, { timeout: 5000 });
// Loop over search results to extract data.
const res = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.Story_container')).map(row => {
return {
title: row.querySelector('.Story_title span').textContent,
url: row.querySelector('.Story_title a').getAttribute('href'),
meta: Array.from(row.querySelectorAll('.Story_meta > span:not(.Story_separator, .Story_comment)')).map(row => {
return row.textContent;
}),
}
});
});
console.log(res);
// Disconnect Puppeteer.
await page.close();
await context.close();
await browser.disconnect();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import { chromium } from 'playwright-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const playwrightopts = {
endpointURL: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Playwright's chromium driver to the browser.
const browser = await chromium.connectOverCDP(playwrightopts);
const context = await browser.newContext({});
const page = await context.newPage();
// Go to hackernews home page.
await page.goto("https://news.ycombinator.com/");
// Type the search term and submit.
await page.locator('input[name="q"]').fill('lightpanda');
await page.keyboard.press('Enter');
// Wait until the search results are loaded.
await page.waitForSelector('.Story_container', { timeout: 5000 });
// Loop over search results to extract data.
const res = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.Story_container')).map(row => {
return {
title: row.querySelector('.Story_title span').textContent,
url: row.querySelector('.Story_title a').getAttribute('href'),
meta: Array.from(row.querySelectorAll('.Story_meta > span:not(.Story_separator, .Story_comment)')).map(row => {
return row.textContent;
}),
}
});
});
console.log(res);
// Disconnect Playwright.
await page.close();
await context.close();
await browser.close();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
Run it
node index.js$ node index.js
🐼 Running Lightpanda's CDP server… { pid: 598201 }
[
{
title: 'Show HN: Lightpanda, an open-source headless browser in Zig',
url: 'https://news.ycombinator.com/item?id=42817439',
meta: [ '319 points', 'fbouvier', '9 months ago', '137 comments' ]
},
{
title: 'Lightpanda: Headless browser designed for AI and automation',
url: 'https://news.ycombinator.com/item?id=42812859',
meta: [ '154 points', 'tosh', '9 months ago', '1 comments' ]
},
...
]File: src/content/guides/lightpanda-agent-tutorial.mdx
title: Use Lightpanda Agent
Use Lightpanda Agent
This tutorial will walk you through how to use Lightpanda Agent to create a
reproducible JavaScript browser script.
Find all flags and REPL commands in the agent command reference.
Prerequisites
- Lightpanda installed and on your
PATH. - A Hacker News account.
- An LLM API key for the natural-language sections (Anthropic, OpenAI,
Gemini, Hugging Face or a local Ollama). Recorded.jsPandaScripts need no key.
Export your HN credentials as LP_* env vars. The LP_ prefix
matters for security: Lightpanda only resolves these placeholders
inside its own subprocess (so your password never reaches the LLM),
and the getEnv tool refuses any variable that doesn't start withLP_ (so the LLM can't read your other secrets).
export LP_HN_USERNAME="your-hn-handle"
export LP_HN_PASSWORD="your-hn-password"Check the variables are set. If one is missing, /fill will silently
type the literal $LP_HN_USERNAME into the form rather than your
username:
lightpanda agent --no-llm> /getEnv LP_HN_USERNAME1. Start the REPL
lightpanda agentThe startup banner shows whether natural language is available;/model prints the resolved model. REPL history lives in .lp-history
in the working directory.
> /help # list every browser tool
> /help goto # JSON schema for one tool
> /quitNo API key? lightpanda agent --no-llm runs the slash-commands-only
REPL.
2. Run a single task
Before doing anything complicated, run a one-shot task to check
everything works:
lightpanda agent --task "what is the top story on news.ycombinator.com?"--task runs one user turn, prints the answer on stdout, exits.
Tool calls and progress go to stderr, so redirecting gives you a
clean answer:
lightpanda agent --task "top story on news.ycombinator.com?" > out.txtUse -a (repeatable) to attach local files.
3. Log in to Hacker News
Paste these into the REPL in order:
> /goto https://news.ycombinator.com/login
> /fill selector='form[action="login"] input[name="acct"]' value='$LP_HN_USERNAME'
> /fill selector='form[action="login"] input[name="pw"]' value='$LP_HN_PASSWORD'
> /click selector='form[action="login"] input[type="submit"][value="login"]'
> /waitForSelector '#logout'A few things worth knowing:
- Slash commands only.
click '#foo'is forwarded to the LLM;
only/click '#foo'runs as a command. TAB completes tool names. /waitForSelector '#logout'is both a sync point and an
assertion. It blocks until HN's logged-in DOM renders. If the
credentials are wrong (or the website throws a captcha, or the layout
changes), the command times out at the line where the failure
actually happened. Use this pattern after every state-changing
action.- Selectors are CSS only. The click-family tools (
/click,/fill,/hover,/selectOption,/setChecked) accept CSS
only. Backend node IDs are invalidated by any DOM mutation and
can't be serialized into recordings.
Confirm the login worked:
> /extract '{"karma": "#karma"}'
{"karma":"42"}/extract takes a JSON schema and prints one JSON object to stdout.
Schema grammar:
"<sel>": text of the first match.["<sel>"]: text of every match.{"selector": "<sel>", "attr": "<name>"}: attribute of the first match.[{"selector": "<sel>", "fields": {…}}]: array of records, with
eachfieldsentry resolved relative to the matched element.
Now go to the front page and pull the story list:
> /goto https://news.ycombinator.com
> /extract '''
{
"topStories": [{
"selector": ".athing",
"fields": {
"rank": ".rank",
"title": ".titleline > a",
"url": {"selector": ".titleline > a", "attr": "href"}
}
}]
}
'''Triple-quoted values let a schema span multiple lines.
How we got those selectors
Skip this if you're happy treating them as given.
/tree prints the semantic tree. On the login page you'll see two
forms (login and signup) with unlabeled textboxes, which means/findElement role=textbox name=username returns nothing.
/detectForms reads the HTML directly and surfaces each form'saction plus each input's name. The first form has action: "login"
and fields named acct and pw, which gives us the form-scoped
selector (form[action="login"] input[name="acct"]) that won't
collide with signup.
/nodeDetails backendNodeId=<n> is the alternative: it returns a
ready-to-use CSS selector for any node ID from /tree.
4. Save the session as a script
Retype the login plus front-page sequence in a single REPL session,
then:
> /save hn_login.js
> /quitIn --no-llm mode, /save transcribes the session deterministically.
With an LLM, it synthesizes an idiomatic script. The result is
JavaScript:
const page = new Page();
await page.goto("https://news.ycombinator.com/login");
page.fill({ selector: "form[action=\"login\"] input[name=\"acct\"]", value: "$LP_HN_USERNAME" });
page.fill({ selector: "form[action=\"login\"] input[name=\"pw\"]", value: "$LP_HN_PASSWORD" });
page.click({ selector: "form[action=\"login\"] input[type=\"submit\"][value=\"login\"]" });
page.waitForSelector("#logout");
await page.goto("https://news.ycombinator.com");
return page.extract({ topStories: [{ selector: ".athing", fields: { rank: ".rank", title: ".titleline > a", url: { selector: ".titleline > a", attr: "href" } } }] });Only state-mutating commands are recorded; read-only ones (/tree,/markdown) are dropped. /extract is recorded because it shapes
what the script returns.
5. Replay the script without an LLM
lightpanda agent hn_login.jsNo --provider, no API key, no token spend. Whatever the scriptreturns is printed automatically as JSON. Because the saved script
ends with return page.extract(...), you get clean JSON on stdout:
lightpanda agent hn_login.js > stories.jsonFrom inside the REPL, /load hn_login.js runs the same script against
the current session.
To reshape the output, assign the result and end with return <value>
(what you return is what prints):
const page = new Page();
await page.goto("https://news.ycombinator.com");
const topStories = page.extract({
topStories: [{
selector: ".athing",
fields: {
rank: ".rank",
title: ".titleline > a",
url: { selector: ".titleline > a", attr: "href" }
}
}]
});
return topStories;6. Add your own JavaScript logic
Agent scripts run in a separate JavaScript context from the page. Nowindow, document, DOM API, require, or process. Browser
interaction happens through the Page object: new Page() makes a
page, await page.goto(url) navigates it, and every other primitive is
a synchronous method on it.
Use page.extract(...) to move page data into local logic, then
process it with normal JavaScript:
const page = new Page();
await page.goto("https://news.ycombinator.com");
const topStories = page.extract({
topStories: [{
selector: ".athing",
limit: 5,
fields: {
rank: ".rank",
title: ".titleline > a",
url: { selector: ".titleline > a", attr: "href" }
}
}]
});
return topStories.map((s) => ({ rank: s.rank, title: s.title, url: s.url }));Use page.evaluate(...) only when you intentionally want a string to
run in the page's JavaScript context. Page evaluate cannot see agent
variables or call agent primitives.
7. Use Lightpanda from another agent (MCP)
If you're driving Lightpanda from a different agent (Claude Code, a
custom MCP client, your own harness), use lightpanda mcp instead.
The calling agent supplies the LLM, so Lightpanda needs no API key.
{
"mcpServers": {
"lightpanda": {
"command": "/path/to/lightpanda",
"args": ["mcp"]
}
}
}Drive the browser with the usual tools (goto, fill, click,waitForSelector), then hand back a script with the save tool:
{
"tool": "save",
"args": {
"path": "hn_login.js",
"script": "const page = new Page();\nawait page.goto(\"...\");\n..."
}
}The path must be relative and free of ... Literal LP_* values are
scrubbed back to placeholders before the file is written. The output
runs unmodified:
lightpanda agent hn_login.jsFurther reading
- Agent: full documentation reference,
- PandaScript: runtime, primitives,
return values.
File: src/content/guides/markdown-axtree.mdx
title: Markdown and AXTree
import { Tabs } from 'nextra/components'
import { Callout } from 'nextra/components'
Markdown and AXTree
Lightpanda outputs web page content as Markdown and Accessibility tree directly from its browser engine, after JavaScript execution. No external HTML-to-Markdown converter needed.
Overview
Three ways to get Markdown and Accessibility tree from any web page:
| Pathway | Best for | How |
|---|---|---|
CLI fetch --dump markdown |
Scripts, pipelines, quick extraction | Single command, stdout |
CDP LP.getMarkdown |
Puppeteer/Playwright automation | Programmatic, after page interactions |
| MCP server | AI agent / LLM tool integration | Model Context Protocol over stdio |
CLI: `fetch --dump`
The fetch command fetches a URL, executes JavaScript, and dumps the result to stdout. Runs standalone with no browser session or CDP connection needed.
Syntax
lightpanda fetch --dump <format> [options] <URL>
# Formats:
# html Full rendered HTML (post-JS)
# markdown CommonMark markdown
# semantic_tree JSON accessibility tree
# semantic_tree_text Human-readable accessibility treeOutput formats
The same page (example.com) in three output formats. Markdown strips all non-semantic markup for a smaller representation.
| Format | Description | Best for |
|---|---|---|
html |
Full post-JS DOM as HTML | Archiving, diffing, debugging |
markdown |
CommonMark markdown (links, headings, lists, tables) | AI/LLM context, summarization |
semantic_tree |
Full accessibility tree as structured JSON | Accessibility audits, structured parsing |
semantic_tree_text |
Indented human-readable accessibility tree | Quick accessibility review, debugging |
Basic example
lightpanda fetch --dump markdown https://example.comExample output
# Example Domain
This domain is for use in documentation examples without needing permission. Avoid use in operations.
[Learn more](https://iana.org/domains/example)`--with-frames` flag
Includes rendered iframe content in the output. Without it, iframe content is excluded.
# Without iframe content (default)
lightpanda fetch --dump markdown https://example.com
# With iframe content included
lightpanda fetch --dump markdown --with-frames https://example.comOn pages without iframes, output is identical either way.
`--strip-mode` flag
Removes groups of tags from the output. Values can be combined with commas:
# Remove JavaScript and CSS from output
lightpanda fetch --dump html --strip-mode js,css https://example.com
# Keep only the main content, reader-mode style
lightpanda fetch --dump html --strip-mode clutter https://example.comFind all options in the fetch command reference.
Semantic tree output
The semantic_tree format returns the page's accessibility tree as structured JSON:
lightpanda fetch --dump semantic_tree https://example.comEach node includes:
| Field | Type | Description |
|---|---|---|
nodeId |
string | Unique node identifier |
backendDOMNodeId |
integer | Backend DOM node ID |
nodeName |
string | HTML element name (e.g., h1, p, a, text) |
xpath |
string | XPath to the node |
nodeType |
integer | DOM node type (1 = element, 3 = text, 9 = document) |
isInteractive |
boolean | Whether the element is interactive |
role |
string | ARIA role (e.g., heading, paragraph, link, none) |
name |
string | Computed accessible name |
nodeValue |
string | Text content (for text nodes) |
attributes |
object | HTML attributes (e.g., href, lang) |
children |
array | Child nodes |
Example output (first two levels)
{
"nodeId": "1",
"backendDOMNodeId": 1,
"nodeName": "root",
"xpath": "",
"nodeType": 9,
"children": [{
"nodeId": "2",
"backendDOMNodeId": 2,
"nodeName": "html",
"xpath": "/html[1]",
"nodeType": 1,
"isInteractive": false,
"role": "none",
"attributes": {"lang": "en"},
"children": ["..."]
}]
}The semantic_tree_text format outputs the same data in a compact readable form:
lightpanda fetch --dump semantic_tree_text https://example.comExample output
[1] RootWebArea: Example Domain
[5] heading: Example Domain
[6] paragraph:
[7] StaticText: This domain is for use in documentation examples without needing permission. Avoid use in operations.
[8] paragraph:
[9] link: Learn moreThe CLI
semantic_treeand the CDPAccessibility.getFullAXTreereturn different JSON schemas. The CLI uses flat field names (role,nameas strings); CDP uses typed objects (e.g.,role: { type: "role", value: "heading" }). See CDP: Accessibility tree for the CDP schema.
CDP: `LP.getMarkdown`
In server mode (lightpanda serve), Lightpanda exposes a WebSocket CDP endpoint with a custom LP domain. LP.getMarkdown converts the current page's live DOM to Markdown.
Unlike --dump markdown which processes a URL from scratch, LP.getMarkdown converts the DOM at call time. You can click, fill forms, or wait for dynamic content, then call it to capture the current state.
Starting the CDP server
# Start CDP server (default: ws://127.0.0.1:9222)
lightpanda serve
# Custom host and port
lightpanda serve --host 0.0.0.0 --port 9333
# Verify it's running
curl http://127.0.0.1:9222/json/versionUsing `LP.getMarkdown`
<Tabs items={['Puppeteer', 'Playwright']} className="-mb-4">
<Tabs.Tab id="Playwright" className="m-0">
// install: npm install playwright-core
import { chromium } from 'playwright-core';
const browser = await chromium.connectOverCDP('ws://127.0.0.1:9222');
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com');
const client = await page.context().newCDPSession(page);
const result = await client.send('LP.getMarkdown', {});
console.log(result.markdown);
await page.close();
await context.close();
await browser.close(); </Tabs.Tab>
<Tabs.Tab id="Puppeteer" className="m-0">
// install: npm install puppeteer-core
import puppeteer from 'puppeteer-core';
const browser = await puppeteer.connect({
browserWSEndpoint: 'ws://127.0.0.1:9222',
});
const context = await browser.createBrowserContext();
const page = await context.newPage();
const client = await page.createCDPSession();
await page.goto('https://example.com', { waitUntil: 'networkidle0' });
const result = await client.send('LP.getMarkdown', {});
console.log(result.markdown);
await page.close();
await context.close();
await browser.disconnect(); </Tabs.Tab>
LP.getMarkdownworks the same way locally or on Lightpanda Cloud.
LP.getMarkdownsupports converting a sub-portion of the page by targeting specific DOM nodes, useful for skipping headers, footers, and sidebars.
CDP: Accessibility tree
Lightpanda supports the standard CDP Accessibility.getFullAXTree command, returning the complete accessibility tree as a structured array of nodes, compatible with Chrome's CDP implementation.
Usage with Puppeteer
const client = await page.createCDPSession();
const result = await client.send('Accessibility.getFullAXTree', {});
console.log(JSON.stringify(result.nodes, null, 2));
console.log(`Total nodes: ${result.nodes.length}`);Usage with Playwright
const client = await page.context().newCDPSession(page);
const result = await client.send('Accessibility.getFullAXTree', {});
console.log(JSON.stringify(result.nodes, null, 2));AX node structure
Example node shape
{
"nodeId": 6,
"backendDOMNodeId": 6,
"role": { "type": "role", "value": "heading" },
"ignored": false,
"name": {
"type": "computedString",
"value": "Example Domain",
"sources": [{ "type": "contents" }]
},
"properties": [
{ "name": "level", "value": { "type": "integer", "value": 1 } }
],
"parentId": 5,
"childIds": [9]
}| Field | Type | Description |
|---|---|---|
nodeId |
integer | Unique node identifier |
backendDOMNodeId |
integer | Backend DOM node ID |
role |
object | { type: "role", value: "<role>" } - ARIA role |
ignored |
boolean | Whether the node is ignored for accessibility |
ignoredReasons |
array | Why the node is ignored (if applicable) |
name |
object | { type: "computedString", value: "<name>", sources: [...] } |
properties |
array | ARIA properties (e.g., level, url, focusable) |
parentId |
integer | Parent node ID |
childIds |
array | Child node IDs |
For example.com, the CDP accessibility tree contains 12 nodes.
MCP server
Lightpanda includes a built-in MCP (Model Context Protocol) server for direct integration with AI tools and LLM frameworks.
lightpanda mcpThe MCP server exposes markdown and semantic_tree tools.
| Name | Description |
|---|---|
| markdown | Get the page content in markdown format. If a url is provided, it navigates to that url first. |
| semantic_tree | Get the page content as a simplified semantic DOM tree for AI reasoning. If a url is provided, it navigates to that url first. |
For full MCP server documentation — tools, resources, handshake protocol, agent configuration, and HTTP transport — see the MCP server guide and the MCP tools reference.
References
- Native Markdown Output in Lightpanda
- Playwright markdown demo
- Puppeteer markdown demo
- Playwright AX tree demo
- Puppeteer AX tree demo
- Lightpanda releases
- Cloudflare: Markdown for Agents
File: src/content/guides/retrieve-an-html-webpage.mdx
title: Retrieve an HTML webpage
import { Callout, Tabs } from 'nextra/components'
Retrieve an HTML webpage
In this guide, you'll connect a CDP client to Lightpanda and extract all reference links from a Wikipedia page.
Unlike curl, which only fetches raw HTML, Lightpanda executes JavaScript and runs query selectors directly in the browser — making it suitable for dynamic pages.
Prerequisites
You'll need Node.js installed on your computer.
Create a hn-scraper directory and initialize a new Node.js project. You can accept all the default values in the npm init prompts.
mkdir hn-scraper && \
cd hn-scraper && \
npm initInstall the lightpanda and the puppeteer-core or playwright-core npm packages.
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
npm install --save puppeteer-core @lightpanda/browser </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
npm install --save playwright-core @lightpanda/browser </Tabs.Tab>
This guide uses a local Lightpanda instance — see installation for setup details. The code can be adapted to connect to Lightpanda Cloud.
Create index.js
Create an index.js file. Import the packages, configure the connection, and set up the browser lifecycle:
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import puppeteer from 'puppeteer-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const puppeteeropts = {
browserWSEndpoint: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Puppeteer to the browser.
const browser = await puppeteer.connect(puppeteeropts);
const context = await browser.createBrowserContext();
const page = await context.newPage();
// Disconnect Puppeteer.
await page.close();
await context.close();
await browser.disconnect();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import { chromium } from 'playwright-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const playwrightopts = {
endpointURL: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Playwright's chromium driver to the browser.
const browser = await chromium.connectOverCDP(playwrightopts);
const context = await browser.newContext({});
const page = await context.newPage();
// Disconnect Playwright.
await page.close();
await context.close();
await browser.close();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
Navigate and extract
Use page.goto to navigate to the Wikipedia page, then run a query selector to extract all external reference links:
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
// Go to Wikipedia page.
await page.goto("https://en.wikipedia.org/wiki/Web_browser");
// Extract all links from the references list of the page.
const reflist = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.references a.external')).map(row => {
return row.getAttribute('href');
});
});
// Display the result.
console.log("all reference links", reflist); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
// Go to Wikipedia page.
await page.goto("https://en.wikipedia.org/wiki/Web_browser");
// Extract all links from the references list of the page.
const reflist = await page.locator('.references a.external').evaluateAll(links =>
links.map(link => link.getAttribute('href'))
);
// Display the result.
console.log("all reference links", reflist); </Tabs.Tab>
Full script
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import puppeteer from 'puppeteer-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const puppeteeropts = {
browserWSEndpoint: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Puppeteer to the browser.
const browser = await puppeteer.connect(puppeteeropts);
const context = await browser.createBrowserContext();
const page = await context.newPage();
// Go to Wikipedia page.
await page.goto("https://en.wikipedia.org/wiki/Web_browser");
// Extract all links from the references list of the page.
const reflist = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.references a.external')).map(row => {
return row.getAttribute('href');
});
});
// Display the result.
console.log("all reference links", reflist);
// Disconnect Puppeteer.
await page.close();
await context.close();
await browser.disconnect();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import { chromium } from 'playwright-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const playwrightopts = {
endpointURL: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Playwright's chromium driver to the browser.
const browser = await chromium.connectOverCDP(playwrightopts);
const context = await browser.newContext({});
const page = await context.newPage();
// Go to Wikipedia page.
await page.goto("https://en.wikipedia.org/wiki/Web_browser");
// Extract all links from the references list of the page.
const reflist = await page.locator('.references a.external').evaluateAll(links =>
links.map(link => link.getAttribute('href'))
);
// Display the result.
console.log("all reference links", reflist);
// Disconnect Playwright.
await page.close();
await context.close();
await browser.close();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
Run it
node index.js$ node index.js
🐼 Running Lightpanda's CDP server... { pid: 34389 }
all reference links [
'https://gs.statcounter.com/browser-market-share',
'https://radar.cloudflare.com/reports/browser-market-share-2024-q1',
'https://web.archive.org/web/20240523140912/https://www.internetworldstats.com/stats.htm',
'https://www.internetworldstats.com/stats.htm',
'https://www.reference.com/humanities-culture/purpose-browser-e61874e41999ede',
...
]File: src/content/guides/use-hermes.mdx
title: Use Hermes Agent
import { Callout } from 'nextra/components'
Use Hermes Agent
Hermes Agent is an open source autonomous agent built by Nous Research. It runs autonomously for long stretches, builds persistent memory, spawns subagents in parallel, and reaches you on Telegram, Discord, Slack, and other platforms while working on a remote VM.
Hermes has two browser drivers, and Lightpanda plugs into both:
- Browser Use mode — the default since August 2026. The agent gets a single
browser_exectool that runs Python against a browser over CDP through the Browser Use CLI. - Built-in browser tools (
browser_navigate,browser_snapshot, …) — the older driver, which reaches browsers through agent-browser.
Pick the section below that matches your driver. The setting is the same (browser.engine: lightpanda), what differs is what else has to be true for Hermes to honour it.
Prerequisites
- Hermes Agent
v0.21.0or later (hermes update, thenhermes --version) - Lightpanda installed and on your
PATH— check withwhich lightpanda
If you don't have Lightpanda yet, follow the installation guide. Hermes and agent-browser also look in ~/.lightpanda/lightpanda and ~/.local/bin/lightpanda.
Option A — Browser Use mode (the Hermes default)
Since Hermes v0.21.0, Browser Use mode honours browser.engine: lightpanda natively: Hermes starts lightpanda serve itself, one process per session, and hands its endpoint to the Browser Use CLI for every browser_exec call.
Either pick Lightpanda in hermes tools → Browser Automation, or set it in ~/.hermes/config.yaml:
browser:
cloud_provider: local
engine: lightpandacloud_provider: local matters: browser.engine is the lowest-precedence browser setting in Hermes. It is ignored whenever a cloud provider is active — and on a setup that never picked a browser, any BROWSERBASE_API_KEY, BROWSER_USE_API_KEY or Nous subscription browser in ~/.hermes/.env selects one automatically. A browser.cdp_url / /browser connect override takes precedence too. Since v0.21.0, /browser status and hermes doctor tell you when the engine is set but shadowed, and by what.
Option B — Built-in browser tools
Switch Hermes off Browser Use mode and select Lightpanda as the local engine:
# ~/.hermes/config.yaml
browser:
backend: "off"
cloud_provider: local
engine: lightpanda(/browser use off inside Hermes writes backend: "off" for you.)
The same precedence rules as Option A apply (cloud_provider: local, no CDP override), plus one more: Camoufox (CAMOFOX_URL) also wins over browser.engine on this path.
Hermes drives Lightpanda through agent-browser --engine lightpanda over CDP, the same way it drives local Chrome, with automatic Chrome fallback: Lightpanda handles navigate, snapshot, click, type, scroll, back, press and eval; screenshots, browser_vision, PDF generation, file uploads and anything that errors on Lightpanda are retried on Chrome.
Verify the configuration
Start Hermes and run /browser status.
Option A shows the engine and the binary Hermes will spawn:
Engine: Lightpanda — Browser Use mode: Hermes spawns `lightpanda serve` per sessionIf it instead reports browser.engine is 'lightpanda' but it is NOT in use: …, the message names what is shadowing it (a cloud provider, a CDP override, …); see the troubleshooting list below.
Option B shows the engine:
🌐 Browser: local Lightpanda (agent-browser --engine lightpanda)
⚡ Lightpanda: faster navigation, no screenshot support
Automatic Chromium fallback for screenshots and failed commandsThen try a prompt that involves browsing:
Visit https://news.ycombinator.com and list the top 5 story titles.To prove the engine rather than trust the status line, ask the agent for navigator.userAgent: Lightpanda reports Lightpanda/1.0.
Troubleshooting: Hermes uses Chrome even though I set the engine
Check, in this order:
- Is Hermes up to date? Browser Use mode only reads
browser.enginefromv0.21.0; runhermes update. From there,/browser statusandhermes doctorsay outright when the engine is shadowed. - Is a cloud provider selected?
browser.cloud_providerinconfig.yaml, or a cloud API key / Nous subscription browser on a never-configured setup. Setcloud_provider: local. - Is a CDP override active?
browser.cdp_urlor a previous/browser connect(undo with/browser disconnect). - Is Camoufox selected? Unset
CAMOFOX_URL. - Linux sandbox flags on Hermes older than
v0.21.0(Option B, see the warning above). - Nightly
--timeoutrejection (Option B, see Prerequisites): runlightpanda serve --timeout 1by hand;FATAL app : unknown argumentmeans agent-browser cannot launch this build.
Further reading
File: src/content/guides/use-stagehand.mdx
title: Use Stagehand
import { Callout } from 'nextra/components'
Use Stagehand with Lightpanda
Stagehand is a popular, open
source AI Browser Automation
Framework.
With Stagehand you can use natural language and code to control browser.
Since Lightpanda supports Accessibilty
tree, you can use it
instead of Chrome with your Stagehand script.
Install the Lightanda and Stagehand dependencies
If not set, create a new npm project and install Stagehand depencies.
npm initnpm install @browserbasehq/stagehand@3 @lightpanda/browserStay on the Stagehand 3.x line. Stagehand 4 moved its DOM work into a Chrome
extension that localBrowser.connect() loads through the CDP Extensions
domain, which Lightpanda does not implement, so 4.x cannot connect. The API in
this guide is the 3.x one.
Write your Stagehand script with Lightpanda
Now you can create your Stagehand's. script to connectm by editing index.js
file.
'use strict'
import { lightpanda } from '@lightpanda/browser';
import { Stagehand } from "@browserbasehq/stagehand";
import { z } from "zod/v3";
const lpdopts = { host: '127.0.0.1', port: 9222 };
const stagehandopts = {
// Enable LOCAL env to configure the CDP url manually in the launch options.
env: "LOCAL",
localBrowserLaunchOptions: {
cdpUrl: 'ws://' + lpdopts.host + ':' + lpdopts.port,
},
// You need an ANTHROPIC_API_KEY env var.
model: "anthropic/claude-haiku-4-5",
verbose: 0,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
try {
// Connect Stagehand to the browser.
const stagehand = new Stagehand(stagehandopts);
await stagehand.init();
// Impportant: in the official documentation, Stagehand uses the default
// existing page. But Lightpanda requires an explicit page's creation
// instead.
const page = await stagehand.context.newPage();
await page.goto('https://demo-browser.lightpanda.io/amiibo/', {waitUntil: "networkidle"});
const name = await stagehand.extract("Extract character's name", z.string());
console.log("===", name);
await stagehand.close()
} finally {
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
}
})();Run your script
Before running you script, make sure you have a valid Anthropic api key
exported into the env var ANTHROPIC_API_KEY.
You can also use another
model supported by
Stagehand.
node index.jsYou should see in the following logs:
=== SandyFile: src/content/run-locally/installation/build-from-sources.mdx
title: Build from sources
import { Callout } from 'nextra/components'
Build from sources
Prerequisites
Lightpanda is written with Zig 0.14.0. You will have to
install it with the right version in order to build the project.
You need also to install Rust for building deps.
Lightpanda also depends on
zig-js-runtime (with v8),
Libcurl and html5ever.
To be able to build the v8 engine for zig-js-runtime, you have to install some libs:
For Debian/Ubuntu based Linux:
sudo apt install xz-utils ca-certificates \
pkg-config libglib2.0-dev \
clang make curl gitFor MacOS, you need Xcode and the following pacakges from homebrew:
brew install cmakeBuild and run
You can build the entire browser with make build or make build-dev for debug
env.
But you can directly use the zig command to run in debug mode:
zig build runEmbed v8 snapshot
Lighpanda uses v8 snapshot. By default, it is created on startup but you can
embed it by using the following commands:
Generate the snapshot.
zig build snapshot_creator -- src/snapshot.binBuild using the snapshot binary.
zig build -Dsnapshot_path=../../snapshot.binSee #1279 for more details.
Telemetry
By default, Lightpanda collects and sends usage telemetry. This can be disabled by setting an environment variable LIGHTPANDA_DISABLE_TELEMETRY=true. You can read Lightpanda's privacy policy at: https://lightpanda.io/privacy-policy.
2. Official Technical Reference & Guides (lightpanda-io/docs)
File: src/content/guides/configure-a-proxy.mdx
title: Use a proxy
import { Tabs } from 'nextra/components'
Use a proxy
Lightpanda supports using your own proxy, both locally and on Lightpanda Cloud. On Lightpanda Cloud, you can also use a shared proxy we provide instead.
<Tabs items={['Local', 'Cloud']}>
<Tabs.Tab>
Use a proxy locally
To use a proxy, start Lightpanda from the command line with --http-proxy followed by an HTTP or HTTPS proxy address. All outgoing requests go through the proxy, regardless of the protocol your client uses to connect to Lightpanda.
The examples below use serve, but fetch, mcp, and agent accept these options too.
lightpanda serve --http-proxy http://127.0.0.1:3000Authenticate with basic auth
Add basic auth credentials to the proxy address with the username:password@ format.
lightpanda serve --http-proxy 'http://me:[email protected]:3000'Authenticate with a bearer token
--proxy-bearer-token authenticates with bearer auth. It adds a Proxy-Authorization header to all outgoing requests.
lightpanda serve --http-proxy 'http://127.0.0.1:3000' --proxy-bearer-token 'MY-TOKEN'Authenticate from your script
You can also pass the proxy credentials from your Puppeteer or Playwright script instead of the command line.
Selenium can't do this, because Lightpanda does not support proxy authentication over WebDriver BiDi. Put the credentials in the --http-proxy URL instead.
<Tabs items={['Puppeteer', 'Playwright']}>
<Tabs.Tab>
With Puppeteer, you have to configure the proxy address when starting
Lightpanda.
lightpanda serve --http-proxy 'http://127.0.0.1:3000'Then you can call page.authenticate function to inject your authentication
from your script.
const page = await context.newPage();
// Set credentials for HTTP Basic Auth
await page.authenticate({
username: 'my_username',
password: 'my_password',
});You can find the full example in our
demo repository.
</Tabs.Tab>
<Tabs.Tab>
With Playwright, configure the proxy when creating the browser's context.
const context = await browser.newContext({
baseURL: baseURL,
proxy: {
server: 'http://127.0.0.1:3000',
username: 'my_username',
password: 'my_password',
},
});
const page = await context.newPage();You can find the full example in our
demo repository.
</Tabs.Tab>
</Tabs.Tab>
<Tabs.Tab>
Use a proxy on Lightpanda Cloud
Add the proxy query parameter to the connection URL to route the session through a proxy. You have two options: use a shared proxy, or add your own.
Use a shared proxy
Every account can use two shared proxies.
fast_dc: the default. A single shared datacenter IP, optimized for speed. Each region has its own, so the IP depends on the endpoint you connect to.datacenter: a pool of shared datacenter IPs with automatic rotation. Accepts an optionalcountryparameter (two-letter country code) to target a specific country.
wss://euwest.cloud.lightpanda.io/ws?proxy=datacenter&country=de&token=TOKENOmit proxy and the session uses fast_dc. Contact us for additional proxy options.
Use your own proxy
Add your proxy on the proxies page in the console, then pass its identifier as the proxy parameter. The console generates the identifier and shows it on the proxy's card. It is not the name you gave the proxy.
wss://euwest.cloud.lightpanda.io/ws?proxy=xK4mP2qR&token=TOKENThe kind you set on the proxy, datacenter or residential, is a label. It does not change how the session connects.
</Tabs.Tab>
File: src/content/guides/interact-with-a-webpage.mdx
title: Interact with a webpage
import { Callout, Tabs } from 'nextra/components'
Interact with a webpage
In this guide, you'll use Lightpanda to run a search on HackerNews, wait for dynamic results to load, and extract structured data from the page.
This example requires Lightpanda because HackerNews uses XHR requests to display search results — raw HTML fetching wouldn't capture them.
Prerequisites
You'll need Node.js installed on your computer.
Create a hn-scraper directory and initialize a new Node.js project. You can accept all the default values in the npm init prompts.
mkdir hn-scraper && \
cd hn-scraper && \
npm initInstall the lightpanda and the puppeteer-core or playwright-core npm package.
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
npm install --save puppeteer-core @lightpanda/browser </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
npm install --save playwright-core @lightpanda/browser </Tabs.Tab>
This guide uses a local Lightpanda instance — see installation for setup details. The code can be adapted to connect to Lightpanda Cloud.
Create index.js
Create an index.js file. Import the packages, configure the connection, and set up the browser lifecycle:
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import puppeteer from 'puppeteer-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const puppeteeropts = {
browserWSEndpoint: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Puppeteer to the browser.
const browser = await puppeteer.connect(puppeteeropts);
const context = await browser.createBrowserContext();
const page = await context.newPage();
// Disconnect Puppeteer.
await page.close();
await context.close();
await browser.disconnect();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import { chromium } from 'playwright-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const playwrightopts = {
endpointURL: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Playwright's chromium driver to the browser.
const browser = await chromium.connectOverCDP(playwrightopts);
const context = await browser.newContext({});
const page = await context.newPage();
// Disconnect Playwright.
await page.close();
await context.close();
await browser.close();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
Navigate and search
Navigate to HackerNews, type a search term, and press Enter:
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
await page.goto("https://news.ycombinator.com/");
await page.type('input[name="q"]', 'lightpanda');
await page.keyboard.press('Enter'); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
await page.goto("https://news.ycombinator.com/");
await page.locator('input[name="q"]').fill('lightpanda');
await page.keyboard.press('Enter'); </Tabs.Tab>
Wait for results
Wait for the search results to appear, with a 5 second timeout:
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
await page.waitForFunction(() => {
return document.querySelector('.Story_container') != null;
}, { timeout: 5000 }); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
await page.waitForSelector('.Story_container', { timeout: 5000 }); </Tabs.Tab>
Extract the data
Loop over the results to extract the title, URL, and metadata for each story:
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
const res = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.Story_container')).map(row => {
return {
title: row.querySelector('.Story_title span').textContent,
url: row.querySelector('.Story_title a').getAttribute('href'),
meta: Array.from(row.querySelectorAll('.Story_meta > span:not(.Story_separator, .Story_comment)')).map(row => {
return row.textContent;
}),
}
});
});
console.log(res); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
const res = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.Story_container')).map(row => {
return {
title: row.querySelector('.Story_title span').textContent,
url: row.querySelector('.Story_title a').getAttribute('href'),
meta: Array.from(row.querySelectorAll('.Story_meta > span:not(.Story_separator, .Story_comment)')).map(row => {
return row.textContent;
}),
}
});
});
console.log(res); </Tabs.Tab>
Full script
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import puppeteer from 'puppeteer-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const puppeteeropts = {
browserWSEndpoint: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Puppeteer to the browser.
const browser = await puppeteer.connect(puppeteeropts);
const context = await browser.createBrowserContext();
const page = await context.newPage();
// Go to hackernews home page.
await page.goto("https://news.ycombinator.com/");
// Type the search term and submit.
await page.type('input[name="q"]', 'lightpanda');
await page.keyboard.press('Enter');
// Wait until the search results are loaded.
await page.waitForFunction(() => {
return document.querySelector('.Story_container') != null;
}, { timeout: 5000 });
// Loop over search results to extract data.
const res = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.Story_container')).map(row => {
return {
title: row.querySelector('.Story_title span').textContent,
url: row.querySelector('.Story_title a').getAttribute('href'),
meta: Array.from(row.querySelectorAll('.Story_meta > span:not(.Story_separator, .Story_comment)')).map(row => {
return row.textContent;
}),
}
});
});
console.log(res);
// Disconnect Puppeteer.
await page.close();
await context.close();
await browser.disconnect();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import { chromium } from 'playwright-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const playwrightopts = {
endpointURL: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Playwright's chromium driver to the browser.
const browser = await chromium.connectOverCDP(playwrightopts);
const context = await browser.newContext({});
const page = await context.newPage();
// Go to hackernews home page.
await page.goto("https://news.ycombinator.com/");
// Type the search term and submit.
await page.locator('input[name="q"]').fill('lightpanda');
await page.keyboard.press('Enter');
// Wait until the search results are loaded.
await page.waitForSelector('.Story_container', { timeout: 5000 });
// Loop over search results to extract data.
const res = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.Story_container')).map(row => {
return {
title: row.querySelector('.Story_title span').textContent,
url: row.querySelector('.Story_title a').getAttribute('href'),
meta: Array.from(row.querySelectorAll('.Story_meta > span:not(.Story_separator, .Story_comment)')).map(row => {
return row.textContent;
}),
}
});
});
console.log(res);
// Disconnect Playwright.
await page.close();
await context.close();
await browser.close();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
Run it
node index.js$ node index.js
🐼 Running Lightpanda's CDP server… { pid: 598201 }
[
{
title: 'Show HN: Lightpanda, an open-source headless browser in Zig',
url: 'https://news.ycombinator.com/item?id=42817439',
meta: [ '319 points', 'fbouvier', '9 months ago', '137 comments' ]
},
{
title: 'Lightpanda: Headless browser designed for AI and automation',
url: 'https://news.ycombinator.com/item?id=42812859',
meta: [ '154 points', 'tosh', '9 months ago', '1 comments' ]
},
...
]File: src/content/guides/lightpanda-agent-tutorial.mdx
title: Use Lightpanda Agent
Use Lightpanda Agent
This tutorial will walk you through how to use Lightpanda Agent to create a
reproducible JavaScript browser script.
Find all flags and REPL commands in the agent command reference.
Prerequisites
- Lightpanda installed and on your
PATH. - A Hacker News account.
- An LLM API key for the natural-language sections (Anthropic, OpenAI,
Gemini, Hugging Face or a local Ollama). Recorded.jsPandaScripts need no key.
Export your HN credentials as LP_* env vars. The LP_ prefix
matters for security: Lightpanda only resolves these placeholders
inside its own subprocess (so your password never reaches the LLM),
and the getEnv tool refuses any variable that doesn't start withLP_ (so the LLM can't read your other secrets).
export LP_HN_USERNAME="your-hn-handle"
export LP_HN_PASSWORD="your-hn-password"Check the variables are set. If one is missing, /fill will silently
type the literal $LP_HN_USERNAME into the form rather than your
username:
lightpanda agent --no-llm> /getEnv LP_HN_USERNAME1. Start the REPL
lightpanda agentThe startup banner shows whether natural language is available;/model prints the resolved model. REPL history lives in .lp-history
in the working directory.
> /help # list every browser tool
> /help goto # JSON schema for one tool
> /quitNo API key? lightpanda agent --no-llm runs the slash-commands-only
REPL.
2. Run a single task
Before doing anything complicated, run a one-shot task to check
everything works:
lightpanda agent --task "what is the top story on news.ycombinator.com?"--task runs one user turn, prints the answer on stdout, exits.
Tool calls and progress go to stderr, so redirecting gives you a
clean answer:
lightpanda agent --task "top story on news.ycombinator.com?" > out.txtUse -a (repeatable) to attach local files.
3. Log in to Hacker News
Paste these into the REPL in order:
> /goto https://news.ycombinator.com/login
> /fill selector='form[action="login"] input[name="acct"]' value='$LP_HN_USERNAME'
> /fill selector='form[action="login"] input[name="pw"]' value='$LP_HN_PASSWORD'
> /click selector='form[action="login"] input[type="submit"][value="login"]'
> /waitForSelector '#logout'A few things worth knowing:
- Slash commands only.
click '#foo'is forwarded to the LLM;
only/click '#foo'runs as a command. TAB completes tool names. /waitForSelector '#logout'is both a sync point and an
assertion. It blocks until HN's logged-in DOM renders. If the
credentials are wrong (or the website throws a captcha, or the layout
changes), the command times out at the line where the failure
actually happened. Use this pattern after every state-changing
action.- Selectors are CSS only. The click-family tools (
/click,/fill,/hover,/selectOption,/setChecked) accept CSS
only. Backend node IDs are invalidated by any DOM mutation and
can't be serialized into recordings.
Confirm the login worked:
> /extract '{"karma": "#karma"}'
{"karma":"42"}/extract takes a JSON schema and prints one JSON object to stdout.
Schema grammar:
"<sel>": text of the first match.["<sel>"]: text of every match.{"selector": "<sel>", "attr": "<name>"}: attribute of the first match.[{"selector": "<sel>", "fields": {…}}]: array of records, with
eachfieldsentry resolved relative to the matched element.
Now go to the front page and pull the story list:
> /goto https://news.ycombinator.com
> /extract '''
{
"topStories": [{
"selector": ".athing",
"fields": {
"rank": ".rank",
"title": ".titleline > a",
"url": {"selector": ".titleline > a", "attr": "href"}
}
}]
}
'''Triple-quoted values let a schema span multiple lines.
How we got those selectors
Skip this if you're happy treating them as given.
/tree prints the semantic tree. On the login page you'll see two
forms (login and signup) with unlabeled textboxes, which means/findElement role=textbox name=username returns nothing.
/detectForms reads the HTML directly and surfaces each form'saction plus each input's name. The first form has action: "login"
and fields named acct and pw, which gives us the form-scoped
selector (form[action="login"] input[name="acct"]) that won't
collide with signup.
/nodeDetails backendNodeId=<n> is the alternative: it returns a
ready-to-use CSS selector for any node ID from /tree.
4. Save the session as a script
Retype the login plus front-page sequence in a single REPL session,
then:
> /save hn_login.js
> /quitIn --no-llm mode, /save transcribes the session deterministically.
With an LLM, it synthesizes an idiomatic script. The result is
JavaScript:
const page = new Page();
await page.goto("https://news.ycombinator.com/login");
page.fill({ selector: "form[action=\"login\"] input[name=\"acct\"]", value: "$LP_HN_USERNAME" });
page.fill({ selector: "form[action=\"login\"] input[name=\"pw\"]", value: "$LP_HN_PASSWORD" });
page.click({ selector: "form[action=\"login\"] input[type=\"submit\"][value=\"login\"]" });
page.waitForSelector("#logout");
await page.goto("https://news.ycombinator.com");
return page.extract({ topStories: [{ selector: ".athing", fields: { rank: ".rank", title: ".titleline > a", url: { selector: ".titleline > a", attr: "href" } } }] });Only state-mutating commands are recorded; read-only ones (/tree,/markdown) are dropped. /extract is recorded because it shapes
what the script returns.
5. Replay the script without an LLM
lightpanda agent hn_login.jsNo --provider, no API key, no token spend. Whatever the scriptreturns is printed automatically as JSON. Because the saved script
ends with return page.extract(...), you get clean JSON on stdout:
lightpanda agent hn_login.js > stories.jsonFrom inside the REPL, /load hn_login.js runs the same script against
the current session.
To reshape the output, assign the result and end with return <value>
(what you return is what prints):
const page = new Page();
await page.goto("https://news.ycombinator.com");
const topStories = page.extract({
topStories: [{
selector: ".athing",
fields: {
rank: ".rank",
title: ".titleline > a",
url: { selector: ".titleline > a", attr: "href" }
}
}]
});
return topStories;6. Add your own JavaScript logic
Agent scripts run in a separate JavaScript context from the page. Nowindow, document, DOM API, require, or process. Browser
interaction happens through the Page object: new Page() makes a
page, await page.goto(url) navigates it, and every other primitive is
a synchronous method on it.
Use page.extract(...) to move page data into local logic, then
process it with normal JavaScript:
const page = new Page();
await page.goto("https://news.ycombinator.com");
const topStories = page.extract({
topStories: [{
selector: ".athing",
limit: 5,
fields: {
rank: ".rank",
title: ".titleline > a",
url: { selector: ".titleline > a", attr: "href" }
}
}]
});
return topStories.map((s) => ({ rank: s.rank, title: s.title, url: s.url }));Use page.evaluate(...) only when you intentionally want a string to
run in the page's JavaScript context. Page evaluate cannot see agent
variables or call agent primitives.
7. Use Lightpanda from another agent (MCP)
If you're driving Lightpanda from a different agent (Claude Code, a
custom MCP client, your own harness), use lightpanda mcp instead.
The calling agent supplies the LLM, so Lightpanda needs no API key.
{
"mcpServers": {
"lightpanda": {
"command": "/path/to/lightpanda",
"args": ["mcp"]
}
}
}Drive the browser with the usual tools (goto, fill, click,waitForSelector), then hand back a script with the save tool:
{
"tool": "save",
"args": {
"path": "hn_login.js",
"script": "const page = new Page();\nawait page.goto(\"...\");\n..."
}
}The path must be relative and free of ... Literal LP_* values are
scrubbed back to placeholders before the file is written. The output
runs unmodified:
lightpanda agent hn_login.jsFurther reading
- Agent: full documentation reference,
- PandaScript: runtime, primitives,
return values.
File: src/content/guides/markdown-axtree.mdx
title: Markdown and AXTree
import { Tabs } from 'nextra/components'
import { Callout } from 'nextra/components'
Markdown and AXTree
Lightpanda outputs web page content as Markdown and Accessibility tree directly from its browser engine, after JavaScript execution. No external HTML-to-Markdown converter needed.
Overview
Three ways to get Markdown and Accessibility tree from any web page:
| Pathway | Best for | How |
|---|---|---|
CLI fetch --dump markdown |
Scripts, pipelines, quick extraction | Single command, stdout |
CDP LP.getMarkdown |
Puppeteer/Playwright automation | Programmatic, after page interactions |
| MCP server | AI agent / LLM tool integration | Model Context Protocol over stdio |
CLI: `fetch --dump`
The fetch command fetches a URL, executes JavaScript, and dumps the result to stdout. Runs standalone with no browser session or CDP connection needed.
Syntax
lightpanda fetch --dump <format> [options] <URL>
# Formats:
# html Full rendered HTML (post-JS)
# markdown CommonMark markdown
# semantic_tree JSON accessibility tree
# semantic_tree_text Human-readable accessibility treeOutput formats
The same page (example.com) in three output formats. Markdown strips all non-semantic markup for a smaller representation.
| Format | Description | Best for |
|---|---|---|
html |
Full post-JS DOM as HTML | Archiving, diffing, debugging |
markdown |
CommonMark markdown (links, headings, lists, tables) | AI/LLM context, summarization |
semantic_tree |
Full accessibility tree as structured JSON | Accessibility audits, structured parsing |
semantic_tree_text |
Indented human-readable accessibility tree | Quick accessibility review, debugging |
Basic example
lightpanda fetch --dump markdown https://example.comExample output
# Example Domain
This domain is for use in documentation examples without needing permission. Avoid use in operations.
[Learn more](https://iana.org/domains/example)`--with-frames` flag
Includes rendered iframe content in the output. Without it, iframe content is excluded.
# Without iframe content (default)
lightpanda fetch --dump markdown https://example.com
# With iframe content included
lightpanda fetch --dump markdown --with-frames https://example.comOn pages without iframes, output is identical either way.
`--strip-mode` flag
Removes groups of tags from the output. Values can be combined with commas:
# Remove JavaScript and CSS from output
lightpanda fetch --dump html --strip-mode js,css https://example.com
# Keep only the main content, reader-mode style
lightpanda fetch --dump html --strip-mode clutter https://example.comFind all options in the fetch command reference.
Semantic tree output
The semantic_tree format returns the page's accessibility tree as structured JSON:
lightpanda fetch --dump semantic_tree https://example.comEach node includes:
| Field | Type | Description |
|---|---|---|
nodeId |
string | Unique node identifier |
backendDOMNodeId |
integer | Backend DOM node ID |
nodeName |
string | HTML element name (e.g., h1, p, a, text) |
xpath |
string | XPath to the node |
nodeType |
integer | DOM node type (1 = element, 3 = text, 9 = document) |
isInteractive |
boolean | Whether the element is interactive |
role |
string | ARIA role (e.g., heading, paragraph, link, none) |
name |
string | Computed accessible name |
nodeValue |
string | Text content (for text nodes) |
attributes |
object | HTML attributes (e.g., href, lang) |
children |
array | Child nodes |
Example output (first two levels)
{
"nodeId": "1",
"backendDOMNodeId": 1,
"nodeName": "root",
"xpath": "",
"nodeType": 9,
"children": [{
"nodeId": "2",
"backendDOMNodeId": 2,
"nodeName": "html",
"xpath": "/html[1]",
"nodeType": 1,
"isInteractive": false,
"role": "none",
"attributes": {"lang": "en"},
"children": ["..."]
}]
}The semantic_tree_text format outputs the same data in a compact readable form:
lightpanda fetch --dump semantic_tree_text https://example.comExample output
[1] RootWebArea: Example Domain
[5] heading: Example Domain
[6] paragraph:
[7] StaticText: This domain is for use in documentation examples without needing permission. Avoid use in operations.
[8] paragraph:
[9] link: Learn moreThe CLI
semantic_treeand the CDPAccessibility.getFullAXTreereturn different JSON schemas. The CLI uses flat field names (role,nameas strings); CDP uses typed objects (e.g.,role: { type: "role", value: "heading" }). See CDP: Accessibility tree for the CDP schema.
CDP: `LP.getMarkdown`
In server mode (lightpanda serve), Lightpanda exposes a WebSocket CDP endpoint with a custom LP domain. LP.getMarkdown converts the current page's live DOM to Markdown.
Unlike --dump markdown which processes a URL from scratch, LP.getMarkdown converts the DOM at call time. You can click, fill forms, or wait for dynamic content, then call it to capture the current state.
Starting the CDP server
# Start CDP server (default: ws://127.0.0.1:9222)
lightpanda serve
# Custom host and port
lightpanda serve --host 0.0.0.0 --port 9333
# Verify it's running
curl http://127.0.0.1:9222/json/versionUsing `LP.getMarkdown`
<Tabs items={['Puppeteer', 'Playwright']} className="-mb-4">
<Tabs.Tab id="Playwright" className="m-0">
// install: npm install playwright-core
import { chromium } from 'playwright-core';
const browser = await chromium.connectOverCDP('ws://127.0.0.1:9222');
const context = await browser.newContext();
const page = await context.newPage();
await page.goto('https://example.com');
const client = await page.context().newCDPSession(page);
const result = await client.send('LP.getMarkdown', {});
console.log(result.markdown);
await page.close();
await context.close();
await browser.close(); </Tabs.Tab>
<Tabs.Tab id="Puppeteer" className="m-0">
// install: npm install puppeteer-core
import puppeteer from 'puppeteer-core';
const browser = await puppeteer.connect({
browserWSEndpoint: 'ws://127.0.0.1:9222',
});
const context = await browser.createBrowserContext();
const page = await context.newPage();
const client = await page.createCDPSession();
await page.goto('https://example.com', { waitUntil: 'networkidle0' });
const result = await client.send('LP.getMarkdown', {});
console.log(result.markdown);
await page.close();
await context.close();
await browser.disconnect(); </Tabs.Tab>
LP.getMarkdownworks the same way locally or on Lightpanda Cloud.
LP.getMarkdownsupports converting a sub-portion of the page by targeting specific DOM nodes, useful for skipping headers, footers, and sidebars.
CDP: Accessibility tree
Lightpanda supports the standard CDP Accessibility.getFullAXTree command, returning the complete accessibility tree as a structured array of nodes, compatible with Chrome's CDP implementation.
Usage with Puppeteer
const client = await page.createCDPSession();
const result = await client.send('Accessibility.getFullAXTree', {});
console.log(JSON.stringify(result.nodes, null, 2));
console.log(`Total nodes: ${result.nodes.length}`);Usage with Playwright
const client = await page.context().newCDPSession(page);
const result = await client.send('Accessibility.getFullAXTree', {});
console.log(JSON.stringify(result.nodes, null, 2));AX node structure
Example node shape
{
"nodeId": 6,
"backendDOMNodeId": 6,
"role": { "type": "role", "value": "heading" },
"ignored": false,
"name": {
"type": "computedString",
"value": "Example Domain",
"sources": [{ "type": "contents" }]
},
"properties": [
{ "name": "level", "value": { "type": "integer", "value": 1 } }
],
"parentId": 5,
"childIds": [9]
}| Field | Type | Description |
|---|---|---|
nodeId |
integer | Unique node identifier |
backendDOMNodeId |
integer | Backend DOM node ID |
role |
object | { type: "role", value: "<role>" } - ARIA role |
ignored |
boolean | Whether the node is ignored for accessibility |
ignoredReasons |
array | Why the node is ignored (if applicable) |
name |
object | { type: "computedString", value: "<name>", sources: [...] } |
properties |
array | ARIA properties (e.g., level, url, focusable) |
parentId |
integer | Parent node ID |
childIds |
array | Child node IDs |
For example.com, the CDP accessibility tree contains 12 nodes.
MCP server
Lightpanda includes a built-in MCP (Model Context Protocol) server for direct integration with AI tools and LLM frameworks.
lightpanda mcpThe MCP server exposes markdown and semantic_tree tools.
| Name | Description |
|---|---|
| markdown | Get the page content in markdown format. If a url is provided, it navigates to that url first. |
| semantic_tree | Get the page content as a simplified semantic DOM tree for AI reasoning. If a url is provided, it navigates to that url first. |
For full MCP server documentation — tools, resources, handshake protocol, agent configuration, and HTTP transport — see the MCP server guide and the MCP tools reference.
References
- Native Markdown Output in Lightpanda
- Playwright markdown demo
- Puppeteer markdown demo
- Playwright AX tree demo
- Puppeteer AX tree demo
- Lightpanda releases
- Cloudflare: Markdown for Agents
File: src/content/guides/retrieve-an-html-webpage.mdx
title: Retrieve an HTML webpage
import { Callout, Tabs } from 'nextra/components'
Retrieve an HTML webpage
In this guide, you'll connect a CDP client to Lightpanda and extract all reference links from a Wikipedia page.
Unlike curl, which only fetches raw HTML, Lightpanda executes JavaScript and runs query selectors directly in the browser — making it suitable for dynamic pages.
Prerequisites
You'll need Node.js installed on your computer.
Create a hn-scraper directory and initialize a new Node.js project. You can accept all the default values in the npm init prompts.
mkdir hn-scraper && \
cd hn-scraper && \
npm initInstall the lightpanda and the puppeteer-core or playwright-core npm packages.
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
npm install --save puppeteer-core @lightpanda/browser </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
npm install --save playwright-core @lightpanda/browser </Tabs.Tab>
This guide uses a local Lightpanda instance — see installation for setup details. The code can be adapted to connect to Lightpanda Cloud.
Create index.js
Create an index.js file. Import the packages, configure the connection, and set up the browser lifecycle:
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import puppeteer from 'puppeteer-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const puppeteeropts = {
browserWSEndpoint: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Puppeteer to the browser.
const browser = await puppeteer.connect(puppeteeropts);
const context = await browser.createBrowserContext();
const page = await context.newPage();
// Disconnect Puppeteer.
await page.close();
await context.close();
await browser.disconnect();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import { chromium } from 'playwright-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const playwrightopts = {
endpointURL: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Playwright's chromium driver to the browser.
const browser = await chromium.connectOverCDP(playwrightopts);
const context = await browser.newContext({});
const page = await context.newPage();
// Disconnect Playwright.
await page.close();
await context.close();
await browser.close();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
Navigate and extract
Use page.goto to navigate to the Wikipedia page, then run a query selector to extract all external reference links:
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
// Go to Wikipedia page.
await page.goto("https://en.wikipedia.org/wiki/Web_browser");
// Extract all links from the references list of the page.
const reflist = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.references a.external')).map(row => {
return row.getAttribute('href');
});
});
// Display the result.
console.log("all reference links", reflist); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
// Go to Wikipedia page.
await page.goto("https://en.wikipedia.org/wiki/Web_browser");
// Extract all links from the references list of the page.
const reflist = await page.locator('.references a.external').evaluateAll(links =>
links.map(link => link.getAttribute('href'))
);
// Display the result.
console.log("all reference links", reflist); </Tabs.Tab>
Full script
<Tabs items={['puppeteer', 'playwright']} className="-mb-4">
<Tabs.Tab id="puppeteer" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import puppeteer from 'puppeteer-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const puppeteeropts = {
browserWSEndpoint: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Puppeteer to the browser.
const browser = await puppeteer.connect(puppeteeropts);
const context = await browser.createBrowserContext();
const page = await context.newPage();
// Go to Wikipedia page.
await page.goto("https://en.wikipedia.org/wiki/Web_browser");
// Extract all links from the references list of the page.
const reflist = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.references a.external')).map(row => {
return row.getAttribute('href');
});
});
// Display the result.
console.log("all reference links", reflist);
// Disconnect Puppeteer.
await page.close();
await context.close();
await browser.disconnect();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
<Tabs.Tab id="playwright" className="m-0">
'use strict'
import { lightpanda } from '@lightpanda/browser';
import { chromium } from 'playwright-core';
const lpdopts = {
host: '127.0.0.1',
port: 9222,
};
const playwrightopts = {
endpointURL: 'ws://' + lpdopts.host + ':' + lpdopts.port,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
// Connect Playwright's chromium driver to the browser.
const browser = await chromium.connectOverCDP(playwrightopts);
const context = await browser.newContext({});
const page = await context.newPage();
// Go to Wikipedia page.
await page.goto("https://en.wikipedia.org/wiki/Web_browser");
// Extract all links from the references list of the page.
const reflist = await page.locator('.references a.external').evaluateAll(links =>
links.map(link => link.getAttribute('href'))
);
// Display the result.
console.log("all reference links", reflist);
// Disconnect Playwright.
await page.close();
await context.close();
await browser.close();
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
})(); </Tabs.Tab>
Run it
node index.js$ node index.js
🐼 Running Lightpanda's CDP server... { pid: 34389 }
all reference links [
'https://gs.statcounter.com/browser-market-share',
'https://radar.cloudflare.com/reports/browser-market-share-2024-q1',
'https://web.archive.org/web/20240523140912/https://www.internetworldstats.com/stats.htm',
'https://www.internetworldstats.com/stats.htm',
'https://www.reference.com/humanities-culture/purpose-browser-e61874e41999ede',
...
]File: src/content/guides/use-hermes.mdx
title: Use Hermes Agent
import { Callout } from 'nextra/components'
Use Hermes Agent
Hermes Agent is an open source autonomous agent built by Nous Research. It runs autonomously for long stretches, builds persistent memory, spawns subagents in parallel, and reaches you on Telegram, Discord, Slack, and other platforms while working on a remote VM.
Hermes has two browser drivers, and Lightpanda plugs into both:
- Browser Use mode — the default since August 2026. The agent gets a single
browser_exectool that runs Python against a browser over CDP through the Browser Use CLI. - Built-in browser tools (
browser_navigate,browser_snapshot, …) — the older driver, which reaches browsers through agent-browser.
Pick the section below that matches your driver. The setting is the same (browser.engine: lightpanda), what differs is what else has to be true for Hermes to honour it.
Prerequisites
- Hermes Agent
v0.21.0or later (hermes update, thenhermes --version) - Lightpanda installed and on your
PATH— check withwhich lightpanda
If you don't have Lightpanda yet, follow the installation guide. Hermes and agent-browser also look in ~/.lightpanda/lightpanda and ~/.local/bin/lightpanda.
Option A — Browser Use mode (the Hermes default)
Since Hermes v0.21.0, Browser Use mode honours browser.engine: lightpanda natively: Hermes starts lightpanda serve itself, one process per session, and hands its endpoint to the Browser Use CLI for every browser_exec call.
Either pick Lightpanda in hermes tools → Browser Automation, or set it in ~/.hermes/config.yaml:
browser:
cloud_provider: local
engine: lightpandacloud_provider: local matters: browser.engine is the lowest-precedence browser setting in Hermes. It is ignored whenever a cloud provider is active — and on a setup that never picked a browser, any BROWSERBASE_API_KEY, BROWSER_USE_API_KEY or Nous subscription browser in ~/.hermes/.env selects one automatically. A browser.cdp_url / /browser connect override takes precedence too. Since v0.21.0, /browser status and hermes doctor tell you when the engine is set but shadowed, and by what.
Option B — Built-in browser tools
Switch Hermes off Browser Use mode and select Lightpanda as the local engine:
# ~/.hermes/config.yaml
browser:
backend: "off"
cloud_provider: local
engine: lightpanda(/browser use off inside Hermes writes backend: "off" for you.)
The same precedence rules as Option A apply (cloud_provider: local, no CDP override), plus one more: Camoufox (CAMOFOX_URL) also wins over browser.engine on this path.
Hermes drives Lightpanda through agent-browser --engine lightpanda over CDP, the same way it drives local Chrome, with automatic Chrome fallback: Lightpanda handles navigate, snapshot, click, type, scroll, back, press and eval; screenshots, browser_vision, PDF generation, file uploads and anything that errors on Lightpanda are retried on Chrome.
Verify the configuration
Start Hermes and run /browser status.
Option A shows the engine and the binary Hermes will spawn:
Engine: Lightpanda — Browser Use mode: Hermes spawns `lightpanda serve` per sessionIf it instead reports browser.engine is 'lightpanda' but it is NOT in use: …, the message names what is shadowing it (a cloud provider, a CDP override, …); see the troubleshooting list below.
Option B shows the engine:
🌐 Browser: local Lightpanda (agent-browser --engine lightpanda)
⚡ Lightpanda: faster navigation, no screenshot support
Automatic Chromium fallback for screenshots and failed commandsThen try a prompt that involves browsing:
Visit https://news.ycombinator.com and list the top 5 story titles.To prove the engine rather than trust the status line, ask the agent for navigator.userAgent: Lightpanda reports Lightpanda/1.0.
Troubleshooting: Hermes uses Chrome even though I set the engine
Check, in this order:
- Is Hermes up to date? Browser Use mode only reads
browser.enginefromv0.21.0; runhermes update. From there,/browser statusandhermes doctorsay outright when the engine is shadowed. - Is a cloud provider selected?
browser.cloud_providerinconfig.yaml, or a cloud API key / Nous subscription browser on a never-configured setup. Setcloud_provider: local. - Is a CDP override active?
browser.cdp_urlor a previous/browser connect(undo with/browser disconnect). - Is Camoufox selected? Unset
CAMOFOX_URL. - Linux sandbox flags on Hermes older than
v0.21.0(Option B, see the warning above). - Nightly
--timeoutrejection (Option B, see Prerequisites): runlightpanda serve --timeout 1by hand;FATAL app : unknown argumentmeans agent-browser cannot launch this build.
Further reading
File: src/content/guides/use-stagehand.mdx
title: Use Stagehand
import { Callout } from 'nextra/components'
Use Stagehand with Lightpanda
Stagehand is a popular, open
source AI Browser Automation
Framework.
With Stagehand you can use natural language and code to control browser.
Since Lightpanda supports Accessibilty
tree, you can use it
instead of Chrome with your Stagehand script.
Install the Lightanda and Stagehand dependencies
If not set, create a new npm project and install Stagehand depencies.
npm initnpm install @browserbasehq/stagehand@3 @lightpanda/browserStay on the Stagehand 3.x line. Stagehand 4 moved its DOM work into a Chrome
extension that localBrowser.connect() loads through the CDP Extensions
domain, which Lightpanda does not implement, so 4.x cannot connect. The API in
this guide is the 3.x one.
Write your Stagehand script with Lightpanda
Now you can create your Stagehand's. script to connectm by editing index.js
file.
'use strict'
import { lightpanda } from '@lightpanda/browser';
import { Stagehand } from "@browserbasehq/stagehand";
import { z } from "zod/v3";
const lpdopts = { host: '127.0.0.1', port: 9222 };
const stagehandopts = {
// Enable LOCAL env to configure the CDP url manually in the launch options.
env: "LOCAL",
localBrowserLaunchOptions: {
cdpUrl: 'ws://' + lpdopts.host + ':' + lpdopts.port,
},
// You need an ANTHROPIC_API_KEY env var.
model: "anthropic/claude-haiku-4-5",
verbose: 0,
};
(async () => {
// Start Lightpanda browser in a separate process.
const proc = await lightpanda.serve(lpdopts);
try {
// Connect Stagehand to the browser.
const stagehand = new Stagehand(stagehandopts);
await stagehand.init();
// Impportant: in the official documentation, Stagehand uses the default
// existing page. But Lightpanda requires an explicit page's creation
// instead.
const page = await stagehand.context.newPage();
await page.goto('https://demo-browser.lightpanda.io/amiibo/', {waitUntil: "networkidle"});
const name = await stagehand.extract("Extract character's name", z.string());
console.log("===", name);
await stagehand.close()
} finally {
// Stop Lightpanda browser process.
proc.stdout.destroy();
proc.stderr.destroy();
proc.kill();
}
})();Run your script
Before running you script, make sure you have a valid Anthropic api key
exported into the env var ANTHROPIC_API_KEY.
You can also use another
model supported by
Stagehand.
node index.jsYou should see in the following logs:
=== SandyFile: src/content/run-locally/installation/build-from-sources.mdx
title: Build from sources
import { Callout } from 'nextra/components'
Build from sources
Prerequisites
Lightpanda is written with Zig 0.14.0. You will have to
install it with the right version in order to build the project.
You need also to install Rust for building deps.
Lightpanda also depends on
zig-js-runtime (with v8),
Libcurl and html5ever.
To be able to build the v8 engine for zig-js-runtime, you have to install some libs:
For Debian/Ubuntu based Linux:
sudo apt install xz-utils ca-certificates \
pkg-config libglib2.0-dev \
clang make curl gitFor MacOS, you need Xcode and the following pacakges from homebrew:
brew install cmakeBuild and run
You can build the entire browser with make build or make build-dev for debug
env.
But you can directly use the zig command to run in debug mode:
zig build runEmbed v8 snapshot
Lighpanda uses v8 snapshot. By default, it is created on startup but you can
embed it by using the following commands:
Generate the snapshot.
zig build snapshot_creator -- src/snapshot.binBuild using the snapshot binary.
zig build -Dsnapshot_path=../../snapshot.binSee #1279 for more details.
Telemetry
By default, Lightpanda collects and sends usage telemetry. This can be disabled by setting an environment variable LIGHTPANDA_DISABLE_TELEMETRY=true. You can read Lightpanda's privacy policy at: https://lightpanda.io/privacy-policy.