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 (subprocesskicad-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 → SetForegroundWindowSetCursorPos + mouse_eventSendKeys 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 defaultx/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.