Launch, attach, or inject
Three ways to get a session against an Electron app, depending on who starts the process and what it exposes:
| Your situation | Tool | Transport |
|---|---|---|
Start a development entry (main) |
electron_launch |
Playwright Electron |
Start a packaged executable (no main) |
electron_launch |
Raw CDP |
| The app is already running with a CDP debug port | electron_attach |
Raw CDP |
| The app is already running without any debug flag | electron_inject |
Node inspector |
All three return { ok, session_id, transport, windows, capabilities }; the session_id threads
through every later call.
Optional MCP progress
When an MCP host requests standard progress, lifecycle calls that cross 250 ms report privacy-safe semantic phases such as resolving the runtime, connecting to a CDP endpoint, registering the session, waiting for the initial renderer, or stopping the app. Discovery uses the same mechanism while scanning loopback endpoints. Calls that finish before the threshold remain silent, and force-kill or detach do not emit synthetic progress for work they cannot meaningfully subdivide. The final success/error envelope—not a progress notification—is always the completion signal.
Launch — Stagewright owns the process
electron_launch { "main": "/abs/path/to/app/main.js" }
mainpoints at the app's main-process entry and uses the Playwright Electron transport.- An
executablePathwithoutmainis a packaged-app launch. Stagewright reserves a loopback port, starts the binary with a managed Chromium remote-debugging endpoint, waits for a page target, connects over CDP, and owns the process through stop/force-kill. - Passing both
mainandexecutablePathselects that Electron runtime for the development entry and stays on Playwright. Both paths must be absolute. args,env, andcwdshape the spawn. Environment variables that would alter the runtime (e.g.NODE_OPTIONS) are rejected withBAD_ARGUMENT.- On macOS and Linux a credential-store switch keeps
safeStorageoff the OS keychain, so a Keychain prompt cannot block startup; see the credential-store default. It goes before yourargs, so an app reading a positional argument fromprocess.argvshould skip switches. - The call resolves when the first window exists AND the renderer finished its initial render
(bounded by
readyTimeoutMs, default 5000 ms) — so a snapshot right after launch sees content.
Dev-server-backed apps (Vite/webpack dev mode): the window appears before the bundle loads, so
raise readyTimeoutMs, or treat renderer_ready: false as "not yet" and wait for a known element:
electron_wait_for_selector { "selector": "#app", "state": "visible", "timeoutMs": 30000 }
Failure modes worth knowing: SINGLE_INSTANCE_LOCK (another copy of the app holds Electron's
single-instance lock — close it first), ALREADY_RUNNING (one live session per server by default;
pass allowMultiple: true to run several), LAUNCH_TIMEOUT (no window within timeoutMs, at most
50 000 ms so the launch ends before the dispatch backstop;
retryable; usually a main process blocked before its first window, for example on an OS prompt
under "credentialStore": "system" or on a startup dialog), and LAUNCH_FAILED (the packaged app
could not start or exited before a driveable renderer appeared).
FUSES_BLOCK_LAUNCH applies only to a main-based Playwright launch whose selected runtime disables
Electron's Node CLI inspect fuse. For a shipped build, omit main and launch the executable over
CDP instead. Unknown or unreadable fuse state does not block a Playwright launch.
ELECTRON_RUNTIME_MISSING means a main-based launch without executablePath found no downloaded
Electron binary for the server. Electron fetches it on first use, which would write to the MCP
stream, so run install-electron with the server's --package list first. When the server was started with
--app-root <dir>, launch paths outside that root are refused — useful when the operator wants to
confine what an agent can start.
Launch a packaged app
electron_launch {
"executablePath": "/Applications/Your App.app/Contents/MacOS/Your App"
}
Stagewright owns --remote-debugging-address and --remote-debugging-port for this path; do not
pass either through args. The endpoint is loopback-only, each discovery probe is bounded, and a
failed handshake reaps the process before returning. Electron documents
--remote-debugging-port
as the HTTP CDP endpoint switch.
Credential-store default
Every launch, development or packaged, defaults to "credentialStore": "testing":
- macOS receives
--use-mock-keychain, avoiding a Keychain prompt that can block startup; - Linux receives
--password-store=basicunlessargsalready selects another password store before any--; - Windows receives no credential-store switch.
This default is for reliable automation, not security verification. A mock macOS keychain or
Linux basic store does not prove genuine OS-backed safeStorage sealing. Use
"credentialStore": "system" when that behavior is part of the assertion; the launch can then
surface an OS credential prompt and may require an already-unlocked test account. Chromium's own
test launcher
uses the same macOS/Linux defaults to prevent test UI and timeouts; Electron's
safeStorage documentation
describes the platform security difference and the Linux basic_text backend.
Use the target project's Electron runtime
An app that loads native addons can require the Electron ABI it was built against. Start the server with an operator-chosen app root, and launches use that project's Electron package:
electron-stagewright --app-root /absolute/path/to/your-app --tool-profile essential
electron_launch { "main": "/absolute/path/to/your-app/dist/main.js" }
With --app-root set, a main launch uses the Electron installed in that project by default and
reports runtime_source: "project". When the project has no Electron of its own, the launch keeps
the server's Electron and returns the reason in runtime_fallback. Pass runtime: "server" to use
the server's Electron anyway, or runtime: "project" to fail instead of falling back.
The project runtime requires main and resolves Electron only below --app-root; it never accepts
a root from a tool call. The resolver reads Electron package metadata rather than evaluating the
target app or Electron package entry point, and rejects a package or binary that resolves outside the
configured root. An explicit executablePath is still authoritative, so do not set both unless you
deliberately want that explicit binary.
Because launches use the project's Electron, the server does not need an Electron package of its
own: doctor reports a missing server Electron as a warning, not a failure, when the project's
resolves and runs. Keep one in the server's package list only for runtime: "server", for a
project Electron whose fuses block Playwright, or for projects without Electron.
Run a non-mutating comparison before launch when native modules are involved:
electron-stagewright doctor --json --app-root /absolute/path/to/your-app
The runtime.server and runtime.project fields report the core, Playwright, Electron, Node, V8,
and NODE_MODULE_VERSION facts used for the comparison, plus a bounded inventory of potential native
addons. A project-runtime warning is diagnostic: it does not prevent launch, but it tells you to use
the app-local runtime or repair the project installation.
Attach — the app is running with a debug endpoint
Start your app with a CDP port (during development, usually a script flag):
your-electron-app --remote-debugging-port=9222
Find it, then attach:
electron_discover_running {}
electron_attach { "port": 9222 }
electron_discover_running scans the conventional loopback ports (9222–9225 by default) and
returns { targets: [{ targetId, port, appName, pid }], scanned } — an empty result is
unambiguous because scanned reports exactly what was probed. Attach accepts port (+ optional
loopback host) or a full loopback cdpUrl. Three notes:
- A
pidalone is not attachable over CDP — but passing it alongsideportlets a laterelectron_stopescalate to SIGKILL if the app ignores the graceful close. - The CDP endpoint exposes renderer targets only, so an attach session has no main-process
evaluation:
electron_eval_mainreturnsTRANSPORT_UNSUPPORTEDandelectron_inforeports null versions. Use launch or inject when you need the main process. - The CDP transport supports snapshot/find, renderer evaluation, reads, observation
(console/dialogs), screenshots, and the core interaction surface on the selected renderer
surface.
electron_surfaces_listandelectron_switch_surfacereach iframes (same- and cross-origin), webview guests andWebContentsViewcontents. CDP cannot tell aBrowserWindowfrom aWebContentsView, so both are listed askind: "other". Pointer and keyboard input are synthesised through the protocol, so there is no Playwright-style actionability auto-wait. On macOS, key chords carry Chromium's editing commands, soMeta+A,Meta+C/V/X,Meta+Z,Alt+Backspaceand the other standard text-editing shortcuts behave as they do in the app. That includes the native bindings for plainHome/End(scroll the document) andControl+letter chords (Control+Amoves to the paragraph start); useMeta+ArrowLeft/Meta+ArrowRightfor the line start and end.
Inject — the app is running with no debug flag
electron_inject { "pid": 12345 }
Injection triggers the Node inspector inside the running main process and attaches to it — no
restart, no pre-arranged flag. The session it produces drives the main process only: main
evaluation (behind --allow-eval=main or bare --allow-eval), window listing, and main-process
console capture. Renderer reads and interaction need a CDP endpoint — when you control how the app
starts, prefer --remote-debugging-port + electron_attach.
The injected target is verified to belong to the pid you named (attaching to a different process
that happens to own the default inspector port is refused with INJECT_FAILED). Main evaluation
receives the electron module as electronApp, as on a launch session. On Windows the
inject trigger is unreliable on some Electron versions; the same discovery path still attaches if
the app was started with --inspect, and the error message says exactly that when the trigger
fails.
When the connection drops
An attached CDP session survives a dropped debugging socket. The next call that touches the app
reconnects (up to three attempts within 10 s), re-discovers the windows, re-selects the one you had
selected (by id, or by URL and title if the app restarted), and resumes console, dialog, and network
capture. Stored snapshots are discarded on reconnect, so take a fresh electron_snapshot before
acting on refs. If the endpoint stays down, the call fails with a retryable CDP_DISCONNECTED and the
next call tries again. Buffered console, dialog, and network reads never wait on a reconnect.
A launched session cannot reconnect: its connection only drops when the process exits or crashes.
Calls that reach the app then fail with the non-retryable SESSION_DISCONNECTED; the buffered
diagnostics stay readable. Release it with electron_stop and launch again.
Ending a session
| Call | What happens |
|---|---|
electron_stop |
Graceful close, bounded by timeoutMs (default 10 s); escalates to SIGKILL on timeout and reports escalated: true. |
electron_force_kill |
Straight to SIGKILL. |
electron_detach |
Releases an attached/injected session without touching the app. Launch-owned Playwright and packaged-CDP sessions reject detach. |
A stopped session's process is never orphaned: either the close landed, or the escalation reaped
it. For attached sessions, escalation needs the pid you optionally passed at attach time.
Design background: the three-transport model and its capability matrix are ADR-003; target-runtime selection is ADR-024; launch preflight, ready-wait, and stop escalation semantics follow the agent-native principles in ADR-007. The model behind sessions, transports, and capabilities: Concepts.