---
name: kicad-bridge-dev
user-invocable: false
description: DEVELOPER-only skill (user-invocable:false — installed for maintainers, never offered to everyday users). Building/debugging the Adom Desktop KiCad bridge: architecture, the 4 control surfaces, how to add a verb, and every fresh-machine gotcha this bridge has hit. Trigger words — kicad bridge dev, edit kicad bridge, add kicad verb, kicad bridge architecture, debug kicad bridge, kicad control surfaces, kicad fresh machine.
---

# kicad-bridge-dev

Developer notes for the `adom-desktop-kicad-bridge`. Source-only (belongs in the repo, never shipped to a user install). Read `adom-desktop-bridge-sdk` first for the AD-core-vs-author boundary.

## What it is

A reverse bridge AD spawns on the user's laptop (`spawn.kind: python`, `entrypoint: server.py`, `port: 0`). It adds the `kicad_*` verb namespace. AD provisions Python (Runtime contract v1.9.63+) and passes `ADOM_BIND_HOST` — bind that, never `0.0.0.0`.

## The 4 control surfaces (how a verb reaches KiCad)

1. **kicad-cli** (`subprocess` → `kicad-cli.exe`) — headless: `pcb drc`, `sch erc`, `pcb/sch export` (gerber/pdf/svg/step/bom), `pcb/sch upgrade`. No GUI. Handlers: `run_drc`, `run_erc`, `lint_*`, `export.py`, `format_upgrade`, `bom.py`, `netlist.py`.
2. **KiCad IPC API (kipy)** — KiCad 9+. Board/schematic introspection + automation (e.g. `place_footprint` confirms via `kipy get_footprints()`). Expands with KiCad 11.
3. **Embedded-Python reverse bridge** (`plugin_payload/adom_bridge.py`) — `usercustomize.py` auto-injects at KiCad's USER_SITE → runs an HTTP server INSIDE each KiCad process, writes a `%TEMP%` discovery file; container-side `handlers/bridge_client.py` POSTs JSON-RPC to `/rpc`, dispatched onto KiCad's wx UI thread via `wx.CallAfter`. Methods: `ping`, `list_frames`, `get_menu_ids`, `wm_command`. Verbs: `bridge_status`, `bridge_call`.
4. **Win32 / UIA / SendKeys** (`handlers/kicad_ui.py`, ctypes/user32) — the last resort: `WM_COMMAND` (open editors), `SendInput`/`keybd_event` (`alt+3`, `ctrl+s`), `PrintWindow`+`EnumWindows` (screenshots), `GetWindowRect`. For opening the 3D viewer, screenshots, clicks, keystrokes, window management.

## 🚫 NON-NEGOTIABLE: input is WINDOW-targeted, never FOCUS-targeted

Opening windows in the background (below) is only half of it. **How you send input
matters just as much**, because the global input APIs force you to foreground:

| API | Goes to | Verdict |
|---|---|---|
| `SendInput`, `keybd_event`, `mouse_event`, `SendKeys` | whatever owns FOCUS | ❌ forces `SetForegroundWindow`; a stray keystroke lands in the user's app |
| `SetCursorPos` | the user's PHYSICAL mouse | ❌ never — moving someone's pointer mid-work is the rudest thing we do |
| `PostMessage(hwnd, WM_KEYDOWN/WM_CHAR/WM_LBUTTONDOWN…)` | ONE window's queue | ✅ default — no focus, works while KiCad is buried |
| `SendMessage(hwnd, WM_SETTEXT, …)` | ONE control | ✅ set a text box without focus |
| UIA `ValuePattern.SetValue()` / `InvokePattern.Invoke()` | the a11y tree | ✅ set text / click controls with no focus |
| plugin `WM_COMMAND` (`bridge_client`) | KiCad's own command table | ✅ best for menu actions — deterministic AND background |

