End-to-end PCB design. Describe a board in plain language, get a placed KiCad layout back — with the reasoning shown and every claim cited.
Silkscreen reads the datasheets, proposes a circuit, refuses to build it if it does not validate, draws a schematic, generates the footprints, places the board with a CP-SAT solver, routes the copper, and then argues against its own design and tells you what it thinks is wrong.
Silkscreen is a Python program you run. The command line is the product; the web UI is a viewer for what it produced, and is the less-supported path — see Which interface.
Python 3.11 or newer. No KiCad install or API key is needed to install it or run the whole test suite. After dependencies are installed, the suite itself makes no network calls.
One command. It finds a Python 3.11+, creates .venv, installs the engine editable
with its extras, and builds the web UI if Node 22+ is on your PATH (skipped, not fatal,
if it isn't). Nothing is written outside the repo and it never uses sudo:
git clone https://github.com/machmoon/silkscreen && cd silkscreen
./scripts/install.sh # macOS / Linux
powershell -ExecutionPolicy Bypass -File scripts\install.ps1 # WindowsThen, optionally, one command to configure a key and one to open the app:
./.venv/bin/silkscreen setup # writes .env; never echoes the key back
./.venv/bin/silkscreen serve # starts the API + UI and opens a browserOr install by hand (three lines, same result)
python3 -m venv .venv
./.venv/bin/pip install -e ".[dev,agents,cloud,adk]" # Windows: .venv\Scripts\pip
./.venv/bin/python -m pytest -q # offline; no keys requiredOr run the web UI in Docker (no Python or Node on your machine)
docker build -t silkscreen .
docker run -p 8080:8080 -e GOOGLE_API_KEY=... silkscreen # http://localhost:8080The image builds the Svelte bundle in a Node stage and serves it from the Python service, same origin as the API.
Then place a board — no API key, nothing to configure, and it finishes in about 20 seconds:
./.venv/bin/python scripts/demo.pyTo go from a prompt to a board you need a Gemini key (GOOGLE_API_KEY); everything
else works without one:
./.venv/bin/silkscreen setup # or: cp .env.example .env, and edit it
./.venv/bin/silkscreen "a 3.3V motor driver around an STM32F103" -o board.kicad_pcbsetup and serve are the only subcommands; any other argument is the plain-language
intent, so silkscreen "..." and python -m silkscreen "..." are the same generator.
One run leaves you a KiCad project, one file per stage:
wrote board.kicad_pro ← open this in KiCad
wrote board.kicad_sch ← the schematic
wrote board.placed.kicad_pcb ← after placement, before any copper
wrote board.kicad_pcb ← routed
Every stage is a real KiCad file you can open and inspect on its own, so you can see where a design went wrong instead of only seeing the last artifact.
1126 tests collected — no network, no API key, no KiCad install
Next: full install guide and troubleshooting · contributing · how it works
Download: tagged releases carry the
Python wheel and built web UI. The native Ada macOS shell currently runs
from a checkout; .dmg packaging, signing, and notarization are not yet built.
python -m silkscreen (recommended) |
Web UI | |
|---|---|---|
Schematic (.kicad_sch) |
✅ | ❌ not surfaced |
| Routed copper | ✅ | ✅ in the downloaded file only — the board well draws placement, not tracks |
| Per-stage files you can open | ✅ | ❌ final board only |
KiCad project (.kicad_pro) |
✅ | ❌ |
| Adversarial review, findings, citations | ✅ text | ✅ nicer to read |
The CLI is still the complete project-output path. The web UI (service/ +
frontend/) now starts with a persistent orchestrator chat, shows the observable model,
tool, validation, and retry activity, and hands you compact cards for the schematic,
placement diagram, review, and final .kicad_pcb. It still does not return the native
.kicad_sch/.kicad_pro or draw routed tracks, so use the CLI when those files matter.
Not to run Silkscreen. Yes, strongly recommended, to do anything with what it makes.
Silkscreen writes the KiCad formats itself, so nothing in the pipeline shells out to
KiCad, imports pcbnew, or touches your mouse. But the output is a KiCad project,
and without KiCad you have files you cannot open, check, or fabricate. Silkscreen's
router leaves hard nets unrouted on purpose and tells you which — finishing them is
work you do in KiCad. Install it unless you have a specific reason not to.
| Without KiCad | With KiCad (strongly recommended) | |
|---|---|---|
| Generate a schematic and a routed board from a prompt | ✅ | ✅ |
| Run the test suite | ✅ | ✅ |
| Deploy the service, use the MCP server or the Slack bot | ✅ | ✅ |
| See the schematic and the board | ❌ | ✅ |
| Finish the nets the router left unrouted | ❌ | ✅ |
| Run DRC and electrical rules check | ❌ | ✅ |
| Export Gerbers and get it fabricated | ❌ | ✅ |
Skip KiCad if you are running Silkscreen in CI, on a server, or inside another tool that consumes the file. Install it if you are a person who wants to see a board. Platform-by-platform commands are in docs/install.md.
| Component | State |
|---|---|
kicad.py — .kicad_pcb read/write |
Working · 33 tests |
packing.py — CP-SAT placer |
Working · 44 tests |
netlist.py — validated circuit IR |
Working · 21 tests |
schematic.py — .kicad_sch + .kicad_pro emission |
Working · 22 tests · KiCad ERC clean |
routing.py — two-layer grid autorouter |
Working, partial by design · 20 tests — see below |
footprints.py + board.py — land patterns, board emission |
Working · 23 tests |
agents/ — datasheet, propose, review, pipeline |
Working · 38 tests |
agents/adk/ — ADK dynamic-workflow driver for the pipeline |
Working · 20 tests |
agents/retrieval.py — page-cited datasheet retrieval |
Working · 15 tests |
agents/resilience.py — provider failover |
Working · 15 tests |
fab.py — Gerber, Excellon, BOM, pick-and-place |
Working · fab package export |
order.py — order options, manufacturability preflight |
Working · blocks an unrouted board |
mcp/ — MCP server over stdio |
Working · 43 tests |
audit/ — optional visual design review |
Working · 52 tests |
service/ — Cloud Run + Firestore cache |
Working · 146 tests · live at https://silkscreen-vqdj4x5qbq-uc.a.run.app |
slackbot/ — Slack bot over the pipeline |
Working · untested against a live workspace |
frontend/ — Svelte review UI, served by the service |
Working · persistent orchestrator chat, expandable traces, session JSON, review, schematic, placement and board tabs |
engine/silkscreen/placement/ — verifier-grounded repair and company profiles |
Working · deterministic and Gemini policies; experimental providers are opt-in |
constraints.py — approved build contract and post-route receipt |
Working · opt-in, fail-closed, and deterministically tested |
| Voice / talk input | Not built |
| Overlay UI, guided cursor | Not built (mockups only) |
Every module above is covered by tests that run with no network, no API key, and no
KiCad install. The count is deliberately not quoted here — it drifts, and
scripts/check_docs.py fails CI when a quoted one goes stale.
After the normal placer produces a board, Silkscreen projects its canonical integer-nanometre geometry into a bounded millimetre verifier, accepts only score-improving moves, writes accepted positions back to the canonical board, and only then routes copper. If the company profile needs more edge or clearance space than CP-SAT's tight outline provides, the adapter minimally expands that outline before repair. The placement view shows the before and after geometry together with a receipt for every proposed move.
The legality boundary is deterministic: board bounds, courtyard clearance,
keepouts, and fixed parts. Company preferences are lower-priority terms for
functional grouping, connector access, compactness, and thermal separation.
Gemini may propose PLACE and MOVE actions, but it cannot override the
verifier. The model-free policy remains available for reproducible runs.
Ollama, Tinker, hybrid policy selection, and training traces are behind an Experimental placement features control that is off by default and enforced again by the service. Trace recording requires separate explicit consent. The focused lab keeps corrections in that browser tab's session storage. The public endpoint applies feedback to one request only; durable shared company memory remains intentionally unavailable until tenant authentication exists.
The focused /?mode=placement lab remains available for comparing the two
included company profiles. See the placement-agent architecture
for the agent, supervised fine-tuning, reinforcement learning, and verifier boundary.
The main prompt form has an optional Verified constraints editor. It is collapsed and disabled by default, so the ordinary prompt-only demo is unchanged. When enabled, the editor requires exact net names, measurable limits, and an explicit engineer approval. Any prompt or manifest edit clears that approval. Version 2 manifests are strict: unknown fields, unknown constraint kinds, duplicate ownership, unsupported layers, and zero-area physical limits are rejected before cache access or a model call.
The approved manifest is included in the circuit-proposal context, then checked again against the validated circuit, final placement, and routed copper. The receipt reports verified, violated, unresolved, and not-required checks for connectivity, routed geometry, pull-ups, board outline, keepouts, fixed placements, and other declared limits. Claims that need evidence this engine does not have—such as controlled impedance without a stackup/field solver, plane continuity, component height, or a complete voltage-drop model—remain unresolved and block production-promotion eligibility rather than being guessed.
This is currently a post-build eligibility receipt, not a second placer or router. Board dimensions, keepouts, fixed locations, and soft weights do not yet configure the CP-SAT or A* solve directly. Soft terms score the one generated result for comparison; they do not prove that alternatives were ranked. A blocked receipt leaves the generated KiCad artifact available for inspection, but the orchestrator identifies it as not eligible for production promotion.
echo 'GOOGLE_API_KEY=...' >> .env
python -m silkscreen "a 3.3V motor driver board around an STM32F030" \
--datasheet "AMS1117-3.3=https://.../ams1117.pdf" \
-o board.kicad_pcbintent ─► datasheets ─► propose/validate ─► CP-SAT place ─► verifier repair
│
.kicad_pcb ◄─ route ◄─ .kicad_sch + placed board ◄┘
│
└─► adversarial review
| Stage | Module | Artifact |
|---|---|---|
| Datasheet reading (Gemini native PDF vision) | agents/datasheet.py |
|
| Retrieval over datasheet text, page-cited | agents/retrieval.py |
|
| Circuit proposal into the IR | agents/propose.py |
|
| Validation + bounded repair loop | netlist.py |
|
| Schematic drawing | schematic.py |
.kicad_sch, .kicad_pro |
| Footprint generation, board emission | footprints.py, board.py |
|
| Placement | packing.py |
.placed.kicad_pcb |
| Verifier-gated placement repair | placement/ |
placement receipt |
| Copper routing | routing.py |
.kicad_pcb |
| Approved constraint verification | constraints.py |
promotion receipt |
| Adversarial review | agents/review.py |
Useful flags: --no-route stops after placement, --no-review skips the adversarial
pass, --board-only writes just the routed .kicad_pcb, --time-limit sets the
solver budget, --repairs how many times a bad proposal goes back to the model.
The schematic and the board are drawn by two emitters from one CircuitSpec, and both
take their reference designators from CircuitSpec.assign_refs() — so C1 on the
drawing is C1 on the board. Numbering them separately would give two files that are
each internally consistent and describe different circuits.
Two gates sit between the model and the board.
Structural. The proposal goes through the circuit IR before anything is built.
Every validation error is collected and fed back as one repair prompt; the loop is
bounded and result.repair_rounds reports how many corrections it took.
Semantic. A reviewer re-reads the datasheets and is prompted to refute the design — an agent asked "is this correct?" says yes. Findings are graded blocker / marginal / note and cite the datasheet page. A part reference the circuit does not contain is stripped out of the finding that named it; the finding itself is still shown.
Everything below agents/ is model-free and network-free, so the whole pipeline —
including its failure paths — is tested against a scripted model with no API key.
schematic.py renders the validated CircuitSpec as a KiCad 8 .kicad_sch, plus the
.kicad_pro that ties the schematic and the board together as one project.
Symbols are generated, not looked up. The file carries its own lib_symbols block,
the same way footprints.py generates land patterns rather than reading a library — so
it opens on a machine with no KiCad symbol libraries installed, and cannot silently
resolve to a different part than the one it was drawn for. Each passive type gets its
own body: a schematic whose crystals are drawn as capacitors reads as correct and
is not.
Connections are a short wire stub from each pin to a net label, which is ordinary KiCad practice and electrically identical to point-to-point wires. The netlist KiCad extracts from the sheet is the netlist the board was built from, and a pin on no net gets no stub and no label rather than a wire to nowhere.
routing.py is a two-layer grid maze router: A* over a uniform lattice with an explicit
via cost, nets routed one at a time, each net grown outward from its first terminal so
later pins join the nearest point of the tree already laid.
It is not a competitive autorouter, and the output says so. A uniform grid cannot reach every pin of a fine-pitch package, and a sequential router paints itself into corners a rip-up-and-retry router escapes. So the contract is honesty rather than completeness — every net it cannot finish is named, with the reason:
Routing: 11/13 nets routed, 47 tracks, 6 vias, 214.3 mm of copper
unrouted SPI1_SCK: no clear path to one of its 3 pads; the channel is blocked
unrouted VDDA: only 1 distinct grid node(s) among its pads; the routing grid is
too coarse for this footprint
Unrouted nets stay as ratsnest in KiCad, for you to finish. A net is all-or-nothing: half a net's tracks laid down would give a board that looks routed everywhere you happen to look. Defaults are 0.2 mm tracks, 0.2 mm clearance, 0.4/0.2 mm vias, on a 0.25 mm lattice.
Rotated footprints are refused rather than approximated, because board.py has a
recorded, unfixed bug in the anchor it writes for a rotated part — routing to those
coordinates would turn a latent placement bug into copper landing on bare laminate.
Nothing sets rotation today.
--no-route stops after placement, which is what every run produced before this
existed.
DRC answers whether a board can be made. Nothing answered whether the circuit
works — and that gap is why a design loop cannot close itself: it can produce a
plausible schematic and a manufacturable board with no evidence the thing does what it
was asked for. spice/ is that missing check, shaped like a test runner rather than a
waveform viewer.
from silkscreen.spice import Assertion, Measurement, Source, Testbench, Transient, verify
bench = Testbench(
analysis=Transient(step=1e-6, stop=2e-3),
sources=[Source.pulse("V1", "VIN", "GND",
initial=0, pulsed=5, width=1e-3, period=2e-3)],
)
report = verify(spec, bench, [
Assertion(name="rise time under 250 us",
measurement=Measurement(kind="rise_time", signal="VOUT",
window=(0, 1e-3)),
op="<", value=250e-6, unit="s"),
])
report.passed # bool
report.summary() # which clause failed, and by how muchpython scripts/simulate_demo.py runs it end to end against an RC low-pass, checking
every result against closed-form circuit theory:
PASS 10-90% rise time is tau*ln(9)
measured 0.00021946s, expected within 0.000219503s (margin -4.28e-08)
PASS -3 dB corner is 1/(2*pi*R*C)
measured 1593.23Hz, expected within 1593.14Hz (margin -0.0889)
Nothing here can return a quiet zero. An agent that gets an empty result reads it as a circuit that behaves. So a missing model, a probe on a net that does not exist, a signal with no rising edge, and a solver that will not converge each raise a distinct, self-describing error. A measurement that cannot be taken fails its clause rather than passing it vacuously.
Where it stops, plainly. A device (an IC) in the circuit IR is a pin map with no
behaviour attached, and no netlist generator can invent one. Trusted Python code can
supply a SubcircuitModel — the part's own SPICE model — directly on Testbench.
The MCP/JSON tool deliberately does not accept raw SPICE programs; IC simulation there
waits for a trusted server-side model registry. Without a model the run raises and names
the part rather than quietly leaving it out. Passive networks need nothing extra. A
diode with no model gets a generic silicon stand-in and a warning saying so;
Testbench(strict=True) turns that warning into an error, which is what you want when
the verdict has to be about the specified part.
ngspice and LTspice sit behind one interface, selected automatically. ngspice is what CI
installs and what this is verified against; LTspice discovery and batch invocation are
implemented but have not been run end to end — see the note in
spice/simulators.py. Install ngspice with brew install ngspice or
apt-get install ngspice; without a simulator the simulation tests skip and the rest of
the suite is unaffected.
Emitting a board means generating real land patterns: pads at real coordinates, a
courtyard, silkscreen. footprints.py builds them parametrically — chip passives
(0402–1210), SOT-23, SOT-223, SOIC, LQFP — so a board can be written with no KiCad
install and no footprint library on disk. Courtyards are fitted to enclose every pad
and the body, which is what makes the placer's clearance guarantee mean anything.
Coverage is narrow on purpose and it raises rather than guessing. A wrong footprint is the most common cause of a dead first-spin board; inventing a land pattern for an unrecognised package would be worse than refusing. Capacitor packages widen with value (a 22 µF part does not fit an 0603), and output is byte-identical across runs, so a regenerated board diffs cleanly in git.
Most AI-and-KiCad tools are plugins: they live inside KiCad's Python environment and drive the IPC API, so they need KiCad running, a supported KiCad version, and a platform KiCad's plugin loader is happy on. Silkscreen takes the other route — it treats the board file as the interface.
| Plugin / IPC approach | Silkscreen | |
|---|---|---|
| Requires KiCad installed | Yes | No |
| Requires KiCad running | Yes | No |
| Headless / CI | Hard | Native |
| Platform lock | KiCad's plugin loader | None — pure Python |
| Testable without KiCad | No | Yes, all 1126 tests |
load_board() → extract_parts() returns a FootprintInfo per footprint:
| Field | Source |
|---|---|
width_nm / height_nm |
F.CrtYd courtyard, falling back to the pad bounding box |
pad_offsets |
Per-pad offsets from the part's bottom-left, flipped into a Y-up frame |
pad_nets |
Net name per pad, used to build the wirelength objective |
library_id |
Footprint library nickname |
extract_nets() turns shared nets into HPWL nets; extract_wires() emits pad-pairs.
- Footprint positions —
apply_placements()moves every footprint, converting the solver's Y-up frame back to KiCad's Y-down, anchoring on the courtyard, not the origin. Edge.Cutsoutline —set_board_outline()draws the board rectangle. Without it the file has no boundary at all, andEdge.Cutsis both what KiCad measures edge clearance against and the only representation of the edge thatmust_be_on_edgewas solved against.- Pads, silkscreen,
F.Fab, courtyards, nets, and zones pass through untouched.
Everything is integer nanometres, KiCad's own internal unit, end to end. Unit confusion between mm, mils, and nm is a silent, board-destroying class of bug, so there are no floats in the pipeline; the solver quantises to a configurable grid (default 0.05 mm) rather than solving at 1 nm.
| Board format | kicad_pcb version 20240108 (KiCad 7–8) |
| Parser | kiutils 1.4.8 — pure Python |
| Solver | OR-Tools CP-SAT 9.15 |
| Python | 3.11+ |
| OS | macOS, Linux, Windows — identical behaviour |
Round-trip is verified by test: a written board reparses, preserves every footprint, and has no two overlapping courtyards.
Silkscreen can also be used as a library on an existing .kicad_pcb, with no
model involved at all — read it, re-place it, write it back:
from silkscreen.kicad import (
load_board, extract_parts, extract_nets, to_parts,
apply_placements, set_board_outline, save_board,
)
from silkscreen.packing import pack
board = load_board("my_board.kicad_pcb")
infos = extract_parts(board) # courtyard extents + pad offsets, in nm
result = pack(
to_parts(infos, edge_refs={"J1"}), # connector pinned to a board edge
nets=extract_nets(infos), # power rails down-weighted, not dropped
clearance_nm=250_000, # 0.25 mm between courtyards
time_limit_s=20.0,
)
apply_placements(board, infos, result.placements, result.board_height_nm)
set_board_outline(board, result.board_width_nm, result.board_height_nm)
save_board(board, "placed.kicad_pcb")Reproduce it with python scripts/demo.py, on the 11-footprint STM32 + regulator +
motor-driver fixture in engine/tests/fixtures/:
11 footprints, 6 nets
status : feasible
board size : 18.25 x 18.00 mm (328.5 mm²)
HPWL : 53.0 mm
placed 11/11 -> placed.kicad_pcb (~43.9 kB, reparses clean)
Identical across consecutive runs. Reproducibility requires workers=1 (the default) —
CP-SAT's multi-worker portfolio interleaves results non-deterministically regardless of seed.
(That run omits edge_refs: the fixture has no connector, and naming a ref that isn't on the
board is an error, not a no-op.)
One caveat, since this is the only measured figure here: the fixture's nets are named
0_device_pin_N by the pipeline that generated it, so none match the power-rail heuristic
and the power-net weighting described below does not fire on this board. It is exercised
by unit tests, not by the number above.
gcloud run deploy silkscreen --source . --region us-central1 \
--set-secrets GOOGLE_API_KEY=google-api-key:latest \
--set-env-vars GOOGLE_CLOUD_PROJECT=your-projectA live instance is running at
https://silkscreen-vqdj4x5qbq-uc.a.run.app (deployed 2026-08-31, project
project-e9121780-d00d-4f9b-8b5; the Gemini key comes from Secret Manager and
POST /generate requires an access token, so browsing to it costs nobody
anything). Probe liveness with GET /, not /healthz — Google's frontend
intercepts /healthz on run.app domains at the edge and answers 404 before
the request reaches the container.
POST /generate with {"intent": "...", "datasheets": {"PART": "url"}} returns
the board, the emitted .kicad_pcb, and a versioned schematic topology block
with stable part ids, board refs, pins and structured net endpoints. Extracted
datasheet facts persist to Firestore, so the second request for a part skips the most expensive stage.
POST /chat/stream is the presentation path: a genuine ADK LlmAgent may ask one
essential clarification and otherwise calls the validated generator as its
generate_board tool. It streams versioned NDJSON events for the orchestrator, tool,
worker calls, and final result. GET /models discovers the current key's
generateContent-capable Gemini models, with a short server cache and configured fallback
catalog. GET /healthz is the readiness probe. The container serves the built UI at /,
same origin as all of these routes, so there is no CORS anywhere.
GET /config/status backs the live backend-readiness section in the right side rail.
It reports whether Gemini, ADK, Ollama, Tinker, and Firestore can use the active
process configuration, probes a configured Ollama server for its selected model,
and notices local .env edits that require a backend restart. The response contains
variable names and status messages only; it never returns configuration values or
credentials. These checks do not make paid generation calls.
slackbot/ puts the pipeline in a hardware team's channel. Mention the bot with
what you want built and it replies in a thread under your message — a live
stage list, the review, a rendered preview of the placement, and the emitted
.kicad_pcb — so the whole team can read the run later, not just whoever asked.
@silkscreen design a 3.3V buck converter from 12V --datasheet TPS62840=https://…
@silkscreen place an stm32f103 breakout # skip the review: faster and cheaper
@silkscreen review # re-run the critic on this thread's run
@silkscreen order 25 # prepare a fab order (never submits one)
@silkscreen help
order prepares a fabrication order and stops: board size, stackup, the files
the run produced, any blocking findings from the review, and what a fabricator still
needs. It posts that draft as a message and a JSON attachment. It does not contact a
vendor, submit anything, or touch a payment method — none of that exists in this
codebase, and a test enforces it by import. A human places the order.
Running it:
./.venv/bin/pip install -e ".[dev,agents,slack]"
export SLACK_BOT_TOKEN=xoxb-… SLACK_SIGNING_SECRET=… GOOGLE_API_KEY=…
python -m slackbot # POST /slack/events on :3000Slack has to reach that port, so in development put a tunnel in front of it
(ngrok http 3000 or equivalent) and give Slack the public URL.
Creating the app (once, in your workspace, at https://api.slack.com/apps):
- Create New App → From scratch, pick your workspace.
- OAuth & Permissions → Bot Token Scopes:
app_mentions:read,chat:write,files:write,reactions:write. Addcommandsif you want the slash command. - Install to Workspace, then copy the Bot User OAuth Token (
xoxb-…) intoSLACK_BOT_TOKEN. - Basic Information → Signing Secret goes into
SLACK_SIGNING_SECRET. Every request is HMAC-verified against it before it is parsed, and requests older than five minutes are refused, so a captured one cannot be replayed. - Event Subscriptions → Enable, request URL
https://your-host/slack/events. Slack verifies the URL with a challenge the bot answers automatically. Under Subscribe to bot events addapp_mention. - Optionally Slash Commands → Create:
/silkscreen, request URLhttps://your-host/slack/commands. - Invite the bot to the channel:
/invite @silkscreen.
Set SILKSCREEN_SLACK_CHANNELS to a comma-separated list of channel IDs to confine
runs to the channels that are paying for them; leave it unset to allow any channel
the bot is in. SILKSCREEN_SLACK_MAX_RUNS (default 2) caps concurrent runs — a
design run costs model calls and a CP-SAT solve, so six people asking at once should
not start six.
Runs are remembered per thread in memory, so review and order work on the
run above them and a restart forgets them; the bot says so rather than acting on the
wrong board. Artifacts are also written under SILKSCREEN_SLACK_WORKDIR
(default slack-runs/).
Secondary and not well supported — see How you are meant to run this.
The UI is a Svelte SPA in frontend/, and it needs Node 22 or newer
(node --version). In development it runs on Vite's dev server, which proxies
/generate, /chat, /models, /config, and /healthz to the Python service — two terminals:
PORT=8081 python -m service.app # terminal 1: the API
cd frontend && npm install && npm run dev # terminal 2: http://localhost:5173For the production path, build the bundle and let the service serve it itself:
cd frontend && npm run build # writes frontend/dist/
python -m service.app # http://localhost:8080 serves the UI and the APIThe UI has its own Vitest suite, which CI runs before the build:
cd frontend && npm testA run stays in the Chat tab as a persistent transcript. Friendly activity summaries
are shown by default; raw orchestrator and worker prompts/responses are expandable for a
demo or debugging. Before submitting, the orchestrator panel selects Gemini 3.7 Flash or
Gemini 3.1 Pro Preview and an Auto/Fast/Standard/Deep thinking effort. Gemini 3 cannot
disable thinking completely, so Fast maps to the supported low level. A separate
request-pace control can space every explicit orchestrator and worker attempt at 15, 6,
or 3 RPM across one service instance; Auto preserves the provider default. This mitigates
RPM bursts but cannot raise Google's per-project token or daily quotas. Clarification,
retry, edit, copy-error, and discovered-model retry
controls remain beside the failed or incomplete turn. Save session exports a versioned
JSON snapshot containing the transcript, trace, result, and board artifact; Open session
restores it locally. Treat that debug export as sensitive if a prompt contains private
design information.
Appearance stays local to the browser. Glass in the title bar switches the whole rendered interface from the opaque Drafting Table skin to a translucent material, while Night independently selects its light or dark reading. Both choices persist across reloads; reduced-transparency system settings replace blur with opaque surfaces, and the PCB canvas keeps its fixed KiCad colours in every combination.
The compact artifact cards open the existing views. Schematic draws the validated
circuit as generic symbols with physical pin numbers and net-labelled connections; it does
not claim to be a native .kicad_sch or a library-accurate sheet. Board draws the
placement the service actually produced — courtyard outlines, pads, and part refs — while
Review shows the grounded findings. Selecting a finding highlights the parts it names,
and the board and review panes offer the emitted .kicad_pcb as a download.
silkscreen serve does both of those for you — it loads .env, starts the service on
--port (or PORT), and opens the browser. Running python -m service.app directly
does not read .env; only the CLIs do, so export the key first. See
docs/install.md.
silkscreen-mcp # JSON-RPC 2.0 over stdioTools: validate_circuit, build_board, emit_kicad_pcb, place_parts,
generate_footprint, simulate_circuit, spice_capabilities.
simulate_circuit accepts typed sources (dc, ac, pulse, sine) and typed
analyses only. Unknown fields and raw model/directive text are rejected before a
simulator starts; runtime is capped at 120 seconds and returned waveforms at 2,000
points per signal. spice_capabilities reports simulator names without exposing local
executable paths.
meetings/ reads the transcript of a meeting that already happened and drafts a
board for what the meeting asked for. Read the limits before the feature:
- It reads a conference after it ends, not while it runs. Nothing listens live; a live listener would need the Meet Media API, which is a different and much larger piece of work.
- It only sees a meeting where the organiser turned transcription on. No transcript means no input, and the run says so rather than reporting an empty meeting.
- It does not join the call. There is no headless browser, no fake participant, and no media plumbing — every open-source meeting bot works that way, and this deliberately does not. It is the Google Meet REST API v2 and nothing else.
- It has never been run against a live Google Workspace account. The Meet API path is unverified live: every test drives a recorded transport offline.
Configuration is environment only, and the package does not perform the OAuth flow — the host supplies an already-obtained bearer token:
export MEET_ACCESS_TOKEN=... # required; scope meetings.space.readonly
export MEET_SPACES=spaces/abc,spaces/def # optional allowlist; empty = every conference the token can see
export MEET_API_BASE=https://meet.googleapis.com/v2 # optional, pinned by default
export MEET_MAX_AGE_HOURS=24 # ignore conferences that ended longer ago
export MEET_MAX_RUNS_PER_POLL=3 # cap the board runs one poll may startToken acquisition, refresh and storage belong to the host application. The scope is read-only on purpose: nothing here creates, modifies or joins a meeting.
What survives the meeting is drafted, not ordered. A request whose quote is not in the transcript is dropped, a request below the confidence floor is recorded but not built, and every skipped request is still reported — a bot that quietly ignores what someone asked for is the failure people actually hit. Nothing is ever purchased.
The board itself comes from the same generator the CLI uses, so this is a
different way to supply the sentence, not a second pipeline. When the output
files matter, python -m silkscreen "..." remains the complete path.
Optional, and separate from generating one. silkscreen-review reviews any
.kicad_pcb — one this project emitted, or one you laid out yourself — and
marks what it finds on the board, not just in a list.
silkscreen-review board.kicad_pcb # standard effort
silkscreen-review board.kicad_pcb -e deep -o review/ # deeper, write reports
silkscreen-review board.kicad_pcb -e quick --no-model # offline, no API key
silkscreen-review board.kicad_pcb --fail-on-blocker # exit 1 for CI-o writes three files: review.html (board render beside the findings,
click either to highlight the other), review.svg (the annotated board on its
own, for a PR comment or a slide) and review.json.
quick ●───○───○ geometry and connectivity. No model call at all.
standard ○───●───○ + clearance sweeps, decoupling distance, track widths,
and one model pass.
deep ○───○───● + manufacturing rules, tighter thresholds, a per-part
model pass for every IC, and every model finding must
survive a refutation prompt before it is reported.
Each level runs a strict superset of the level below it — enforced by test, so
"deeper" cannot quietly become "different". Higher levels are slower and, above
quick, cost model calls.
The report keeps two kinds of finding apart, because they are not equally trustworthy:
- Proven — measured by a deterministic checker in
audit/rules.py, and shown with the measurement that proves it (gap 0.100 mm, clearance 0.300 mm). Outlined solid on the render. - Suggested — argued by the model: wrong capacitor value, floating mode pin, a topology mistake. No measurement, dashed on the render, and dropped entirely if it names no part the board contains.
Nothing the model returns can delete, downgrade or reword a proven finding, and a model failure loses only the suggested half — the report then says why the model did not run rather than showing a shorter list.
The report also states what was checked, so an empty finding list cannot be mistaken for a clean board when it only means a rule never ran.
CP-SAT. Variables are each part's bottom-left corner on an integer grid;
AddNoOverlap2D enforces disjointness, AddMaxEquality derives the bounding box,
AddAbsEquality linearises wirelength. The objective minimises board half-perimeter
plus total HPWL.
| Feature | Why it's there |
|---|---|
| Real clearance | Parts inflate by clearance_nm/2 per side before no-overlap. Flush-packed boards can't be assembled. |
| 90° rotation | A boolean per part swaps interval sizes; pin offsets rotate with two implications, not a centre approximation. |
| Edge constraints | Connectors and antennas pin to an edge via a disjunction over four half-reified literals. |
| Symmetry breaking | 24 identical caps admit 24! relabelings. Forcing a lexicographic order collapses each orbit to one representative — on a 27-part board, optimality in 0.72 s instead of 16.7 s. |
| HPWL, one box per net | A pairwise clique makes a 50-pad ground net contribute 1,225 terms that swamp every signal. A star overestimates length and makes layout depend on footprint order in the file. |
| Power rails down-weighted, not dropped | A decap's only connections are VCC and GND — drop power nets and it has no objective term and drifts. Measured: excluding power put a cap 9.65 mm from its IC pin; weighting at 0.25 brings it to 5.15 mm. |
| Pinned parts | Part(fixed_at_nm=(x, y)) holds a part where you put it. Without this every re-solve reshuffles the board, so you can't keep a placement you like and let the solver work around it. |
| Keepouts | Keepout(x, y, w, h) reserves a region — mounting holes, a connector's mating envelope, a mechanical boss. Modelled as an immovable participant in the same no-overlap constraint as the parts, because that's what it is. |
| Degrades, doesn't fail | If CP-SAT finds nothing in budget, a deterministic shelf packer returns a valid layout flagged FALLBACK, warning about every constraint it couldn't honour — including the pins and keepouts it can't. |
Keep what you like and re-solve the rest:
from silkscreen import Keepout, Part
first = pack(parts, nets=nets)
good = {p.ref: p for p in first.placements}
parts = [
Part(..., ref="J1", fixed_at_nm=(good["J1"].x_nm, good["J1"].y_nm))
if p.ref == "J1" else p
for p in parts
]
second = pack(
parts,
nets=nets,
keepouts=[Keepout(mm(3), mm(3), mm(3.2), mm(3.2), name="MH1")], # M3 hole
)fixed_at_nm names where the part goes, not its clearance-inflated box, and is
snapped to the solver grid. Pinning closer to the origin than clearance_nm/2 raises,
because the clearance ring has to exist.
2D packing with a wirelength objective is NP-hard. On real boards the solver returns
FEASIBLE, not OPTIMAL, inside 20 s — and Silkscreen reports it as FEASIBLE.
Coarsening the grid from 0.025 mm to 0.5 mm barely moves the result, so the bottleneck
is combinatorial, not resolution. Treat the output as a strong starting placement,
not a proof.
No support for: two-sided placement (everything is one layer), connector
orientation (must_be_on_edge puts a part on an edge but says nothing about which
way it faces), thermal relief, or differential pairs. Two-sided placement is
the one that most limits real use.
silkscreen.netlist is the contract between a model and anything that touches KiCad.
A model proposes a CircuitSpec; nothing is instantiated until it validates, and all
failures are collected so the batch goes back as one repair prompt.
Rejects: a pin the device doesn't have · a part that doesn't exist · a bare part name
where a terminal is required (C1 not C1.1) · a passive wired on one leg · a net with
fewer than two endpoints · unsupported passive types.
That fourth check matters most. Connecting one specific leg of a decoupling capacitor to a specific pin is the most common operation in this domain, and an IR that can only join whole parts to nets cannot express it at all.
engine/
silkscreen/
units.py nm/mm/mil conversion, grid quantisation
packing.py CP-SAT placer
netlist.py validated circuit IR
footprints.py parametric IPC-7351 land patterns
board.py emit a .kicad_pcb from a circuit
schematic.py emit a .kicad_sch and the .kicad_pro that ties them
routing.py two-layer A* copper router
kicad.py read/modify an existing .kicad_pcb via kiutils
ids.py stable UUIDs, so two runs diff cleanly
cli.py python -m silkscreen "..."
spice/ typed testbenches, deck building, simulators, measurements
placement/ verifier-gated repair + opt-in Ollama/Tinker policy adapters
constraints.py approved manifest parsing + deterministic post-route receipt
mcp/ JSON-RPC tools, including the bounded simulation verifier
agents/ Gemini-backed worker and orchestrator calls
model.py provider seam + scripted stand-in for tests
datasheet.py PDF -> structured facts, with page citations
propose.py intent -> circuit, with a bounded repair loop
review.py adversarial design review
stages.py shared stage bodies for both drivers
pipeline.py prompt -> PCB
adk/ ADK dynamic workflow over the same stage bodies
audit/ optional visual review of a finished board
tests/ 1126 tests — no network, no API keys, no KiCad
fixtures/ ref.kicad_pcb -- 11-footprint board fixture
scripts/
demo.py end-to-end: read -> place -> write -> verify
simulate_demo.py closed-form RC checks through a real ngspice run
check_docs.py fails CI if a quoted test count goes stale
frontend/
src/
lib/ api client, run store, severity + format helpers
components/ title bar, intent form, progress, findings, side rail
styles/ paper/glass material and light/dark design tokens
dist/ built bundle -- service/app.py serves it at /
vendor/
mudriknow/ third-party (MIT), reference only -- not imported, not tested
Top-level mcp/, pcb/, packing/, footprint/, frontend-archive/, lcsc.py and
test_skidl.py are pre-rewrite hackathon code, kept for reference and not part of the
layout above. They are name-collision traps — see CONTRIBUTING.md.
Install and environment problems are in docs/install.md. Problems with a run:
| Symptom | Cause |
|---|---|
edge_refs names refs not on this board |
A ref in edge_refs/rotatable_refs matches no footprint. The error lists valid refs — a typo would otherwise become a silently missing constraint. |
status is FALLBACK |
CP-SAT found nothing in budget. Raise time_limit_s, coarsen grid_nm, or relax max_board_nm. Check result.warnings. |
status is FEASIBLE, not OPTIMAL |
Expected on real boards. The solution is valid but unproven. |
| Results differ between runs | You set workers > 1. Use workers=1 for determinism. |
| KiCad won't open the output | Confirm the board is format 20240108 (KiCad 7–8). Older KiCad won't read it. |
| Parts overlap in KiCad's DRC | DRC measures pad/copper clearance; clearance_nm is courtyard clearance. Raise it. |
Every result in this README can be reproduced from a clean clone in about four minutes. No KiCad install, no network access, and no API keys are required — the test suite and the demo both run fully offline.
| Python | 3.11 or newer (python3 -V) |
| OS | macOS, Linux, or Windows — all three run in CI |
| Network | Not needed after pip install |
| API keys | None. agents/ tests use a scripted stand-in model, not a live provider |
| KiCad | Not needed. Board files are parsed by kiutils, which is pure Python |
| Disk | ~400 MB, almost all of it OR-Tools |
git clone https://github.com/machmoon/silkscreen.git
cd silkscreen
python3 -m venv .venv
./.venv/bin/pip install -e ".[dev,agents,cloud,adk]"Then run the same four Python checks CI runs, in the same order:
./.venv/bin/python -m pytest -q # 1. tests (~2 min)
./.venv/bin/python -m ruff check engine service scripts # 2. lint (~1 s)
./.venv/bin/python scripts/check_docs.py # 3. doc drift (~5 s)
./.venv/bin/python scripts/demo.py # 4. end-to-end(~20 s)Check 3 re-counts the suite and fails if any number quoted in this README or in
DEVPOST.md has gone stale, so those figures cannot drift from the code.
On Windows, use .venv\Scripts\python.exe in place of ./.venv/bin/python.
CI runs two more jobs that need Node 22 and Docker rather than Python, so they are not in the list above:
cd frontend && npm ci && npm test && npm run build # the `web` job
docker build . # the `docker` job1. Test suite — live-model and local-simulator cases skip when their optional dependency is unavailable. Pytest prints the current collected, passed, and skipped counts; the documentation check below verifies every quoted test-count claim against that same collection.
The suite is dominated by the 20-second solver budget in a handful of placement tests; the rest run in milliseconds. Google ADK currently emits one warning for its experimental JSON-schema function-declaration feature.
2. Lint:
All checks passed!
3. Doc drift — re-counts the suite and checks every figure quoted in the docs:
docs ok: every test-count claim matches the collected suite
4. End-to-end demo — reads the 11-footprint fixture board, places it, writes a
real .kicad_pcb, and re-parses it to prove the round-trip:
3. Solve (OR-Tools CP-SAT)
--------------------------------------------------------------
status : feasible
board size : 18.25 x 18.00 mm (328.5 mm^2)
HPWL : 53.0 mm
solve time : 20.00 s
warning : Time limit reached; solution is feasible but not proven
optimal (gap bound 696000 vs 1785000).
4. Write a real .kicad_pcb
--------------------------------------------------------------
placed 11/11 -> placed.kicad_pcb (43,936 bytes)
5. Prove the round-trip
--------------------------------------------------------------
reparsed OK, 11 footprints preserved
These are the exact figures quoted in The placer. To inspect the
result, open placed.kicad_pcb in KiCad's PCB Editor — but note that installing
KiCad is only ever needed to look at the output, never to produce it.
Placement is reproducible only with workers=1, which is the default. CP-SAT's
multi-worker portfolio search interleaves results non-deterministically regardless
of seed, so raising workers trades byte-identical output for speed. The
determinism test in test_packing.py asserts this contract by solving the same
model twice and comparing placements exactly.
Two caveats worth stating plainly:
status: feasibleis expected, not a failure. 2D packing with a wirelength objective is NP-hard; inside a 20-second budget the solver returns a valid solution plus a bound rather than a proof. The reported gap is genuine.- Solve time varies with your machine, even though the result does not. The 20-second figure is the budget, not a benchmark.
.github/workflows/ci.yml runs ruff check engine and
pytest -q on Ubuntu, macOS, and Windows against Python 3.11, on every push to
main and every pull request. fail-fast is off, so one platform failing does not
mask the others. Two further jobs run on Ubuntu only: web builds the UI bundle,
and docker builds the container image, which is the only thing that exercises the
Dockerfile.
CONTRIBUTING.md has the setup, the checks to run before opening a PR, and the
conventions that are easy to violate by accident — the integer-nanometre rule, the
Y-up/Y-down coordinate boundary, and which top-level directories are dead code.
MIT — see LICENSE.
Dependencies: kiutils (MIT), OR-Tools (Apache-2.0).

