lucarne
v1.8.0
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/*) with two packages:
| Package | What it is |
|---|---|
| lucarne | the engine (this README) — sessions you can drive (CDP), watch + control (porthole), and record; plus the in-page widget infrastructure (lucarne/widget/*). |
| 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, the records store both write into, and the platform's one MCP server (lucarne-mcp). |
Each package is independently published/versioned, and lucarne-interact
depends on no lucarne package: it reaches a session over its cdpUrl and a
daemon over HTTP. 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).
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 — stdlib Python client. →
python_drive.py
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.
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.
## The in-page widget (`lucarne/widget/*`)
Reusable glassmorphic in-page widget infrastructure: mount a durable,
draggable, page-CSS-immune glass panel inside a page of a session you
control; **stream state in; drain intents out.** Everything is namespaced by
a caller-supplied `ns`, so two unrelated consumers can each mount their own
widget on the same page without cross-talk. The engine owns the **shell** —
chrome (pill/panel/tablist/drag/resize/theme), the versioned host↔iframe
envelope, and the mount/push/onIntent lifecycle; what a consumer renders
inside the panel is its own.
The widget's UI is **build-time composition**: the consumer bundles its
panel code into ONE self-contained srcdoc HTML with `lucarne/widget/build`
(an esbuild helper — `esbuild` is an optional peer, lazy-imported only
here), and the host injects that artifact. Nothing is evaluated from
strings at runtime; past the one mount call (`POST /sessions/:id/inject`)
the host talks to the page over the session's `cdpUrl` with a small
self-contained CDP helper.
```ts
// host side (Node, next to the session)
import { WidgetHost } from "lucarne/widget/host";
const host = await WidgetHost.attach(sessionId, {
ns: "myapp",
html: readFileSync("dist/widget.html", "utf8"), // built via lucarne/widget/build
engine: { baseUrl, token },
});
host.push({ status: "watching" }); // → envelope into every top frame
host.onIntent("ctl", (intent) => { /* ... */ }); // drains a named intent queue
// iframe side (bundled into your srcdoc entrypoint)
import { createWidget } from "lucarne/widget/runtime";
import { mountPanel } from "lucarne/widget/preact"; // optional — preact is an optional peer
const w = createWidget({ ns: "myapp", version: 1 });
w.registerPanel({ id: "status", title: "Status", render: mountPanel(StatusFace), default: true });createWidget includes Lucarne's original pill/panel chrome for compatibility. When a dedicated
overlay runtime owns the surrounding window, use the transport-only API and render the app directly:
import { createWidgetTransport } from "lucarne/widget/runtime";
const transport = createWidgetTransport({ ns: "myapp" });
renderApp(document.getElementById("app")!, transport.state);
transport.onPatch(() => renderApp(document.getElementById("app")!, transport.state));
transport.sendIntent("ctl", { action: "refresh" });The host-side delivery shell is replaceable without replacing Lucarne's browser attachment or wire
semantics. Pass a source factory as injector; the factory receives the same validated ns and built
html used by the default injector:
await WidgetHost.attach(sessionId, { ns: "myapp", html, injector });Alternate shells can install window[disposeGlobal(ns)] for exact teardown. WidgetHost.remove() calls
that hook before removing the conventional host node and compatibility globals.
WidgetHost.selftest asserts singleton / top-frame-only / size-stable /
survives-reload / responsive against a live session; it lazy-imports
playwright-core (optional peer) only when called.
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(thedockerbackend publishes with-p 127.0.0.1:<port>:9222, the native backend never passes--remote-debugging-address). Never expose acdpUrl; drivers/agents run on the same host. - Token enforced off loopback. Set
LUCARNE_TOKEN(ornew Lucarne({ token })) to requireAuthorization: Bearer <t>/?token=<t>on the control API and the porthole (HTTP + WebSocket).lucarne serveauto-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 withexport 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
Hostor a cross-originOrigin(403), so a malicious web page can't drive your localhost daemon. (A token, when set, is the auth instead.) ?interactable=0drops 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=1adds a URL bar + back/forward/reload./loginis 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/uploadonly accepts paths inside the session's/filesworkspace — stage a file there first. /injectgrants 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:truedisables 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/injectas equivalent to/act//contentaccess 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 optionalinjectPolicy(id) => booleanhook (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 restrictsource— that's the caller's responsibility. An injectedsourcemay execute twice per document — once at document-start (wheredocument.documentElement/bodymay still benull) and again at load — so sources must be null-safe and idempotent.- Sessions run real browsers logged into real accounts — treat access to
lucarneas 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):
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