**What went wrong (2026-07-20, John):** `navigate_symbol.ps1` did an Alt-key
foreground trick → `SetForegroundWindow` → `SetCursorPos` + `mouse_event` →
`SendKeys` typing, just to put a name in the search box. He was working across
five projects and the bridge repeatedly seized his screen and typed over him:
*"when you take over my screen to type into the kicad window it's crazy rude!!!"*
It now uses UIA `ValuePattern.SetValue` (WM_SETTEXT fallback) + posted Enter.
`kicad_send_key`/`kicad_click` post to the target hwnd by default.

**Rules for any new verb:**
1. Reach for the plugin `WM_COMMAND` pathway first; then UIA; then `PostMessage`.
2. Never call `SetForegroundWindow`, `SetCursorPos`, `SendKeys` or `SendInput` on
   a default path. Gate them behind an explicit caller opt-in
   (`allowForeground` / `allowFocusSteal` / `allowCursorClick`), default OFF.
3. When something genuinely can't work in the background, **don't just do it** —
   return `errorCode: "foreground_required"` with a hint telling the AI to warn
   the user ("I need your screen for ~5 seconds") via `notify_user` and retry
   with the opt-in. `_foreground_warning()` in `handlers/kicad_ui.py` builds it.
4. Known limit: posted messages don't set global modifier state, so **chords
   (Ctrl+Shift+E) are unreliable via PostMessage** — use the plugin pathway.

## 🧪 And NEVER test on John's working machine

Test on a VM (ADOMGPU / a Hyper-V box), not `AdomLapper`. Even background windows
plus library installs on his primary box are disruptive, and a foregrounding bug
lands directly on top of whatever he's doing. If a VM isn't available, ASK before
driving his laptop.

## 🪟 NON-NEGOTIABLE: KiCad windows ALWAYS open in the BACKGROUND

**The bridge must NEVER steal the user's focus or interrupt their work.** A user may
be typing in another app when the AI opens a board — the KiCad window must appear
*behind* their active window, not yank their keyboard/foreground. This is a product
rule, not a nicety. (Requested by John, 2026-07.)

**How it's enforced (central, in `dispatch_command`):** for **EVERY GUI verb** (everything
except the headless `_NO_GUI` kicad-cli set — opens, clicks, key sends, dialog sweeps, all
of it) the dispatcher:
1. `win_focus.capture_foreground()` — records the USER's active window **before** the handler runs.
2. runs the handler.
3. `win_focus.restore_foreground(user_hwnd)` **after** the handler + dialog sweep — hands
   focus back. Uses **AttachThreadInput** so the cross-process SetForegroundWindow actually
   sticks (a plain call from a background process is blocked by the foreground lock).
   The response carries `_userFocusRestored: true/false`.
4. `win_focus.restore_foreground_persistent(user_hwnd, seconds=…)` — a daemon thread that keeps
   re-restoring the user for a few seconds. **This is the part that actually works** — a one-shot
   restore (step 3 alone) is NOT enough, because KiCad activates its window *asynchronously, a beat
   after the handler returns.* Two distinct cases, two durations (see `_SPAWNS_WINDOW` block):
   - **Launched editors** (`open_board`/`open_schematic`/`open_symbol_editor` → a fresh
     pcbnew/eeschema via `subprocess.Popen`): pass `win_focus.background_startupinfo()` as
     `startupinfo=` so the process shows its window with `SW_SHOWNOACTIVATE` (opens behind), **and**
     a **6s** persistent restore for the one late self-activation. Verified: window lands at z-index 1,
     right behind the user's z-0 window.
   - **In-process child windows** (the **3D viewer** — pcbnew *creates and re-raises* it repeatedly
     while it renders the board, over several seconds): `STARTUPINFO` can't touch it (no new process)
     and a 6s restore expires mid-render, so it needs a longer **16s** restore
     (`open_3d_viewer`/`place_footprint`). Don't assume one restore duration fits all — match it to how
     long the window keeps re-raising.

