Personae MCP

Personae lets AI agents use MCP to safely control a specific already-open, account-isolated browser identity—without mixing sessions or launching a separate browser.

Documentation

Personae

Personae

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.

  • Each browser identity is its own Chromium persist: partition — cookies, localStorage and login state are fully isolated, so you can be signed into the same site under several accounts at once.
  • Each identity is a standalone window with back/forward buttons and an address bar. It behaves like an ordinary browser.
  • The agent-browser binary is bundled, and those windows are exposed over MCP, so Codex / Claude Code can drive the page of a specific identity.

Users don't need to pre-install Node, Chrome for Testing, or any CLI.

MCP Registry

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:

npx -y @leek-emperor/personae-mcp

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.

The problem it solves

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:

  1. The app opens a CDP port on startup (system-assigned, loopback only).
  2. When an identity window opens, the main process resolves the authoritative targetId of its content page via Target.getTargetInfo.
  3. A local bridge exposes the identity → targetId mapping.
  4. The MCP server uses that targetId directly as a tab ref — a single-step hit.

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.

Quick start

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.

pnpm install
pnpm bundle:ab      # copy the agent-browser binary and core skill into resources/
pnpm dev

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.

Tests

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:

pnpm test

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:

pnpm build:mac      # or build:win

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:

CSC_LINK=/path/to/cert.p12 CSC_KEY_PASSWORD=... \
APPLE_ID=... APPLE_APP_SPECIFIC_PASSWORD=... APPLE_TEAM_ID=... \
pnpm build:mac

Connecting Codex / Claude Code

The fastest path: let the agent connect itself

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.

Or click one button

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.

Or configure it by hand

On macOS:

[mcp_servers.personae]
command = "/Applications/Personae.app/Contents/MacOS/Personae"
args = ["/Applications/Personae.app/Contents/Resources/mcp-server.mjs"]

[mcp_servers.personae.env]
ELECTRON_RUN_AS_NODE = "1"

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:

claude mcp add personae \
  --env ELECTRON_RUN_AS_NODE=1 \
  -- /Applications/Personae.app/Contents/MacOS/Personae \
     /Applications/Personae.app/Contents/Resources/mcp-server.mjs

MCP tools

Every tool takes an identity argument (name or id).

ToolPurpose
load_skillFetch the real command syntax of the bundled agent-browser version (returns a section index by default; pass section for a specific one)
list_identitiesList all identities with open state and current URL
open_identityOpen an identity's window
snapshotAccessibility-tree snapshot returning [ref=eN] element refs
navigateNavigate to a URL
click / fill / pressInteraction; click accepts either a ref or visible text
actRun several commands inside one agent-browser process
get_text / get_urlRead content
screenshotCapture a screenshot
eval_jsEvaluate 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.

Architecture

┌─────────────────────────────────────────────────────────┐
│  Electron main process                                   │
│                                                          │
│  ├─ CDP server (port 0 → system-assigned, 127.0.0.1 only)│
│  ├─ IdentityManager   storage / window lifecycle / target │
│  └─ Agent Bridge      local HTTP, exposes identity↔target │
└───────────┬─────────────────────────────────┬────────────┘
            │                                 │
  ┌─────────▼──────────┐          ┌───────────▼───────────┐
  │ Identity window    │          │  Discovery file       │
  │ (one per identity) │          │  userData/            │
  │                    │          │  agent-bridge.json    │
  │ BrowserWindow shell│          │  (port changes every  │
  │  ├ WebContentsView │          │   launch; found via   │
  │  │   chrome.html   │          │   a fixed path)       │
  │  └ WebContentsView │          └───────────┬───────────┘
  │      content       │                      │
  │      ↑ agent acts  │                      │
  └────────────────────┘                      │
                                              │
            ┌─────────────────────────────────▼───────────┐
            │  scripts/mcp-server.mjs (stdio JSON-RPC)    │
            │  read discovery → get targetId → run CLI    │
            └───────────────────┬─────────────────────────┘
                                │
                    ┌───────────▼────────────┐
                    │  Codex / Claude Code   │
                    └────────────────────────┘

Why each identity window is "a shell plus two WebContentsViews"

The goal is to wrap third-party pages in our own navigation UI without using the <webview> tag. So:

  • the shell BrowserWindow is only a container and loads about:blank;
  • the top bar is a local chrome.html with a dedicated preload exposing just six navigation methods;
  • the content area is a 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".

