lucarne
v1.6.1
Published
Self-hostable browser sessions you can drive (Playwright/CDP), watch and control (porthole), and record — on your own machine and your own IP.
Maintainers
Readme
lucarne
Self-hostable browser sessions you can drive, watch, and record — on your own machine and your own IP.
A browser session in lucarne exposes three surfaces at once:
- drive — a CDP endpoint; point Playwright at it (
chromium.connectOverCDP(session.cdpUrl)) - watch + control — a porthole URL; open it or
<iframe>it to see the browser and take over by hand - record — an ambient rolling recording in the session's data dir
It's the missing middle between headless automation (drivable, but you can't watch) and remote desktop (watchable, but not cleanly drivable) — a real browser an agent can operate and a human can supervise, running where you choose.
Who it's for: agent builders who need a browser an agent drives while a human can watch and take over, and anyone automating their own logged-in accounts on their own machine and IP (no cloud browser farm, no handing your cookies to a third party).
┌──────────────── lucarne engine ────────────────┐
you ──▶ │ porthole (watch + control) ◀── browser ──▶ CDP │ ◀── Playwright / your agent
│ │ │
│ └──▶ recording │
└───────────────────────────────────────────────┘The monorepo
lucarne is an npm-workspaces monorepo (packages/*) — this engine package is
its first, published citizen, and four newer packages build a
browse/interact/record/retrieve stack on top of it:
| Package | What it is |
|---|---|
| lucarne | the engine (this README) — sessions you can drive (CDP), watch + control (porthole), and record. |
| lucarne-records | the one provenance record language for the platform — a normalized cross-site schema (Profile/Post/Comment) plus a dependency-free node:fs record store + query API. |
| lucarne-interact | non-bot-like interaction: act + observe/record, screen + wire sensors, zero synthetic requests — a human-paced ACT plane (open/snap/scroll/activate/type/send) over a session's cdpUrl, with an enforced pause after every verb, plus a passive, read-only OBSERVE/recall half that only records what a genuine session organically loads. |
| lucarne-widget | the reusable glassmorphic in-page widget infrastructure — mount a durable, draggable, namespaced glass panel inside a session's page; stream state in, drain intents out. |
| lucarne-corpus-mcp | an optional, thin, read-only stdio MCP bin over a lucarne-records store — answers get_profile/get_post/get_comments/search/get_timeline from what's already been captured; a miss says so structurally instead of fetching. |
Each package is independently published/versioned. lucarne-interact and
lucarne-widget depend on this engine package (the HTTP client, for
/inject); lucarne-corpus-mcp depends on lucarne-records for its schema
and store. See each package's own README for its install, usage, and
Charter/Security posture sections.
Install
npm install lucarne # the engine + `lucarne` CLI
npm install playwright # only if you'll DRIVE sessions over CDP (most people will)Requires Node ≥ 22. Prerequisites by what you use:
nativebackend → Google Chrome installed (or pointLUCARNE_CHROME/chromePathat a Chromium binary).dockerbackend → Docker (lucarne build-imageonce).- recording (on by default) →
ffmpegon the engine host. No ffmpeg ⇒ no recordings (everything else still works).
Driving is vanilla Playwright against the session's cdpUrl, so playwright is a peer you install yourself — npm install lucarne alone does not pull it in.
Quickstart
npx lucarne serve # start the engine on :7800
npx lucarne create -b native -p alpha # mint a session -> { cdpUrl, viewUrl }
npx lucarne open alpha # watch + control it in your browserDrive that same session with vanilla Playwright — nothing custom:
import { chromium } from "playwright";
const res = await fetch("http://127.0.0.1:7800/sessions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ profile: "alpha", backend: "native" }),
});
const session = await res.json();
const browser = await chromium.connectOverCDP(session.cdpUrl); // ← drive
const page = browser.contexts()[0].pages()[0];
await page.goto("https://example.com");
// meanwhile, open session.viewUrl to watch + take overOr use the typed client against a running daemon:
import { LucarneClient } from "lucarne";
import { chromium } from "playwright";
const lucarne = new LucarneClient({ baseUrl: "http://127.0.0.1:7800", token: process.env.LUCARNE_TOKEN });
const session = await lucarne.create({ profile: "alpha", backend: "native" });
const browser = await chromium.connectOverCDP(session.cdpUrl); // drive with PlaywrightThe client covers the full API — create/list/get/status/act/login/tabs/logs/content/activity/screenshot/pdf/recordings/exportContext/… .
The full API is described by an OpenAPI 3.1 spec at /openapi.json, with a Swagger UI at /docs.
There's also a stdlib-only Python client (clients/python/lucarne.py) and an MCP server
(lucarne-mcp, stdio) that exposes lucarne as agent tools — point any MCP client at it with
LUCARNE_URL / LUCARNE_TOKEN. Per-session knobs include mobile, quality, proxy, geo,
metadata, maxLifetimeMs/inactivityMs, and engine-level maxConcurrent + cors.
Or embed the engine directly, no daemon:
import { Lucarne } from "lucarne";
const engine = new Lucarne();
const session = await engine.create({ profile: "alpha", backend: "native" });
console.log(session.cdpUrl, session.viewUrl);Recipes
Concrete jobs, each a runnable example in examples/:
- Operate your own logged-in accounts — a durable
profile(seed it from your real Chrome once) stays logged in across runs; drive it with Playwright. →drive.ts, Profiles - Agent computer-use with a human watching — high-level
act(click/type/scroll/ screenshot) over the same input plane the porthole shows, plus an actor-tagged activity feed so the agent knows what the human just did and yields instead of fighting. →computer-use.ts - Supervised login (secret never leaves the host) — store a credential encrypted
at rest; the daemon injects username/password/TOTP server-side, so the agent
logs in without ever seeing the secret. →
supervised-login.ts - Record everything for audit / replay — sessions record by default; pull the mp4
segments or open the built-in player. →
record-and-replay.ts - Embed the porthole in your own UI — single-origin
viewUrl, drop it in an<iframe>(read-only or with URL-bar controls). →embed-porthole.html - From Python, or any MCP agent — stdlib Python client, or the
lucarne-mcpstdio server. →python_drive.py,mcp-config.json
Use it from Python
The daemon is a Node CLI, but you only ever talk to it over HTTP + CDP, so the language you drive from is your choice. There's a stdlib-only Python client on PyPI:
npm install -g lucarne && lucarne serve # the daemon (Node ≥ 22), once
pip install lucarne playwright # the client + Playwright to drive cdpUrlfrom lucarne import LucarneClient
from playwright.sync_api import sync_playwright
luc = LucarneClient("http://127.0.0.1:7800")
s = luc.create(profile="demo", backend="native")
with sync_playwright() as p:
page = p.chromium.connect_over_cdp(s["cdpUrl"]).contexts[0].pages[0]
page.goto("https://example.com")The Python client covers health/create/list/get/destroy/act/content; for the rest of
the surface, call the HTTP API directly (see API / /openapi.json). Full example:
python_drive.py.
MCP server
lucarne-mcp is a stdio MCP server that exposes lucarne as agent tools — give your
AI assistant (Claude Desktop, etc.) a browser. Start a daemon first (lucarne serve);
the MCP server is a thin bridge to it over LUCARNE_URL, so sessions outlive the agent.
native sessions need Chrome installed.
{
"mcpServers": {
"lucarne": {
"command": "npx",
"args": ["-y", "lucarne-mcp"],
"env": { "LUCARNE_URL": "http://127.0.0.1:7800", "LUCARNE_TOKEN": "" }
}
}
}Tools: lucarne_create, lucarne_list, lucarne_destroy, lucarne_act
(click/move/type/key/scroll/screenshot), lucarne_content (rendered HTML).
LUCARNE_TOKEN: "" = no auth (fine on loopback); set it if you started serve with a token.
(Restart your MCP client after editing its config.) The MCP lane is deliberately thin and
act is coordinate-based — for selector-driven automation, drive the session's cdpUrl
with Playwright instead.
Backends
A backend is only an isolation strategy. Drive, watch (porthole), and record are shared engine code over CDP — identical for every backend. Both backends just spawn an isolated Chrome and expose CDP; the engine does the rest, so a session behaves the same whichever backend it ran on.
| | native | docker |
|---|---|---|
| isolation | local process + own profile | container (process + fs + net) |
| Chrome | real local Chrome, off-screen | Linux Chrome in a container |
| fingerprint | real (your actual machine) | Linux / no-GPU (bot-detectable) |
| IP | your residential IP | your residential IP |
| needs | Google Chrome | Docker + lucarne build-image |
Shared by both (engine-side, over CDP): the porthole (CDP screencast → JPEG frames
over a WebSocket → canvas — survives reverse proxies/tunnels, unlike MJPEG), and
recording (CDP screencast → ffmpeg, hardware-encoded on macOS; needs ffmpeg on the
engine host). The container is therefore tiny — just Chrome + Xvfb + a CDP bridge, no
VNC/GStreamer stack.
The porthole has full input fidelity — modifiers, virtual key codes, editing shortcuts
(select-all / copy / cut / paste / undo via CDP commands), clipboard paste (text pasted
in the porthole lands in the focused field), drag, double/triple-click, right-click,
scroll, touch (phone gestures → Input.dispatchTouchEvent), and IME composition
(CJK input commits through Input.imeSetComposition + insertText).
Use native when you're operating your own accounts (real fingerprint + IP matter, isolation-from-your-main-browser is enough). Use docker when you want stronger sandboxing and don't mind the occasional "verify new device".
By default native is headful (a real, off-screen window — the authentic lane). Pass
headless: true (or LUCARNE_HEADLESS=1, or per session create({ headless: true })) to run
--headless=new instead — no window and no focus steal, ideal for servers, CI, or when you
don't need to watch it on this machine.
Build the docker image once:
npx lucarne build-image # builds lucarne-browser:latest from the bundled DockerfileProfiles (stay logged in)
A named profile is durable: its cookies, logins, localStorage and extensions live
under ~/.lucarne/profiles/<name> (override the root with LUCARNE_HOME) and persist
across sessions — so an agent operating your accounts stays logged in. An anonymous
session (no profile) is ephemeral and wiped on stop. Durable profiles graceful-shutdown
so writes flush to disk.
await engine.create({ profile: "alpha" }); // durable, reused by name
await engine.create({ profile: "alpha", seedFromChrome: true }); // first run: seed from your real Chrome
await engine.create({ profile: "alpha", seedFrom: "/path/to/Chrome" }); // …or any user-data-dir
await engine.create({ persist: false }); // one-off, ephemeralSeeding copies cookies/logins/storage only on a profile's first creation — it never clobbers an established profile. On the same machine the OS-keychain key is shared, so seeded cookies decrypt and you start authenticated.
Load custom unpacked extensions with create({ extensions: ["/path/to/ext"] }) (native
backend) — a persistent/seeded profile also brings its own installed extensions along.
Durable sessions survive a daemon restart: their specs persist to
LUCARNE_HOME/sessions.json, and lucarne serve re-spawns them on startup from the
on-disk profile (login state intact). A clean close() keeps them; an explicit
destroy / DELETE /sessions/:id forgets them so a restart won't bring them back.
API
const engine = new Lucarne(options?);
await engine.listen(); // start the HTTP control API
const s = await engine.create({ profile, backend }); // -> Session
engine.list(); // -> Session[]
engine.get(id); // -> Session | undefined
await engine.destroy(id);
await engine.close(); // stop API + tear down all sessionsSession = { id, backend, cdpUrl, viewUrl, createdAt, metadata? }.
HTTP control API (what the CLI talks to):
POST /sessions CreateSessionOptions -> Session
{profile?, backend?, persist?, seedFrom?, seedFromChrome?, headless?, extensions?, mobile?,
quality?, proxy?, geo?, activity?, metadata?, maxLifetimeMs?, inactivityMs?}
GET /sessions -> Session[]
DELETE /sessions -> { released } (release-all)
GET /sessions/:id -> Session
GET /sessions/:id/status -> SessionStatus (uptime, idle, dims, limits)
POST /sessions/:id/touch -> { ok } (reset the inactivity clock)
GET /sessions/:id/context -> { cookies, localStorage, sessionStorage, origin } (export)
POST /sessions/:id/context {cookies?, localStorage?} -> { ok } (import)
GET /sessions/:id/tabs -> { active, tabs:[{id,url,title}] }
POST /sessions/:id/tabs/:targetId -> { ok } (point porthole at that tab)
GET /sessions/:id/logs[?kind=&limit=] -> LogEntry[] (network/console/browser)
GET /sessions/:id/logs?stream=1 -> text/event-stream (live SSE)
GET /sessions/:id/content -> text/html (rendered outerHTML)
GET /sessions/:id/activity[?format=&stream=1] -> { now, recent } (agent-readable: what the human/agent did)
GET /sessions[?meta.key=val] -> Session[] (filter by user metadata)
PUT/GET/DELETE /credentials/:name -> store creds (GET is blurred — never returns secrets)
GET /credentials/:name/totp -> { code } (RFC 6238 TOTP)
POST /sessions/:id/login {credential, userSelector?, passSelector?, totpSelector?, submitSelector?}
POST /sessions/:id/inject {id, source, bypassCSP?} | {id, remove:true} -> { ok, id|removed } (sticky script injection — see Security)
GET /sessions/:id/inject -> { ids: string[] } (currently-registered, policy-accepted injection ids)
POST /sessions/:id/act {action:"click|move|type|key|scroll|screenshot", x?,y?,...} (computer-use; coordinate-based — for selector-driving use Playwright over cdpUrl)
GET /sessions/:id/replay -> text/html (recording player)
PUT/GET/DELETE /extensions/:name/:file -> upload/manage extensions; create({extensions:["name"]})
GET /openapi.json · GET /docs -> OpenAPI 3.1 spec + Swagger UICredentials are encrypted at rest (AES-256-GCM under a machine-local key) and the secret
never leaves the engine: POST /sessions/:id/login injects username/password/TOTP into the
page server-side, so the agent logs in without ever seeing the password. Per-session
quality (1–100) controls screencast/recording JPEG size.
DELETE /sessions/:id -> { ok }
POST /sessions/:id/upload {path, selector?} -> { ok } (inject a host file into )
GET /sessions/:id/downloads -> string[] (captured download filenames, oldest first)
GET /sessions/:id/downloads/:file -> application/octet-stream
DELETE /sessions/:id/downloads/:file -> { ok }
GET /sessions/:id/screenshot -> image/png (current page)
GET /sessions/:id/pdf -> application/pdf
GET /sessions/:id/recordings -> string[] (segment filenames, oldest first)
GET /sessions/:id/recordings/:file -> video/mp4
GET /health -> { ok, sessions } (no token needed; ids only when authed)
GET /profiles -> [{ name, active }] (durable profiles on disk)
DELETE /profiles/:name -> { ok } (refused while a session is live)
GET /files | PUT/GET/DELETE /files/:name -> durable global workspace
GET /sessions/:id/files | PUT/GET/DELETE .../files/:name -> per-session scratch workspace
`Session = { id, backend, cdpUrl, viewUrl, createdAt, metadata? }`. Recording is on by default
(`record: false` or `LUCARNE_RECORD=0` to disable), a rolling buffer of `retentionMin`
minutes (default 60) of one-minute segments. The **semantic activity log** is off by default;
set `LUCARNE_ACTIVITY=1` (or per session `create({ activity: true })`) to make sessions capture
it by default.
## Security
`lucarne` binds to `127.0.0.1` by default — keep it there unless you add a token.
- **CDP is full, unauthenticated control of the browser.** It always binds **loopback only** — for both backends, independent of the daemon's `--host` (the `docker` backend publishes with `-p 127.0.0.1:<port>:9222`, the native backend never passes `--remote-debugging-address`). Never expose a `cdpUrl`; drivers/agents run on the same host.
- **Token enforced off loopback.** Set `LUCARNE_TOKEN` (or `new Lucarne({ token })`) to require `Authorization: Bearer <t>` / `?token=<t>` on the control API **and** the porthole (HTTP + WebSocket). `lucarne serve` **auto-provisions and prints a token** whenever you bind off loopback (`--host` ≠ loopback, or `--tunnel`), so a remotely-reachable daemon is never unauthenticated — set your own with `export LUCARNE_TOKEN=$(openssl rand -hex 32)` to override.
- **Loopback CSRF/DNS-rebinding guard.** In the default tokenless loopback mode the daemon rejects requests with a non-loopback `Host` or a cross-origin `Origin` (403), so a malicious web page can't drive your localhost daemon. (A token, when set, is the auth instead.)
- **`?interactable=0`** drops input for *that* porthole connection server-side — but it is a per-connection mode, **not a capability boundary**: the same token can open an interactable socket or call `/act`. For a true read-only handoff, don't share the token. `?controls=1` adds a URL bar + back/forward/reload.
- **`/login` is not a confidentiality boundary against the *caller*.** It injects a stored secret server-side so the agent needn't *handle* it, but a caller that can drive the browser can render the value into a page and read it back — treat API access as access to the credentials, as below.
- **File access is confined.** Navigation refuses `file://`/`chrome://` (no host-file read via `/content`/`/screenshot`), and `/upload` only accepts paths inside the session's `/files` workspace — stage a file there first.
- **`/inject` grants arbitrary script execution on every page of the session, CSP included.** `POST /sessions/:id/inject {id, source, bypassCSP?}` registers a *sticky* injection (`Page.addScriptToEvaluateOnNewDocument`) that re-runs on every reload, every newly opened tab, and — because it's persisted into the session spec — every daemon restart. `bypassCSP:true` disables the page's Content-Security-Policy for the session (`Page.setBypassCSP`, held on a live per-page CDP session for as long as the page is open), which is exactly as strong as running the script with devtools open: it can read/exfiltrate anything the page can, override page behavior, and defeat CSP protections the site relies on. Treat calling `/inject` as equivalent to `/act`/`/content` access plus standing devtools-level control — anyone who can call it can call it with *any* source, not just yours. There is no built-in content policy: an optional `injectPolicy(id) => boolean` hook (default **permissive** — every id accepted) lets an embedder restrict *which ids* may be registered (e.g. a shell-only allow-list), but the engine itself doesn't inspect or restrict `source` — that's the caller's responsibility. An injected `source` may execute **twice per document** — once at document-start (where `document.documentElement`/`body` may still be `null`) and again at load — so sources **must be null-safe and idempotent**.
- Sessions run real browsers logged into real accounts — treat access to `lucarne` as access to those accounts.
### Exposing it (remote / from your phone)
`lucarne serve --tunnel` exposes the daemon through a tunnel **you already have
installed** — it shells out to the binary, prints the public token-gated `viewUrl`, and
**auto-provisions a token** (a tunneled daemon is never left unauthenticated):
```sh
lucarne serve --tunnel ngrok # or: --tunnel cloudflared
# → lucarne tunnel: https://ab12.ngrok-free.app (token-gated)
# phone view: https://ab12.ngrok-free.app/sessions/<id>/view/?token=…&controls=1Any other tunnel works via --tunnel-cmd — the command just has to print its public
https://… URL (tailscale, ssh -R, a corporate/relay client). lucarne sets
LUCARNE_LOCAL_URL/LUCARNE_PORT in its environment:
lucarne serve --tunnel-cmd "ssh -R 80:localhost:7800 my.relay.example"lucarne shells out to a tunnel you installed and bundles none — no extra dependency,
no vendor lock-in (so a private relay is just a --tunnel-cmd). Prefer this loopback +
tunnel posture over binding directly; if you must, serve --host 0.0.0.0 --port <n> needs
a token and your own TLS. Never tunnel a cdpUrl — only the viewUrl.
Run it as a service (systemd)
Durable sessions survive a daemon restart, so a unit + Restart=always is enough. Keep
the token in a 0600 env-file, not inline (so it isn't in systemctl show):
# /etc/systemd/system/lucarne.service
[Service]
EnvironmentFile=/etc/lucarne.env # LUCARNE_TOKEN=... (chmod 0600)
Environment=LUCARNE_HOME=/var/lib/lucarne
ExecStart=/usr/bin/lucarne serve
Restart=always
User=lucarne
[Install]
WantedBy=multi-user.targetHealth-probe it at GET /health ({ ok, sessions }, no token needed). The docker
backend is selected per session — lucarne create -b docker -p alpha (or {"backend":"docker"}).
Status & testing
1.0 — the API is stable and follows SemVer: no breaking
changes to the documented surface without a major bump. New capabilities land in minor
releases; read CHANGELOG.md before upgrading.
Every feature lands with a committed, re-runnable acceptance proof that asserts real
behavior — a rendered JPEG frame, real-Chrome state, a valid mp4, an RFC TOTP vector —
never an HTTP 200 (see ROADMAP.md "Proof of completion" and
CONTRIBUTING.md). The suite (npm test) runs the native backend
against real Chrome and is enforced in CI on Linux (google-chrome-stable + ffmpeg,
under xvfb). The docker backend is smoke-tested when Docker is available (building the
~700 MB image per CI run is intentionally not gated); the native lane is the primary,
fully-proven path. A separate docker CI lane (.github/workflows/docker.yml) builds the
image and drives a real container (npm run test:docker) — on demand, weekly, and whenever
docker-relevant code changes — so the docker backend is proven too, just not on every push.
Why "lucarne"
A lucarne is a small window set into a roof or a spire — a little opening that lets you see into (and out of) something much larger. That's the porthole onto a browser session.
License
MIT © Aaron Volter