**Verifying background behavior (do NOT trust `GetForegroundWindow` via `run_script`):** measuring
foreground *from* an AD `run_script` is unreliable — the measuring PowerShell/conhost briefly *becomes*
the foreground (you read `''` or itself), and on some machines `run_script` just times out at 75s. Use
**`desktop_list_windows`** (AD-core, no bridge, returns top-level windows in **EnumWindows / top-of-Z-order
first**) and compare the z-index of the KiCad window vs the user's window. If the user's window has a
lower index than every KiCad window, it opened in the background. This is the reliable, non-perturbing test.

**Mid-operation matters too, not just the end state.** Restoring focus *after* still lets a
window FLASH forward mid-verb, which the user sees and hates. So the handlers themselves must
not foreground: `_force_foreground` in open_symbol/footprint_editor was gutted to
`ShowWindow(SW_SHOWNOACTIVATE)` (visible, not activated). The two fallbacks that genuinely
can't avoid it are **OPT-IN, off by default**:
- the launcher-icon **click** (moves the user's mouse via `SetCursorPos`) → needs `{"allowCursorClick": true}`.
- the **Alt+3** keystroke for the 3D viewer (needs pcbnew foregrounded) → needs `{"allowFocusSteal": true}`.
Default behavior is the **background plugin path** (WM_COMMAND via the embedded reverse bridge —
no focus, no cursor). If it can't (plugin not loaded), the verb fails gracefully pointing at
"open a board first so the plugin loads," rather than hijacking the desktop.

**Background fallback = UIA, not the cursor.** Between the plugin path and the opt-in disruptive
fallbacks sits **Tier 1.5: `handlers/uia.py`** — it shells to PowerShell's `System.Windows.Automation`
and **Invokes** the "Footprint Editor" / "3D Viewer" control by accessible name. UIA Invoke is
programmatic: no foreground, no focus steal, no cursor move. KiCad (wxWidgets) exposes an invokable
UIA tree, so this is the reliable background way to open editors when the WM_COMMAND plugin path
isn't loaded. `uia.uia_invoke(hwnd, contains=...)` / `uia.uia_set_value(...)`. This is the bridge's OWN
subprocess (like kicad-cli) — NOT AD's gated `run_script`.

**Rules when you touch UI code:**
- A new window-raising verb → add it to **`_SPAWNS_WINDOW_FOCUS`** in `server.py`. That's the
  whole opt-in; the dispatcher does the rest.
- Prefer **`win_focus.show_without_activating(hwnd)`** (SW_SHOWNOACTIVATE) over
  `ShowWindow(SW_RESTORE)+SetForegroundWindow+BringWindowToTop` when a window just needs to be
  visible. The legacy `_force_foreground` in open_symbol/footprint_editor is used for
  SendInput-nav that genuinely needs transient focus — the dispatcher restore covers its cost,
  but don't add NEW gratuitous foregrounding.
- Deliver input by **`PostMessage`/`SendMessage` to an exact hwnd** where you can (no focus
  needed) instead of `SendInput` (global, needs foreground). `kicad_click`/`kicad_send_key`
  still foreground+SendInput for the hard cases; that's fine as long as the dispatcher restore
  runs after.
- Recording/demo doctrine agrees: **NEVER foreground a window to fix a blank frame.** Background
  capture (WGC) + background windows is the standard.

## Adding a verb (3 places, keep in sync)

1. Write `handle_<verb>` in a handler; return a dict with `success` + a rich **`_hint`** (mandatory — the AI reads verb OUTPUT, not this skill).
2. Register in `server.py` `COMMAND_HANDLERS`.
3. Add `kicad_<verb>` to `bridge.json` `verbs[]` AND an entry (summary/hint/related/pitfalls) to `_VERB_CATALOG` in `server.py` (the `kicad_describe` catalog). A test checks these three stay aligned.

**Dispatcher special cases** (`dispatch_command`): `readiness`, `describe`, `check_for_updates` return BEFORE the plugin auto-install + the KiCad-not-installed pre-check (they must work with KiCad absent). `upgrade` also bypasses that pre-check (it INSTALLS KiCad — do not gate the installer behind "KiCad not installed") and refreshes the detection cache on success.

### ⚖️ Every file you add to this repo may ship to every AD user

The release zip is **auto-bundled into Adom Desktop**, so repo weight is user-facing weight. Before
adding a file, know which artifact it belongs to:

| Adding… | Goes in | Ships in the zip? |
|---|---|---|
| handler / runtime `.py` | repo root / `handlers/` | **yes** |
| a data file the code READS (e.g. `templates/blank-board.kicad_pcb`) | `templates/` | **yes** — and it must be in the zip or the verb breaks at runtime |
| a `.ps1` a handler shells out to | repo root | **yes** |
| docs, tutorials, demo scripts, screenshots, hero art, fonts, sample projects, videos | `docs/`, `demo/`, `tour-pack*/` | **NO** |
| a skill (user/dev/publish) | `skills/<name>/SKILL.md` | **NO** — skills ride the container **pkg**; duplicating them in the zip is the #1 source of bloat |

If a new verb needs a data file, put it in `templates/` and confirm it survives the zip's exclusion
filter — the publish skill's §2 KEEP list must name it. (`kicad_export_molecule` is the worked
example: pure code + the existing `kicad-cli`, no new assets.)