Popups and OAuth

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?":

  • Same-window vs child. A plain <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.
  • Belongs to the identity. The child is created with 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.
  • Popup blocker (on by default). The only thing distinguishing "should open" from "shouldn't" is user activation — was there a real click/keypress in the last second? This is exactly how mainstream browsers' popup blockers work: it never inspects the domain or content. Script-driven pop-unders (no preceding input) are blocked and surfaced as a quiet toast; you can turn the blocker off in the top bar.
  • Agents can drive it. Each child is a separate CDP target, resolved to its authoritative 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.

Proxy per identity

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.

  • Where it applies. A proxy is set on the identity's 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.
  • Supported. HTTP, HTTPS and SOCKS5. You can fill the fields separately, or paste a full string like socks5://user:pass@host:port into the host box.
  • Authentication. Proxies with a username/password work. Credentials never go into the proxy rules string (Chromium rejects user:pass@ there); auth is answered through Electron's login event instead.
  • Passwords are encrypted at rest. The password is stored via 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.
  • Test connection. A button fetches an IP-echo service through that identity's session and shows the actual egress IP, so you can confirm the proxy is really in effect.
  • WebRTC leak guard. For any identity with a proxy, 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.

Things that bit us

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.

Known limitations and security notes

  • Isolation is a convention, not an architectural boundary. The CDP port has no access control. It only listens on loopback with a random port, but any local process that connects can drive every identity, crossing partition boundaries. That's the direct cost of letting external agents attach. Don't handle sensitive accounts on a shared machine.
  • In-window navigation for ordinary links. 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.
  • Each identity occupies at least 3 CDP page targets (shell + top bar + content), plus one more per open popup child, so it scales at roughly 3× the identity count.
  • The bundled agent-browser version is pinned at build time; upstream fixes are not picked up automatically.
  • Runtime behaviour is only verified on macOS (arm64), including the packaged build. Windows packaging succeeds in CI (macOS + Windows are both built and produce installers), but the app has never actually been run on Windows, so runtime behaviour there is unverified. Linux is not a build target. bundle:ab bundles for the current platform only.
  • Adhoc-signed builds are not distributable: they run only on the build machine and are blocked by Gatekeeper elsewhere. Real distribution needs your own certificate and notarization.
  • The Codex side has not been verified with a real client: the MCP flow was tested with a script acting as the client (including the packaged build in a Node-free environment), but never against an actual codex run.
  • Proxy support only changes the network exit, not the fingerprint. See Proxy per identity — it is not an anti-detect browser.

Project layout

src/main/
  cdp.ts          CDP port setup/discovery, targetId resolution
  identity.ts     identity storage, window lifecycle, nav-bar IPC, proxy
  window-open.ts  pure decision for window.open / target=_blank (unit-tested)
  proxy.ts        proxy input parsing → proxyRules (unit-tested)
  secret-store.ts safeStorage-backed proxy password storage
  url-input.ts    address-bar input → URL normalization (unit-tested)
  toml.ts         TOML section splice for the Codex config (unit-tested)
  agent-bridge.ts local HTTP bridge + discovery file
  mcp-setup.ts    one-click Codex configuration
  index.ts        main entry and IPC registration
src/shared/
  colors.ts       identity palette — imported by BOTH main and renderer,
                  so a window's top-bar dot always matches its list entry
src/preload/
  index.ts        main-window API
  chrome.ts       top-bar preload (navigation methods only)
src/renderer/
  chrome.html     navigation-bar UI
  src/App.tsx     identity management and connection panel
  src/ProxyPanel.tsx     per-identity proxy settings UI
  src/agent-prompt.ts    builds the copy-and-paste prompt for agents
  src/assets/fonts/      self-hosted latin subsets (the CSP blocks
                         external font CDNs; CJK falls back to the system)
scripts/
  mcp-server.mjs           MCP server (stdio JSON-RPC)
  bundle-agent-browser.mjs bundles the binary and core skill
  make-icons.mjs           SVG → PNG / icns / ico
test/
  *.test.ts                node:test unit tests for the pure modules above
design/logo/
  icon.svg                 icon source (edit this, then run pnpm icons)
  concept*.svg             alternative concepts from the design pass
.github/workflows/
  release.yml     build and publish a GitHub Release (manual)
  check.yml       lint / typecheck / packaging smoke test (manual)

resources/bin/ and resources/skills/ are generated by pnpm bundle:ab and not committed.

Icon

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:

pnpm icons

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.

Releasing

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:

WorkflowPurposeInputs
Build & ReleaseBuild macOS + Windows, create a Releaseversion (optional), platform (all / macos / windows), create release, prerelease
Checklint + typecheck + packaging smoke testwhether 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.

License

MIT — see LICENSE.

The bundled agent-browser is Apache-2.0; copyright belongs to its authors.