# Project export: PinPal

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Claude vibecodes software, PinPal vibecodes hardware. We connect breadboards, SPICE simulations, and embedded programming in one seamless workflow.
- Devpost: https://devpost.com/software/pinpal-smpl9r
- GitHub: https://github.com/Cedroz/PinPal
- Video: https://www.youtube.com/embed/HHWMD_Y6ay0?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 3 GitHub contributor(s) — Mann Malviya (18 commits), Cedroz (8 commits), Claude Sonnet 4.6 (5 commits)

## Devpost submission (written by the team)

### Inspiration

We love using Claude Code as our go-to coding agent for building software, and we wanted to keep using the agent we love when building and debugging our hardware projects too. The problem: Claude Code lives in our IDE and terminal, with no idea what's happening on the breadboard and microcontroller in front of us. So we built PinPal, an MCP server that gives your Claude Code instance real context about your circuits: how components are wired together, which GPIO pins they connect to, and even the code running on the microcontrollers inside the circuit.

### What it does

PinPal bridges your coding agent and the physical world. Once connected, Claude Code can "see" your circuit, understand its wiring as a netlist, read the firmware running on it, and reason about it, so you can debug real hardware the same way you'd debug code, all from your terminal.

### How we built it

We set up a persistent MCP server running on a Raspberry Pi 4B. Your Claude Code instance connects to it over a Tailscale SSH tunnel, so the link is secure and works from anywhere. A Pi Camera is pointed at your circuit and snaps a picture whenever Claude Code asks for context. That image is streamed over the SSH tunnel to a Claude Code subagent, which runs computer vision on it to extract the circuit's netlist, essentially recovering the schematic of components and connections from the photo. Because computer vision is never perfect, we kept a human in the loop. Before the netlist ever enters your context window, a UI renders so you can fix any mistakes the CV made and confirm the circuit is correct, guarding against errors even if the model powering the agent hallucinates. From there, you can run simulations with ngspice or let Claude Code troubleshoot directly with the new context in hand.

### Challenges we ran into

Designing the system end-to-end and getting the processing pipeline (camera → CV → netlist → context) to flow cleanly. Designing the human-in-the-loop so it caught CV and hallucination errors without getting in the user's way. Cracking the SD card that stored the Pi OS and all of our code, right before submission, while we were mounting everything into our 3D-printed case.

### Accomplishments we're proud of

We got a full end-to-end pipeline working: a real photo of a real breadboard becomes a verified netlist inside Claude Code, ready to simulate. We're especially proud of the human-in-the-loop correction step that keeps the agent honest, and of recovering from a cracked SD card with the clock running.

### What we learned

Hardware is messy, and that's exactly why an imperfect CV pipeline needs a human checkpoint rather than blind trust. We also learned how powerful MCP is as a layer for connecting agents to the physical world, and that the last 10% of a hardware demo (mounting, casing, cabling) can break you faster than the code ever will.

### What's next

We want to support more microcontrollers and richer firmware introspection, improve the CV so the netlist needs less correction, and let Claude Code close the loop, proposing fixes and re-checking the circuit automatically after each change.

## README (from the GitHub repository)

# Pin Pal

![Pin Pal](pinpalogo-github-readme-banner.png)

A Raspberry Pi that clips onto a breadboard and gives Claude Code real hardware senses —
I2C, GPIO, serial, and a camera — plus the ability to flash firmware and run code on a
target board. You talk to Claude Code on your own laptop; it calls out to the Pi to look at
and act on whatever's wired up.

This doc is the practical "how do I actually use this" guide. For the full design rationale
(why vision is never trusted alone, the netlist pipeline, etc.) see `app/PIN_PAL.md`.

![Pin Pal pipeline](pinpal_pipeline.png)

## What works right now

- The Pi (hostname `pinpal`, reachable as `pinpal.local`) runs all 6 tools: `scan_i2c`,
  `read_gpio`, `read_serial`, `capture_image`, `flash_firmware`, `deploy_run`. Once its owner
  has run `./pi/provision.sh` once, the dependencies are installed and the venv auto-activates on
  login — start the server by hand with `python pi/server.py`.
- The camera works and is verified.
- **No target board or sensor is wired up by default.** Until you connect one, `scan_i2c`
  will report an empty bus and `read_serial`/`flash_firmware` have nothing to talk to —
  that's expected, not broken. `capture_image` and basic GPIO reads still work with no
  target attached.
- `flash_firmware`'s toolchains (`arduino-cli`, `esptool`, `mpremote`) aren't installed on
  the Pi yet — that happens whenever someone first needs to flash an actual board.