Audit 2026-07-19 found **649 KB** of never-referenced weight (219 KB of hero *fonts*, five
duplicated `skills/` trees, demo docs in the zip; 496 KB of PNGs in the pkg). Don't re-grow it —
full KEEP/EXCLUDE policy lives in `kicad-bridge-publish` §2/§4.

## 🎬 The demo verb (`kicad_demo`) — match the ecosystem contract

HD's installer demos BOTH bridges, so `kicad_demo` deliberately returns the SAME
shape as `fusion_demo`. Do not invent fields: an AI driving both should not have to
special-case them.

| Field | Meaning |
|---|---|
| `stage` | which beat you're on |
| `done` | keep calling until `true` |
| `narrate` | speak this — written for a NEWCOMER, not an engineer |
| `screenshots` | `[{label,path}]`, **captured by the verb**; labels `schematic`/`board_2d`/`board_3d` match Fusion's |
| `steps` | what it did (logging) |
| `_hint` | the exact next action |

Rules borrowed from `fusion-demo` (read that skill — it's the canonical one):

- **A demo is not "launch the app."** It finishes the blockers and shows real work.
  No KiCad? OFFER to install it and then do it, in the same call.
- **Never end a demo on an error screen.** A beat that won't open narrates the skip
  and moves on; `success` stays true and `data.failedSteps` carries the detail.
- **Fixed tour order** — symbol → footprint → 3D part → schematic → 2D board → 3D board.
  It tells the story of how a board is actually made.
- **One beat per call** is the default: the AI narrates, shows the screenshot, then
  calls `data.nextCall`. A bulk run also blows AD's per-request budget.
- **Land it in the user's words** ("export the Gerbers", "run DRC"), never verb names.
- KiCad's honest pitch vs Fusion's: free, open source, **no trial clock and no
  feature tier** — say it, it's a real differentiator.

Cold-start gotcha: Symbol Editor is hosted in **eeschema** and Footprint Editor in
**pcbnew**, so the demo warms BOTH documents before beat 1 — otherwise the editor
beats fail with "could not open via the background (plugin) path".

## Fresh-machine gotchas (all hard-won, all fixed — don't regress them)

- **Install scope is mandatory.** KiCad 10's NsisMultiUser installer: bare `/S` = `rc=666660` (invalid params). Pass `/allusers /S` (elevated) or `/currentuser /S` (per-user, `%LOCALAPPDATA%\Programs\KiCad`, **no UAC**). See `handlers/upgrade.py`.
- **TLS on the portable Python.** AD's provisioned Python has no CA bundle → `CERTIFICATE_VERIFY_FAILED`. We bundle `certs/cacert.pem` (certifi) and load it in `_ssl_context()`. Any new https fetch must use that context.
- **Truncated downloads** read as EOF and pass MZ/size checks, then NSIS fails at install. `upgrade.py` HEADs `Content-Length` and refuses/deletes a size-mismatched file.
- **Config doesn't exist pre-first-launch.** `%APPDATA%/kicad/<ver>/` (lib tables) is created on KiCad's first GUI run. `kicad_detect.ensure_win_user_config()` bootstraps it (seeds tables from the install template) so `install_library`/`_footprint` work on a never-launched KiCad.
- **Detection cache is built once at startup.** After `kicad_upgrade` installs KiCad, `dispatch_command` re-runs `detect_all_kicad_versions()` so subsequent verbs see it without a bridge restart. Detect paths include `%LocalAppData%\Programs\KiCad` (per-user installs).
- **The bridge's env is STRIPPED — don't trust `%LOCALAPPDATA%`.** AD provisions the bridge's Python with a minimal environment where `LOCALAPPDATA` can be UNSET. A `/currentuser` (no-UAC) `kicad_upgrade` installs to `%LOCALAPPDATA%\Programs\KiCad`, but if detection keys off `os.environ["LOCALAPPDATA"]` it comes back EMPTY and the just-installed KiCad is invisible (every verb → "not installed") even though AD's own glob-based `bridge_readiness` finds it. `kicad_detect.WIN_KICAD_BASES` therefore derives the LocalAppData root from `LOCALAPPDATA` **and** `USERPROFILE\AppData\Local` **and** `Path.home()`. Root-caused on ADOMBASELINE 2026-07 (v0.9.41). Rule: never read a user-profile path from a single env var in bridge code.
- **Blocking dialogs stall everything.** `handlers/close_windows.py` scans EVERY window owned by a running KiCad process (by PID — `_win_scan_kicad_dialogs`, reliable; title/owner matching is NOT), auto-expires benign ones (OpenGL notice), screenshots them, and returns hints. `window_info` self-heals by default; `kicad_dismiss_dialogs {all|forceSoftwareCanvas}`.
- **GPU-less hosts.** No OpenGL → the notice pops and pcbnew/3D may not render. On expiring that notice we persist `graphics.canvas_type=2` (Cairo) to `kicad_common.json`. NOTE: a truly minimal VM (Hyper-V, no GPU) may still not render the heavy editors even in Cairo — that's the hardware. Validate GUI/screenshot work on a real-GPU machine.

## Diagnostics you can call

- **`kicad_diagnostics {}` — reach for this FIRST, and never AD's approval-gated `run_script`/shell.** The bridge runs Python on the desktop with full FS/subprocess access, so it can answer every "where did KiCad install / what env do I see / what's running" question itself. Returns: `environment` (the env the BRIDGE sees — a null `LOCALAPPDATA` here is the tell for a per-user install going undetected), `kicadSearch.bases` (every path the detector scans + the version dirs / `kicad.exe` it finds), `processes` (running KiCad exes), `kicadInfo` (+ config/user dir existence). Pass `probePaths:[...]` to stat/list any path and `readFile:"..."` to peek a small config/output file. All read-only, zero shell approval. (Added v0.9.44 precisely so debugging never needs `run_script` — which pops a 1-hour shell-approval gate.)
- `kicad_upgrade {"diagnoseOnly":true}` — token elevation type + UAC `EnableLUA` + cached-installer/truncation facts (no install).
- `kicad_dismiss_dialogs {"debug":true}` — raw process-based dialog scan (PIDs + every KiCad window + class + isDialog).

## Testing on a fresh AD / VM

`refresh_bridges {name:kicad}` pulls the new cache; the RUNNING instance keeps its old code until it dies — `bridge_kill {name:kicad}` forces a fresh spawn that loads the new version and re-detects. The first verb after a kill returns a `bridge_starting` envelope — retry. Confirm the running version via `bridge_log_read` ("starting v0.9.xx").

**Smoke test that a repackaged zip is COMPLETE** (run after any change to the zip's file set — a
missing data file only shows up at runtime, not at build):

1. Static: every local import in the zip resolves inside the zip, and each data file the code reads
   (`certs/cacert.pem`, `templates/blank-board.kicad_pcb`, `*.ps1`) is present.
2. Live, on a real target: `kicad_describe` → expected **verb count** (44 @ v0.9.62 — a drop means a
   handler failed to import), then call one verb that touches a data file.
3. "KiCad is not installed" from a verb is the correct **guard**, not a packaging failure — don't
   chase it as a regression. Check `kicad_readiness.detected` to tell the two apart.

## Hard-won field notes (winvm full-battery, 2026-07)

**Headless / GPU-less VM 3D rendering — deploy Mesa llvmpipe.** KiCad's 3D viewer needs
OpenGL ≥ 1.5; a bare Azure/cloud Windows VM only has the GDI generic GL 1.1, so the 3D
viewer shows a black canvas + *"Your OpenGL version is not supported."* Fix: drop the Mesa
software-GL DLLs next to `pcbnew.exe` (its dir wins the DLL search order). Get
`mesa3d-<ver>-release-msvc.7z` from `pal1000/mesa-dist-win`, extract the **x64** DLLs and copy
`opengl32.dll` + `libgallium_wgl.dll` (the ~60 MB llvmpipe renderer) + `dxil.dll` +
`libGLESv2.dll` into KiCad's `bin`. KiCad 10 via winget installs **per-user**
(`%LOCALAPPDATA%/Programs/KiCad/10.0/bin`, writable without admin), not Program Files. After
that, full board 3D renders live and stably (software-rendered).

**Menu-command ids DRIFT across KiCad builds — resolve them live.** The PCB "3D Viewer" id was
20563 on one build, **20568** on KiCad 10 as shipped to winvm. Never hardcode: use
`bridge_client.resolve_menu_id_via_bridge(exe, contains="3D Viewer", frame_index=…)`
(get_menu_ids + text match) with the constant only as a last-ditch fallback.

**The Footprint Editor is a FRAME inside the pcbnew process** (KiCad 10), so the reverse-bridge
plugin can drive its 3D viewer with NO focus steal: `resolve_frame_index_via_bridge("pcbnew",
"Footprint Editor")` → `wm_command{frame_index, menu_id}`. `wm_command` honors frame_index even
though `get_menu_ids` currently only reports frame 0. This replaced the old Alt+3 keystroke
path (needed foreground + raised `UnboundLocalError` on a shadowed `VK_MENU`).

**Legacy `(module …)` footprints** (KiCad ≤5 / EAGLE-imported / most chip-fetcher parts) are
rejected by a v9 board parser. `place_footprint` now normalizes `(module`→`(footprint` + quotes
the top-level `(layer X)`; that splices into a preview board and KiCad loads it. (pcbnew's GUI is
stricter than `kicad-cli lint` — a footprint can lint clean yet the GUI aborts with "Open
canceled by user"; when that happens, screenshot the `#32770` error dialog to see why.)

**`kicad_click` is RELATIVE by default** — `x`/`y` are fractions 0.0–1.0 of the window, NOT
pixels. Passing pixels with the default clicks far off-screen (can hit another window's close
box and crash KiCad). v0.9.59 guards this, but always pass fractions or `{"relative": false}`.

**Capture without a round-trip:** AD-core `desktop_screenshot_window {hwnd}` returns a
container-local path in `localFullPath` (already pulled) — Read it directly, no `pull_file`
needed. It's also background-safe (PrintWindow), unlike `kicad_screenshot_all` which can time
out capturing every window.
