---
name: developing-the-bridge
description: "DEVELOPER skill for maintainers of the adom-browser-extension (native-browser) bridge - NOT for driving it. Read this before editing bridge/server.js, host/native-host.js, or extension/. Covers the architecture (bridge + native-messaging host + MV3 extension, and how AD provisions Node), the #1 rule that every nbrowser_* verb must return rich _hint/_next/related/pitfalls via the VERB_META table, the verb contract, local testing, the AD-vs-bridge boundary, hard-won runtime gotchas (chrome.tabs.goBack fails on debugger-driven tabs; UIA Invoke fails on Win32 dialogs), and the safety invariants. Everyday users never need this."
---
Parent skill: **adom-browser-extension**. This is a maintainer-only skill (not user-invocable). Bridge
SDK: https://wiki.adom.inc/adom/adom-desktop-bridges

# Developing the native-browser bridge

## ⭐ THE loudest rule: the AI reads your verb OUTPUT, not this file
AD relays every field of a verb response verbatim to the calling AI. A verb that returns
`{success:true}` and nothing else makes the AI guess and retry-loop. EVERY `nbrowser_*` response MUST
carry a rich **`_hint`** (what just happened + the obvious next verb + the trap) plus `_next` /
`related` / `pitfalls`. This is the single highest-leverage thing in the whole bridge.

Mechanism in `server.js`: a single **`VERB_META`** table is the source of truth. It feeds BOTH
`nbrowser_describe` (per-verb hint/related/pitfalls) AND a `decorateHint(verb, r)` wrapper that stamps
top-level `_hint`/`related`/`_next` onto EVERY dispatch response. Add a verb → add its VERB_META entry
in the same commit, or it ships hint-less.

## The 3 components (know which one you're touching)
1. **The bridge** (`bridge/server.js`) — AD spawns it (`spawn.kind:"node"`), so **AD provisions Node**
   (v1.9.63 runtime contract; you never install Node for it). Binds `127.0.0.1:0` on
   `process.env.ADOM_BIND_HOST || '127.0.0.1'` (never `0.0.0.0` → no firewall prompt). Writes a
   discovery file `~/.adom/bridges/native-browser/host.json` + `node-path.txt`. Serves `/status`
   (health chip: led/summary/tooltip, AND a top-level `version` AD reads as `liveVersion`) + `/command`
   for AD, plus a loopback TCP listener the host dials.
2. **The native-messaging host** (`host/native-host.js` via `native-host.bat`) — **Chrome launches it**,
   NOT AD, so it does NOT inherit AD's managed-runtime PATH. It reuses AD's Node via
   `node-path.txt` (published by the bridge = `process.execPath`, backslash-normalized) and falls back
   to PATH `node`. This is the ONLY place system-Node dependence could bite a fresh machine — keep the
   `.bat`'s two-branch resolution intact.
3. **The MV3 extension** (`extension/`) — runs inside Chrome/Edge, no runtime. SW `connectNative` →
   host → bridge loopback. Deterministic ID (`manifest.json` pins `key`) so the host manifest's
   `allowed_origins` is known ahead of time.

Data path: container → relay → AD → bridge `/command` → loopback → host stdio → extension SW → CDP/native.