- The netlist/web-verification UI (a teammate's separate companion service) is **not**
  merged into `main` yet and isn't part of this flow.

## Pi owner: one-time setup

If you're the one who owns the Pi, run this **once on the Pi** to set up its dependencies:

```bash
./pi/provision.sh
```

It installs system deps, enables I2C, sets the hostname to `pinpal` (so it advertises
`pinpal.local`), builds a `--system-site-packages` venv, and auto-activates that venv on login —
after which you can start the probe server by hand with `python pi/server.py` (no venv to
reactivate). Everyone else skips this and goes straight to step 1.

## 1. Connect Claude Code to the Pi

You need: this repo cloned, `git`/`ssh` available, and Claude Code installed.

```bash
./scripts/pinpal_connect.sh
```

**First time only:** this will fail and print a public key line. Send that line to whoever
manages the Pi. On **their own laptop** (not on the Pi itself — the script SSHs into the Pi
remotely, it doesn't run there), they run:

```bash
./scripts/pinpal_authorize.sh "<the line you were sent>"
```

Then re-run `./scripts/pinpal_connect.sh` yourself. It should now succeed: it opens a
self-healing SSH tunnel (auto-reconnects if it drops) and registers the Pi with Claude Code
automatically. You won't need to repeat this step again on this machine.

The script finds the Pi at `pinpal.local` over mDNS — no IP to configure. On Linux that needs
`avahi-daemon`/`libnss-mdns` installed (macOS has it built in); if mDNS isn't available, pass the
Pi's IP directly: `PINPAL_HOST=<pi-ip> ./scripts/pinpal_connect.sh`.

Connection is **always over the SSH tunnel** — never a direct LAN/HTTP connection to the Pi.
The tunnel forwards a local port to the Pi's `:8000`, so Claude Code talks to `localhost`
and SSH carries the traffic (works across networks, encrypted, and survives WiFi drops).

## 2. Start a fresh Claude Code session

MCP connections are only picked up when a session starts — if you had Claude Code open
*before* running the connect script, close it and start a new session now.

Verify it worked:
```bash
claude mcp list
```
You should see `pin-pal ... ✔ Connected`.

## 3. Try it

Just talk to Claude Code in plain English — it decides which tool to call.

- **"Take a picture of the breadboard"** → calls `capture_image`, returns a real photo from
  the Pi's camera.
- **"Is anything on the I2C bus?"** → calls `scan_i2c`. With nothing wired up, expect an
  empty list — that's correct, not an error.
- **"Read GPIO pin 17"** → calls `read_gpio`, reports HIGH/LOW.
- **"My sensor isn't reading anything"** (once you have one wired up) → Claude scans the
  bus, checks the relevant pins, looks at a photo, and gives you a single diagnosis instead
  of just one tool's output.
- **"Flash this code to the Arduino and check the serial output"** (once a board is
  plugged in via USB) → drives the full write-then-observe loop.

## Known rough edges right now

- This session's tools won't reach the Pi until you've completed step 1 *and* started a
  fresh session (step 2) — reusing an old session is the most common reason "nothing
  happens."
- Captured images are never saved anywhere — each `capture_image` call returns the photo
  directly into that one conversation and nothing is persisted on the Pi or in this repo.
- No vision-reliability calibration ("vision spike") has been done yet, so there's no
  documented answer yet for how much to trust the camera on a dense/cluttered board —
  always treat a visual claim as a guess until `scan_i2c`/`read_gpio` confirms it.


## Detected evidence (automated analysis)

Indexed codebase: 32 recognized source files, 127 KB.
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Claude Code — evidence: config files committed to the repository; commit authorship or trailers

## Codebase structure (from repository index)

### Files (42 of 42)

```
.claude/agents/circuit-netlist-extractor.md
.claude/settings.json
.env.example
.gitignore
.mcp.json
app/netlist_window.py
app/PIN_PAL.md
app/README.md
app/requirements.txt
app/sample_netlist.json
app/schema.py
app/ui_server.py
app/web/index.html
app/web/package.json
app/web/src/App.tsx
app/web/src/bridge.ts
app/web/src/ComponentNode.tsx
app/web/src/layout.ts
app/web/src/main.tsx
app/web/src/netlist.ts
app/web/src/styles.css
app/web/src/symbols.tsx
app/web/src/vite-env.d.ts
app/web/tsconfig.json
app/web/tsconfig.node.json
app/web/vite.config.ts
CLAUDE.md
onboard.sh
pi/camera.py
pi/config.py
pi/display.py
pi/provision.sh
pi/run_server.sh
pi/server.py
README.md
requirements.txt
scripts/capture_guard_hook.sh
scripts/netlist_gate_hook.sh
scripts/pinpal_authorize.sh
scripts/pinpal_connect.sh
scripts/pinpal_tunnel_loop.sh
scripts/session_start_hook.sh
```

### Dependencies

- app/requirements.txt: mcp[cli]@>=1.2.0, PySide6, pywebview@>=5.0, qtpy
- app/web/package.json: @dagrejs/dagre@^1.1.4, @types/react@^18.3.12, @types/react-dom@^18.3.1, @vitejs/plugin-react@^4.3.4, @xyflow/react@^12.3.5, react@^18.3.1, react-dom@^18.3.1, typescript@^5.6.3, vite@^5.4.11
- requirements.txt: esptool@>=4.0.0, gpiozero@>=2.0, lgpio@>=0.2.0, mcp[cli]@>=1.0.0, mpremote@>=1.0.0, opencv-python@>=4.9.0, pillow@>=10.0.0, pyserial@>=3.5, python-dotenv@>=1.0.0, smbus2@>=0.4.0, st7789@>=0.0.4

### Recent commits (newest first)

- minor ts fix
- removed the tidy button from the pinpal-ui and added a script for the pi that runs the mcp server persistently
- added ui fix that prevents overlap of the wires in the pinpal-ui
- whenever the netlist is parsed from img before entering the context window is passed through the pinpal-ui verifier to correct mistakes from the cv step
- changed the ui little claude creature on the pi
- added screen pi creature + text when tool call in prog, also chnages to onboarding the app side
- added the connection to from claude code to pin pall over normal IP ssh and fallback to tailscale and also installing a session start hook into your claude code for connecting to pin pal each session of claude code
- removed arduino-cli from pip packages list as its causing error
- removed most of the boiler plate coded added at the start, because unused, sighs...
- on boaridng scripts added
- onb oarindg scripts added
- added microcontroller UI component to the netlist ui
- Add circuit-image to verified-netlist pipeline
- Merge feat/netlist-ui into main: netlist UI
- net-list-ui wired up and working
- add README with practical getting-started guide
- fix CLAUDE.md merge conflict, add team connect scripts, ignore node_modules/dist
- netlist ui added one shot with Claude
- rewrite: Pin Pal universal hardware probe with 6 MCP tools
- rewrite: pivot to universal hardware probe (Pin Pal)

## Key source files (fetched from GitHub, selected and truncated for size)

### CLAUDE.md

```markdown
# Pin Pal — Claude Code Context

You are a hardware engineer with a physical probe attached to a target circuit.
The Raspberry Pi exposes 7 tools. Use them to debug broken circuits and build
working firmware autonomously.

## Tools available

| Tool | What it does |
|---|---|
| `scan_i2c` | Scan I2C bus → list responding addresses. First call for any sensor bug. |
| `read_gpio` | Read a pin as HIGH/LOW. The electrical oracle — confirms what the camera hypothesizes. |
| `read_serial` | Capture serial output from the target for N seconds. |
| `capture_image` | Take a photo of the breadboard. Vision = hypothesis only, never final answer. |
| `capture_circuit` | Take a *settled* photo for netlist extraction (waits for the scene to stop moving). Used by the `circuit-netlist-extractor` subagent — don't call it directly. |
| `flash_firmware` | Compile + flash code to Arduino/ESP32/MicroPython target. |
| `deploy_run` | scp + run Python on a Linux/Pi-class target over SSH. |

## Core rule — vision is a hypothesis, probe is the oracle

Camera can identify component presence/absence, LED state, and gross wiring on simple boards.
It cannot be trusted alone. Every visual claim must be confirmed by `scan_i2c` or `read_gpio`
before you act on it.

Correct pattern:
1. `capture_image` → "it looks like the SDA jumper might not be seated"
2. `scan_i2c` → empty → confirms no device responding
3. Fused conclusion: "Camera shows nothing in the SDA row and the bus is empty — reseat SDA"
4. User reseats → `scan_i2c` again → 0x76 appears → confirmed fixed

## Getting circuit context (verified netlist)

When you need to know how the board is actually wired — the user references their wiring, or
you want to ground codegen/debugging in the real topology — delegate to the
`circuit-netlist-extractor` subagent (one `Task` call). It photographs the board, parses it
into a netlist, has the **user verify/correct it in a UI**, and returns the approved netlist.
Treat that approved netlist as trusted topology; the raw image never enters this chat. This is
still "vision is a hypothesis" — the human gate + the netlist is the trust boundary, and live
probes (`scan_i2c`/`read_gpio`) remain the electrical oracle for actual state.

This gate is now hard-enforced by hooks, not just convention: calling **either** capture tool
(`capture_circuit` or `capture_image`) directly from this session is **denied** — all board
photography must go through the subagent — and the subagent is **blocked** from returning a
netlist unless the user approved it in the UI. Don't describe wiring from a prior photo or
memory either; route any wiring question through the extractor.

## Debug workflow (Act 1)

When a sensor/component isn't working:
1. `scan_i2c` first — is anything responding on the bus?
2. `read_gpio` on the relevant pins — is there a signal at all?
3. `read_serial` — what is the target actually outputting?
4. `capture_image` — form a hypothesis about the wiring
5. Fuse electrical + visual → give
[truncated — 1054 more characters]
```

### app/PIN_PAL.md

```markdown
# Build Plan — Pin Pal

*A hardware dev environment for Claude Code.* A Raspberry Pi clips onto your breadboard,
exposes the target's I2C / UART / GPIO / camera as MCP tools, and flashes firmware to it —
so Claude Code on your laptop can **debug** and **build** physical hardware projects over the LAN.

## Context

Berkeley AI Hackathon project. A Raspberry Pi acts as a **universal hardware probe + programmer**
for a *separate* target (Arduino / ESP32 / MicroPython board / breadboard). The Pi exposes the
target's buses and camera as **MCP tools** and can **flash firmware** to it; Claude Code runs on
the developer's laptop and connects over the LAN. This plan gives everything needed to start
building — tool signatures, Pi setup, wiring, the laptop connection config, and a two-act demo —
while avoiding the failure trap of "just Claude Code with a shell."

The product is not only a debugger. Because the probe gives Claude *senses* and `flash_firmware`
gives it *reach*, Claude can close the full agentic loop on hardware:

```
write firmware  →  flash to target  →  observe with the probe  →  iterate
```

### Decisions locked (from this session)
- **Name:** **Pin Pal.**
- **Transport:** HTTP/SSE MCP server on the Pi; laptop connects **over an SSH tunnel, never
  direct LAN** (the tunnel forwards a local port to the Pi's `:8000`).
- **Voltage sensing:** **Digital-only** — no ADC. `read_gpio` reports logic HIGH/LOW
  (3.3V threshold) only. **Pitch line:** *"your code drives GPIO17 HIGH, the probe on that
  pin reads LOW, and the camera shows the jumper isn't seated — your wire fell out."*
  Same fusion wow, honest about the hardware. (ADS1115 ADC = clean v2 upgrade for true voltage.)
- **Scope:** **Full build loop** — read tools (sense) + write tools (reach). Two-act demo: debug + build.
- **Target families:** support **both MCUs and Linux boards.** `flash_firmware` for
  Arduino/ESP32/MicroPython (compile + upload); `deploy_run` for Pi-class Linux targets
  (scp the code + ssh run). Sensing works on every target regardless. Pick the specific board(s)
  when hardware is in hand; backends dispatch on board/target type.
- **Camera role:** vision is a **hypothesis generator, never an oracle.** A VLM can read binary
  state (LED lit, display content) and gross presence/absence reliably, and can attempt wiring
  reads on *simple* boards — but it fails *confidently* on dense ones. So every visual claim is
  **gated behind an electrical/behavioral check** (the probe is the oracle: "camera guessed,
  `scan_i2c` confirmed"). Camera also serves as the build-loop **output verifier** (did the LED
  actually light / the display actually show the value?). Analyzer is the multimodal model itself
  (no custom CV); optional cheap assists = pixel-brightness sampling + before/after frame diff.
- **Vision spike first:** before committing the camera's reach, empirically test the VLM on ~10
  representative breadboard photos (sparse↔dense, varied lighting) and calibrate from d
[truncated — 15137 more characters]
```

### requirements.txt

```
# Pi dependencies (install on the Raspberry Pi)
mcp[cli]>=1.0.0
smbus2>=0.4.0
pyserial>=3.5
gpiozero>=2.0
lgpio>=0.2.0
opencv-python>=4.9.0
python-dotenv>=1.0.0
esptool>=4.0.0
mpremote>=1.0.0
st7789>=0.0.4        # ST7789 SPI status display
pillow>=10.0.0       # creature + shimmer-text rendering

# picamera2 is a system package on Pi OS — do NOT pip install it:
#   sudo apt-get install -y python3-picamera2

# Flash toolchain (system packages):
#   sudo apt-get install -y arduino-cli i2c-tools

# Note: voice/Deepgram removed from MVP

```

### app/requirements.txt

```
# pin-pal-ui — laptop-side netlist confirmation UI + MCP server
mcp[cli]>=1.2.0
pywebview>=5.0

# Webview backend. macOS/Windows use the OS-native webview (nothing extra needed).
# Linux has no bundled webview, so ship the Qt backend via pip — self-contained, no
# system GTK and no --system-site-packages venv required.
qtpy; sys_platform == "linux"
PySide6; sys_platform == "linux"

```

### app/web/package.json

```
{
  "name": "pin-pal-netlist-ui",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@dagrejs/dagre": "^1.1.4",
    "@xyflow/react": "^12.3.5",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "@types/react": "^18.3.12",
    "@types/react-dom": "^18.3.1",
    "@vitejs/plugin-react": "^4.3.4",
    "typescript": "^5.6.3",
    "vite": "^5.4.11"
  }
}

```

### pi/server.py

```python
"""
pin_pal_server.py — MCP server exposing Pi hardware as tools.

Run on the Pi:
  python server.py

Connect from laptop:
  claude mcp add --transport http pin-pal http://<PI_LAN_IP>:8000/mcp
  Verify with /mcp — should list 2 tools (capture_image, capture_circuit).
"""

import base64
import functools
import os
import subprocess
import tempfile
import time

from mcp.server.fastmcp import FastMCP
from mcp.types import ImageContent

from display import Display

mcp = FastMCP("pin-pal", host="0.0.0.0", port=8000)

display = Display()
display.start()

# tool name -> verb shown on the display while it runs
VERBS = {
    "capture_image": "Capturing", "capture_circuit": "Capturing",
    "scan_i2c": "Scanning", "read_gpio": "Probing", "read_serial": "Listening",
    "flash_firmware": "Flashing", "deploy_run": "Deploying",
}


def shows_busy(fn):
    """Drive the display to the shimmering busy state for the duration of a tool call."""
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        display.set_busy(VERBS.get(fn.__name__, "Working"))
        try:
            return fn(*args, **kwargs)
        finally:
            display.set_idle()
    return wrapper


# ---------------------------------------------------------------------------
# READ TOOLS (sense)
# ---------------------------------------------------------------------------

# @mcp.tool()  # not exposed — only the capture tools are registered
def scan_i2c(bus: int = 1) -> dict:
    """
    Scan an I2C bus and return all responding 7-bit device addresses in hex.
    First call for any 'sensor not reading' bug — splits wiring vs. code.
    Also grounds Claude's codegen (confirms BME280 is at 0x76 not 0x77).
    Cross-check: should match `i2cdetect -y 1` output on the Pi.
    """
    try:
        import smbus2
        found = []
        with smbus2.SMBus(bus) as b:
            for addr in range(0x08, 0x78):
                try:
                    b.read_byte(addr)
                    found.append(hex(addr))
                except OSError:
                    pass
        return {"bus": bus, "devices": found, "count": len(found)}
    except Exception as e:
        return {"error": str(e), "hint": "Is I2C enabled? Run: sudo raspi-config → Interface Options → I2C"}


# @mcp.tool()  # not exposed — only the capture tools are registered
def read_gpio(pin: int) -> dict:
    """
    Read one GPIO pin as digital HIGH or LOW (BCM numbering).
    Digital only — no voltage. Use when the probe is clipped to a target pin.
    The probe is the oracle: always confirm camera wiring claims with this before acting.
    Example: camera guesses 'jumper seated', read_gpio confirms signal actually present.
    """
    try:
        from gpiozero import InputDevice
        device = InputDevice(pin)
        value = device.value
        device.close()
        return {"pin": pin, "level": "HIGH" if value else "LOW", "value": int(value)}
    except Exception as e:
        return {"error": str(e)}


# @mcp.tool()  # not exposed — only the capture tools are registered
def read_serial(
    port: str = "/dev/ttyUSB0",
    baud: int = 9600,
    duration_s: float = 2.0,
) -> dict:
    """
    Capture serial bytes from the target for duration_s seconds.
    Returns decoded text, raw hex, and a garbage flag (True = likely baud mismatch or swapped TX/RX).
    Primary way to observe a freshly-flashed sketch's Serial.println() output.
    Try /dev/ttyACM0 if /dev/ttyUSB0 is not found (Arduino Uno uses ACM).
    """
    try:
        import serial
        with serial.Serial(port, baud, timeout=1) as ser:
            start = time.time()
            chunks = []
            while time.time() - start < duration_s:
                waiting = ser.in_waiting
                if waiting:
                    chunks.append(ser.read(waiting))
                else:
                    time.sleep(0.05)
            raw = b"".join(chunks)
            text = raw.decode("utf-8", errors="replace")
            printable = sum(1 for c in text if c.isprintable() or c in "\n\r\t")
            garbage = len(text) > 10 and (printable / len(text)) < 0.7
            return {
                "port": port,
                "baud": baud,
                "text": text,
                "raw_hex": raw.hex(),
                "looks_like_garbage": garbage,
                "bytes_read": len(raw),
                "hint": "If looks_like_garbage=true, try flipping TX/RX or changing baud." if garbage else "",
            }
    except Exception as e:
        return {"error": str(e)}


@mcp.tool()
@shows_busy
def capture_image(filename: str | None = None) -> list[ImageContent]:
    """
    Capture a still of the breadboard from the Pi Camera.
    Returns viewable image content so Claude can see wiring, component orientation,
    LED state, display output, and loose jumpers.
    IMPORTANT: Vision is a hypothesis generator, not an oracle.
    Always confirm any wiring claim by invoking the pin-pal-ui MCP (confirm_netlist)
    before acting on it.
    """
    try:
        from picamera2 import Picamera2
        cam = Picamera2()
        cam.configure(cam.create_still_configuration())
        cam.start()
        time.sleep(0.5)
        with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
            tmp = f.name
        cam.capture_file(tmp)
        cam.stop()
        cam.close()
    except ImportError:
        # Fallback to OpenCV for testing on non-Pi hardware
        import cv2
        cap = cv2.VideoCapture(0)
        ret, frame = cap.read()
        cap.release()
        if not ret:
            return {"error": "No camera found"}
        with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
            tmp = f.name
        cv2.imwrite(tmp, frame)

    with open(tmp, "rb") as f:
        b64 = base64.standard_b64encode(f.read()).decode()
    os.remove(tmp)
    return [ImageContent(type="image", data=b64, mimeType="image/jpeg")]


@mcp.tool()
@shows_busy
def capture_circuit(filename: st
[truncated — 7699 more characters]
```

### app/web/src/main.tsx

```typescript
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ReactFlowProvider } from "@xyflow/react";
import App from "./App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <ReactFlowProvider>
      <App />
    </ReactFlowProvider>
  </StrictMode>,
);

```

### app/web/src/layout.ts

```typescript
// Auto-layout for the netlist graph using Dagre (layered, left-to-right).
//
// Components are placed so they don't overlap and wire crossings are minimized.
// We feed Dagre each node's *measured* size (chip width/height are render-dependent),
// so this must run after React Flow has measured the nodes — see useNodesInitialized
// in App.tsx. Results are written back into node.position (top-left), which graphToNetlist
// already round-trips, so no other code needs to change.

import dagre from "@dagrejs/dagre";
import type { Edge } from "@xyflow/react";

import type { CompNode } from "./netlist";

// Used only if a node hasn't been measured yet (layout is gated on measurement, so
// these are a safety net rather than the common path).
const FALLBACK_W = 140;
const FALLBACK_H = 80;

export function layoutGraph(
  nodes: CompNode[],
  edges: Edge[],
  opts: { pinned?: Set<string> } = {},
): CompNode[] {
  const pinned = opts.pinned ?? new Set<string>();

  const g = new dagre.graphlib.Graph();
  g.setGraph({ rankdir: "LR", nodesep: 40, ranksep: 90 });
  g.setDefaultEdgeLabel(() => ({}));

  const size = (n: CompNode) => ({
    width: n.measured?.width ?? FALLBACK_W,
    height: n.measured?.height ?? FALLBACK_H,
  });

  for (const n of nodes) g.setNode(n.id, size(n));
  for (const e of edges) g.setEdge(e.source, e.target);

  dagre.layout(g);

  return nodes.map((n) => {
    if (pinned.has(n.id)) return n;
    const d = g.node(n.id);
    if (!d) return n;
    const { width, height } = size(n);
    // Dagre returns node centers; React Flow positions by top-left corner.
    return { ...n, position: { x: d.x - width / 2, y: d.y - height / 2 } };
  });
}

```

### app/web/src/App.tsx

```typescript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
  addEdge,
  Background,
  ConnectionLineType,
  ConnectionMode,
  Controls,
  MiniMap,
  Panel,
  ReactFlow,
  useEdgesState,
  useNodesInitialized,
  useNodesState,
  useReactFlow,
  type Connection,
  type Edge,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";

import { ComponentNode } from "./ComponentNode";
import { cancel, getNetlist, submit } from "./bridge";
import { layoutGraph } from "./layout";
import {
  graphToNetlist,
  netlistToGraph,
  pinnedIds,
  type CompNode,
  type Netlist,
} from "./netlist";
import "./styles.css";

// Default pin sets for "Add component" so new parts get sensible handles.
const DEFAULT_PINS: Record<string, string[]> = {
  led: ["anode", "cathode"],
  resistor: ["1", "2"],
  capacitor: ["1", "2"],
  diode: ["anode", "cathode"],
  power: ["+"],
  ground: ["-"],
  switch: ["1", "2"],
  ic: ["1", "2", "3", "4"],
  sensor: ["vcc", "gnd", "sda", "scl"],
  other: ["1", "2"],
};
const TYPES = Object.keys(DEFAULT_PINS);

export default function App() {
  const nodeTypes = useMemo(() => ({ component: ComponentNode }), []);
  const [nodes, setNodes, onNodesChange] = useNodesState<CompNode>([]);
  const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [loaded, setLoaded] = useState(false);
  const original = useRef<Netlist>({ components: [], nets: [] });
  const { fitView } = useReactFlow();
  const nodesInitialized = useNodesInitialized();
  const didInitialLayout = useRef(false);

  useEffect(() => {
    getNetlist().then((nl) => {
      original.current = nl;
      const { nodes, edges } = netlistToGraph(nl);
      setNodes(nodes);
      setEdges(edges);
      setLoaded(true);
    });
  }, [setNodes, setEdges]);

  // Auto-arrange once, after React Flow has measured node sizes. The ref latch keeps
  // this to a single run so later edits (e.g. adding a part) don't reshuffle the graph;
  // the Tidy button is the explicit re-layout path.
  useEffect(() => {
    if (!loaded || !nodesInitialized || didInitialLayout.current) return;
    didInitialLayout.current = true;
    setNodes((ns) => layoutGraph(ns, edges, { pinned: pinnedIds(original.current) }));
    requestAnimationFrame(() => fitView());
  }, [loaded, nodesInitialized, edges, setNodes, fitView]);

  const onConnect = useCallback(
    (c: Connection) => setEdges((eds) => addEdge({ ...c, type: "step" }, eds)),
    [setEdges],
  );

  const selected = nodes.find((n) => n.id === selectedId) ?? null;

  const patchSelected = useCallback(
    (patch: Partial<CompNode["data"]>) => {
      if (!selectedId) return;
      setNodes((ns) =>
        ns.map((n) => (n.id === selectedId ? { ...n, data: { ...n.data, ...patch } } : n)),
      );
    },
    [selectedId, setNodes],
  );

  const addComponent = useCallback(
    (type: string) => {
      const ids = new Set(nodes.map((n) => n.id));
      const prefix = type.slice(0, 3).toUpperCase();
      let i = 1;
      while (ids.has(`${prefix}${i}`)) i++;
      const id = `${prefix}${i}`;
      const node: CompNode = {
        id,
        type: "component",
        position: { x: 60 + nodes.length * 24, y: 60 + nodes.length * 24 },
        data: { type, label: id, pins: DEFAULT_PINS[type] ?? ["1", "2"], value: null },
      };
      setNodes((ns) => [...ns, node]);
      setSelectedId(id);
    },
    [nodes, setNodes],
  );

  const onApprove = useCallback(() => {
    submit(graphToNetlist(nodes, edges, original.current));
  }, [nodes, edges]);

  return (
    <div className="app">
      <ReactFlow
        nodes={nodes}
        edges={edges}
        nodeTypes={nodeTypes}
        connectionMode={ConnectionMode.Loose}
        connectionLineType={ConnectionLineType.Step}
        defaultEdgeOptions={{ type: "step" }}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onConnect={onConnect}
        onSelectionChange={({ nodes }) => setSelectedId(nodes[0]?.id ?? null)}
        deleteKeyCode={["Backspace", "Delete"]}
        fitView
      >
        <Background />
        <Controls />
        <MiniMap pannable zoomable />

        <Panel position="top-left" className="toolbar">
          <strong>Confirm the circuit</strong>
          <span className="hint">
            Drag pins to wire · select + Delete to remove · edit the selected part →
          </span>
        </Panel>

        <Panel position="top-right" className="add-panel">
          <span className="panel-title">Add part</span>
          <div className="add-buttons">
            {TYPES.map((t) => (
              <button key={t} onClick={() => addComponent(t)} title={`Add ${t}`}>
                {t}
              </button>
            ))}
          </div>
        </Panel>

        {selected ? (
          <Panel position="bottom-right" className="inspector">
            <span className="panel-title">Edit · {selected.id}</span>
            <label>
              Label
              <input
                value={selected.data.label}
                onChange={(e) => patchSelected({ label: e.target.value })}
              />
            </label>
            <label>
              Type
              <select
                value={selected.data.type}
                onChange={(e) => patchSelected({ type: e.target.value })}
              >
                {TYPES.map((t) => (
                  <option key={t} value={t}>
                    {t}
                  </option>
                ))}
              </select>
            </label>
            <label>
              Value
              <input
                value={selected.data.value ?? ""}
                placeholder="e.g. 220Ω"
                onChange={(e) => patchSelected({ value: e.target.value || null })}
              />
            </label>
          </Panel>
        ) : null}

        <Panel position="bottom-center" className="actions">
          <but
[truncated — 268 more characters]
```

### onboard.sh

```shell
#!/usr/bin/env bash
# onboard.sh — one-shot Pin Pal onboarding. Idempotent: every step runs only if needed,
# so it's safe to re-run any time (e.g. after a fresh clone, or to connect the Pi later).
#
#   1. Build the netlist editor web UI          (skips if web/dist/ is up to date)
#   2. Python venv + deps for pin-pal-ui         (skips if .venv already has the deps)
#   3. pin-pal-ui MCP                            (registered via .mcp.json; clears stray dup)
#   4. Connect the Pi's pin-pal probe server     (always over SSH tunnel; skips if added)
#   5. Register the SessionStart hook            (auto-reconnects the tunnel each session)
#
# Run from the repo root:  ./onboard.sh
# Needs: node/npm, python3, and the `claude` CLI on PATH.

set -euo pipefail

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP="$ROOT/app"

ok()  { printf '  \033[32m✓\033[0m %s\n' "$1"; }
run() { printf '  \033[33m→\033[0m %s\n' "$1"; }

# --- 1. web UI -------------------------------------------------------------
echo "[1/5] Netlist editor (web/dist/)"
if [ -f "$APP/web/dist/index.html" ] && \
   [ -z "$(find "$APP/web/src" -newer "$APP/web/dist/index.html" 2>/dev/null)" ]; then
  ok "already built and up to date"
else
  run "building…"
  ( cd "$APP/web" && npm install && npm run build )
fi

# --- 2. python env ---------------------------------------------------------
echo "[2/5] Python venv + requirements"
if [ -x "$APP/.venv/bin/python" ] && "$APP/.venv/bin/python" -c "import webview, mcp" 2>/dev/null; then
  ok ".venv already present with deps"
else
  run "creating venv + installing…"
  python3 -m venv "$APP/.venv"
  "$APP/.venv/bin/pip" install --upgrade pip
  "$APP/.venv/bin/pip" install -r "$APP/requirements.txt"
fi

# --- 3. pin-pal-ui (laptop MCP) --------------------------------------------
# .mcp.json registers it at project scope; just clear any stray local duplicate.
echo "[3/5] pin-pal-ui MCP"
claude mcp remove pin-pal-ui -s local >/dev/null 2>&1 || true
ok "registered via .mcp.json — approve it when you launch claude in this repo"

# --- 4. pin-pal (Pi probe server) ------------------------------------------
# Always over the SSH tunnel — never a direct LAN/HTTP add.
echo "[4/5] pin-pal MCP (the Pi probe server, over SSH tunnel)"
if claude mcp list 2>/dev/null | grep -q '^pin-pal:'; then
  ok "already registered — skipping"
else
  run "opening SSH tunnel via scripts/pinpal_connect.sh…"
  # First run exits non-zero on purpose: it generates your key and prints it for the Pi's
  # owner to authorize. Don't let that abort onboarding (we run under `set -e`).
  if "$ROOT/scripts/pinpal_connect.sh"; then
    ok "pin-pal connected over the SSH tunnel"
  else
    run "send the public key printed above to the Pi's owner (scripts/pinpal_authorize.sh),"
    run "then re-run ./onboard.sh to finish registering pin-pal"
  fi
fi

# --- 5. SessionStart hook --------------------------------------------------
# Registers scripts/session_start_hook.sh as a Claude Code SessionStart hook in
# the user's global settings, merging into any existing hooks. Idempotent: it
# strips any prior Pin Pal SessionStart entry first, so re-runs never duplicate.
echo "[5/5] SessionStart hook (auto-reconnect tunnel each session)"
SETTINGS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json"
HOOK_CMD="bash $ROOT/scripts/session_start_hook.sh"
if PINPAL_SETTINGS="$SETTINGS" PINPAL_HOOK_CMD="$HOOK_CMD" python3 - <<'PY'
import json, os, sys

path = os.environ["PINPAL_SETTINGS"]
cmd  = os.environ["PINPAL_HOOK_CMD"]

try:
    with open(path) as f:
        data = json.load(f)
except FileNotFoundError:
    data = {}
except json.JSONDecodeError:
    sys.exit("settings.json is not valid JSON — leaving it untouched")

hooks = data.setdefault("hooks", {})
events = hooks.setdefault("SessionStart", [])

def is_pinpal(group):
    return any("session_start_hook.sh" in h.get("command", "") or "pinpal" in h.get("command", "")
               for h in group.get("hooks", []))

# Drop any prior Pin Pal entry so this is idempotent, then add the canonical one.
events[:] = [g for g in events if not is_pinpal(g)]
events.append({"matcher": "", "hooks": [{"type": "command", "command": cmd}]})

os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as f:
    json.dump(data, f, indent=2)
    f.write("\n")
PY
then
  ok "registered in $SETTINGS"
else
  run "couldn't update $SETTINGS — add a SessionStart hook running: $HOOK_CMD"
fi

# --- Netlist review gate hooks ---------------------------------------------
# These are project-scoped (checked in at .claude/settings.json), so they load
# automatically — no registration needed. Just make the scripts executable and
# assert they're wired.
chmod +x "$ROOT/scripts/netlist_gate_hook.sh" "$ROOT/scripts/capture_guard_hook.sh" 2>/dev/null || true
if grep -q netlist_gate_hook "$ROOT/.claude/settings.json" 2>/dev/null; then
  ok "netlist review gate hooks active (.claude/settings.json)"
else
  run "netlist gate hooks missing from .claude/settings.json"
fi

echo
echo "Done. Verify with:  claude mcp list"

```

[23 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]