skills/pdok/SKILL.md
Resolve and enrich Dutch addresses from a Node / Next.js / Worker backend using PDOK (the Dutch national geo / open-data platform) and the Kadaster BAG API. Covers the PDOK Locatieserver for geocoding (suggest + lookup + free endpoints, free and no key) to turn an address or postcode into coordinates plus the official BAG identifiers, then the Kadaster BAG API Individuele Bevragingen (free API key) to read a building's bouwjaar (build year), oppervlakte (floor area) and gebruiksdoelen (use purpose). Includes copy-pasteable Node fetch helpers, a real worked example, and the real-estate / CRM use cases (enrich a listing with build year + floor area + map coordinates, validate and normalise an address, generate real Dutch addresses). Use when the user wants to geocode a Netherlands address, look up a Dutch postcode, validate or normalise an NL address, enrich a property listing with build year / floor area / coordinates, or mentions "pdok", "kadaster", "bag", "dutch address", or "geocode netherlands".
npx skillsauth add RonanCodes/ronan-skills pdokInstall 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.
Resolve and enrich Dutch addresses without a paid data feed. Two services do almost everything a Dutch CRM or property tool needs:
PDOK (Publieke Dienstverlening Op de Kaart, "public service on the map") is the Dutch government's open-geodata platform, run by the Kadaster (the national land registry). Everything it serves is open data, so the geocoding half is genuinely free to call with no auth. The BAG (Basisregistratie Adressen en Gebouwen, "base registry of addresses and buildings") is the authoritative register of every address and building in the Netherlands.
This skill teaches the two-step flow over plain fetch / curl so it works identically in Node, Next.js route handlers, and Cloudflare Workers, with zero runtime deps.
oppervlakte and gebruiksdoelen.oorspronkelijkBouwjaar (the build year). One pand can hold many verblijfsobjecten (think an apartment block).woonfunctie, kantoorfunctie, winkelfunctie. Lives on the verblijfsobject.flowchart TD
A["Your backend<br/>(address or postcode + huisnummer)"] --> B["PDOK Locatieserver<br/>suggest -> lookup (or free)<br/>FREE, no key"]
B --> C["coordinates (lat/long)<br/>+ BAG ids<br/>(nummeraanduiding, adresseerbaarobject)"]
C --> D["Kadaster BAG API<br/>/adressen?expand=...<br/>FREE key (X-Api-Key)"]
D --> E["bouwjaar (pand)<br/>oppervlakte (verblijfsobject)<br/>gebruiksdoelen"]
C --> F["map pin<br/>(no BAG call needed)"]
For a map pin or a normalised address you only need step one (no key). You only call the BAG API when you want the building facts.
| Service | Base URL | Auth |
|---------|----------|------|
| PDOK Locatieserver (v3.1) | https://api.pdok.nl/bzk/locatieserver/search/v3_1/ | none (open data) |
| Kadaster BAG API (v2) | https://api.bag.kadaster.nl/lvbag/individuelebevragingen/v2/ | free API key in X-Api-Key header |
Note: the BAG API is not served from api.pdok.nl (that path 404s). It lives on api.bag.kadaster.nl. PDOK and the BAG API are operated by the same agency but are separate hosts.
The Locatieserver has three endpoints. Pick by what you have:
suggest — type-ahead. Pass a partial query, get a ranked list of candidates (display name + id + type). Use this for an autocomplete box. The id it returns is what lookup consumes.lookup — resolve a known Locatieserver id to its full record (coordinates, BAG ids). Use after suggest when the user picks a candidate.free — a single free-text or fielded Solr query (it is a Solr index under the hood) that returns full records directly. Use for batch / server-side resolution when you already have a clean address or a postcode: + huisnummer: pair and want the full record in one call.curl -s "https://api.pdok.nl/bzk/locatieserver/search/v3_1/suggest?q=Dam+1+Amsterdam&rows=1"
returns (trimmed):
{
"response": {
"numFound": 370,
"docs": [
{
"type": "adres",
"weergavenaam": "Dam 1, 1012JS Amsterdam",
"id": "adr-2a8dc1af055da20b8bcdc8e4dbda1eaa",
"score": 7.5886006
}
]
}
}
curl -s "https://api.pdok.nl/bzk/locatieserver/search/v3_1/free?q=postcode:1012JS%20and%20huisnummer:1&fl=weergavenaam,centroide_ll,nummeraanduiding_id,adresseerbaarobject_id,postcode,huisnummer,straatnaam,woonplaatsnaam"
returns the one matching address with everything you need:
{
"response": {
"numFound": 1,
"docs": [
{
"type": "adres",
"weergavenaam": "Dam 1, 1012JS Amsterdam",
"straatnaam": "Dam",
"huisnummer": 1,
"postcode": "1012JS",
"woonplaatsnaam": "Amsterdam",
"centroide_ll": "POINT(4.8937175 52.37329259)",
"nummeraanduiding_id": "0363200003761447",
"adresseerbaarobject_id": "0363010003761571"
}
]
}
}
Two things to note about the response shape:
centroide_ll is WGS84 lat/long but encoded as WKT POINT(lon lat) (longitude first). Parse it, and remember the order is lon lat, the reverse of how you usually write lat,lng.nummeraanduiding_id and adresseerbaarobject_id are the BAG identifiers. Feed nummeraanduiding_id (or just the postcode + huisnummer) to the BAG API in step two.// geocode.ts — no key, works in Node / Workers / Next route handlers
const LS = "https://api.pdok.nl/bzk/locatieserver/search/v3_1";
export type GeoResult = {
displayName: string; // "Dam 1, 1012JS Amsterdam"
street: string;
houseNumber: number;
postcode: string; // "1012JS" (no space)
city: string;
lat: number;
lng: number;
nummeraanduidingId: string; // BAG address id
adresseerbaarObjectId: string; // BAG addressable-object id
};
function parsePoint(wkt: string): { lng: number; lat: number } {
// "POINT(4.8937175 52.37329259)" -> lon first, then lat
const m = /POINT\(([-\d.]+)\s+([-\d.]+)\)/.exec(wkt);
if (!m) throw new Error(`bad WKT point: ${wkt}`);
return { lng: parseFloat(m[1]), lat: parseFloat(m[2]) };
}
// Free-text / fielded resolve in one call. Returns the best match or null.
export async function geocode(query: string): Promise<GeoResult | null> {
const fl = [
"weergavenaam", "straatnaam", "huisnummer", "postcode",
"woonplaatsnaam", "centroide_ll",
"nummeraanduiding_id", "adresseerbaarobject_id",
].join(",");
const url = `${LS}/free?q=${encodeURIComponent(query)}&fl=${fl}&rows=1`;
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`Locatieserver ${res.status}`);
const data = await res.json();
const doc = data?.response?.docs?.[0];
if (!doc) return null;
const { lat, lng } = parsePoint(doc.centroide_ll);
return {
displayName: doc.weergavenaam,
street: doc.straatnaam,
houseNumber: doc.huisnummer,
postcode: doc.postcode,
city: doc.woonplaatsnaam,
lat, lng,
nummeraanduidingId: doc.nummeraanduiding_id,
adresseerbaarObjectId: doc.adresseerbaarobject_id,
};
}
// Resolve a clean postcode + house number (the common CRM case).
export function geocodePostcode(postcode: string, huisnummer: number) {
const pc = postcode.replace(/\s+/g, "").toUpperCase();
return geocode(`postcode:${pc} and huisnummer:${huisnummer}`);
}
// Type-ahead candidates for an autocomplete box (suggest endpoint).
export async function suggest(q: string, rows = 5) {
const url = `${LS}/suggest?q=${encodeURIComponent(q)}&rows=${rows}`;
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`Locatieserver ${res.status}`);
const data = await res.json();
return (data?.response?.docs ?? []).map((d: any) => ({
id: d.id, label: d.weergavenaam, type: d.type, score: d.score,
}));
}
// Resolve a suggest() id to its full record (lookup endpoint).
export async function lookup(id: string): Promise<GeoResult | null> {
const fl = [
"weergavenaam", "straatnaam", "huisnummer", "postcode",
"woonplaatsnaam", "centroide_ll",
"nummeraanduiding_id", "adresseerbaarobject_id",
].join(",");
const url = `${LS}/lookup?id=${encodeURIComponent(id)}&fl=${fl}`;
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`Locatieserver ${res.status}`);
const doc = (await res.json())?.response?.docs?.[0];
if (!doc) return null;
const { lat, lng } = parsePoint(doc.centroide_ll);
return {
displayName: doc.weergavenaam, street: doc.straatnaam,
houseNumber: doc.huisnummer, postcode: doc.postcode, city: doc.woonplaatsnaam,
lat, lng,
nummeraanduidingId: doc.nummeraanduiding_id,
adresseerbaarObjectId: doc.adresseerbaarobject_id,
};
}
The free query language is Solr: postcode:1012JS and huisnummer:1 is two fielded clauses. You can also pass a plain string (geocode("Dam 1 Amsterdam")) and let the ranker pick. Restrict to addresses only with &fq=type:adres if you do not want gemeenten / streets in the results.
The BAG API needs a free API key. Without one you get:
{ "error": "Kadaster - Niet geauthenticeerd.", "errorDetail": "Missing API Key" }
BAG data is open data (Creative Commons Public Domain Mark), so the key is free, it just gates the endpoint for rate-limiting.
BAG_API_KEY in ~/.claude/.env locally and as a Worker/Vercel secret in prod. Never hardcode it.The key goes in the X-Api-Key header on every call.
expand matters)The facts live on three linked resources:
/adressen) links to a verblijfsobject (the addressable object) and a pand (the building).oppervlakte and gebruiksdoelen live on the verblijfsobject.oorspronkelijkBouwjaar (build year) lives on the pand.You could chase three calls, or you can pass expand=panden,adresseerbaarObject to /adressen and get everything embedded in one response. Prefer the single expanded call.
curl -s \
-H "X-Api-Key: $BAG_API_KEY" \
-H "Accept: application/hal+json" \
-H "Accept-Crs: epsg:28992" \
"https://api.bag.kadaster.nl/lvbag/individuelebevragingen/v2/adressen?postcode=1012JS&huisnummer=1&exacteMatch=true&expand=panden,adresseerbaarObject"
The response is HAL+JSON. The fields you want, by path:
_embedded.adressen[].panden[].pand.oorspronkelijkBouwjaar_embedded.adressen[].adresseerbaarObject.verblijfsobject.oppervlakte (m²)_embedded.adressen[].adresseerbaarObject.verblijfsobject.gebruiksdoelen (array, e.g. ["woonfunctie"])...pand.status (e.g. Pand in gebruik)Accept-Crs: epsg:28992 asks for geometry in the Dutch RD grid (the BAG default). The coordinates a web map wants you already have from the Locatieserver in WGS84, so the CRS here only matters if you read the BAG geometry directly.
// bag.ts — needs BAG_API_KEY
const BAG = "https://api.bag.kadaster.nl/lvbag/individuelebevragingen/v2";
export type BuildingFacts = {
bouwjaar: number | null; // build year (pand)
oppervlakte: number | null; // floor area m² (verblijfsobject)
gebruiksdoelen: string[]; // use purpose(s)
pandStatus: string | null;
};
export async function bagFacts(
postcode: string,
huisnummer: number,
apiKey = process.env.BAG_API_KEY!,
): Promise<BuildingFacts | null> {
const pc = postcode.replace(/\s+/g, "").toUpperCase();
const url =
`${BAG}/adressen?postcode=${pc}&huisnummer=${huisnummer}` +
`&exacteMatch=true&expand=panden,adresseerbaarObject`;
const res = await fetch(url, {
headers: {
"X-Api-Key": apiKey,
Accept: "application/hal+json",
"Accept-Crs": "epsg:28992",
},
});
if (res.status === 404) return null; // address not in BAG
if (!res.ok) throw new Error(`BAG ${res.status}`);
const data = await res.json();
const adres = data?._embedded?.adressen?.[0];
if (!adres) return null;
const vbo = adres?.adresseerbaarObject?.verblijfsobject;
const pand = adres?.panden?.[0]?.pand;
return {
bouwjaar: pand?.oorspronkelijkBouwjaar ?? null,
oppervlakte: vbo?.oppervlakte ?? null,
gebruiksdoelen: vbo?.gebruiksdoelen ?? [],
pandStatus: pand?.status ?? null,
};
}
If exacteMatch=true returns nothing for an address you believe exists, drop it: the BAG sometimes stores a huisletter / huisnummertoevoeging that an exact match without those fields will miss. Pass them through (&huisletter=A, &huisnummertoevoeging=bis) or query without exacteMatch and pick from the list.
One geocode call plus one BAG call gives you everything to render a property card:
import { geocodePostcode } from "./geocode";
import { bagFacts } from "./bag";
export async function enrichListing(postcode: string, huisnummer: number) {
const geo = await geocodePostcode(postcode, huisnummer); // free, no key
if (!geo) return null;
const facts = await bagFacts(postcode, huisnummer); // free key
return {
address: geo.displayName,
map: { lat: geo.lat, lng: geo.lng }, // drop straight onto Leaflet / Mapbox
bouwjaar: facts?.bouwjaar ?? null, // real build year, authoritative
oppervlakte: facts?.oppervlakte ?? null, // real floor area in m²
use: facts?.gebruiksdoelen ?? [], // ["woonfunctie"], ["kantoorfunctie"], ...
};
}
This replaces self-reported or scraped figures with the authoritative register. Useful to cross-check a Funda scrape (see the apify skill): if the listing claims a build year that disagrees with the BAG pand, flag it.
The Locatieserver is your validator. Run the user's free-text address through geocode():
null result, or numFound: 0 -> the address does not resolve. Reject or ask the user to re-enter.weergavenaam ("Dam 1, 1012JS Amsterdam") is the normalised display string; postcode comes back without the internal space (1012JS), straatnaam / huisnummer / woonplaatsnaam are the canonical parts. Store those, not the raw input.This is the cheapest way to stop dirty Dutch addresses entering a CRM, and it needs no key.
Do not invent postcodes. Query the Locatieserver for real ones. Two patterns:
free?q=straatnaam:Kalverstraat and woonplaatsnaam:Amsterdam&rows=20&fl=weergavenaam,postcode,huisnummer,centroide_ll.free?q=postcode:1012*&rows=20.Every row is a genuine address with a genuine postcode and real coordinates, so demo data looks right and (bonus) round-trips through your own validator.
geodata.nationaalgeoregister.nl Locatieserver host is deprecated; use api.pdok.nl/bzk/locatieserver/search/v3_1 (the URLs above).There is no published hard quota for these open services, but they are shared national infrastructure. Be a good citizen:
suggest-backed type-ahead, debounce ~300ms and only fire from ~3 characters. Do not call on every keystroke.429 / 503 with a short retry.User-Agent identifying your app so the operators can reach you if a job misbehaves.5xx as transient, not as "address invalid".PDOK serves far more than addresses, via standard OGC interfaces. You will not need these for a CRM, but know they exist:
Browse the catalogue at https://www.pdok.nl/datasets. For a CRM, the Locatieserver + BAG API pair above is the right altitude; reach for WFS/OGC API only when you need geometry, layers, or bulk extracts.
apify skill (funda-nl-scraper) and cross-check its build years against the BAG._embedded HAL path for the expanded pand/verblijfsobject (panden[].pand vs _embedded.panden[]) can vary slightly between /adressen and /adressenuitgebreid. The helper reads adres.panden[].pand and adres.adresseerbaarObject.verblijfsobject, which matches /adressen?expand=panden,adresseerbaarObject; confirm the shape against one live response with your key before relying on it in production, and consider /adressenuitgebreid if you want the building facts flattened onto the address record.oppervlakte. Decide per use case whether you want the unit area or an aggregate.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".