Everything the interface can do is an API call. Create a key under Settings → API keys, then send it as a bearer token.
curl https://pool.biswas.me/api/pools \ -H "Authorization: Bearer pool_sk_..."
Want to try the API without signing up? Open the live demo, then create a key under Settings — the demo account works exactly like a real one.
An X-API-Key header works too. Session cookies authenticate the same endpoints from the browser,
so there is no separate API surface to keep in sync.
A key carries a read or read,write scope, enforced on the HTTP method: a read key
cannot POST, PATCH or DELETE, and gets a 403 if it tries. A key cannot issue another key.
The full OpenAPI 3.1 document lives at
/api/openapi.yaml. It is public and needs no credential —
requiring one to discover how to present one is a loop.
curl -s https://pool.biswas.me/api/openapi.yaml -o pool.yaml
Hand it and a key to an LLM or a client generator and it can work out the calls unaided. Creating a key
returns the document's location and a curl that works as printed, alongside the secret.
Create a pool, record a test, and log what you spent — the three calls that matter.
KEY="pool_sk_..."
BASE="https://pool.biswas.me/api"
# 1. Create a pool. Volume drives every dose calculation.
curl -sX POST $BASE/pools -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{
"name": "Backyard pool",
"volume_l": 58000,
"sanitizer": "salt",
"surface": "vinyl",
"location": "Outdoor"
}'
# 2. Record a water test. Every reading is optional.
curl -sX POST $BASE/tests -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{
"pool_id": 1,
"tested_at": "2026-07-29",
"company_name": "Jameson Pool & Spa",
"free_chlorine": 0.23,
"total_chlorine": 0.49,
"total_salt": 2161,
"ph": 7.30,
"total_alkalinity": 106,
"calcium_hardness": 159,
"cyanuric_acid": 5,
"temperature": 21,
"total_copper": 0.30,
"iron": 0.10
}'
# 3. Log what went in, and what it cost. Backdating is normal.
curl -sX POST $BASE/log -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{
"pool_id": 1,
"occurred_on": "2026-06-10",
"category": "chemical",
"item": "Chlorine",
"quantity": 10, "unit": "L",
"cost": 42.50,
"vendor": "Jameson Pool & Spa"
}'
The test response comes back with the readings scored against the right ranges for your pool, the saturation index, derived warnings, and a dosing plan.
name, volume_l required{"applied": true}cost (dollars) or cost_centsPhotograph the printout the pool store hands over and the whole of it — twenty readings, the date, who tested it — becomes a scored test with a dosing plan, without anyone typing a number.
curl -sX POST $BASE/tests/from-photo \ -H "Authorization: Bearer $KEY" \ -F "[email protected]" \ -F "pool_id=1" \ -F "hint=the salt row is smudged"
The response is the usual test detail — readings, warnings, doses — plus parsed, which reports
what was read off the paper, and insight, the analysis, which runs in the same request unless you
send analyse=false.
Nothing is written until the photo has been read, so an unreadable picture leaves no empty test behind. A date you supply beats the one printed on the sheet. The photo is filed against the test it produced, so the readings can always be checked against the paper they came from.
Readings that are physically impossible for pool water — a pH of 73, a negative hardness — are discarded
rather than corrected, and listed in parsed.rejected: there is no way to know which digit was
misread, and a wrong number here becomes a dose recommendation. Whatever the model could not read is filed as
a note on the test, so a blank row still has an explanation months later.
Requires an AI provider — the server's, or your own key from PUT /api/me/ai.
Upload through /api/attachments — where the image is sniffed, downscaled and re-encoded — and
then point /parse at it. The picture never has to be sent twice, and a sheet filed weeks ago can
still become a scored test.
ID=$(curl -sX POST $BASE/attachments \ -H "Authorization: Bearer $KEY" \ -F "[email protected]" -F "pool_id=1" -F "kind=test_sheet" | jq .id) curl -sX POST $BASE/attachments/$ID/parse -H "Authorization: Bearer $KEY"
The attachment is filed against the test it produces. Both paths return the same document: the test detail,
parsed, and insight.
The photo endpoints call whatever model this server is configured with. If you have a better one — an agent with a frontier vision model, say — do the reading yourself and post the result. Nothing about that path is second-class: the chemistry, the dosing plan and the season rollups are computed by the server either way.
# 1. Post the readings you transcribed. Say what did the reading.
TEST=$(curl -sX POST $BASE/tests -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{
"pool_id": 1, "tested_at": "2026-08-22", "source": "claude-code",
"operator": "Jet", "test_count": 3, "company_name": "Jameson Pool & Spa",
"ph": 7.30, "total_alkalinity": 109, "calcium_hardness": 244,
"cyanuric_acid": 23, "free_chlorine": 4.17, "total_copper": 0.50
}' | jq -r .test.id)
# 2. Keep the photo with the test it came from.
curl -sX POST $BASE/attachments -H "Authorization: Bearer $KEY" \
-F "[email protected]" -F "pool_id=1" -F "kind=test_sheet" -F "test_id=$TEST"
# 3. Fetch the brief — the same context the server would give its own model.
curl -s $BASE/tests/$TEST/context -H "Authorization: Bearer $KEY" | jq -r .brief
# 4. File the analysis you wrote against those instructions.
curl -sX POST $BASE/tests/$TEST/insight -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -d '{
"headline": "Copper at 0.50 ppm — sequester before any oxidiser",
"summary": "...",
"findings": [{"title": "...", "detail": "...", "severity": "serious"}],
"actions": ["..."], "watch": ["..."],
"author": "claude-opus-4.6 via Claude Code"
}'
The context response also carries instructions — the prompt the local model is held
to. Follow it. It contains the dosing-order rule, which is a safety constraint rather than a stylistic one:
metal sequestrant always goes in before any oxidiser.
A supplied analysis is validated, bounded and severity-normalised exactly like a locally generated one, and
recorded with provider: external so it stays obvious months later which is which. Posting
/insight with no body still asks the server to generate one.
curl -sX POST $BASE/attachments \ -H "Authorization: Bearer $KEY" \ -F "[email protected]" \ -F "pool_id=1" \ -F "total=86.40" \ -F "purchased_on=2026-06-10" \ -F "vendor=Jameson Pool & Spa"
Images are downscaled to 1600px on the long edge and re-encoded, so a 12 MB phone photo typically lands under 300 KB. The response reports both sizes. PDFs are stored as uploaded.
chemical · equipment · service · maintenance ·
utility · opening · closing · other
free_chlorine total_chlorine combined_chlorine total_salt
bromine ph total_alkalinity calcium_hardness
cyanuric_acid phosphate borate tds temperature
total_copper free_copper combined_copper iron
Combined chlorine is derived from total minus free when you don't supply it. Adjusted alkalinity, the saturation index and the 0–100 score are always computed server-side.
Failures return the matching HTTP status with {"error": "..."}. A revoked or expired key returns
401; a key whose scope does not cover the request returns 403. An upload over 25 MB
returns 413, and a photo nothing could be read from returns 422.