## Hard-won runtime gotchas (extension side)
- **`chrome.tabs.goBack`/`goForward` throw on tabs we drive.** On a tab navigated via
  `chrome.tabs.update({url})` with the debugger lazily attached, `chrome.tabs.goBack` throws *"Cannot
  find a next page in history"* even when `window.history.length` proves there IS a back entry. **Route
  back/forward through the PAGE's own history** instead: `chrome.scripting.executeScript({target:{tabId},
  func: () => history.back()})`. Reliable, and stays banner-free (no debugger attach). (Fixed in ext
  0.0.16; verified on ADOMBASELINE.)
- **UIA Invoke doesn't fire on Win32 common-dialog buttons / some Edge controls.** `desktop_ui_click`
  (UIA Invoke) returns `success:true` but the dialog's OK/Select-Folder/Reload button doesn't actuate.
  Fall back to a foreground `desktop_click {x,y}` (SendInput) at the control's screen rect. (See
  installing-the-extension for the picker + Reload cases.)
- **Keyboard chords route through AD's `desktop_press_key`** (full modifier chords) - don't reimplement
  OS-level modifier handling in the bridge. `nbrowser_press_key` is for in-page key events (CDP).

## Verb contract
Request `{command, args}`; response `{success, output(JSON string), error?, errorCode?}` + the hints.
`nbrowser_readiness` is READ-ONLY (never spawns/installs) — mirror `bridge_readiness`. Long ops: return
a job id + `statusVerb`, don't block. Declare per-verb budgets >60s in `bridge.json` `timeouts`
(v1.9.79) so AD doesn't cut them at 60s. `nbrowser_describe` returns the full catalog.

## Test locally
`node -c bridge/server.js` (syntax). Run `server.js` standalone → it binds ephemeral + writes the
discovery file; `GET 127.0.0.1:<port>/status` → led/summary/tooltip + `version`. End-to-end:
`nbrowser_readiness` through AD (`--target <laptop>`), then open bg window → screenshot → close.

**Testing on John's VMs (winvm / ADOMBASELINE):** John ALWAYS keeps an RDP session open to his VMs —
**assume it; don't pause to confirm his RDP view before driving a VM over the `--target <vm>` AD path.**
Full fresh-VM onboarding = install bridge (`bridge_install {manifestUrl}`), deploy `extension/`+`host/`,
write `host/host-manifest.json` (path→`native-host.bat`, `allowed_origins`→`chrome-extension://<keyed-id>/`),
register the native host in HKCU (`…\Microsoft\Edge\NativeMessagingHosts` / `…\Google\Chrome\…`), then load
the extension (Edge honors `--load-extension`; Chrome 150 is flaky with it - GUI Load-unpacked is the
fallback). The keyed ext id (`nncjgceobmlenfliamnibncjojpedbcc`) is stable, so the host `allowed_origins`
matches regardless of load method.

## Reaching the user: NEVER notify only the local desktop
The bridge can run on a **VM** while the user is on their **laptop** - a `notify_user` that fires on the
LOCAL AD then lands on an unwatched VM desktop and the user never sees it (real bug: Chrome-install UAC
toast fired on the VM, John on his laptop saw nothing). **Route user notifications cross-AD:** query
`targets` via the direct-API; if any PEER desktop exists, send `notify_user`/`notify_response` with
`target:"all"` (top-level field on the `/command` payload) so it reaches the user wherever they're
attended; no peers (bridge on the user's own machine) = a local toast. `adCommand(app,cmd,args,target)`
adds `payload.target`. The toast BODY names the host (`…on ${host}`) since the action (e.g. a UAC) is on
the bridge's host but the alert is on the user's laptop - they keep an RDP session open to approve it.

## The boundary with AD (don't file bridge bugs as AD bugs)
AD owns: spawn/reap (`refresh_bridges` force-restarts a running bridge as of v1.9.76; `restart_bridge`
is the explicit primitive), stable port, supervisor, cache install + auto-update, status
classification, the GUI card, `liveVersion`/`staleProcess` reporting. YOU own: verbs + hints,
`/status`, `nbrowser_describe`, the wiki page + triggers, the manifest + zip, the skills pkg. A generic
missing capability (new lifecycle verb, relay field) → file against AD; a misbehaving `nbrowser_*` verb
→ fix here.

## Safety invariants (never regress — one wiped a user's other AI threads)
Only ever drive a window YOU opened; NEVER navigate/reload/close the user's existing window/tab (incl.
OS `desktop_navigate`). Background by default; foreground is the user's choice. `nbrowser_credentials`
read-only, never returns passwords; auto-login gated on `confirm:true`+`host:`. Honor the blocklist.

## ⭐ Cross-cutting features must fire on EVERY addressing path (learned 2026-07-15)
Verbs can be addressed THREE ways: `sessionId`-keyed, **profile-keyed** (`profile:` only, NO sessionId
- fully legitimate and common), and spawn-adopted. Any cross-cutting feature - the taskbar badge, flash,
the activity log, guards, future telemetry - must hook ALL of them, or it silently half-works and the
USER is the one who notices. The incident: the "Adom is driving" badge re-asserted only in
`touchBadge(payload.sessionId, ...)`; a shopping thread opened with a sessionId but then drove with
profile-keyed screenshots (`sid=None` in the activity log), so the badge never re-appeared and John saw
an unbadged window being actively driven. Same class of bug: in-memory `sessionHwnd` wiped by every
bridge redeploy ("no badge until a fresh window"). The fixes to keep intact: `touchProfileBadge`
(profile-keyed twin of touchBadge; repairs hwnds by asking the profile's OWN extension - PWA-safe, never
OS-title guessing) and `sessions.json` persistence restored+validated at startup. **Test matrix for any
badge/flash/log change: (1) sid-keyed drive, (2) profile-keyed drive with no sessionId, (3) drive
continuing across a bridge restart.** If a feature keys off one map, ask what happens on the other paths.

