iaqualink-mcp
# iaqualink-mcp
MCP server for **iAqualink** (Zodiac / Jandy / Fluidra) pool and spa equipment.
Read temperatures, set points, pumps, heaters, lights, aux circuits and salt
chlorinator state, and switch equipment — every change confirmation-gated and
verified by a re-read. It signs in with your own iAqualink account (the same
email and password as the iAqualink app); no browser, extension or bridge.
Supports the two system types the reference client supports: **iAqua**
(Jandy iAquaLink panels) and **eXO** (Zodiac salt chlorinators). Other device
types on the account (robotic cleaners, …) are listed but not controlled.
> **Status: partly verified live.** Request shapes come from
> [iaqualink-py](https://pypi.org/project/iaqualink/) 0.7.0 (what Home
> Assistant uses) and a WebTouch capture (see
> [docs/IAQUALINK-API.md](docs/IAQUALINK-API.md)).
> - **Seen working on a real iAqua (RS) panel, 2026-10-11/12:** sign-in, the
> system list (`devices.json`), the iAqua status reads (`get_home`,
> `get_devices`) and WebTouch (location lookup, init, button presses and the
> display stream).
> - **Still derived, not yet exercised live:** the iAqua `set_*` commands (pump,
> heater, aux, light, temperature), every eXO request (shadow read and
> writes), `iaqualink_set_chlorinator`'s inferred shape, and the iQ20 schedule
> fallback. Every write still re-reads to confirm what happened.
This project was developed and is maintained by AI (Claude Code). Use at your
own discretion.
## Tools
| Tool | What it does | Annotation |
| --- | --- | --- |
| `iaqualink_list_systems` | Systems on the account and their serials | read |
| `iaqualink_get_status` | Temperatures, set points, pumps, heaters, aux/lights, chlorinator, salinity/pH/ORP (`view`: compact · full · raw) | read |
| `iaqualink_healthcheck` | Whether a sign-in is configured and accepted | read |
| `iaqualink_set_equipment` | Pool/spa pump, pool/spa/solar heater; eXO filter pump, production, boost, low, heating — on/off | write, not destructive |
| `iaqualink_set_aux` | Aux circuit or light on/off (by number, `aux_<n>` or label) | write, not destructive |
| `iaqualink_set_light` | Dimmable brightness (0/25/50/75/100) or color effect (iAqua) | write, not destructive |
| `iaqualink_set_temperature` | Pool and/or spa set point (iAqua °F/°C; eXO °C pool) | write, not destructive |
| `iaqualink_set_chlorinator` | eXO chlorinator output % — **inferred shape, not in iaqualink-py** | write, not destructive |
| `iaqualink_list_schedules` | RS panel programs via WebTouch (iAqua; iQ20 fallback probe); eXO timers | read |
| `iaqualink_add_schedule` | Add an RS panel program (circuit, start, stop, run days) via WebTouch | write, not destructive |
| `iaqualink_edit_schedule` | Change an RS panel program's start / stop / run days via WebTouch | write, not destructive |
| `iaqualink_delete_schedule` | Delete an RS panel program via WebTouch — **no undo** | write, **destructive** |
| `iaqualink_set_schedule` | Set / re-time / disable an eXO timer (start–stop, rpm on variable-speed pumps) — **eXO only, unverified live** | write, not destructive |
| `iaqualink_check_panel` | Is an RS system online, and what is its panel showing (screen, open dialog, Edit mode with unsaved changes)? Run it when the app and these tools disagree | read |
| `iaqualink_list_pump_speeds` | RS panel variable-speed pump: the named speeds, their RPM and which one runs, via WebTouch | read |
| `iaqualink_set_pump_speed` | Run the pump at a named speed via WebTouch | write, not destructive |
| `iaqualink_set_pump_speed_rpm` | Change a named speed's RPM (600–3450) via WebTouch; a speed that is not running runs briefly while set, then the running speed is restored | write, not destructive |
| `iaqualink_list_onetouch` | RS panel OneTouch scenes (Spa Mode, All OFF, …) and their state, via WebTouch | read |
| `iaqualink_set_onetouch` | Turn a OneTouch scene on/off via WebTouch — **toggle not exercised live yet** | write, not destructive |
**Systems by name.** At start the server lists the account's systems once;
when that works, every `serial` argument advertises the supported systems'
names (e.g. "Pool", "Spa") as an enum, and every tool accepts a name or a
serial — an unknown name is refused with the list of known systems. If the
listing fails (no sign-in yet, iAqualink down, past 5 s), the argument is a
plain serial and nothing else changes. Every tool also carries `_meta.mint`
hints (domain `home`; `lookup` for reads, `turn-on-off` for the `set_*`
switches, `change` for schedule edits) that mcp-host's context groups read and
other clients ignore.
Every write has an inverse in this set and reaches no other person — except
deleting a panel program, which the panel stores immediately with no undo, so
that one is marked destructive.
**Schedules and OneTouch on a Jandy AquaLink RS panel** go through
**WebTouch**, the panel emulation the iAqualink web app uses: the server opens
the panel's display stream, presses the same buttons a person would (checking
before every press that the panel is on the expected screen and row, and
aborting otherwise), returns to Home, and re-reads to verify. Only one WebTouch
session per system runs at a time. The add / edit / delete sequences follow a
live capture on an RS panel (2026-10-11) — see
[docs/IAQUALINK-API.md](docs/IAQUALINK-API.md#webtouch-aqualink-rs-panel-emulation).
If WebTouch is unavailable, `iaqualink_list_schedules` falls back to iQ20
schedules (adapter firmware 3+), probing `r-api.iaqualink.net/v2` (Bearer),
`p-api.iaqualink.net/v2` (Bearer), `p-api.iaqualink.net/v2` (bare token) and
`prm.iaqualink.net/v1` (bare token) once each and reporting every attempt.
iQ20 schedule *writes* are not built.
**How a write works.** It reads the current state first and sends nothing if
it already matches — iAqua's pump/heater/aux commands are *toggles*, so a blind
send would flip whatever the state happened to be. It then asks you to confirm
(an elicitation prompt where the client supports one; otherwise a preview plus
a `confirmToken`, bound to the state it was planned against), sends, and
re-reads until the requested value shows up. The result is `confirmed`,
`unchanged`, or `sent-unconfirmed` with what was observed (eXO applies changes
asynchronously — check again rather than retrying).
## Setup
```sh
npm i -g @chrischall/iaqualink-mcp
```
| Variable | Required | Meaning |
| --- | --- | --- |
| `IAQUALINK_EMAIL` | for every tool | Your iAqualink app sign-in email |
| `IAQUALINK_PASSWORD` | for every tool | Your iAqualink password (not trimmed) |
| `IAQUALINK_STATUS_CACHE_SECONDS` | no | Seconds a status read is reused (default 5; 0 disables) |
| `IAQUALINK_WEBTOUCH_STREAM_HOSTS` | no | Extra hosts (comma-separated) the WebTouch display stream may come from; default only `prm.iaqualink.net` |
| `IAQUALINK_VERIFY_TIMEOUT_MS` | no | Milliseconds a write re-reads to confirm the change before reporting `sent-unconfirmed`, for iAqua and eXO alike (positive integer; default 15000 iAqua, 30000 eXO) |
| `MCP_CONFIRM_MODE` | no | `ask-user` (default) / `auto` / `refuse` |
| `MCP_CONFIRM_TTL_SECONDS` | no | confirmToken lifetime (default 600) |
| `MCP_CONFIRM_ELICITATION` | no | `off` skips the prompt even on clients that declare elicitation |
The server starts without credentials (so hosts can list its tools) and
reports them missing on the first call. **A rejected password is never
retried**: the refusal is remembered until the server restarts, so a typo
cannot lock the account.
Claude Code: `claude mcp add iaqualink -e IAQUALINK_EMAIL=… -e IAQUALINK_PASSWORD=… -- npx -y @chrischall/iaqualink-mcp`
### Hosted (claude.ai)
`mint.yaml` declares per-user children with an email/password form on the
connector's authorize page (each person signs in as themselves), egress to
exactly `prod.zodiac-io.com`, `r-api.iaqualink.net`, `p-api.iaqualink.net`
(iQ20 probe only) and `prm.iaqualink.net` (WebTouch, and the iQ20 probe); the
WebTouch display stream is refused on any other host unless it is added to
`IAQUALINK_WEBTOUCH_STREAM_HOSTS` (and egress). No persistent
state — the session lives in memory and one sign-in rebuilds it after a cold
start.
## Skills
- `skills/iaqualink` — how to use these tools.
- `skills/iaqualink-curl` — the same reads straight from a shell with `curl`,
credentials from the macOS Keychain (the sign-in, system list and iAqua reads
are seen live; the eXO shadow read is still derived from iaqualink-py).
## Development
```sh
npm install
npm run build
npm run test:coverage # typecheck (src + tests) + vitest, 100% coverage — what CI runs
```
Tests run the real client against an in-memory iAqualink
(`tests/support/fake-server.ts`) built from iaqualink-py's fixtures.
TDQS
Scored across 19 tools
Most tools target a distinct resource+action (systems, status, schedules, OneTouch, pump speeds), and descriptions explicitly scope iAqua vs eXO behavior. Two real overlap points remain: iaqualink_set_equipment covers eXO 'production'/'boost' chlorine control while iaqualink_set_chlorinator sets output percentage, and iaqualink_set_aux turns light circuits on/off while iaqualink_set_light sets brightness/color. iaqualink_check_panel vs iaqualink_healthcheck is also a fine distinction an agent must reason about.
All names share the iaqualink_ prefix and snake_case verb_noun structure (list_systems, get_status, set_equipment, add/edit/delete_schedule). The only deviation is iaqualink_healthcheck, which omits a verb, and the schedule tools split awkwardly across set_schedule (eXO) versus add/edit/delete_schedule (iAqua) for what is nominally one concept.
Nineteen tools is on the heavy side, but the server genuinely spans two hardware families plus schedules, OneTouch scenes, VSP speeds, and diagnostics, so most tools earn their place. A few could be collapsed — set_chlorinator largely duplicates the eXO branch of set_equipment, and check_panel/healthcheck are adjacent diagnostics.
Coverage is strong for a control server: discovery, status, equipment/aux/light/temperature writes, full schedule CRUD for iAqua panels, OneTouch and pump-speed management, plus healthcheck and panel diagnostics. Gaps exist on the eXO side, which has list+set schedule but no add or true delete (only a disable-to-00:00), and there is no history/energy/log surface.