# Zap! for AI agents

**Machine summary (for software and AI agents).**

- Base URL `https://zap.mutual.solutions`. Auth: header `Authorization: Bearer <api key>` 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 (`<board>.routed.zap-report.json`) and one picture
per copper layer (`<board>.routed.F_Cu.svg`, `<board>.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 <key>`. 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 `<board>.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 `<board>.kicad_pro`
(and `<board>.kicad_dru` if there is one) to `<board>.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": "<the approved text, 1 to 4,000 characters>", "user_approved":
true, "job_id": "<optional, one of this key's jobs>", "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 `<board>.routed.zap-report.json` and one picture per copper layer
as `<board>.routed.F_Cu.svg`, `<board>.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.