## Log everything (bounded) + janitor it - so you can investigate other threads' behavior
You cannot debug "why did thread X see Y?" from memory that a restart wipes. The bridge keeps TWO tiers:
the in-memory ring `nbrowser_activity` serves (600 / ~1h, fast), and a persistent JSONL at
`~/.adom/bridge-logs/native-browser-activity.jsonl` that survives restarts. EVERY forwarded verb logs
{ts, verb, sessionId, thread, profile, ok, errorCode} (+`internal:true` for bridge-originated extQuery
calls). The janitor keeps it bounded FOREVER: startup + every 6h, trim to newest 4000 lines AND <=14
days when >8MB; it also sweeps stale composited overlay icons (>30 days) from the icon temp dir. If you
add a new persistent artifact (log, cache, icon, temp file), you MUST add it to the janitor in the same
PR - unbounded growth on the user's machine is a regression, not a nice-to-have.

## ⭐ AD self-updates rotate the control port under you (2026-07-18 incident)
The env vars AD injects at spawn (ADOM_DIRECT_API_URL/_PORT) freeze the port AD had THEN. AD
self-updates restart it on a new port while your process keeps running - and every adCommand
(desktop_list_windows, desktop_taskbar, notify) starts failing. If those failures are swallowed, whole
features (badge, flash, hwnd correlation) die SILENTLY for hours; a Jul-16 bridge process did exactly
that until the user noticed a missing badge. Rules: (1) adCommand must re-discover from the
~/.adom/direct-api-port FILE on connection failure, retry, and cache the live base; (2) NEVER swallow an
AD-link failure silently - log the outage + recovery to the bridge log AND the activity trail (_ad_link
entries) so a missing badge is diagnosable in one query; (3) suspect stale strays: multiple node.exe
bridge processes days old = AD lost track of them - kill by PID, never by image name.

## ⭐ The graceful-degradation ladder + ABE upsell (John, 2026-07-19 - "this is gold")
Getting a user to actually INSTALL the extension is hard. Do not let that block the value: there is a
LADDER of capability, and the bridge must serve every rung while nudging users up it.

