The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Personae listing page.
A multi-identity browser · every identity is an isolated partition, every window is agent-controllable
Bundles agent-browser and an MCP server — no Node or CLI install needed
English · 中文
An Electron desktop browser that combines two things: multi-account isolation and letting AI agents drive the browser.
persist: partition — cookies, localStorage and login state are fully isolated, so you can be signed into the same site under several accounts at once.Users don't need to pre-install Node, Chrome for Testing, or any CLI.
Personae is published to the official MCP Registry
as io.github.leek-emperor/personae after its first Registry-enabled release.
The Registry package is a small stdio
launcher for a running, separately installed Personae desktop app — it does
not replace the browser application or create a cloud browser.
After installing and launching Personae, a client that supports npm MCP packages can run:
The launcher discovers Personae through its local bridge and keeps each tool call scoped to the identity named in the request. It uses no API key and opens no public network listener. The app's Configure Codex control remains the recommended setup path because it uses the bundled Electron runtime and needs no Node installation.
Existing browser-automation tools assume the agent launches the browser. If your product is a browser client, it's the other way around: the windows already exist, and they belong to different account identities. What the agent needs is to attach without crossing identities.
The approach here:
Target.getTargetInfo.identity → targetId mapping.So snapshot(identity: "Account A") and snapshot(identity: "Account B") always land on the right window, even when both have the same site open with identical titles and URLs.
Requires Node ≥ 20 and pnpm (for development only; the packaged app depends on neither).
agent-browser must be ≥ 0.36. Below that, tab list --json does not emit targetId,
which is the only thing tying an identity to its window — every tool would fail to find its
target. The version in package.json is already pinned; don't downgrade it.
Add one or two browser identities in the UI, then click an identity to open its window.
The interface is in English by default. Use the EN / 中 toggle in the top-right corner to switch to Chinese; the choice is remembered and also applies to the toolbar inside each identity window and to the agent prompt you copy out.
The tricky, mostly-undocumented decisions live in small pure modules so they can be
locked down by tests. They run on node:test with no extra dependencies:
Covered: the window.open / popup decision (window-open.ts), the proxy input
parsing (proxy.ts), address-bar URL normalization (url-input.ts), the
Codex-config TOML splice (toml.ts), and the identity palette (colors.ts).
Packaging:
The default is adhoc signing (identity: '-'), which needs no Apple Developer certificate and runs fine on the build machine. But an adhoc-signed app cannot be distributed — Gatekeeper will block it on someone else's Mac. For real distribution, supply a certificate via environment variables and set notarize to true:
The UI has a "Let the agent connect itself" block that renders a ready-to-send prompt. Copy it, paste it into Codex or Claude Code, and the agent will write its own MCP config, restart, and verify the link by calling list_identities.
The prompt isn't just a config snippet — it also tells the agent what the 13 tools do and which mistakes to avoid (stale refs, guessing at agent-browser syntax, trying to tell identities apart by page title). It's generated from live runtime values, so the paths in it are always correct for the machine it's running on.
The same panel has a one-click install that writes the MCP server into ~/.codex/config.toml (idempotent, with an automatic backup first). Paths are resolved at runtime from process.execPath, so they can't be wrong.
On macOS:
The executable under Contents/MacOS/ follows productName, so it's Personae. Note that electron-builder's executableName only applies to Windows.
command points at the app's own binary; ELECTRON_RUN_AS_NODE=1 makes it degrade into a plain Node runtime. That's why no Node install is required (verified: with PATH set to /usr/bin:/bin, the full flow still works).
Claude Code:
Every tool takes an identity argument (name or id).
| Tool | Purpose |
|---|---|
load_skill | Fetch the real command syntax of the bundled agent-browser version (returns a section index by default; pass section for a specific one) |
list_identities | List all identities with open state and current URL |
open_identity | Open an identity's window |
snapshot | Accessibility-tree snapshot returning [ref=eN] element refs |
navigate | Navigate to a URL |
click / fill / press | Interaction; click accepts either a ref or visible text |
act | Run several commands inside one agent-browser process |
get_text / get_url | Read content |
screenshot | Capture a screenshot |
eval_js | Evaluate JavaScript |
When click / fill receive an @eN ref they automatically take a snapshot first, because refs are only valid within a single agent-browser process — reusing one across processes always fails with Unknown ref. For multi-step interactions, put them in one act batch.
Driving a popup child window. When a page opens a popup (an OAuth sign-in, a share dialog…), that popup becomes a child window belonging to the same identity (see Popups and OAuth). It's a separate CDP target, listed under the identity in list_identities as children. Every acting/reading tool takes an optional target argument — pass a child's targetId (or its index) to drive the popup instead of the main window; omit it to drive the main window as before.
The goal is to wrap third-party pages in our own navigation UI without using the <webview> tag. So:
BrowserWindow is only a container and loads about:blank;chrome.html with a dedicated preload exposing just six navigation methods;WebContentsView where the partition applies, and it deliberately loads no preload at all, keeping third-party pages untouched.Nested BrowserWindows and BrowserView both work too, and the agent can drive all three (all verified). WebContentsView was chosen because BrowserView is marked @deprecated in Electron's type definitions, and because a nested window is a separate native window on macOS — dragging, resizing and minimizing would all need manual bounds syncing, which never quite feels like "one browser window".
A page can open a popup with window.open(url, name, features) — OAuth sign-ins, "share to…" dialogs, and so on. These need to be real windows: an OAuth popup relies on window.opener and postMessage/window.close() to hand the result back, so it can't be flattened into an in-page navigation without breaking the flow.
The approach here treats a popup as a first-class child of its identity, without ever trying to guess "is this an OAuth window?":
<a target="_blank"> or a feature-less window.open stays an in-window navigation (no opener, no new target) — that's safe and keeps the "one identity, one main target" invariant. Only a new-window-disposition open (i.e. window.open with window features) becomes a real child window.parent set to that identity's shell window and the same partition, so it floats over the right window and its cookies land in the right account. window.opener is preserved, so every OAuth style works. outlivesOpener: false means closing the identity closes its popups.targetId and listed under the identity as children. Every MCP tool takes an optional target to address a specific popup. The "one main target per identity" contract for the outside world isn't broken — it's honestly extended to "one main plus tracked children".The decision itself (same-window / allow-child / ignore) lives in a pure function, src/main/window-open.ts, and is unit-tested.
Each identity can route through its own proxy — useful when you want the same site's accounts to come from different regions, or to test geo-behaviour. You bring your own proxy (buy it from IPRoyal / Webshare / Bright Data / whoever); Personae only provides the setting and wires it up.
persist: partition via session.setProxy, so it's genuinely per-identity — identity A can exit via a US IP while identity B exits via Japan. No proxy set means direct connection.socks5://user:pass@host:port into the host box.user:pass@ there); auth is answered through Electron's login event instead.safeStorage (Keychain on macOS, DPAPI on Windows) in userData/proxy-secrets.json, never in identities.json and never sent back to the renderer. Where safeStorage is unavailable it falls back to plaintext and the UI says so.setWebRTCIPHandlingPolicy('disable_non_proxied_udp') is applied so WebRTC can't bypass the proxy and leak your real IP.This is not an anti-detect browser. It changes your network exit, nothing more. Beyond the WebRTC guard above, it does not do fingerprint spoofing (Canvas / WebGL / UA / timezone / fonts…), so several identities on the same machine still share the same browser fingerprint. If a platform correlates accounts by fingerprint, a proxy alone won't hide that.
The proxy parsing (parseProxyInput / buildProxyRules) is a pure function in src/main/proxy.ts and is unit-tested, including that credentials never leak into the rules string.
Kept here because most of them aren't documented anywhere.
The CDP port is per browser process. --remote-debugging-port is a process-level switch and has no webPreferences counterpart, so one Electron app has exactly one CDP server; you cannot give each window its own port. Use port 0 to let the system assign one, then read it back from userData/DevToolsActivePort — that file is written before the bind succeeds, so also probe /json/version for liveness.
--pin-tab simply fails on Electron. It triggers Target.createTarget, which Electron reports as Not supported. Worse, pinning is sticky: once a session has used it, the state is persisted and even close --all won't clear it, breaking every later command. So pass --no-pin-tab explicitly on every call.
A page target with an empty title hangs agent-browser. If the shell window never calls loadURL, it appears in CDP as a page target with empty url and title; attaching to it hangs forever (tab list returns nothing, not even a timeout). Hence the shell must load about:blank and set a readable title.
Use about:blank rather than data:text/html,<title>... — non-ASCII titles in the latter get decoded as latin-1 and turn into mojibake.
Setting the title also has a race: about:blank loads so fast that did-finish-load may fire before the listener is attached, leaving the title stuck at about:blank when several identities open concurrently. Fix: call setTitle synchronously once, then again after load.
Never identify a window by title, url, or tab index. Titles and URLs are identical when several identities open the same site, and the CDP target order does not match agent-browser's tab index order, so indices can't be derived either. Only targetId works.
agent-browser's skill content changes between versions — don't copy syntax off the web. Its own SKILL.md states explicitly that it contains no command syntax and requires skills get to fetch it from the CLI. This project proved the point: tab --url "*settings*" and --pin-tab found online don't exist in earlier versions, while tab list --json emitting targetId is a newer capability — and this project's entire targeting scheme is built on it.
agent-browser searches upward from the binary's location for a skills/ directory, which can collide with an unrelated project's directory of the same name. Pass AGENT_BROWSER_SKILLS_DIR explicitly.
A bundled binary can't rely on asarUnpack alone. asarUnpack places files under app.asar.unpacked/resources/bin/, while process.resourcesPath points at Contents/Resources — not the same location, so the binary isn't found after packaging. Use extraResources to align the paths, and keep asarUnpack limited to files actually imported with ?asset, otherwise the same binary ships twice.
On macOS you can't just "skip signing". Setting identity: null makes electron-builder skip signing entirely; the bundle keeps Electron's original adhoc signature, but the resources have been modified, so signature and content disagree and the app refuses to launch silently (no error, no log; spctl reports code has no resources but signature indicates they must be present). The correct approach is identity: '-' for adhoc signing plus com.apple.security.cs.disable-library-validation in the entitlements — set both entitlements and entitlementsInherit, since the former covers the main process.
The userData directory follows package.json's name, while the executable follows productName. These are two different fields, and executableName only applies to Windows — so if they disagree, you will look in the wrong place. This project deliberately sets both to Personae to avoid that. (Watch out on Linux: its filesystem is case-sensitive, so a directory created under an older lowercase name won't be found.)
publish: generic with a placeholder URL crashes packaging at the very last step. electron-builder's template ships provider: generic with url: https://example.com/auto-updates. Switching to provider: github makes it try to infer a release channel at the end of packaging, and without owner/repo context it throws TypeError: Cannot read properties of null (reading 'channel') — the build is already complete, yet it exits as a failure. This project publishes via gh release upload in CI, so publish: null it is.
A job-level if in GitHub Actions cannot access the matrix context. Writing if: inputs.platforms == matrix.name to filter platforms fails silently (actionlint flags context "matrix" is not allowed here, but GitHub itself doesn't complain), so every platform gets built regardless of the input. Generate the matrix JSON in an upstream job and feed it to strategy.matrix with fromJSON.
When rendering SVG with Electron, suppress window-all-closed. The icon script loops "create window → capture → destroy → create next", and each destroy() drops the window count to zero, which by default quits the whole app — the symptom is a crash after only the first size, with the child reporting No rendezvous client, terminating process (parent died?). It looks like a timing issue but retrying doesn't help. Register an empty window-all-closed handler.
Two more: on Retina displays capturePage outputs at devicePixelRatio (asking for 512 yields 1024), so pin zoomFactor / deviceScaleFactor; and inlining a moderately long SVG into data:text/html;base64,... exceeds the URL length limit and makes loadURL fail with ERR_FAILED (-2), so use a temp file with a file:// reference.
setWindowOpenHandler turns window.open and target="_blank" links into same-window navigation so that one identity always maps to one main target. Genuine popups (window.open with features, e.g. OAuth) are the exception — they open as tracked child windows; see Popups and OAuth.bundle:ab bundles for the current platform only.resources/bin/ and resources/skills/ are generated by pnpm bundle:ab and not committed.
The source is design/logo/icon.svg — three non-overlapping coloured cards for three isolated identities, with a pointer for agent control. After editing the SVG, regenerate every platform format:
This produces build/icon.png (1024), build/icon.icns, build/icon.ico and resources/icon.png (512, the runtime window icon).
Rendering goes through Electron's bundled Chromium, so rsvg-convert / Inkscape / ImageMagick aren't needed — those are usually absent from a clean environment, whereas Electron is a dependency this project has anyway. .icns is assembled by the system iconutil; .ico is written byte by byte.
Both workflows are manual only (workflow_dispatch) and never run on push or tag.
Go to Actions on GitHub, pick a workflow, then Run workflow:
| Workflow | Purpose | Inputs |
|---|---|---|
| Build & Release | Build macOS + Windows, create a Release | version (optional), platform (all / macos / windows), create release, prerelease |
| Check | lint + typecheck + packaging smoke test | whether to run packaging |
The Release is created as a draft; review the assets, then hit Publish yourself. If the tag already exists, assets are appended to it (--clobber overwrites same-named files), so re-runs don't just fail.
Publishing does not use electron-builder's own publish step (publish: null in electron-builder.yml); a single aggregation job uploads everything with gh release upload instead, because three platforms running in parallel would otherwise each try to create the same Release and clobber one another.
When the Build & Release workflow creates a release, it also publishes the
version-matched @leek-emperor/personae-mcp stdio launcher to npm and then
publishes server.json with the official mcp-publisher CLI. Set the
repository NPM_TOKEN secret (an npm automation token with permission to
publish that public package) before the first release; MCP Registry
authentication uses GitHub OIDC and needs no separate secret.
macOS artifacts from CI are adhoc-signed too. For real signing, add CSC_LINK / CSC_KEY_PASSWORD to the repository secrets and set notarize to true in electron-builder.yml.
MIT — see LICENSE.
The bundled agent-browser is Apache-2.0; copyright belongs to its authors.