# Zap! (guide and agent recipe in one file) Source: https://zap.mutual.solutions/llms.txt # Zap! guide: KiCad PCB autorouter API, MCP server and CLI **Zap! your ratsnest.** Upload a placed KiCad board; get the same board back with tracks, vias and copper pours added. - Base URL: `https://zap.mutual.solutions` - Status: early access. Every routed board must be reviewed by an engineer before fabrication. - Contact: hello@mutual.solutions **Machine summary (for software and AI agents).** - Base URL `https://zap.mutual.solutions`. Auth: header `Authorization: Bearer ` on every `/v1/jobs`, `/v1/check` and `/v1/feedback` request. Never put the key in a URL, source code, log, or command-line argument. - Flow: (1) `POST /v1/check`, multipart with `board` (.kicad_pcb, required), `project` (.kicad_pro), `rules` (.kicad_dru), `currents` (JSON), `router` (`stable`/`next`), `keep_existing` (`true`: keep the board's own tracks, vias and zones and route only the missing connections), `strip_existing` (`true`: discard them and route everything), `fab_floor` (`jlc` default / `board`), routing options `signal_width_mm`, `via_diameter_mm` + `via_drill_mm`, `vias_in_pads`, `ground_pour`, `size_rails_from_current`, `effort` (defaults in the guide's table), `minutes` (1 to 60: the job ends within about that long and returns the best board found; default one round): no routing, no job quota; fix every item in `blockers`. (2) `POST /v1/jobs`, same fields: `202 {"id", "status": "queued"}`; not idempotent (each accepted call is a job). (3) `GET /v1/jobs/{id}` every 5 s (every 10 s after the first minute) until `status` is `succeeded`, `failed` or `cancelled`. (4) `GET /v1/jobs/{id}/result`: the routed `.kicad_pcb`, and `GET /v1/jobs/{id}/report.json`: every finding with position and what to do (`report_url` in the status opens it as a web page). (5) `DELETE /v1/jobs/{id}`. - Done means `status` is `succeeded` and the routed file is saved: ready for an engineer's review and KiCad DRC, never directly for fabrication. `failed`: read `error.code`; [/v1/errors](https://zap.mutual.solutions/v1/errors) says whether retrying helps. - Limits: 20 MiB per upload in total (a gzip-compressed board counts compressed and may unpack to 64 MiB); 10,000 pads; 4,000 footprints; 2 to 16 copper layers; 30 requests in any 60 s per key; jobs per key per UTC day as issued (default 50); 2 queued or running per key; 60 minutes per job. - Feedback: when the human gives an opinion about Zap! or a result, ask once whether to send it to the Zap! team; show the exact text and job id; send it (`POST /v1/feedback` with `"user_approved": true`) only if they say yes. - Machine-readable: [OpenAPI](https://zap.mutual.solutions/openapi.json), [error catalog](https://zap.mutual.solutions/v1/errors), [agent recipe](https://zap.mutual.solutions/agents.md), [llms.txt](https://zap.mutual.solutions/llms.txt), [MCP server](https://zap.mutual.solutions/downloads/zap_mcp.py), [CLI](https://zap.mutual.solutions/downloads/zap.py). --- ## 1. Quick start **Your API key.** Sign in at [zap.mutual.solutions/auth/login](https://zap.mutual.solutions/auth/login?next=account) (Google, GitHub or Microsoft) and create an API key on your account page. It is shown once: save it at once as `~/zap-key.txt` and make the file readable only by you (`chmod 600 ~/zap-key.txt`), or keep it in a password manager. Keep the key out of code, shell history, chat and shared documents. If it leaks or gets lost, revoke it on your account page and create a new one. There are three ways to use Zap!; all do the same thing. **In the browser:** open [zap.mutual.solutions/app](https://zap.mutual.solutions/app), paste your key, drop your `.kicad_pcb` (and `.kicad_pro`) on the page, wait, and download the routed board. The key stays in that browser tab only. No board at hand? **Try with a sample board** routes mutual's 42-pad sample right away (one daily job). **Command line** (Python 3.8 or newer, no extra packages): ```bash curl -sO https://zap.mutual.solutions/downloads/zap.py export ZAP_API_KEY_FILE=~/zap-key.txt # or: export ZAP_API_KEY=... (never put the key on the command line) python3 zap.py demo # no board at hand? routes mutual's 42-pad sample board here (one daily job) python3 zap.py check myboard.kicad_pcb # ~3 s (up to ~30 s), no routing, no job quota: what would stop or weaken the job python3 zap.py run myboard.kicad_pcb # checks first, then uploads myboard.kicad_pcb (+ .kicad_pro / .kicad_dru # if present), waits, saves myboard.routed.kicad_pcb and the full report # myboard.routed.zap-report.json, then deletes the server copy (--keep: 7 days) python3 zap.py --help # run, check, submit, status, wait, download, delete, list, version ``` **HTTP API** with curl: ```bash URL=https://zap.mutual.solutions # the key goes to curl as a header file written by bash's built-in printf: never on a command line zcurl() { curl -s -H @<(printf 'Authorization: Bearer %s\n' "$(cat zap-key.txt)") "$@"; } # 1. Check, then submit a placed board and keep its job id (leave out "-F project=..." if you have no .kicad_pro) zcurl -F board=@myboard.kicad_pcb -F project=@myboard.kicad_pro $URL/v1/check # "ok": true means ready ID=$(zcurl -F board=@myboard.kicad_pcb -F project=@myboard.kicad_pro \ $URL/v1/jobs | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r.get("id") or r)') echo "$ID" # a 32-character job id; anything else is the error message # a board over 20 MiB: gzip -k myboard.kicad_pcb, then send -F board=@myboard.kicad_pcb.gz (up to 64 MiB unpacked) # 2. Wait until the job has finished (every 5 s) while :; do ST=$(zcurl $URL/v1/jobs/$ID | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("status") or d.get("error"))') case "$ST" in queued|running) sleep 5;; *) break;; esac done zcurl $URL/v1/jobs/$ID > myboard.status.json # {"id":"...","status":"succeeded", ..., "metrics":{"unconnected":0,"critical_unconnected":0,"drc_errors":0,"wall_time_s":5.1,"fab":{...}}} # 3. Download the routed board, then delete the server copy zcurl -f -o myboard.routed.kicad_pcb $URL/v1/jobs/$ID/result && cp myboard.kicad_pro myboard.routed.kicad_pro zcurl -X DELETE $URL/v1/jobs/$ID ``` Python (needs `pip install requests`): ```python import os, time, requests URL = "https://zap.mutual.solutions" H = {"Authorization": f"Bearer {open('zap-key.txt').read().strip()}"} files = {"board": open("myboard.kicad_pcb", "rb")} if os.path.exists("myboard.kicad_pro"): # optional: your design rules files["project"] = open("myboard.kicad_pro", "rb") job = requests.post(f"{URL}/v1/jobs", headers=H, files=files).json() if "id" not in job: raise SystemExit(job["message"]) while True: st = requests.get(f"{URL}/v1/jobs/{job['id']}", headers=H).json() if st["status"] in ("succeeded", "failed", "cancelled"): break time.sleep(5) if st["status"] == "succeeded": r = requests.get(f"{URL}/v1/jobs/{job['id']}/result", headers=H) open("myboard.routed.kicad_pcb", "wb").write(r.content) print(st["metrics"]) else: print(st["error"]) ``` ## 2. What to upload | field | file | required | notes | |---|---|---|---| | `board` | `.kicad_pcb` | yes | Board saved by KiCad 6, 7, 8, 9 or 10 (a KiCad 5 board: open it in a newer KiCad and save it first), with every footprint placed and the board outline drawn on **Edge.Cuts**. It must not contain tracks or vias yet, unless you send `strip_existing=true` (discard them and route everything) or `keep_existing=true` (keep them and route only the missing connections). May be gzip-compressed (`board.kicad_pcb.gz` in the same field, up to 64 MiB unpacked): large boards then fit the upload limit, and zap.py, the MCP tool and the web page compress every board for you. | | `project` | `.kicad_pro` | recommended | Your design rules and net classes (track width, clearance, via size). Without it, Zap! routes and checks to KiCad's default rules (0.2 mm clearance and track width, 0.5 mm from the board edge). It must come from the same KiCad project as the board. | | `rules` | `.kicad_dru` | optional | Custom design rules, if you use them. | | `currents` | `.json` file (or the same JSON as a text field) | optional | Currents you know for some nets; see "Currents you know" below. | | `fab_floor` | `jlc` / `board` | optional | jlc (default): never go below JLC's capability; nets that need finer copper are left unrouted and listed with the reason. board: follow your board's own minimums, for fabs finer than JLC; JLC findings are still reported. Recorded per job. The check warns (`rules_finer_than_jlc`) when your net classes are finer than JLC and `fab_floor` is `jlc`. | | `router` | `stable` / `next` | optional | Default `stable`. `next` is a newer router build, offered only when `GET /v1/version` lists one under `routers.next`; otherwise the job is refused with `router_unavailable`. Same limits and quota for both. | | `keep_existing` | `true` / `false` | optional | Default `false`. `true` for a partly routed board: Zap! keeps your tracks, vias and zones exactly as they are and routes only the missing connections around them. Any other value is refused (`invalid_keep_existing`). | | `strip_existing` | `true` / `false` | optional | Default `false`. `true` for a board saved with old or unwanted routing: Zap! discards its tracks and vias and routes everything, as if you had deleted them in KiCad (Edit > Global Deletions). Not together with `keep_existing`. | | `consent_training` | `true` / `false` | optional | Default `false`. Any other value is refused (`invalid_consent`). See section 7. | **Currents you know.** If you know how much current some nets carry (a motor supply, a battery feed, a regulator output), send them; Zap! then sizes the copper of those nets for your values instead of its own estimate, and the temperature check uses them too: ```json {"nets": {"+5V": {"amps": 3.0}, "VBAT": {"amps": 2.5, "peak_amps": 6.0}}, "ambient_c": 40} ``` Net names as in your board (KiCad shows them in the PCB editor's Net Inspector): the full name, or just its last part when that is unique on the board (`PoE_5V` for `/PoE_5V`). State supply nets (the rail a regulator or connector feeds), not ground: a stated ground current is spread evenly over every part on it, which says little. `amps` is the continuous (RMS) current, 0 to 100 A, and is what the 10 °C check uses; `peak_amps` (optional, at least `amps`) is recorded only; `ambient_c` (optional, -40 to 125) adds the hottest copper's absolute temperature. At most 2,000 nets. Nets you leave out keep the estimate from the board's parts. In the browser, drop the `.json` with your board; on the command line, `python3 zap.py run myboard.kicad_pcb --currents currents.json`. **Routing options and defaults.** Each setting comes from your option if you send one, else from your board's rules (`.kicad_pro`, `.kicad_dru`), else from the default below. Without a `.kicad_pro` the board's rules are KiCad's defaults, and the report says "No project file: defaults used". Every report lists the settings used and where each came from. The defaults belong to the router build in `GET /v1/version`; a changed default is listed in the release notes. | setting | upload field, values | default when you send nothing | |---|---|---| | signal track width | `signal_width_mm`, 0.05 to 5 mm | the board's Default net class width, else 0.2 mm | | via diameter and drill | `via_diameter_mm` + `via_drill_mm`, mm, drill at least 0.1 mm smaller | the board's Default net class via, else 0.6 / 0.3 mm; other net classes keep their own | | clearance, other net classes | (from the board) | the board's net classes, else 0.2 mm | | smallest sizes | `fab_floor`: `jlc`, `board` | `jlc`: never below JLC (2 layers 0.127 mm track, 0.4 / 0.3 mm via; 4+ layers 0.09 mm, 0.25 / 0.15 mm); options below that are refused | | supply track width | `size_rails_from_current`: `on`, `off` | `on`: widened from current (your currents file, else estimated from the parts) where room allows, never below the net-class width; rails can stay at the net-class width (a 2.94 A rail stayed at 0.2 mm on our 840-pad test board without a project file). `off`: the net-class width, which may overheat. The report counts every copper section over 10 °C and lists the 10 hottest with positions | | via cost | (not adjustable yet) | normal | | vias in SMD pads | `vias_in_pads`: `forbid`, `allow` | `forbid`; `allow` needs the board made with filled and capped vias, an extra fab option | | ground pour | `ground_pour`: `on`, `off` | `on`, on every copper layer | | ground stitching vias | (not adjustable yet) | on | | silkscreen and solder mask after routing | `fab_fix`: `on`, `off` | `on`: silkscreen is clipped where it covers exposed copper, and solder-mask margins are set so JLC's 0.1 mm mask dam fits between pads; copper is not changed. The report counts what changed (`fab_fix`). `off` keeps your silk and mask exactly | | copper layers | (from the board) | all copper layers; on 4 or more layers In1 (and the second-to-last inner layer on 6 or more) are kept as planes | | effort | `effort`: `quick`, `normal`, `thorough` | `normal`. thorough: about twice the routing work and keeps the best intermediate result; slower on large boards, usually fewer unrouted connections. quick: about one eighth of the routing work: faster, more unrouted connections | | time budget | `minutes`, 1 to 60 | one round of 5 attempts, usually a few minutes (see "Time budget" below) | | existing copper | `keep_existing` or `strip_existing`: `true`, `false` | `false`: a board with tracks or vias is refused (`input_has_copper`) | | router | `router`: `stable`, `next` | `stable` | A value outside these is refused with `invalid_option` (`option` names the field, `fix` says what to send). **Time budget (`minutes`).** `minutes`: let Zap! keep trying for up to N minutes (1 to 60) and return the best result; default is one round of a few minutes. The whole job, checks and report included, ends within about N minutes: on a board whose first round alone would take longer, each attempt stops searching in time and finishes the board it has, and the connections it could not make are listed as unconnected. Finishing a board still takes time after the search stops (a few minutes on some boards): if no attempt has finished within N minutes, the job returns the first one to finish, later than asked, and `metrics.time_budget.stopped` says `over_time`. After the first round (5 settings at once), the job routes the same board again while the time allows, up to 6 attempts at once: the same 5 settings with twice the work, then with four times the work if the time left covers it, then `effort=thorough` and other settings. It returns the best by the usual ranking (fewest missing connections, then shorts and rule errors, then vias). A new attempt starts only if it can finish in time, judged from this board's first round, and routing ends by minute 50 at the latest, so the checks and the report fit in the job's 60 minutes. The job stops early once an attempt has no missing connection and no short or clearance error. One job with `minutes` over 10 runs at a time across the service: another one waits in the queue (its status has a `queue_note` saying so), while jobs without `minutes`, or with 10 or fewer, are not held back. The result's `metrics.time_budget` says how many attempts ran, which one was kept and the time used (section 3). Command line: `--minutes 20`. The free trial without a key routes one round only. Before uploading: - Footprints are kept exactly where you put them. Zap! does not move parts. - Existing copper pours are replaced by Zap!'s own ground and supply pours, whose nets Zap! chooses, unless you send `keep_existing=true`. The check names each pour that will be replaced, with its net and layers (`existing_pours_replaced`), and the report lists the pours replaced and the ones Zap! made (`pours`). - **Old tracks and vias.** A board that still has tracks or vias is refused (`input_has_copper`). Send `strip_existing=true` (browser: tick "discard them and route everything"; command line: `--strip-existing`) to route it from scratch, or `keep_existing=true` to keep them. - **Partly routed boards.** Route the nets you care about by hand (for example a high-speed pair or a power path), then send the board with `keep_existing=true` (browser: tick "My board is partly routed"; command line: `--keep-existing`). Your tracks, vias and zones come back unchanged; Zap! adds only what is missing. The job status says how many of each were kept and added (`metrics.existing_copper`). - Lock in your net classes and rules in KiCad first. Zap! routes to the rules you send. **Check before you route.** `POST /v1/check` takes the same fields as `POST /v1/jobs`, routes nothing, answers in about 3 seconds for most boards (about 30 seconds for the largest, 10,000 pads) and does not count against your daily jobs (it does count against the request rate; one check at a time per key). The browser page runs it as soon as you have a key and a board, and `zap.py run` runs it before submitting. The answer: ```json {"ok": true, "summary": "Ready to route. 2 warnings.", "board": {"kicad_version": 8, "file_version": 20240108, "returned_as": "a KiCad 8 file, like the one you sent", "footprints": 19, "pads": 94, "nets": 42, "copper_layers": 2, "size_mm": [88.64, 78.81], "rules": {"project": "uploaded", "custom_rules": "none"}, "router": {"choice": "stable", "build": "..."}}, "blockers": [], "warnings": [{"code": "footprints_outside_board", "message": "...", "footprints": [{"ref": "R4", "at_mm": [400, 400], "side": "front", "whole_footprint": true}]}, {"code": "courtyards_overlap", "message": "...", "items": [{"refs": ["R4", "RV1"], "at_mm": [118.69, 121.82]}]}]} ``` `blockers` would stop the job: every refusal code in section 5 (for example `input_has_copper`, `invalid_board` with `detail` `no_outline`, `invalid_currents`, `board_too_large` with the predicted memory of one routing attempt, the outline, copper layers and grid cells in `memory`) and `unsupported_rules` (the custom rule and the function the router cannot apply). `warnings` weaken the result: `footprints_outside_board` (the references and positions of footprints off the outline: Zap! routes the rest and leaves their pads unconnected), `no_project` (KiCad's default rules will be used), `unknown_net_class` (net classes assigned but not defined), `rules_finer_than_jlc` (net classes finer than JLC while `fab_floor` is `jlc`: some nets may stay unrouted), `courtyards_overlap` and `malformed_courtyard` (references and positions, from KiCad's own check under your rules), `check_incomplete` (part of the check could not run), and `very_large_board` (an outline so large the job may hit the memory limit). Every message says what to change and where in KiCad. Positions are in mm as KiCad shows them. ## 3. What you get back - `routed.kicad_pcb`: your board with tracks, vias and copper pours added, in the same KiCad file version you uploaded, so it opens in the KiCad you use (tested with boards from KiCad 6, 7, 8, 9 and 10). Copper pours are filled by KiCad's own zone filler under your rules, so they keep clear of keepout areas and the board edge. - Zap! never returns a board in which routed copper shorts two different nets: such a job fails with `output_has_shorts`. - Zap! never routes below JLC's minimum sizes (1-2 layers: 0.127 mm tracks and gaps, 0.3 mm via drill, 0.4 mm via; 4 or more layers: 0.09 mm, 0.15 mm, 0.25 mm) and never puts a via inside an SMD pad. If your net classes ask for finer copper, those nets may stay unrouted; the report lists them with that reason. - Summary numbers, measured with KiCad's own design-rule check on the returned file after the pours are filled. All are counts; **lower is better**, 0 is best: | metric | meaning | |---|---| | `unconnected` | connections still missing | | `critical_unconnected` | of those, connections on supply, ground, clock, reset, boot or debug nets (never more than `unconnected`) | | `drc_errors` | design-rule errors under the rules you uploaded, including problems caused by the placement itself (for example overlapping courtyards) | | `wall_time_s` | time the job took on our server, in seconds | | `time_budget` | jobs sent with `minutes` only: `attempts` (started, the first round's 5 included), `attempts_finished`, `rounds`, `routing_s` (seconds from the job's start until routing ended), `winner_attempt` with `winner` (`standard`, `work_x2`, `work_x4` or `alternative`) and `winner_setting`, `stopped` (`complete`, `time`, `settings_used_up` or `memory`), and `summary`, the same in one sentence. Counts, not findings | If `unconnected` is not 0, finish those connections by hand in KiCad. Review the routed board and run KiCad's DRC (Inspect > Design Rules Checker) before ordering boards. **Your own copper (`metrics.existing_copper`, `keep_existing=true` jobs only).** Counts, not problems: `tracks_kept`, `vias_kept`, `zones_kept` should match what you uploaded and `tracks_removed`, `vias_removed`, `zones_removed` should be 0 (Zap! never removes your copper; anything else is a fault, please tell us); `tracks_added` and `vias_added` are what the router added. **Fab check (`metrics.fab`).** Every routed board is also checked for overheating and manufacturability, under the same rules as the numbers above. The check only reports; it never changes which routing Zap! returns. | field | meaning | |---|---| | `check` | version of the check, for example `fab1 1.6` | | `rules` | which rules were used: `{"project": "uploaded" or "kicad_defaults", "custom_rules": "uploaded" or "none"}` | | `computed` | `false` with a `reason` (`time limit`, `check failed`) if the check did not run; the routed board is returned anyway | | `thermal.worst_rise_c` | the hottest copper section, in °C above ambient, per IPC-2152 (lower is better; the limit is `thermal.limit_c`, 10 °C; 1000 means far beyond the chart) | | `thermal.sections_over_limit` | track, via and copper-pour sections above the limit (lower is better, 0 is best) | | `thermal.nets_over_limit` | the nets those sections are on (kept with the routed board; `null` once it is deleted) | | `thermal.hottest` | up to 10 hottest sections: `net`, `kind` (`track`, `via` or `pour`), `layer`, `rise_c`, `current_a`, `current_source` (`stated`: your value, `estimated`: from the board's parts), `at_mm` (x, y); kept with the routed board, `null` once it is deleted | | `thermal.stated_currents` | present only when you sent currents: `{"nets": N, "applied": true}`: the router sized copper from your values and this check used them on those nets (the estimate on all others) | | `thermal.stated_not_checked` | stated nets the check could not use because it found no source or no load part on them; check those by hand (kept with the routed board, `null` once it is deleted) | | `thermal.sections_over_by_current_source` | present only when you sent currents: sections over the limit split by where the current came from: `{"stated": N, "estimated": N}` | | `thermal.off_chart` | `true` when the worst rise is beyond the IPC-2152 chart (`worst_rise_c` 1000 or more): far too hot, not a real temperature | | `thermal.ambient_c`, `thermal.worst_temp_c` | present only when you sent `ambient_c`: your ambient, and ambient plus the worst rise (`null` when off the chart) | | `thermal.currents` | how the currents were found: **Estimated from the board's parts. May under-estimate large power stages; confirm high-current nets yourself.** | | `thermal.rails_checked`, `thermal.rails_not_checked` | power nets the estimate could solve, and those it could not (no source or no loads found) | | `shorts` | places where copper of two different nets touches (0 is best). Zap! never adds one, so any here were already in your uploaded board | | `shorts_already_in_board` | of those, how many were already in the board you uploaded (for example a copper graphic over a pad of another net): fix them in KiCad | | `drc_errors`, `drc_errors_by_type` | KiCad DRC errors under your rules (the same count as `drc_errors` above) | | `jlc_violations`, `jlc_violations_by_type` | features JLCPCB's standard process cannot make (lower is better): track, gap, via, annular ring and drill sizes below its minimums for `jlc_capabilities` (`1-2 layers` or `multilayer`), board-edge and hole clearances, solder-mask slivers, copper necks and slivers, plus three checks of our own: `via_in_pad` (a via in an SMD pad, which needs filled and capped vias), `acute_angle_acid_trap` (tracks meeting at less than 90 degrees) and `silk_over_copper` | | `jlc_items` | where each JLC violation is: `type`, `at_mm` (x, y), `layer`, `net`, `refs` (footprints involved), `description`; every one (at most 2,000), sorted by type. Kept with the routed board, `null` once it is deleted | | `jlc_items_total`, `jlc_items_truncated` | how many items the list would hold, and `true` when it shows fewer than `jlc_violations` | | `counts_capped` | violation types KiCad stopped counting at 199; those counts are minimums | | `time_s` | seconds the check took | The temperature part estimates each power net's current from the parts on your board: regulators, inductors, fuses, ferrite beads and the chips each rail feeds. It has no datasheet currents, so it can **under-estimate large power stages** (a processor core supply, a motor bridge, Power over Ethernet, a module's supply connector): a low number there does not prove the copper is wide enough. Compare every high-current net with your real load current, and widen the copper or add a pour where needed. A high number means "look at this net". If the job has already used nearly all of its 60 minutes, the temperature part is skipped (`thermal.computed` is `false`, `reason` `time limit`); if no supply rail with a source and loads is recognised, it says so in `reason`. Jobs from before release 2026.10.13.8 have `fab: null`. ### The result report Every finished job has a report: `GET /v1/jobs/{id}/report.json` (with your key), or the web page at `report_url` in the job status (it asks for the key). It lists, each with its position in mm as KiCad shows them (so you can find it with KiCad's cursor readout), its layer and net where KiCad names them, and **what to do** (one sentence with the KiCad command): - connections still missing (critical ones first), with both ends; - design-rule errors under your rules; - JLC capability violations; - the 10 hottest copper sections; - track ends and vias that connect nothing, and copper islands with no connection. It also has a picture of each copper layer with the board outline (`image.layers`, SVGs from KiCad's own plot, all in one frame), with the findings marked on it in the web page. Lists hold at most 2,000 items each (`total` and `truncated` say when there are more). The report is kept with the routed board: deleted after 7 days or with `DELETE`. `zap.py run` saves it next to the routed board as `.routed.zap-report.json` and one `.routed..svg` per copper layer; `zap.py report JOB_ID` fetches it. ## 4. Endpoints All `/v1/jobs` endpoints, `POST /v1/check`, `POST /v1/feedback` and `/v1/snapshots` need the header `Authorization: Bearer ` (the word `Bearer` in any letter case). A machine-readable description is at [/openapi.json](https://zap.mutual.solutions/openapi.json). | method and path | what it does | success | |---|---|---| | `POST /v1/check` | pre-flight check of the same upload: no routing, no job quota (section 2) | `200 {"ok", "summary", "board", "blockers", "warnings"}` | | `POST /v1/jobs` | submit a board (multipart upload, fields in section 2) | `202 {"id", "status": "queued"}` | | `GET /v1/jobs` | your last 50 jobs (id, status, times) | `200 {"jobs": [...]}` | | `GET /v1/jobs/{id}` | status: `queued`, `running`, `succeeded`, `failed` or `cancelled`; metrics when succeeded; `queue_position` while queued; `version` (which Zap! and router build ran it) | `200` | | `GET /v1/jobs/{id}/result` | download the routed board | `200`; `409` if not finished or failed; `410` after deletion | | `GET /v1/jobs/{id}/report.json` | the result report: every finding with position, layer, net and what to do (section 3) | `200`; `409` if not finished or cancelled; `410` after deletion | | `GET /v1/jobs/{id}/report/{layer}.svg` | picture of one copper layer (`F_Cu`, `B_Cu`, `In1_Cu` ...) with the board outline | `200` (SVG) | | `DELETE /v1/jobs/{id}` | queued: cancel it (never runs, not counted as a daily job); running: stop it now (status `cancelled`, nothing kept); finished: delete the files | `200 {"id", "status", "files_deleted": true}` | | `POST /v1/feedback` | your opinion about Zap! or a result, sent to the Zap! team with your approval (section 9) | `201 {"id", "received_at"}` | | `POST /v1/jobs/{id}/snapshot` | a picture of one area (at most 40 mm square) of the routed board, to see and, if you agree, attach to feedback (section 9) | `201 {"snapshot_id", "png_url", "expires_at", "region", "nets_in_region", "issues_marked"}` | | `GET /v1/snapshots/{id}.png` | that picture (PNG), for the key that made it | `200` (PNG) | | `DELETE /v1/snapshots/{id}.png` | delete a picture you do not want to attach, at once | `200 {"id", "deleted": true}` | | `GET /v1/health` | service check, no key needed | `200 {"ok": true}` | | `GET /v1/status` | queue, expected and typical wait, routing time, router build, no key needed (web page: `/status`) | `200 {"jobs_waiting", "jobs_running", "accepting_jobs", "estimated_wait_s", "typical_wait_s", "typical_run_s", ...}` | | `GET /v1/version` | service version and the routers offered, no key needed | `200 {"service", "router", "routers": {"stable": "", "next": "" or null}}` | Job status fields (`GET /v1/jobs/{id}`): | field | meaning | |---|---| | `id` | job id | | `status` | `queued`, `running`, `succeeded`, `failed` or `cancelled` (stopped with DELETE) | | `created_at`, `started_at`, `finished_at` | UTC times (`null` until reached) | | `queue_position` | while queued: 1 means next to run | | `queue_note` | while queued, only when the job waits for another reason than its place: a job with `minutes` over 10 waits while another such job runs | | `consent_training` | the preference you sent (section 7) | | `keep_existing` | `true` if the job kept your own tracks, vias and zones (section 2) | | `files_deleted` | `true` once the routed board has been deleted | | `report_url`, `report_json` | finished jobs: the result report as a web page (asks for the key) and the path of its JSON | | `version` | `{"service", "router", "router_choice"}`: which Zap! release and router build ran the job, and whether you chose `stable` or `next` (`null` while queued) | | `metrics` | when succeeded: the numbers in section 3 | | `error` | when failed: `{"code", "message"}` (section 5) | Server time per job (`wall_time_s`), measured 2026-10-13 with router rc5e1a55, Zap! 2026.10.13.33. Layer count and density drive the time more than pad count; queue time comes on top; the limit is 3,600 s. | copper layers | pads | board size | server time, s (lower is better) | |---|---|---|---| | 2 | 94 | 89 x 79 mm | 8 to 14 | | 2 | 227 | 115 x 75 mm | 24 | | 2 | 266 | 320 x 81 mm | 90 | | 2 | 840 | 222 x 132 mm | 512 | | 2 | 1,880 | 461 x 329 mm | 424 | | 4 | 322 | 62 x 28 mm | 129 | | 4 | 347 | 50 x 50 mm | 382 | | 4 | 707 | 285 x 48 mm | 740 | | 6 | 767 | 55 x 95 mm | 1,374 | | 6 | 1,894 | 119 x 29 mm | 2,107 | ## Connect with OAuth Sign in with Google, GitHub or Microsoft instead of an API key. API keys keep working unchanged. Signing in creates your Zap! account for your verified email address (50 jobs a day, 2 at a time, 30 requests a minute; for more, write to hello@mutual.solutions). Two ways to use the account: create an API key on your [account page](https://zap.mutual.solutions/auth/login?next=account) and use it like any key (zap.py, the MCP tool, the REST API), or connect an MCP client with OAuth sign-in as below, with no key. | Client | Flow | Token for | Access token | Renewal | |---|---|---|---|---| | MCP client (Claude Code, Cursor, Codex) | authorization code + PKCE (S256), self-registration | `https://zap.mutual.solutions/mcp` | 1 h | refresh token, new one at each use; 30 days unused or 90 days after sign-in: sign in again | | Command line, headless agent | device code: `zap.py login` | `https://zap.mutual.solutions/v1` | 1 h | as above | | Your server (backend) | client credentials | `https://zap.mutual.solutions/v1` | 1 h | ask for a new token | Every token has your account's limits (jobs per UTC day, jobs at once, requests per minute), the same as an API key. A token works only where it is for: an MCP token is refused on `/v1`, and the reverse. Your account page, [/account](https://zap.mutual.solutions/account), lists connected apps (revoke any) and your developer credentials. **MCP** (remote server `https://zap.mutual.solutions/mcp`; tools `check_board`, `submit_job`, `get_job`, `list_jobs`, `get_report`, `get_result`, `delete_job`). Boards travel as file text (`board`, `project`, `rules`; 20 MiB in total); the routed board comes back as an embedded resource (up to 8 MiB; larger: `zap.py download` after `zap.py login`). ```bash # Claude Code: add, then type /mcp in Claude Code, choose zap and sign in (a browser opens) claude mcp add --transport http zap https://zap.mutual.solutions/mcp ``` ```json // Cursor: ~/.cursor/mcp.json; Cursor then offers to sign in to the server {"mcpServers": {"zap": {"url": "https://zap.mutual.solutions/mcp"}}} ``` ```bash # Codex: ~/.codex/config.toml gets the two lines below, then sign in # [mcp_servers.zap] # url = "https://zap.mutual.solutions/mcp" codex mcp login zap ``` **Command line.** ```bash python3 zap.py login # prints https://zap.mutual.solutions/device and a code: open the address, type the code, approve python3 zap.py run myboard.kicad_pcb python3 zap.py logout # ends the sign-in on the server ``` Without an API key, `zap.py` uses the saved sign-in (`~/.config/zap/oauth.json`, readable only by you). After the approval it prints the account that approved; if that is not yours, run `zap.py logout`. **Server to server.** Create a client id and secret on [/account/credentials](https://zap.mutual.solutions/account/credentials) (the secret is shown once), then: ```bash # the id and secret go to curl through stdin (bash's built-in printf): never on a command line printf 'user = "%s:%s"\n' "$ZAP_CLIENT_ID" "$ZAP_CLIENT_SECRET" | \ curl -s -K - -d grant_type=client_credentials https://zap.mutual.solutions/oauth/token # {"access_token": "zat_...", "token_type": "Bearer", "expires_in": 3600, "scope": "zap"} ``` Send it as `Authorization: Bearer ` on `/v1/...`, like an API key; after a `401`, get a new token. | Endpoint | Address | |---|---| | Server metadata (RFC 8414) | `GET /.well-known/oauth-authorization-server` | | Resource metadata (RFC 9728) | `GET /.well-known/oauth-protected-resource/mcp`, `.../v1` | | Register an app (RFC 7591) | `POST /oauth/register`; redirect URIs: `http://127.0.0.1`, `http://[::1]` or `http://localhost` (any port), or `https://` | | Authorize, token, revoke (RFC 7009) | `GET /oauth/authorize`, `POST /oauth/token`, `POST /oauth/revoke` | | Device code (RFC 8628) | `POST /oauth/device_authorization`, page `/device` | | Scope | `zap` (the only one) | OAuth endpoints answer in OAuth's error format (`{"error": "invalid_grant", "error_description": "..."}`), not the table below. A `401` on `/v1` or `/mcp` carries `WWW-Authenticate: Bearer resource_metadata="..."`. Sign-in data: your email address, the provider's account id, and hashes of your tokens; kept on the Zap! server and in its daily backup copies, which are deleted after 14 days ([privacy](https://zap.mutual.solutions/privacy)). ## 5. Errors Every error is JSON with a stable code and one sentence saying what to fix: `{"error": "", "message": "..."}`. A failed job reports `{"error": {"code": "...", "message": "..."}}` in its status. Codes do not change; messages may get clearer. | code | HTTP | from | retry helps | what it means and what to do | |---|---|---|---|---| | `unauthorized` | 401 | any authenticated request | no: Fix the key; do not retry with the same key. | The API key is missing or not valid: send it as 'Authorization: Bearer ' and check it was copied completely. | | `rate_limited` | 429 | any authenticated request, `POST /v1/claim` | yes: Wait the seconds in the Retry-After header, then repeat the same request. | Too many requests: wait the number of seconds in the Retry-After header, then try again. | | `daily_quota_exceeded` | 429 | `POST /v1/jobs` | no: Stop and tell the human: the key's daily jobs are used up until 00:00 UTC (Retry-After gives the seconds); mutual can raise the quota. | This key has used all of today's jobs: try again after 00:00 UTC, or ask mutual to raise the quota. | | `check_limit_reached` | 429 | `POST /v1/check` | no: Not today: this account's pre-flight checks are used up until 00:00 UTC (Retry-After gives the seconds); submit the job directly, it runs the same checks. | This account has used today's pre-flight checks: submit the job directly (it runs the same checks), or check again after 00:00 UTC. | | `server_busy` | 503 | `POST /v1/jobs` | yes: Wait the seconds in the Retry-After header, then submit again; estimated_wait_min is the expected wait. | Zap! is busy: too many jobs are waiting right now (jobs_waiting, estimated_wait_min); submit again after the Retry-After seconds, or see https://zap.mutual.solutions/status. | | `too_many_active_jobs` | 429 | `POST /v1/jobs` | yes: Wait until one of your queued or running jobs finishes, then submit again. | This key already has the maximum number of jobs queued or running: wait for one to finish, then submit again. | | `too_large` | 413 | `POST /v1/jobs`, `POST /v1/check` | no: Send smaller files. | The upload is over 20 MiB in total: send the board gzip-compressed (board.kicad_pcb.gz in the same board field, up to 64 MiB unpacked; zap.py, the MCP tool and the web page do this for you) and only the .kicad_pcb (and .kicad_pro); if the board itself is that large, remove embedded 3D models or images (Footprint Properties > 3D Models) and save again. | | `board_too_large_unpacked` | 413 | `POST /v1/jobs`, `POST /v1/check` | no: Send a smaller board. | The compressed board unpacks to more than 64 MiB (max_unpacked_bytes): remove embedded 3D models or images (Footprint Properties > 3D Models), save again and send it again. | | `invalid_compressed_board` | 400 | `POST /v1/jobs`, `POST /v1/check` | no: Send the board again, compressed whole with gzip or not compressed. | The board looks gzip-compressed but cannot be unpacked (cut short, damaged, or more than one file): compress the .kicad_pcb again with gzip, or send it uncompressed. | | `length_required` | 411 | `POST /v1/jobs`, `POST /v1/check` | no: Send a normal multipart upload with a Content-Length. | The upload has no total length (for example it was sent with Transfer-Encoding: chunked), which Zap! cannot read: send a normal multipart upload with a Content-Length (curl -F, Python requests and zap.py do this). | | `board_required` | 400 | `POST /v1/jobs`, `POST /v1/check` | no: Put the board in the multipart field 'board'. | No board was attached: send the placed board in the multipart field 'board' (curl -F board=@myboard.kicad_pcb). | | `invalid_board` | 422 | `POST /v1/jobs`, `POST /v1/check`, job failure | no: Fix the board file; resubmitting the same file gives the same answer. | The file could not be read as a KiCad board: open the board in KiCad's PCB Editor, use File > Save, and upload that .kicad_pcb (not a schematic, Gerber or zip). With `detail` `blank_lines`: The file has more than 10,000 blank lines, which KiCad never writes: save it again in KiCad (File > Save) and upload that .kicad_pcb. With `detail` `incomplete`: The file is incomplete (its brackets do not close, so it was probably cut off while copying or uploading): save it again in KiCad (File > Save) and upload the complete .kicad_pcb. With `detail` `no_outline`: The board has no outline: draw it on the Edge.Cuts layer in KiCad (select Edge.Cuts, then Place > Draw Lines or Draw Rectangle), save, and upload again. | | `invalid_project` | 400 | `POST /v1/jobs`, `POST /v1/check` | no: Attach the right .kicad_pro or leave it out. | The project file is not a valid KiCad project: attach the .kicad_pro that KiCad writes next to the board when you use File > Save in the PCB Editor, or leave it out. | | `invalid_rules` | 400 | `POST /v1/jobs`, `POST /v1/check` | no: Attach the right .kicad_dru or leave it out. | The custom rules file is not valid: attach the .kicad_dru that KiCad saves from File > Board Setup > Design Rules > Custom Rules (its first line starts with '(version'), or leave it out. | | `invalid_currents` | 422 | `POST /v1/jobs`, `POST /v1/check` | no: Fix the currents JSON (see unknown_nets / ambiguous_nets in the answer). | The currents file is not valid: send JSON {"nets": {"": {"amps": A}}} with net names exactly as KiCad shows them (Inspect > Net Inspector), amps from 0 to 100 and at most 2,000 nets (names not found in the board are listed in unknown_nets). | | `invalid_router` | 422 | `POST /v1/jobs`, `POST /v1/check` | no: Send router=stable, router=next, or leave it out. | The router field is not valid: send stable or next, or leave it out (which means stable). | | `router_unavailable` | 409 | `POST /v1/jobs`, `POST /v1/check` | no: Use router=stable (GET /v1/version lists routers.next when it is offered). | The next router is not available right now; use stable. | | `invalid_consent` | 422 | `POST /v1/jobs`, `POST /v1/check` | no: Send consent_training=true or false, or leave it out. | The consent_training field is not valid: send true or false, or leave it out (which means false). | | `invalid_keep_existing` | 422 | `POST /v1/jobs`, `POST /v1/check` | no: Send keep_existing=true or false, or leave it out. | The keep_existing field is not valid: send true or false, or leave it out (which means false). | | `keep_existing_unavailable` | 409 | `POST /v1/jobs`, `POST /v1/check` | no: Use router=stable, or send keep_existing=false with a board that has no tracks or vias. | The router you chose cannot keep existing copper: use router=stable, or remove the tracks and vias in KiCad (Edit > Global Deletions, tick Tracks and Vias) and send keep_existing=false. | | `invalid_fab_floor` | 422 | `POST /v1/jobs`, `POST /v1/check` | no: Send fab_floor=jlc or fab_floor=board, or leave it out. | The fab_floor field is not valid: send jlc (the default: never below JLC's capability) or board (your board's own minimums, for fabs finer than JLC), or leave it out. | | `fab_floor_unavailable` | 409 | `POST /v1/jobs`, `POST /v1/check` | no: Send fab_floor=jlc, or use router=next when GET /v1/version lists one. | The router you chose cannot follow your board's own minimums yet: send fab_floor=jlc, or use router=next when GET /v1/version lists one. | | `invalid_option` | 422 | `POST /v1/jobs`, `POST /v1/check` | no: Change the option named in option as fix says, or leave it out to use the default. | A routing option is not valid (option names it, fix says what to send): correct it, or leave it out to use the default. | | `options_unavailable` | 409 | `POST /v1/jobs`, `POST /v1/check` | no: Leave the routing options out for now (GET /v1/version lists options when they are on). | Routing options are not switched on yet: leave them out to use the defaults (GET /v1/version shows "options": true once they are on). | | `input_has_copper` | 422 | `POST /v1/jobs`, `POST /v1/check`, job failure | no: Send strip_existing=true to discard them and route everything, or keep_existing=true to route around them. | The board already has tracks or vias: send strip_existing=true to discard them and route everything, keep_existing=true to keep them and route only the missing connections, or remove them in KiCad (Edit > Global Deletions, tick Tracks and Vias) and upload again. | | `too_many_pads` | 422 | `POST /v1/jobs`, `POST /v1/check` | no: Not retryable: ask mutual for a larger limit. | The board has more than 10,000 pads, the current limit: contact hello@mutual.solutions if you need a larger board routed. | | `too_many_footprints` | 422 | `POST /v1/jobs`, `POST /v1/check` | no: Not retryable: ask mutual for a larger limit. | The board has more than 4,000 footprints, the current limit: contact hello@mutual.solutions if you need a larger board routed. | | `board_too_large` | 422 | `POST /v1/jobs`, `POST /v1/check`, job failure | no: Not retryable as is: email hello@mutual.solutions to arrange a business (B2B) job. | The board needs more memory per routing attempt than the self-serve limit (memory gives the predicted GB, the outline, the copper layers and the grid cells): for boards this large, email hello@mutual.solutions to arrange a business (B2B) job. | | `unsupported_layer_count` | 422 | `POST /v1/jobs`, `POST /v1/check` | no: Fix the stackup in KiCad, then submit again. | The board must have 2 to 16 copper layers: set the count in File > Board Setup > Board Stackup > Physical Stackup (Copper layers) and save. | | `not_found` | 404 | `GET /v1/jobs/{id}`, `DELETE /v1/jobs/{id}`, `GET /v1/jobs/{id}/result`, `GET /v1/jobs/{id}/report.json`, `GET /v1/jobs/{id}/report/{layer}.svg`, `POST /v1/feedback`, any unknown address, `POST /v1/jobs/{id}/snapshot` | no: Check the job id and use the key that submitted it. | No job with this id exists for this key: check the id and use the same key that submitted the job. With `detail` `path`: Not found: check the URL; the endpoints are listed at /docs. | | `not_ready` | 409 | `GET /v1/jobs/{id}/result`, `GET /v1/jobs/{id}/report.json`, `GET /v1/jobs/{id}/report/{layer}.svg`, `POST /v1/jobs/{id}/snapshot` | yes: Poll GET /v1/jobs/{id} every 5 s until status is succeeded or failed, then download. | The job is still queued or running: poll GET /v1/jobs/{id} until the status is 'succeeded'. | | `no_result` | 409 | `GET /v1/jobs/{id}/result`, `GET /v1/jobs/{id}/report/{layer}.svg`, `POST /v1/jobs/{id}/snapshot` | no: The job failed: read error.code in GET /v1/jobs/{id}. | The job failed, so there is no routed board: see the error in GET /v1/jobs/{id}. | | `job_cancelled` | 409 | `GET /v1/jobs/{id}/result`, `GET /v1/jobs/{id}/report.json`, `GET /v1/jobs/{id}/report/{layer}.svg`, `POST /v1/jobs/{id}/snapshot` | no: The job was cancelled (DELETE /v1/jobs/{id}); submit the board again if you still need it. | The job was cancelled, so there is no routed board: submit the board again if you still need it. | | `expired` | 410 | `GET /v1/jobs/{id}/result`, `GET /v1/jobs/{id}/report.json`, `GET /v1/jobs/{id}/report/{layer}.svg`, `POST /v1/jobs/{id}/snapshot` | no: The routed board was deleted: submit the board again. | The routed board was deleted (7 days after the job, or on request): submit the board again. | | `method_not_allowed` | 405 | any address | no: Use the method the endpoint documents. | This address does not accept that method: see /docs for the endpoints. | | `bad_request` | 400 | `POST /v1/jobs`, `POST /v1/check`, any request | no: Fix the request shape (see /docs.md). | The request could not be read: send a multipart/form-data upload as shown in /docs. | | `check_busy` | 503 | `POST /v1/check`, `POST /v1/jobs`, `POST /v1/jobs/{id}/snapshot` | yes: Wait the seconds in Retry-After, then repeat the check. | Several board checks are running right now: try again in a minute. | | `internal` | 500 | any authenticated request, job failure | yes: Retry once; if it repeats, send the job id to hello@mutual.solutions. | Something went wrong on our side: try again, and if it repeats send the job id to hello@mutual.solutions. | | `timeout` | (job) | job failure | no: Not retryable as is: send the job id to hello@mutual.solutions. | The job hit the 60-minute limit: tell hello@mutual.solutions the job id; a board this size may need a larger limit. | | `timeout_or_limit` | (job) | job failure | no: Not retryable as is: send the job id to hello@mutual.solutions. | The job hit the time or memory limit (very large outlines can exceed the memory): tell hello@mutual.solutions the job id; a board this size may need a larger limit. | | `route_failed` | (job) | job failure | no: Fix the board outline / placement; send the job id if it repeats. | The router could not produce a result for this board: check in KiCad that the outline on Edge.Cuts is closed and every footprint is inside it (Inspect > Design Rules Checker shows both), then send the job id to hello@mutual.solutions if it repeats. | | `rules_check_failed` | (job) | job failure | no: Send matching .kicad_pro/.kicad_dru or leave them out, then submit again. | KiCad could not check the routed board with the rules you sent: attach the .kicad_pro (and .kicad_dru) saved with this board (File > Save; rules in File > Board Setup > Design Rules), or leave them out to use KiCad's defaults. | | `unsupported_rules` | (job) | `POST /v1/check`, job failure | no: Remove or simplify the custom rule, then submit again. | Your .kicad_dru uses a custom rule condition Zap! cannot apply yet (for example insideCourtyard()): remove or simplify that rule in File > Board Setup > Design Rules > Custom Rules, or leave the .kicad_dru out, and submit again. | | `output_has_shorts` | (job) | job failure | no: Send your .kicad_pro; send the job id if it repeats. | Every routing attempt left a short circuit between different nets, so no board is returned: attach the .kicad_pro saved with the board (File > Save) so Zap! routes to your rules, and send the job id to hello@mutual.solutions if it repeats. | | `claim_invalid` | 404 | `POST /v1/claim` | no: Check the whole claim link. | This claim link is not valid: check that you copied the whole link, or ask mutual for a new one. | | `claim_used` | 410 | `POST /v1/claim` | no: Ask for a new key. | This key was already claimed and is shown only once: ask hello@mutual.solutions for a new key. | | `claim_expired` | 410 | `POST /v1/claim` | no: Ask for a new claim link. | This claim link has expired (links last 72 hours): ask hello@mutual.solutions for a new one. | | `feedback_needs_user_approval` | 400 | `POST /v1/feedback` | no: Ask your user whether to send this exact text; send it again with user_approved true only if they agree. | Feedback is sent only with the user's approval (user_approved is not true): ask your user whether to send this exact text; send only if they agree. | | `feedback_message_length` | 400 | `POST /v1/feedback` | no: Send 1 to 4,000 characters; shorten only with your user's agreement. | The feedback message is empty or longer than 4,000 characters: send the user's words in 1 to 4,000 characters, shortened only with their agreement. | | `invalid_feedback` | 400 | `POST /v1/feedback` | no: Fix the field named in field, then send again. | The feedback request is not valid (field names the first problem): send JSON with message (text), user_approved true, and optionally job_id, kind (praise, problem, idea or other) and contact_ok (true or false), snapshot_ids (at most 3 pictures of this job), and nothing else. | | `feedback_limit_reached` | 429 | `POST /v1/feedback` | no: Not today: this key's feedback messages are used up until 00:00 UTC (Retry-After gives the seconds); email hello@mutual.solutions instead. | This key has sent today's feedback messages (20 per UTC day): send more after 00:00 UTC, or email hello@mutual.solutions. | | `snapshots_off` | 403 | `POST /v1/jobs/{id}/snapshot` | no: Not with this key: send the feedback as text only. | Board pictures are switched off for this key (keys a platform uses for its own customers have them off, as their boards are not the key holder's to show): send the feedback as text only. | | `invalid_snapshot` | 400 | `POST /v1/jobs/{id}/snapshot` | no: Fix the field named in field, then ask again. | The picture request is not valid (field names the first problem): send JSON with x_mm and y_mm (the area's centre in mm, as KiCad shows positions), and optionally size_mm (1 to 40), layers (copper layer names such as F.Cu) and show_issues (true or false), and nothing else. | | `snapshot_too_large` | 400 | `POST /v1/jobs/{id}/snapshot` | no: Send size_mm of at most 40 and aim at the spot. | The picture area is larger than 40 mm (pictures show one spot, never the whole board): send size_mm of at most 40, centred on the spot (the report's at_mm positions help). | | `snapshot_outside_board` | 422 | `POST /v1/jobs/{id}/snapshot` | no: Aim x_mm and y_mm at a spot on the board. | The picture area misses the board (board_mm gives the board's extent): aim x_mm and y_mm at a spot on the board, for example a position from the report. | | `snapshot_limit_reached` | 429 | `POST /v1/jobs/{id}/snapshot` | no: Not today: this key's board pictures are used up until 00:00 UTC (Retry-After gives the seconds); send the feedback as text only. | This key has made today's board pictures (30 per UTC day): make more after 00:00 UTC, or send the feedback as text only. | | `snapshot_not_found` | 404 | `GET /v1/snapshots/{id}.png`, `DELETE /v1/snapshots/{id}.png`, `POST /v1/feedback` | no: Make a new picture with POST /v1/jobs/{id}/snapshot. | No picture with this id is available for this key and job (a picture is deleted 24 hours after it was made unless attached to feedback, and attaches to one feedback only): make a new one with POST /v1/jobs/{id}/snapshot. | | `tryit_limit_reached` | 429 | `POST /v1/try/jobs` | no: Not today: this address's free trial jobs are used up until 00:00 UTC (Retry-After gives the seconds); or sign in at https://zap.mutual.solutions/auth/login and create an API key on your account page. | Your network address has used today's free trial jobs: try again after 00:00 UTC, or sign in at https://zap.mutual.solutions/auth/login and create an API key on your account page. | | `tryit_board_too_large` | 422 | `POST /v1/try/jobs` | no: Not retryable: send a smaller board, or use an API key. | The board is too large for the free trial (more pads than max_pads in this answer, or a very large outline): try a smaller board, or sign in at https://zap.mutual.solutions/auth/login and create an API key on your account page to route this one. | | `tryit_layers_unsupported` | 422 | `POST /v1/try/jobs` | no: Not retryable: send a board with 2 or 4 copper layers, or use an API key. | The free trial routes only boards with 2 or 4 copper layers: try such a board, or sign in at https://zap.mutual.solutions/auth/login and create an API key on your account page to route this one. | | `tryit_busy` | 503 | `POST /v1/try/jobs` | yes: Wait the seconds in the Retry-After header, then submit again. | The free trial is busy right now (another visitor's board is routing, or today's trial jobs are used up): try again after the Retry-After seconds, or sign in at https://zap.mutual.solutions/auth/login and create an API key on your account page. | | `tryit_human_check_failed` | 403 | `POST /v1/try/jobs` | yes: Complete the human check again, then submit. | The human check did not pass or has expired: complete it again on the page, then press the button once more. | | `tryit_not_found` | 404 | `GET /v1/try/jobs/{id}`, `GET /v1/try/jobs/{id}/result`, `GET /v1/try/jobs/{id}/report.json`, `GET /v1/try/jobs/{id}/report/{layer}.svg` | no: Use the id and token from the upload's answer. | No free trial job matches this id and token: the token is given only once, in the answer to the upload, so check the job from the same page. | | `invalid_access_request` | 422 | `POST /v1/access-requests` | no: Fix the field named in field, then send again. | The request is incomplete or not valid (field names the first problem): give a valid email address of at most 254 characters, a use case of 1 to 2,000 characters and at most 200 characters of name or company, tick the consent box, then send it again. | | `access_requests_closed` | 503 | `POST /v1/access-requests` | no: Email hello@mutual.solutions instead. | We are not taking more access requests through this form right now: email hello@mutual.solutions instead. | Pre-flight findings (`POST /v1/check`) that are not request errors: | code | kind | what it means and what to do | |---|---|---| | `very_large_board` | warning | The board outline is very large for its copper layers, so the job may hit the memory limit and fail with timeout_or_limit: if it does, split the panel into single boards in KiCad and route them one by one. | | `rules_finer_than_jlc` | warning | Your net classes ask for finer copper than JLC can make and fab_floor is jlc, so some nets may stay unrouted: use fab_floor=board if your fab supports it (sizes: File > Board Setup > Design Rules > Net Classes). | | `courtyards_overlap` | warning | Some footprint courtyards overlap: Zap! keeps your placement, so these parts may end up too close to route or assemble; move them apart (Inspect > Design Rules Checker lists them). | | `malformed_courtyard` | warning | Some footprints have a broken courtyard outline: fix the footprint's F.Courtyard / B.Courtyard drawing in the Footprint Editor (Tools > Load Footprint from Board). | | `no_project` | warning | No .kicad_pro was sent: Zap! routes and checks to KiCad's default rules (0.2 mm clearance and track width); attach the .kicad_pro next to your board (File > Save writes it) to use your own net classes and rules. | | `unknown_net_class` | warning | Your .kicad_pro assigns nets to net classes it does not define, so those nets fall back to the Default class: define them in File > Board Setup > Design Rules > Net Classes. | | `footprints_outside_board` | warning | Some footprints are outside the board outline (listed in footprints, with positions): Zap! routes the rest of the board and leaves their pads unconnected (they appear in the report's unconnected list); if they belong on the board, move them onto it in KiCad (select the footprint and press M), save and check again. | | `existing_pours_replaced` | warning | Zap! removes every copper pour on the board and makes its own ground and supply pours, choosing their nets itself; pours lists each one that will be replaced (net and layers): compare the routed board's pours with your design, or send keep_existing=true to keep your pours, tracks and vias exactly. | | `check_incomplete` | warning | Part of the check could not run, so some problems may only show when the job runs: run Inspect > Design Rules Checker in KiCad yourself, or check again in a minute. | A job either returns a complete routed board or fails. There are no partial results. During maintenance (usually under a minute) the network edge in front of Zap! may answer `502` with an HTML page instead of JSON. Wait a minute and try again; `zap.py` retries status checks and downloads by itself. A `403` with the text `error code: 1010` comes from Cloudflare, the network edge in front of Zap!, not from Zap!: send any User-Agent header, e.g. `my-agent/1.0` (Python's built-in urllib sends a default name that the edge blocks). ## 6. Limits | limit | value | |---|---| | upload size per job | 20 MiB (20,971,520 bytes) in total, as sent | | board size unpacked (gzip-compressed board) | 64 MiB (67,108,864 bytes) | | pads per board | 10,000 | | footprints per board | 4,000 | | copper layers | 2 to 16 | | requests | 30 in any 60 seconds per key, status checks included (poll every 5 seconds) | | pre-flight checks | one at a time per key; not counted in the daily jobs | | jobs | per key, set when the key is issued (shown on the claim page); by default 50 per UTC day and 2 queued or running at once | | one job | 60 minutes at most | | whole service | a limited number of jobs wait at once; when the queue is full, `POST /v1/jobs` answers `503 server_busy` with `Retry-After` and `estimated_wait_min`. [zap.mutual.solutions/status](https://zap.mutual.solutions/status) shows the queue and the typical wait | | pages and downloads without a key | 120 requests in any 60 seconds per IP address | | feedback | 20 per key per UTC day, 4,000 characters each, at most 3 pictures each | | board pictures | 30 per key per UTC day, at most 40 mm square, only while the routed board is kept (7 days); deleted after 24 hours unless attached to feedback | **Submitting many boards.** Each key runs 2 jobs at a time and allows 30 requests a minute (the defaults; a key issued with other numbers shows them on its claim page). Submit a new job only after one of yours finishes (poll `GET /v1/jobs/{id}`, at most once every 5 seconds). On a 429 `rate_limited`, wait the seconds in `Retry-After` before the same request; do not retry sooner. `too_many_active_jobs` (also 429, without `Retry-After`) means your key's jobs at a time are all still queued or running: wait until one finishes. `daily_quota_exceeded` means stop until 00:00 UTC and tell your user. ## 7. Your data The full [Privacy page](/privacy) ([한국어](/privacy/ko)) and the [Terms](/terms) apply; in short: - Your files are used only to route that job. - Uploaded files are deleted as soon as the job finishes. The routed board is deleted 7 days later, or immediately with `DELETE /v1/jobs/{id}`. - We keep a job record (id, times, status and the summary counts) without any board data, for 2 years. - We do not use your boards to train or develop our software unless you agree in writing. `consent_training=true` records your preference; it is not by itself that written agreement. - Request logs hold the time, IP address, key id, request path, status and size. They never hold board contents, file names, keys or feedback text. - Your API key is stored only as a one-way hash. If you lose it, we issue a new one. - **Feedback.** If you, or your AI agent with your OK, send feedback, only the text and any board pictures you attach are used to improve Zap!. They are linked to the job's id and the summary numbers we already keep; we never keep or use the board file. The Zap! team reads feedback, with help from AI tools. We don't publish or quote it without asking you, don't sell it, and don't use it to train AI models. We reply only if you allowed contact. We keep it for at most 2 years or until you ask us to delete it at hello@mutual.solutions; backup copies expire within 14 days. Please leave out personal or confidential details. - The job records, key hashes, feedback and sign-in accounts (no board data) are backed up daily to private, encrypted storage in the same cloud region as the service; each backup copy is deleted after 14 days. ## 8. What Zap! does not do (yet) - Placement: parts stay where you put them. - Schematic checks. - Length tuning beyond differential-pair skew, or guaranteed impedance. - Manufacturing sign-off. Review every board and run your own DRC before fabrication. ## 9. Feedback Tell us what you kept, what you changed and what you threw away. A short note is always welcome. Two ways: - **Through your AI agent or the command line.** When you tell an agent that uses Zap! what you think of Zap! or a result, it asks whether to send that to us, shows you the exact text and the job id, and sends it only if you say yes. Command line: `python3 zap.py feedback "the routing near U3 looks messy" --job JOB_ID` shows the text and asks first. API: `POST /v1/feedback` with your key and JSON `{"message": "...", "user_approved": true, "job_id": "...", "kind": "problem", "contact_ok": true}`; `kind` is `praise`, `problem`, `idea` or `other`, and `contact_ok: true` lets us reply to your account's email address. The answer is `201 {"id", "received_at"}`. Up to 4,000 characters, 20 per key per UTC day. Board files are never sent this way. - **A picture of the spot.** If your feedback is about one place on the board, the agent can show you a picture of just that area (at most 40 mm square, with the report's missing connections and design-rule errors marked) and attach it if you agree: `POST /v1/jobs/{id}/snapshot` with `{"x_mm": X, "y_mm": Y, "size_mm": 20}`, then `"snapshot_ids"` in the feedback (at most 3). Command line: `python3 zap.py snapshot JOB_ID --at 93.5,69.5` saves the picture; `zap.py feedback "..." --job JOB_ID --snapshot ID` attaches it. A picture you do not attach is deleted after 24 hours, or at once if you say you do not want it (`DELETE /v1/snapshots/{id}.png`). Never the whole board. - **By email** to hello@mutual.solutions. If you also send an edited `.kicad_pcb`, say in the same message whether we may use it to improve Zap!: without that written OK we use it only to answer you, and then delete it. **For AI agents.** Whenever the user expresses an opinion about Zap! or a routing result (something they liked, a problem, a wish), ask once whether they would like to send it to the Zap! team. When you ask, say in one short sentence what happens, without overstating or understating it, for example: "Send this to the Zap! team? Only this text (and the picture, if any) is used to improve Zap!, never your board file, and you can ask them to delete it." Show the exact text you would send (their words, or a summary they approve) and the job id. Send it with `send_feedback` or `POST /v1/feedback` only if they say yes. If they say no, do not ask again in this session. When the feedback is about a spot on the board, offer to attach a picture of just that area (`board_snapshot` or `POST /v1/jobs/{id}/snapshot`), aimed with the report's issue positions; show the picture to the user before sending and attach it only if they say yes; if they do not want it, delete it (`delete_snapshot`). Never the whole board. Never include board files or private details they did not approve. --- # Zap! for AI agents **Machine summary (for software and AI agents).** - Base URL `https://zap.mutual.solutions`. Auth: header `Authorization: Bearer ` on every `/v1/jobs`, `/v1/check` and `/v1/feedback` request. Never put the key in a URL, source code, log, or command-line argument. - Flow: (1) `POST /v1/check`, multipart with `board` (.kicad_pcb, required), `project` (.kicad_pro), `rules` (.kicad_dru), `currents` (JSON), `router` (`stable`/`next`), `keep_existing` (`true`: keep the board's own tracks, vias and zones and route only the missing connections), `strip_existing` (`true`: discard them and route everything), `fab_floor` (`jlc` default / `board`), routing options `signal_width_mm`, `via_diameter_mm` + `via_drill_mm`, `vias_in_pads`, `ground_pour`, `size_rails_from_current`, `effort` (defaults in the guide's table), `minutes` (1 to 60: the job ends within about that long and returns the best board found; default one round): no routing, no job quota; fix every item in `blockers`. (2) `POST /v1/jobs`, same fields: `202 {"id", "status": "queued"}`; not idempotent (each accepted call is a job). (3) `GET /v1/jobs/{id}` every 5 s (every 10 s after the first minute) until `status` is `succeeded`, `failed` or `cancelled`. (4) `GET /v1/jobs/{id}/result`: the routed `.kicad_pcb`, and `GET /v1/jobs/{id}/report.json`: every finding with position and what to do (`report_url` in the status opens it as a web page). (5) `DELETE /v1/jobs/{id}`. - Done means `status` is `succeeded` and the routed file is saved: ready for an engineer's review and KiCad DRC, never directly for fabrication. `failed`: read `error.code`; [/v1/errors](https://zap.mutual.solutions/v1/errors) says whether retrying helps. - Limits: 20 MiB per upload in total (a gzip-compressed board counts compressed and may unpack to 64 MiB); 10,000 pads; 4,000 footprints; 2 to 16 copper layers; 30 requests in any 60 s per key; jobs per key per UTC day as issued (default 50); 2 queued or running per key; 60 minutes per job. - Feedback: when the human gives an opinion about Zap! or a result, ask once whether to send it to the Zap! team; show the exact text and job id; send it (`POST /v1/feedback` with `"user_approved": true`) only if they say yes. - Machine-readable: [OpenAPI](https://zap.mutual.solutions/openapi.json), [error catalog](https://zap.mutual.solutions/v1/errors), [agent recipe](https://zap.mutual.solutions/agents.md), [llms.txt](https://zap.mutual.solutions/llms.txt), [MCP server](https://zap.mutual.solutions/downloads/zap_mcp.py), [CLI](https://zap.mutual.solutions/downloads/zap.py). ## If you are an AI agent integrating Zap! You route a placed KiCad board for a human. Follow these steps exactly. Everything here is also in [/openapi.json](https://zap.mutual.solutions/openapi.json) and [/v1/errors](https://zap.mutual.solutions/v1/errors). **Which path first.** If you can register MCP servers and restart, use the MCP server (`claude mcp add zap -- python3 zap_mcp.py` with `ZAP_API_KEY_FILE` set): its tools appear only after the agent session restarts, and the command registers the file's current path, so keep `zap_mcp.py` in a permanent folder (not a temporary or download folder). Otherwise use `zap.py`, which works at once, no restart: `curl -sO https://zap.mutual.solutions/downloads/zap.py`, then `python3 zap.py run BOARD.kicad_pcb` with `ZAP_API_KEY_FILE` set. Both save the routed board, the full report (`.routed.zap-report.json`) and one picture per copper layer (`.routed.F_Cu.svg`, `.routed.B_Cu.svg`, ...). ### 0. The key - The human gives you an API key (a string starting with `mrk_`), usually as a file path. Read it from the environment variable `ZAP_API_KEY` or from the file named by `ZAP_API_KEY_FILE`. - Send it only in the header `Authorization: Bearer `. Never put it in source code, a URL, a command-line argument, a log, a commit, a chat message or an error report. Never print it, not even partly. - If there is no key yet and the board is small (up to 300 pads, 2 or 4 copper layers), route it with the free trial instead (no key, no sign-up, 2 boards a day per address, one round, no copper pictures): `curl -s -X POST https://zap.mutual.solutions/v1/try/jobs -H 'X-Zap-Try: 1' -F board=@BOARD.kicad_pcb -F project=@BOARD.kicad_pro` answers `{"id": ..., "token": ...}`; poll `GET /v1/try/jobs/ID` with the header `X-Zap-Try-Token: TOKEN` until `status` is `succeeded`, then fetch `/v1/try/jobs/ID/report.json` and `/v1/try/jobs/ID/result` (downloadable once). The npm MCP server (`npx -y zap-pcb-mcp`) does this for you when no key is set. - If the key is missing or refused (`401 unauthorized`) and the trial does not fit the board, stop and ask the human for a key. Do not guess or retry. Tell them how to get one: sign in at https://zap.mutual.solutions/auth/login and create an API key on the account page, then save it as `~/zap-key.txt` with `chmod 600 ~/zap-key.txt` (or use OAuth below: no key at all). ### 1. Check first: `POST /v1/check` Send the same multipart upload you would submit (field `board` = the `.kicad_pcb`; `project` = the `.kicad_pro` next to it if it exists; `rules` = the `.kicad_dru` if it exists; optional `currents`, `router`, `keep_existing`, `fab_floor`). It routes nothing, takes about 3 s for most boards (about 30 s for the largest, 10,000 pads; use a timeout of at least 120 s) and uses none of the key's daily jobs. - `ok: true`: go on to step 2. Mention any `warnings` to the human later (for example `no_project`: KiCad's default rules were used). - `ok: false`: do not submit. Show the human every item in `blockers`: its `message` (cause and the KiCad fix) and its references and positions (`footprints`, `items`: `ref`, `at_mm` = x, y in mm as KiCad shows them). Submitting would only fail and use a job. ### 2. Submit: `POST /v1/jobs` Same fields. The answer is `202 {"id": "<32 hex>", "status": "queued"}`. Keep the id. The submission reads the placement first (under a second, measured 0.3 to 0.8 s). Footprints off the board outline do not stop the job: the check lists them (warning `footprints_outside_board`) and their pads stay unconnected. A board too large for the job's memory gives `422 board_too_large` with `memory` (predicted GB per routing attempt, outline, copper layers); tell the human that a board this large is over the self-serve limit and that they can email hello@mutual.solutions to arrange a business (B2B) job. A job can also fail with it before routing, once the supply rails are known. **Not idempotent:** every accepted submission creates a job and uses one of the key's daily jobs. If the request fails without an answer (network error, timeout, an HTML 502 page from the edge), do not resubmit blindly: first `GET /v1/jobs` and look for a job created in the last few minutes (`created_at`); use it if it is there. ### 3. Wait: `GET /v1/jobs/{id}` Poll every 5 s for the first minute, then every 10 s, until `status` is `succeeded`, `failed` or `cancelled`. While `queued`, `queue_position` 1 means next. A job takes seconds for small boards and up to 2,107 s for the 1,894-pad 6-layer test board; it is stopped at 3,600 s. Status requests count toward the request rate (30 in any 60 s per key). **More time for a better result: `minutes`.** Let Zap! keep trying for up to N minutes (1 to 60) and return the best result; default is one round of a few minutes. Send it only when the human wants the best result and can wait (for example `minutes=20`); the job then runs up to that long (routing ends by minute 50) plus the checks. One job with `minutes` over 10 runs at a time across the service: while another runs, yours stays `queued` with a `queue_note` saying so and a longer `estimated_wait_s`. Tell the human `metrics.time_budget.summary` (how many attempts ran, which one was kept, the time used). ### 4. Download: `GET /v1/jobs/{id}/result` Save the bytes as `.routed.kicad_pcb` next to the input (for `myboard.kicad_pcb`: `myboard.routed.kicad_pcb`; the download header only suggests `routed.kicad_pcb`). Never overwrite the input board. Then copy `.kicad_pro` (and `.kicad_dru` if there is one) to `.routed.kicad_pro` (`.kicad_dru`): KiCad opens a board with the project file of the same name, so without the copy its DRC would use KiCad's default rules instead of the human's. `zap.py run` and the MCP server's `route_board` do this copy. `409 not_ready` means the job is not finished (go back to step 3); `409 no_result` means it failed (read `error`); `409 job_cancelled` means it was cancelled (submit again if the human still wants it). ### 5. Delete: `DELETE /v1/jobs/{id}` The same call cancels: on a `queued` job it removes it from the queue (it never runs and does not count as a daily job); on a `running` job it stops the job at once (no result, no files left on the server) and the status becomes `cancelled`; the answer is `{"id", "status": "cancelled", "files_deleted": true}`. On a finished job it deletes the files. After you have saved the file **and the full `GET /v1/jobs/{id}` answer**: deleting also removes the parts of the answer that name nets and positions (`fab.thermal.hottest`, `nets_over_limit`, `stated_not_checked` become `null`). Otherwise the server deletes the files after 7 days. ### Retries and backoff | answer | do this | |---|---| | `429 rate_limited` | wait the seconds in `Retry-After`, then repeat the same request | | `429 too_many_active_jobs` | wait until one of the key's jobs finishes, then submit | | `429 daily_quota_exceeded` | stop and tell the human: the key's daily jobs are used up until 00:00 UTC (`Retry-After` gives the seconds) | | `503 check_busy` | wait `Retry-After`, repeat the check | | `502`/`503`/`504` with an HTML body (no JSON) | short maintenance at the edge: wait 30 to 60 s; repeat GETs; for a POST, see "Not idempotent" above | | `403` with the text `error code: 1010` (no JSON) | Cloudflare, the network edge in front of Zap!, blocked the client name: send any User-Agent header, e.g. `my-agent/1.0` (Python's built-in urllib sends a default name that the edge blocks), and repeat | | `500 internal`, or a job `failed` with `internal` | retry once; if it repeats, tell the human the job id | | any other `4xx` | do not retry; fix the input or ask the human (the `message` says what to change) | | job `failed` | read `error.code` and `error.message`; [/v1/errors](https://zap.mutual.solutions/v1/errors) says whether retrying can help | ### Reading the result All numbers are counts or temperatures where **lower is better** and 0 is best. | field | meaning | acceptable | |---|---|---| | `metrics.unconnected` | connections still missing | 0 = fully routed; otherwise the human finishes them in KiCad | | `metrics.critical_unconnected` | of those, on supply, ground, clock, reset, boot or debug nets | must be 0 before ordering boards | | `metrics.drc_errors` | KiCad design-rule errors under the uploaded rules (some come from the placement) | 0 | | `metrics.fab.shorts` | copper of two nets touching | 0; Zap! never adds one (a board with a routed short is never returned), so any here are counted in `shorts_already_in_board`: tell the human they were already in the uploaded board | | `metrics.fab.jlc_violations` (`_by_type`) | features JLCPCB's standard process cannot make | 0; `via_in_pad` means filled and capped vias are needed (extra cost) | | `metrics.fab.thermal.worst_rise_c`, `sections_over_limit` | hottest copper, °C above ambient; sections over `limit_c` (10 °C) | `sections_over_limit` 0; `off_chart: true` means far too thin | | `metrics.fab.thermal.currents`, `hottest[].current_source` | `estimated` from the board's parts, or `stated` by the human | estimates may be low for large power stages: ask the human for real currents of high-current supply nets and resubmit with `currents` | Fields that only appear in some answers: `fab.thermal.stated_currents`, `stated_not_checked`, `sections_over_by_current_source` only when currents were sent; `ambient_c`, `worst_temp_c` only when `ambient_c` was sent; `fab.thermal.reason` and `fab.reason` only when that part was not computed; `queue_position` only while queued; `error` only when failed. Treat a missing field as "not applicable", not as 0. JLC violations come by type (`jlc_violations_by_type`) and with their positions in `fab.jlc_items` (type, x/y mm, layer, net, footprints; every one, at most 2,000; `jlc_items_truncated` says when the list is shorter than the count). Zap! never routes below JLC's minimum sizes and never puts a via inside an SMD pad; nets whose net class asks for finer copper may stay unrouted, and the report gives them `reason` "finer than JLC: ...". Tell the human which nets and why. A result is **ready for human review** when `status` is `succeeded`, `critical_unconnected` is 0 and `fab.shorts` is 0. It is **clean** when, in addition, `unconnected`, `drc_errors`, `fab.jlc_violations` and `fab.thermal.sections_over_limit` are 0. ### Reporting to the human Show, in plain words: 1. The routed file's path and the job id. 2. The numbers in the table above, each with "lower is better", and whether the result is clean. 3. The top problems with where they are and what to do: `GET /v1/jobs/{id}/report.json` lists every missing connection, design-rule error, JLC violation, hot spot and leftover copper with `at_mm` (x/y mm as KiCad shows them), `layer`, `net` and `what_to_do` (the KiCad command); quote `what_to_do` and give the human `report_url` (the same report as a web page with the copper picture). Save the report before `DELETE`: it is deleted with the board. Also any check `warnings`. 4. Which currents were estimated and which were stated. 5. Always this sentence: **"Review the routed board and run KiCad's DRC (Inspect > Design Rules Checker) before ordering boards."** Never say the board is ready for fabrication. ### Feedback from your user - Whenever the user expresses an opinion about Zap! or a routing result (something they liked, a problem, a wish), ask once whether they would like to send it to the Zap! team. - When you ask, say in one short sentence what happens, without overstating or understating it, for example: "Send this to the Zap! team? Only this text (and the picture, if any) is used to improve Zap!, never your board file, and you can ask them to delete it." - Show the exact text you would send (their words, or a summary they approve) and the job id. - Send it with `send_feedback` (MCP), `python3 zap.py feedback "TEXT" --job JOB_ID --yes` or `POST /v1/feedback` only if they say yes. If they say no, do not ask again in this session. - When the feedback is about a spot on the board, offer to attach a picture of just that area (`board_snapshot`, `python3 zap.py snapshot JOB_ID --at X,Y`, or `POST /v1/jobs/{id}/snapshot`), aimed with the report's issue positions (`at_mm`). Show the picture to the user before sending and attach it only if they say yes; if they do not want it, delete it (`delete_snapshot`, or `DELETE /v1/snapshots/{id}.png`). Never the whole board. - Never include board files or private details they did not approve. `POST /v1/feedback` takes the key and JSON `{"message": "", "user_approved": true, "job_id": "", "kind": "praise|problem|idea|other" (optional), "contact_ok": true|false (optional: the Zap! team may reply to the account's email)}` and answers `201 {"id", "received_at"}`. Without `"user_approved": true` it answers `400 feedback_needs_user_approval` and stores nothing. At most 20 per key per UTC day (`429 feedback_limit_reached`). Tell the user it was sent. **Pictures.** `POST /v1/jobs/{id}/snapshot` with JSON `{"x_mm": X, "y_mm": Y, "size_mm": 20}` (centre in mm as KiCad shows positions; `size_mm` 1 to 40, default 20; optional `layers` such as `["F.Cu"]`, `show_issues` true by default) draws one square area of the job's routed board with the report's missing connections and design-rule errors marked, and answers `201 {"snapshot_id", "png_url", "expires_at", "region", "nets_in_region", "issues_marked"}`. `GET png_url` with the key gives the PNG (at most 2 MB) to show the user. Add the id to the feedback's `"snapshot_ids"` (at most 3, same job) only if they agree; a picture not attached is deleted after 24 hours, or at once with `DELETE /v1/snapshots/{id}.png` when the user does not want it. Only while the routed board is kept (7 days, `410 expired` after); 30 per key per UTC day (`429 snapshot_limit_reached`); some keys have pictures off (`403 snapshots_off`: send text only). `zap.py snapshot JOB_ID --at X,Y [--size MM] [-o FILE]` saves the PNG; `zap.py feedback "TEXT" --job JOB_ID --snapshot ID --yes` attaches it. ### Stating currents If the human knows the current of supply nets (a motor supply, a battery feed, a regulator output), send them as the `currents` field: `{"nets": {"+5V": {"amps": 3.0}}, "ambient_c": 40}`. Names as in the board (or a unique last part: `PoE_5V` for `/PoE_5V`); `amps` is the continuous current, 0 to 100; state supply nets, not ground. The router sizes those nets' copper from them and the temperature check uses them. ### Partly routed boards If the board already has tracks or vias (the check reports `input_has_copper`), ask the human whether to keep them. If yes, send `keep_existing=true` with both the check and the job: the board's own tracks, vias and zones are kept exactly and only the missing connections are routed. Afterwards tell the human `metrics.existing_copper`: `tracks_kept`/`vias_kept`/`zones_kept` should match the upload and every `*_removed` must be 0 (otherwise report it to hello@mutual.solutions). If no, send `strip_existing=true` with both the check and the job: the board's tracks and vias are discarded and everything is routed (no KiCad needed; zap.py: `--strip-existing`). ### Copper pours Zap! replaces the board's copper pours with its own ground and supply pours and picks their nets itself. The check warns `existing_pours_replaced` and lists each pour (`pours`: net and layers); tell the human before submitting. After the job, report.json's `pours` lists `replaced` and `made`: show both, so the human sees if a pour net changed (for example their +12V pour replaced by a +24V one). `keep_existing=true` keeps their pours exactly (and their tracks). ### Smallest copper sizes: `fab_floor` `fab_floor=jlc` (default): never go below JLC's capability; nets that need finer copper are left unrouted and listed with the reason. `fab_floor=board`: follow the board's own minimums, for fabs finer than JLC; JLC findings are still reported. Keep the default unless the human says their fab is finer than JLC. When the check warns `rules_finer_than_jlc` ("N nets may stay unrouted"), ask the human which fab they use before switching to `board`. The job status records the value (`fab_floor`). ### MCP server A ready-made MCP server (stdio, one file, Python 3.9+, standard library only) does all of the above: [/downloads/zap_mcp.py](https://zap.mutual.solutions/downloads/zap_mcp.py). Tools: `check_board`, `route_board` (check, submit, wait, download, save the full report as `.routed.zap-report.json` and one picture per copper layer as `.routed.F_Cu.svg`, `.routed.B_Cu.svg` ..., delete the server copy), `get_report` (every finding with position and what to do), `get_job`, `list_jobs`, `delete_job`, `send_feedback` (the user's opinion to the Zap! team, only after they approved the exact text), `board_snapshot` (a picture of one small area of the routed board, saved locally and returned as an image to show the user), `delete_snapshot` (a picture the user does not want, at once), `service_info` (version, router, queue; no key) and `error_catalog`. Every upload option of the API is an argument of `check_board` and `route_board`, with the same name and default. It reads the key from `ZAP_API_KEY` or `ZAP_API_KEY_FILE` in its environment, never from tool arguments, and warns when the key file is readable by other users. Both clients use https only and never follow a redirect, so the key cannot be sent to another address. ```bash curl -sO https://zap.mutual.solutions/downloads/zap_mcp.py claude mcp add zap -e ZAP_API_KEY_FILE=$HOME/zap-key.txt -- python3 $PWD/zap_mcp.py # Claude Code ``` Claude Desktop (`claude_desktop_config.json`): ```json {"mcpServers": {"zap": {"command": "python3", "args": ["/absolute/path/to/zap_mcp.py"], "env": {"ZAP_API_KEY_FILE": "/absolute/path/to/zap-key.txt"}}}} ``` ### No API key: OAuth Zap! also accepts OAuth sign-in (Google, GitHub or Microsoft; signing in creates the human's Zap! account). The human can also create an API key on their account page (https://zap.mutual.solutions/auth/login?next=account) and give it to you as a key file. Pick one: - **Remote MCP server** `https://zap.mutual.solutions/mcp`: your MCP client signs in by itself (authorization code + PKCE, self-registration, RFC 9728 discovery from the `401`). Same tools as the local server, but boards are passed as file text: `board` (the .kicad_pcb text), `project` (.kicad_pro text), `rules`; `get_result` returns the routed board as an embedded resource (up to 8 MiB). Claude Code: `claude mcp add --transport http zap https://zap.mutual.solutions/mcp`, then `/mcp`. Codex: `[mcp_servers.zap]` `url = "https://zap.mutual.solutions/mcp"`, then `codex mcp login zap`. Cursor: `{"mcpServers": {"zap": {"url": "https://zap.mutual.solutions/mcp"}}}`. - **Shell access**: run `python3 zap.py login` and show the human the printed address and code; they approve it in a browser; then every `zap.py` command works without a key. Never approve the code yourself. - A `401` with `WWW-Authenticate: Bearer resource_metadata="..."` means the token expired or was revoked: renew (MCP clients and `zap.py` do this automatically) or ask the human to sign in again. Do not retry with the same token. ### Command line No board yet? `python3 zap.py demo` downloads mutual's sample board (42 pads, placed, not routed: [zap-sample.kicad_pcb](https://zap.mutual.solutions/downloads/zap-sample.kicad_pcb) and [zap-sample.kicad_pro](https://zap.mutual.solutions/downloads/zap-sample.kicad_pro)) and routes it with one of the key's daily jobs; the web app has the same as "Try with a sample board". [/downloads/zap.py](https://zap.mutual.solutions/downloads/zap.py) (Python 3.8+, no extra packages): `ZAP_API_KEY_FILE=~/zap-key.txt python3 zap.py run myboard.kicad_pcb` checks, submits, waits, saves `myboard.routed.kicad_pcb` and the full report `myboard.routed.zap-report.json`, then deletes the server copy, as `route_board` does (`--keep` leaves it for 7 days); `python3 zap.py check myboard.kicad_pcb` only checks; `python3 zap.py feedback "TEXT" --job JOB_ID` shows the text and asks y/N before sending (`--yes` only after the user agreed to that exact text). ### Minimal HTTP sequence (curl) The key goes to curl as a header file (`-H @...`) written by bash's built-in `printf`, so it never appears in a command line or the process list: ```bash URL=https://zap.mutual.solutions zcurl() { curl -s -H @<(printf 'Authorization: Bearer %s\n' "$(cat "$ZAP_API_KEY_FILE")") "$@"; } zcurl -F board=@myboard.kicad_pcb -F project=@myboard.kicad_pro $URL/v1/check # 1. check ID=$(zcurl -F board=@myboard.kicad_pcb -F project=@myboard.kicad_pro $URL/v1/jobs \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])') # 2. submit while :; do # 3. wait ST=$(zcurl $URL/v1/jobs/$ID | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("status") or d.get("error"))') case "$ST" in queued|running) sleep 5;; *) break;; esac # stops on errors too done; echo "$ST" # succeeded, failed, cancelled, or an error code zcurl $URL/v1/jobs/$ID > myboard.status.json # keep the full answer zcurl -f -o myboard.routed.kicad_pcb $URL/v1/jobs/$ID/result && cp myboard.kicad_pro myboard.routed.kicad_pro # 4. download zcurl -X DELETE $URL/v1/jobs/$ID # 5. delete ``` A board over 20 MiB: compress it first (`gzip -k myboard.kicad_pcb`) and send `-F board=@myboard.kicad_pcb.gz` in the same field (at most 64 MiB unpacked). zap.py and the MCP tool compress every board themselves.