skills/ep-online/SKILL.md
--- name: ep-online description: Look up Dutch building energy labels from EP-Online, the official national energy-label register (RVO). Two access modes — the per-address REST web service (apikey via the Authorization header) and the monthly bulk public-data file (full dataset, CSV / XML / XLSX zip, ~monthly cadence, free, good for a cron). Covers the exact download endpoint + auth from a captured request, the CSV column schema, joining a label to a listing by postcode + house number, and a cop
npx skillsauth add RonanCodes/ronan-skills skills/ep-onlineInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
EP-Online is the official national register of Dutch building energy labels (energielabels), run by RVO (Rijksdienst voor Ondernemend Nederland, the Netherlands Enterprise Agency). Every registered label for a dwelling or building, keyed by address (postcode + house number) and by BAG object id, lives here. If a property listing is missing an energy label, this is where the real one comes from.
There are two ways in, and they need slightly different grants from the same key portal:
GET /api/v5/PandEnergielabel/Adres?postcode=...&huisnummer=.... One address, live answer. Good for enriching a single listing on demand.Both are free once you hold a key. Pick the web service for low-volume on-demand lookups, the bulk file for "enrich every listing we have" or any high-volume join.
A++++ down to G. The headline value most listings want.BAGVerblijfsobjectID, BAGPandIDs) so you can join on a stable object id instead of fuzzy address text.1234AB (4 digits, 2 uppercase letters). Combined with huisnummer (house number) it identifies a dwelling.total file is the full snapshot; daily mutation files are the changes since.Berekeningstype column.Request a key at apikey.ep-online.nl. Notes that bite:
401 against the /api/v5/... web service until the web-service grant is enabled for it. If the web service 401s with a key that works for bulk download, that is the cause — request web-service access for the key.EP_ONLINE_API_KEY in ~/.claude/.env (a 128-char token). Read it from the environment, never hardcode it. In production set it as a Worker / Vercel / Fly secret.EP_ONLINE_API_KEY="$(grep '^EP_ONLINE_API_KEY=' ~/.claude/.env | cut -d= -f2)"
Base URL: https://public.ep-online.nl. Auth: the key goes in the Authorization header, raw (no Bearer prefix). Every endpoint except Ping requires it.
flowchart LR
A["listing<br/>(postcode + huisnummer)"] -->|"GET /api/v5/PandEnergielabel/Adres"| B["public.ep-online.nl"]
B -->|"Authorization: $KEY"| C["JSON: Energieklasse + dates + BAG ids"]
C --> D["write label onto listing"]
Endpoints (OpenAPI v5):
| Method + path | Purpose |
|---|---|
| GET /api/v5/Ping | health check, no auth |
| GET /api/v5/PandEnergielabel/Adres | label by address (params below) |
| GET /api/v5/PandEnergielabel/AdresseerbaarObject/{bagId} | label by BAG object id (most reliable join) |
| GET /api/v5/Mutatiebestand/DownloadInfo | metadata for the latest monthly total file |
| GET /api/v5/Mutatiebestand/DownloadInfo/{datum} | metadata for a daily mutation file (datum = yyyymmdd) |
Address-lookup query params (all validated server-side):
postcode — ^[1-9][0-9]{3}[A-Z]{2}$ (e.g. 3039SG, uppercase, no space)huisnummer — ^[1-9][0-9]{0,4}$huisletter — optional, single letterhuisnummertoevoeging — optional, the toevoeging (addition)detailaanduiding — optional, building detail designationEP_ONLINE_API_KEY="$(grep '^EP_ONLINE_API_KEY=' ~/.claude/.env | cut -d= -f2)"
# health check (no auth)
curl -s https://public.ep-online.nl/api/v5/Ping
# label by address
curl -s -H "Authorization: $EP_ONLINE_API_KEY" \
"https://public.ep-online.nl/api/v5/PandEnergielabel/Adres?postcode=3039SG&huisnummer=47"
A 401 with "Valid API key required to access this resource." means the key is not (yet) web-service-enabled. See the Auth note above. For high-volume enrichment, do not loop this endpoint per listing — use Mode B instead.
The whole register is published as one zip on the first of every month, in three formats. CSV is the easiest to parse and the smallest, so prefer it.
| Format | Filename pattern | Approx size (zip) |
|---|---|---|
| CSV (recommended) | v20260601_v4_csv.zip | ~220 MB |
| XML | v20260601_v4_xml.zip | ~330 MB |
| XLSX | v20260601_v4_xlsx.zip | ~350 MB |
The version date in the filename (v2026MMDD) is the publication date. Uncompressed the CSV is ~1.5 GB. Daily delta files follow d20260626_v4.zip.
The download is a POST to https://www.ep-online.nl/PublicData/Download with an application/x-www-form-urlencoded body. Captured from a real authenticated browser download:
POST https://www.ep-online.nl/PublicData/Download
Content-Type: application/x-www-form-urlencoded
Cookie: <antiforgery cookie from GET /PublicData>
id=4963&apiKey=<EP_ONLINE_API_KEY>&__RequestVerificationToken=<token from GET /PublicData>
Response: 200 with Content-Disposition: attachment; filename=v20260601_v4_csv.zip and Content-Type: application/octet-stream, body is the zip.
Three things to know about this form:
apiKey is the same EP_ONLINE_API_KEY, passed in the POST body (not a header) for this legacy public-data form.__RequestVerificationToken is an ASP.NET anti-forgery (CSRF) token. It is NOT static — you must first GET https://www.ep-online.nl/PublicData, scrape the hidden <input name="__RequestVerificationToken" ... value="...">, AND keep the matching cookie the same response set. POST with both or it rejects you. Save the cookie jar on the GET and reuse it on the POST.id identifies which file. In the capture id=4963 was the CSV total for v20260601. The id changes every month, so do not hardcode it. Two ways to get the current id:
GET /api/v5/Mutatiebestand/DownloadInfo (with the Authorization header) — it returns the latest total file's metadata / download reference, no scraping. Use this if your key is web-service-enabled./PublicData page for the row matching the format you want and read its id.Because the id rotates monthly and the token is per-session, the robust automated path is: GET the page (cookie + token), discover the id, POST the form. The helper below does exactly that.
Free with the key. The total file refreshes monthly (1st of the month), so a monthly cron (e.g. the 2nd of each month) is the right cadence to mirror the full dataset. If you need fresher data, layer the daily Mutatiebestand deltas on top. Do not re-download daily — the total file only changes monthly.
The CSV is semicolon-delimited (;), UTF-8. The first two lines are metadata, then a header row, then data:
PublicatieDatum;01-06-2026
LaatstVerwerkteMutatievolgnummer;19606738
Registratiedatum;Opnamedatum;GeldigTot;Certificaathouder;...
20260531;20260531;20360531;BuildingLabel B.V.;Basisopname;Bestaand;...
So: skip the first 2 lines, treat line 3 as the header, split every line on ;. Dates are yyyymmdd. Decimal numbers use a comma as the decimal separator (90,00 = 90.0) — replace , with . before parseFloat.
The columns most listings care about (full list is ~40 fields; these are the load-bearing ones):
| Column | Meaning |
|---|---|
| Postcode | postal code, 3039SG form — join key |
| Huisnummer | house number — join key |
| Huisletter | house letter (often empty) |
| Huisnummertoevoeging | house-number addition (often empty) |
| Energieklasse | the energy label (A++++…G) — the value you want |
| Registratiedatum | when the label was registered (yyyymmdd) |
| GeldigTot | valid-until date (yyyymmdd) — labels expire (typically 10y) |
| Opnamedatum | survey/recording date |
| Berekeningstype | calc method, e.g. NTA 8800:2025 (basisopname woningbouw) |
| Gebouwklasse | building class (W = woning/dwelling, U = utiliteit) |
| Gebouwtype | e.g. Appartement, Rijwoning tussen, Twee-onder-één-kap |
| Bouwjaar | construction year |
| GebruiksoppervlakteThermischeZone | usable floor area (m2), comma-decimal |
| BAGVerblijfsobjectID | BAG dwelling id — the most reliable join key |
| BAGPandIDs | BAG building id(s) |
| PrimaireFossieleEnergie | primary fossil energy (kWh/m2/yr) |
| EnergieIndex | legacy energy index (older labels only; blank on NTA 8800) |
Two strategies, best-to-worst:
BAGVerblijfsobjectID. Exact and unambiguous.3039 SG → 3039SG), trim the huisnummer. Match on Postcode + Huisnummer, then refine with Huisletter / Huisnummertoevoeging when the listing has them, since one postcode+number can have multiple units (apartments).Index the bulk CSV by ${Postcode}|${Huisnummer} (or by BAG id) once on import, then enrichment is an O(1) map lookup per listing.
Plain Node, no deps (fetch is built in on Node 18+; uses the platform unzip). Downloads the current monthly CSV, unzips, parses, builds an address index, and exposes a lookup. Run with EP_ONLINE_API_KEY in the environment.
// ep-online.mjs — download monthly bulk CSV, parse, lookup by postcode+huisnummer
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const KEY = process.env.EP_ONLINE_API_KEY;
if (!KEY) throw new Error("EP_ONLINE_API_KEY not set");
const BASE = "https://www.ep-online.nl";
// 1. GET /PublicData to grab the antiforgery cookie + hidden token, and the file id.
async function getDownloadContext(format = "csv") {
const res = await fetch(`${BASE}/PublicData`);
const setCookie = res.headers.getSetCookie?.() ?? [res.headers.get("set-cookie")];
const cookie = setCookie.filter(Boolean).map((c) => c.split(";")[0]).join("; ");
const html = await res.text();
const token = html.match(
/name="__RequestVerificationToken"[^>]*value="([^"]+)"/,
)?.[1];
if (!token) throw new Error("could not find __RequestVerificationToken on /PublicData");
// Find the id for the total file in the requested format. The page lists rows like
// v20260601_v4_csv.zip alongside a numeric id. Match the id nearest the filename.
const fnameRe = new RegExp(`v\\d{8}_v\\d+_${format}\\.zip`);
const fname = html.match(fnameRe)?.[0];
// Pull every "id"-ish number and the filenames, then pick the id closest to our filename.
const id = findIdForFile(html, fname);
if (!id) {
throw new Error(
`could not resolve file id for ${format} from /PublicData; ` +
`if your key is web-service-enabled, call GET /api/v5/Mutatiebestand/DownloadInfo instead`,
);
}
return { cookie, token, id, fname };
}
// The id sits near the filename in the page markup. This is a best-effort scrape;
// prefer the DownloadInfo web service when the key allows it.
function findIdForFile(html, fname) {
if (!fname) return null;
const idx = html.indexOf(fname);
const window = html.slice(Math.max(0, idx - 400), idx + 400);
// common shapes: name="id" value="4963", data-id="4963", id=4963
return (
window.match(/(?:name="id"\s+value=|data-id=|[?&]id=)"?(\d{3,6})/)?.[1] ?? null
);
}
// 2. POST the form, stream the zip to disk.
async function downloadBulkZip(format = "csv") {
const { cookie, token, id, fname } = await getDownloadContext(format);
const body = new URLSearchParams({
id,
apiKey: KEY,
__RequestVerificationToken: token,
});
const res = await fetch(`${BASE}/PublicData/Download`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Cookie: cookie,
Origin: BASE,
Referer: `${BASE}/`,
},
body,
});
if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
const dir = mkdtempSync(join(tmpdir(), "ep-online-"));
const zipPath = join(dir, fname ?? `ep_${format}.zip`);
writeFileSync(zipPath, Buffer.from(await res.arrayBuffer()));
return { dir, zipPath };
}
// 3. Unzip (platform unzip) + parse the semicolon CSV into an address index.
function parseCsvToIndex(zipPath, dir) {
execFileSync("unzip", ["-o", zipPath, "-d", dir]);
const csvName = execFileSync("sh", ["-c", `ls "${dir}"/*.csv`]).toString().trim();
const lines = readFileSync(csvName, "utf8").split(/\r?\n/);
// line 0 = PublicatieDatum, line 1 = LaatstVerwerkteMutatievolgnummer, line 2 = header
const header = lines[2].split(";");
const col = (name) => header.indexOf(name);
const iPost = col("Postcode");
const iNum = col("Huisnummer");
const iLetter = col("Huisletter");
const iToev = col("Huisnummertoevoeging");
const iClass = col("Energieklasse");
const iValid = col("GeldigTot");
const iReg = col("Registratiedatum");
const iBag = col("BAGVerblijfsobjectID");
const byAddress = new Map();
for (let i = 3; i < lines.length; i++) {
if (!lines[i]) continue;
const f = lines[i].split(";");
const key = `${(f[iPost] || "").toUpperCase()}|${f[iNum] || ""}`;
const rec = {
energyClass: f[iClass] || null,
registeredOn: f[iReg] || null,
validUntil: f[iValid] || null,
bagId: f[iBag] || null,
houseLetter: f[iLetter] || null,
addition: f[iToev] || null,
};
// one postcode+number can have several units; keep them all
if (!byAddress.has(key)) byAddress.set(key, []);
byAddress.get(key).push(rec);
}
return byAddress;
}
function normalisePostcode(pc) {
return pc.replace(/\s+/g, "").toUpperCase();
}
export async function buildIndex(format = "csv") {
const { dir, zipPath } = await downloadBulkZip(format);
return parseCsvToIndex(zipPath, dir);
}
export function lookup(index, postcode, huisnummer, { letter, addition } = {}) {
const hits = index.get(`${normalisePostcode(postcode)}|${huisnummer}`) ?? [];
if (hits.length <= 1) return hits[0] ?? null;
// refine multi-unit matches
return (
hits.find(
(h) =>
(!letter || h.houseLetter === letter) &&
(!addition || h.addition === addition),
) ?? hits[0]
);
}
// --- demo ---
if (import.meta.url === `file://${process.argv[1]}`) {
const index = await buildIndex("csv");
console.log("rows indexed:", index.size);
console.log("3039SG 47 →", lookup(index, "3039 SG", "47"));
}
For a single on-demand lookup (no bulk download), use Mode A instead:
export async function lookupLive(postcode, huisnummer, opts = {}) {
const u = new URL("https://public.ep-online.nl/api/v5/PandEnergielabel/Adres");
u.searchParams.set("postcode", postcode.replace(/\s+/g, "").toUpperCase());
u.searchParams.set("huisnummer", String(huisnummer));
if (opts.letter) u.searchParams.set("huisletter", opts.letter);
if (opts.addition) u.searchParams.set("huisnummertoevoeging", opts.addition);
const res = await fetch(u, {
headers: { Authorization: process.env.EP_ONLINE_API_KEY },
});
if (res.status === 401)
throw new Error("key not web-service-enabled; request web-service access at apikey.ep-online.nl");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
The driving use case: a property feed (e.g. scraped listings) where some rows have no energy label. EP-Online is the authoritative source.
flowchart TD
A["listings DB<br/>(some missing label)"] --> B{"volume?"}
B -->|"few, on demand"| C["Mode A: per-address web service"]
B -->|"many / nightly"| D["Mode B: monthly bulk CSV"]
D --> E["monthly cron:<br/>download + parse + index"]
E --> F["join by BAG id, else<br/>postcode+huisnummer"]
C --> G["write Energieklasse +<br/>validUntil onto listing"]
F --> G
GeldigTot through — an expired label should be flagged, not shown as current.401 on /api/v5/.... If you need Mode A, confirm/request web-service access for the key at apikey.ep-online.nl./api/v5/PandEnergielabel/Adres (the dataset uses the Dutch column names above; the REST response keys may differ slightly). Verify against a live 200 once the key is web-service-enabled.testing
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".