**The rungs:**
1. **No extension anywhere** - the bridge still knows every profile (Local State), can wake profiles
   with no window (`wake_profile`), can OPEN the user's real browser at a URL in the RIGHT profile in
   the BACKGROUND (`open_os_window`: brief blip -> minimize -> orange taskbar flash = "come click when
   ready"), can inventory windows (`os_windows`), and can clean up (`force_close {hwnd}`, attested).
2. **Extension in some profiles** - full driving there; rung-1 verbs for the rest, with per-profile
   extension-state diagnosis in every refusal.
3. **Extension everywhere** - the full surface: navigate, read, click, screenshot, guards, badges.

**Why rung 1 matters (the killer use case):** OAuth consent. adom-google, Autodesk APS, GitHub - the
user is ALREADY signed in in their native browser; the AI only needs the consent page OPEN in the right
profile, the USER clicks Approve, and the CLI's token poll picks up the result out-of-band. That single
capability unlocks whole product setups (adom-google!) with zero extension install. After the auth, the
user rarely needs more - so serving this WITHOUT the install is pure value.

**The upsell rule:** every rung-1/rung-2 response carries a hint that (a) states honestly what the
bridge CANNOT do right now ("I opened it but I cannot see or drive it - the user must click"), and
(b) names the upgrade + its payoff ("with ABE installed I could fill this form, read the result, and
clean up - see installing-the-extension; it installs in about a minute"). The AI relays the upsell to
the user at a NATURAL moment - never mid-auth, never nagging. Hints are how the bridge teaches; a
degraded mode that fails silently or begs constantly both lose the install.

**Background even at OS level, always:** rung-1 opens must not steal focus any more than rung-3 ones.
Post-launch minimize (ShowWindow SW_MINIMIZE returns focus to the user automatically) + a taskbar flash
is the pattern - the user opens the window when THEY are ready. foreground:true stays gated behind
foregroundReason + notify, exactly like the extension path.

## ⭐ Opening native browser windows without ABE — the hard lessons (John, 2026-07-19)
Spent a whole session flailing at "open a background window in the live profile" and CRASHED the user's
Edge (and the AI thread running in it) FOUR times. The final root cause was small and the user named it
before I did. Read this before writing any browser-launch code.

**THE ACTUAL ROOT CAUSE: opening was always fine — the BACKGROUNDING call crashed the browser.** I was
minimizing the freshly-launched window with `ShowWindow(hwnd, SW_MINIMIZE)` **inside a 30ms poll loop,
the instant the window first appeared** — i.e. hammering ShowWindow on a browser window while Chromium
was still constructing it. That race crashed the whole browser PROCESS (all its windows, including the
workspace), then Edge session-restored (windows come back with NEW hwnds — which looks like "you closed
it"). The launch itself never restarted anything. I wasted the session on exotic theories (singleton
takeover, session isolation, "Edge is fundamentally unsafe") instead of looking at my own two-line
backgrounding code and the ONE working reference implementation.

**READ THE WORKING IMPLEMENTATION FIRST.** Hydrogen Desktop's browser picker already opens native
browser windows reliably — `hydrogen-desktop/src-tauri/src/control.rs`, the `/open-in-profile` handler:
for Edge it just does `cmd /c start msedge <url>` — **no `--profile-directory`, no `--new-window`, and
it never minimizes.** When a working impl exists in the codebase, copy it before hand-rolling P/Invoke.

**The correct recipe (shipped 0.1.41, verified live on the user's single-profile Edge — window opened
minimized/background, workspace hwnd unchanged, no crash):**
1. **Launch clean, like HD:** don't pass `--profile-directory=Default` (only pass a profile dir when it
   is NOT the default). Args as a PowerShell ARRAY, never an embedded-quote string (`'--profile-directory=
   "Default"'` passes LITERAL quotes to Chromium). e.g. `Start-Process $exe -ArgumentList @('--new-window',$url)`.
2. **Detect the new window READ-ONLY.** Poll `EnumWindows` for a genuinely-new `Chrome_WidgetWin` hwnd
   (exclude a pre-snapshot + a PROTECTED set: the workspace/editor window — titles `- Editor -|Hydrogen|
   code-server|claude.ai|galliaApril` for the right brand — and other threads' live-session hwnds).
   **Do NOT touch any window inside this loop.**
3. **Background with ONE settled, gentle call.** After the window is found, `Start-Sleep 700ms` to let
   Chromium FULLY build it, then ONE `ShowWindow(hwnd, SW_SHOWMINNOACTIVE=7)` (minimize WITHOUT stealing
   focus). One call, on a settled window, no loop. This is the crash-safe part — the whole bug was here.

**Do NOT build a footgun, and do NOT wall off a feature you can't root-cause.** I twice over-reacted: (a)
added a `confirmLiveProfile:true` override to a guard, then passed it myself on the live profile; (b)
when crashes continued I re-added a HARD refuse that blocked the exact thing the user asked for. Both
were me guessing instead of fixing the real (tiny) bug. If you're refusing a whole capability, you don't
understand the failure yet — keep debugging.

**Verify `node --check server.js` PASSES before EVERY release.** I shipped 0.1.34 and 0.1.36 with syntax
errors because the release commands ran after a failed check in the same `&&` chain wasn't actually
gating them. A syntax error = the bridge won't spawn = native browser is DOWN for the user. Make syntax
verification a hard, separate gate: `node --check server.js || exit 1` on its own line.

**Never demo a destructive-capable op against the user's LIVE working profile.** John's workspace runs
inside Edge's only profile; every Edge open I did was aimed at the very session he was using. If there is
no safe surface to prove something on, say "I can't safely prove this on your machine" — do not gamble.

**Isolated `--user-data-dir` on a work machine is NOT clean.** A fresh Edge profile auto-signs-in the
Windows work account and pops Microsoft's "we're syncing your data across devices" first-run dialog.
It's confined to that throwaway user-data-dir (delete it after), and it does NOT flip sync on the real
Default profile — but it looks alarming to the user. Prefer not launching extra browser instances on the
user's machine at all when you can avoid it.

**Diagnostics gotchas:** `Get-Process msedge | MainWindowTitle` is unreliable for a multi-window browser
(one process, one MainWindow) — use `EnumWindows` + `GetWindowText` to truly enumerate windows. And
`$pid` is a READ-ONLY PowerShell automatic variable (the shell's own PID) — never assign to it; your
"pid" readouts will be garbage. When checking "did I just kill a window", enumerate windows directly;
`desktop_list_windows` returning 0 during AD churn is "can't see", not "gone".

**The honest product truth to keep surfacing:** for the user's REAL signed-in session, opening a window
via **ABE** (`chrome.windows.create` FROM INSIDE the running browser — no process launch at all) is
strictly safer than any external launch. Extension-free `open_os_window` is the graceful-degradation
wedge; ABE is the clean path. Say so.
