skills/cbs/SKILL.md
Query CBS (Statistics Netherlands / Centraal Bureau voor de Statistiek) open data from a Node / Next.js / Worker backend. Covers the StatLine OData API (v3 ODataApi and v4 datasets.cbs.nl), how to find a table id from the catalog, querying with $filter / $select / $top / $skip, the difference between untyped and typed datasets and dimension codelists, a copy-pasteable Node fetch helper, and real-estate worked examples (average existing-dwelling sale price per region/period, Kerncijfers wijken en buurten neighbourhood stats) to enrich a Dutch property CRM's neighbourhood context. Free, no API key, no auth. Use when the user wants CBS data, Dutch statistics, neighbourhood/wijk-buurt enrichment, average house prices for a region, or mentions "cbs", "statistics netherlands", "dutch open data", "statline", "kerncijfers wijken en buurten", or "bestaande koopwoningen".
npx skillsauth add RonanCodes/ronan-skills cbsInstall 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.
CBS (Centraal Bureau voor de Statistiek, Statistics Netherlands) publishes its entire StatLine database as machine-readable open data over the OData protocol. Every published table has a numeric-ish id (e.g. 83625NED), and you read it as a normal HTTP GET that returns JSON. No SDK, no signup, no API key. The data is free to reuse; CBS asks only that you attribute the source.
This skill teaches how to find a table id from the catalog, how to query a table (filter, select, paginate), the two OData versions CBS runs side by side, the difference between the typed and untyped dataset endpoints, and how dimension codelists turn cryptic codes into human labels. It ends with two worked, verified real-estate examples for enriching a listing's neighbourhood context.
Everything uses plain fetch / curl so it works identically in Node, Next.js route handlers, and Cloudflare Workers, with zero runtime dependencies.
83625NED. The NED suffix marks the Dutch-language edition (some tables also have an ENG edition). You need this id to query.RegioS (region) and Perioden (period). Cells are identified by their dimension codes.RegioS code NL01 means "Nederland"; Perioden code 2024JJ00 means the year 2024. Each dimension has its own endpoint that returns its codelist.UntypedDataSet returns every value as a string exactly as stored; TypedDataSet casts numbers to numbers. Use TypedDataSet for analysis; use UntypedDataSet only when you need the raw string form.GemiddeldeVerkoopprijs_1, average sale price). In v4 these live under MeasureCodes.There is no token and no header to set. Every endpoint below is a public GET.
The data is free to reuse. CBS asks that you credit the source. A line like "Source: CBS, StatLine (opendata.cbs.nl)" on any chart or page that surfaces the numbers is enough. See CBS open data.
CBS runs two services in parallel against the same tables. Pick one and stay on it; the shapes differ.
| | v3 (ODataApi) | v4 (datasets) |
|---|---|---|
| Base | https://opendata.cbs.nl/ODataApi/odata/<tableId>/ | https://datasets.cbs.nl/odata/v1/CBS/<tableId>/ |
| Data endpoint | TypedDataSet, UntypedDataSet | Observations |
| Dimensions | one endpoint per dimension (RegioS, Perioden) | Dimensions, <dim>Codes, <dim>Groups |
| Measures | columns on each data row | MeasureCodes + a Measure field per observation |
| JSON | add ?$format=json (XML otherwise) | JSON by default |
| Catalog | https://opendata.cbs.nl/ODataCatalog/Tables | https://datasets.cbs.nl/odata/v1/CBS/Datasets |
The v3 TypedDataSet is the friendliest for a quick read: one row per cell, each measure as its own named column. The v4 Observations shape is long-format (one row per measure value, with a Measure field) and is the better fit if you want a uniform shape across many tables. The worked examples below use v3 as the default and note the v4 equivalent.
flowchart TD
A["You: a keyword<br/>('koopwoningen')"] --> B["Catalog<br/>ODataCatalog/Tables ?filter"]
B --> C["Table id<br/>(e.g. 83625NED)"]
C --> D["Service root<br/>list endpoints/dimensions"]
D --> E["Codelists<br/>RegioS, Perioden"]
D --> F["TypedDataSet<br/>?filter + ?select + ?top"]
E --> G["Map codes to labels<br/>NL01 -> Nederland"]
F --> G
G --> H["Rows into your CRM<br/>(region + period + value)"]
The catalog is itself an OData feed. Filter Title with substringof.
# v3 catalog: find tables whose title mentions koopwoningen (owner-occupied dwellings)
curl -s "https://opendata.cbs.nl/ODataCatalog/Tables?\$filter=substringof('koopwoningen',Title)&\$select=Identifier,Title,Period&\$top=5&\$format=json"
Returns (verified):
{"value":[
{"Identifier":"85773NED","Title":"Bestaande koopwoningen; verkoopprijzen prijsindex 2020=100","Period":"januari 1995 - mei 2026"},
{"Identifier":"85792NED","Title":"Bestaande koopwoningen; verkoopprijzen, prijsindex 2020=100, regio","Period":"1e kwartaal 1995 - 1e kwartaal 2026"}
]}
The two real-estate tables worth knowing:
83625NED — Bestaande koopwoningen; gemiddelde verkoopprijzen, regio. The average sale price in euros per region (NL, landsdelen, provincies, gemeenten) per year. This is the one to enrich a listing with "what does an existing home cost around here".85773NED / 85792NED — the price index (2020=100), monthly/quarterly. Use for trend ("prices up 4% YoY"), not for an absolute euro figure.85984NED — Kerncijfers wijken en buurten 2024 (a new edition each year: 86165NED is 2025). Per-neighbourhood (buurt) and per-district (wijk) demographics: population, households, average WOZ value, income, density. This is the neighbourhood-context goldmine.You can also browse titles in the StatLine portal and copy the id out of the URL.
GET the service root to see what a table offers.
curl -s "https://opendata.cbs.nl/ODataApi/odata/83625NED?\$format=json"
For 83625NED this lists: TableInfos, UntypedDataSet, TypedDataSet, DataProperties, CategoryGroups, and the two dimension codelists RegioS and Perioden. TableInfos carries the title, summary, period coverage, and last-modified date.
Before reading data, learn the codes for the dimensions you care about, so you can both filter on them and label the results.
# Period codes: 1995JJ00 -> "1995" (JJ00 = full-year aggregate)
curl -s "https://opendata.cbs.nl/ODataApi/odata/83625NED/Perioden?\$top=2&\$format=json"
# -> {"value":[{"Key":"1995JJ00","Title":"1995",...},{"Key":"1996JJ00","Title":"1996",...}]}
# Region codes: NL01 -> Nederland, then landsdelen / provinces / municipalities
curl -s "https://opendata.cbs.nl/ODataApi/odata/83625NED/RegioS?\$top=5&\$format=json"
Note a wart: in TypedDataSet, the RegioS code comes back space-padded to a fixed width ("NL01 "). When you $filter on it you must match that padding (URL-encode the trailing spaces as %20). The codelist Key is the un-padded form. Trim before display.
The query options are standard OData. Combine them with &.
$filter — row predicate. eq, and, or, startswith(field,'x'), substringof('x',field).$select — return only these columns (smaller payloads).$top / $skip — page size / offset. Page with $skip for tables over the 10,000-cell standard-API limit.$format=json — JSON instead of XML (v3 only; v4 is JSON by default).$orderby — sort.$inlinecount=allpages (v3) gives a total count alongside the page.Worked example, verified: average existing-dwelling sale price for the whole of the Netherlands (RegioS = NL01) in 2024.
curl -s "https://opendata.cbs.nl/ODataApi/odata/83625NED/TypedDataSet?\$filter=Perioden%20eq%20'2024JJ00'%20and%20RegioS%20eq%20'NL01%20%20'&\$select=RegioS,Perioden,GemiddeldeVerkoopprijs_1&\$format=json"
Returns:
{"value":[{"RegioS":"NL01 ","Perioden":"2024JJ00","GemiddeldeVerkoopprijs_1":450985}]}
So the average sale price for an existing home in the Netherlands in 2024 was EUR 450,985.
The v4 equivalent (long-format, no $format needed):
curl -s "https://datasets.cbs.nl/odata/v1/CBS/83625NED/Observations?\$filter=Perioden%20eq%20'2024JJ00'%20and%20RegioS%20eq%20'NL01'&\$select=Measure,RegioS,Perioden,Value"
# -> {"value":[{"Measure":"M001534","RegioS":"NL01","Perioden":"2024JJ00","Value":450985.0}]}
Note v4 does not space-pad the codes.
Drop-in, zero deps, works in Node 18+, Next.js route handlers, and Workers. Pulls a table page, optionally filtered, and maps rows.
// cbs.ts — CBS StatLine OData v3 client. No auth.
const CBS_V3 = "https://opendata.cbs.nl/ODataApi/odata";
const CBS_CATALOG = "https://opendata.cbs.nl/ODataCatalog/Tables";
type ODataPage<T> = { value: T[]; "odata.nextLink"?: string };
async function odataGet<T>(url: string): Promise<ODataPage<T>> {
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) throw new Error(`CBS ${res.status} for ${url}`);
return res.json() as Promise<ODataPage<T>>;
}
/** Find candidate table ids from a keyword in the title. */
export async function findTables(keyword: string) {
const q = new URLSearchParams({
$filter: `substringof('${keyword.replace(/'/g, "''")}',Title)`,
$select: "Identifier,Title,Period",
$top: "10",
$format: "json",
});
const { value } = await odataGet<{ Identifier: string; Title: string; Period: string }>(
`${CBS_CATALOG}?${q}`
);
return value;
}
/**
* Read a table's TypedDataSet, auto-paginating past the 10k-cell limit.
* `filter` and `select` are raw OData expressions (already URL-safe-ish; we encode them).
*/
export async function fetchTable<T = Record<string, unknown>>(
tableId: string,
opts: { filter?: string; select?: string; max?: number } = {}
): Promise<T[]> {
const pageSize = 10000;
const rows: T[] = [];
let skip = 0;
for (;;) {
const q = new URLSearchParams({ $top: String(pageSize), $skip: String(skip), $format: "json" });
if (opts.filter) q.set("$filter", opts.filter);
if (opts.select) q.set("$select", opts.select);
const { value } = await odataGet<T>(`${CBS_V3}/${tableId}/TypedDataSet?${q}`);
rows.push(...value);
if (value.length < pageSize) break;
skip += pageSize;
if (opts.max && rows.length >= opts.max) break;
}
return opts.max ? rows.slice(0, opts.max) : rows;
}
/** Load a dimension codelist as a code -> label map (e.g. RegioS, Perioden). */
export async function codeMap(tableId: string, dimension: string): Promise<Map<string, string>> {
const q = new URLSearchParams({ $select: "Key,Title", $top: "5000", $format: "json" });
const { value } = await odataGet<{ Key: string; Title: string }>(
`${CBS_V3}/${tableId}/${dimension}?${q}`
);
return new Map(value.map((r) => [r.Key.trim(), r.Title]));
}
Usage: enrich a listing's region with the latest average sale price, labelled.
import { fetchTable, codeMap } from "./cbs";
type PriceRow = { RegioS: string; Perioden: string; GemiddeldeVerkoopprijs_1: number };
const TABLE = "83625NED";
const regions = await codeMap(TABLE, "RegioS"); // "NL01" -> "Nederland", "GM0363" -> "Amsterdam"
const rows = await fetchTable<PriceRow>(TABLE, {
filter: "Perioden eq '2024JJ00'",
select: "RegioS,Perioden,GemiddeldeVerkoopprijs_1",
});
const enriched = rows.map((r) => ({
regionCode: r.RegioS.trim(),
regionLabel: regions.get(r.RegioS.trim()) ?? r.RegioS.trim(),
year: 2024,
avgSalePriceEur: r.GemiddeldeVerkoopprijs_1,
}));
// -> { regionCode: "NL01", regionLabel: "Nederland", year: 2024, avgSalePriceEur: 450985 }, ...
Municipality region codes are GM + 4 digits (Amsterdam = GM0363). To enrich a specific listing, map its municipality to a GM code and filter on it. Get the full GM codelist from the table's RegioS endpoint, or from the dedicated CBS region-codes tables.
Table 85984NED (2024 edition) has per-buurt (neighbourhood) and per-wijk (district) figures: population, households, average household size, average WOZ value, density. The dimension is WijkenEnBuurten, coded GM#### (gemeente), WK###### (wijk), BU######## (buurt).
# list the topic columns available
curl -s "https://opendata.cbs.nl/ODataApi/odata/85984NED/DataProperties?\$select=Key,Title&\$format=json" | head -c 1500
# pull every buurt in Amsterdam (codes starting BU0363) with a couple of useful fields
curl -s "https://opendata.cbs.nl/ODataApi/odata/85984NED/TypedDataSet?\$filter=startswith(WijkenEnBuurten,'BU0363')&\$top=5&\$format=json"
For a listing CRM, the move is: from the listing's postcode or municipality, resolve the BU (buurt) code, then pull that buurt's row to attach "this neighbourhood: ~X residents, average WOZ EUR Y, Z households" alongside the listing. WOZ value and population density are the two fields that most change how a buyer reads a street.
$skip. For genuinely large pulls CBS also offers the "feed" / bulk variant, but for region+period slices the paged read is plenty.TypedDataSet. Match the padding in $filter, trim for display. v4 does not pad.JJ00 = full-year aggregate in Perioden; KW01..KW04 are quarters, MM01..MM12 months. Annual tables only carry JJ00.85984NED 2024, 86165NED 2025). Resolve the latest id from the catalog rather than hardcoding.GemiddeldeVerkoopprijs_1 = average sale price, Bevolking = population, GemiddeldeWoningwaarde = average WOZ home value. Read DataProperties for the full list per table.datasets.cbs.nl service exposes richer dimension metadata (Groups, hierarchy) than v3. If you need the full region hierarchy (which buurts roll up to which wijk to which gemeente), prefer v4's RegioSGroups / WijkenEnBuurtenGroups over reconstructing it from code prefixes. Confirm the group shape against the specific table before relying on it.1011AB) to a CBS BU buurt code is not done by CBS's price/wijk tables themselves. CBS publishes a separate postcode-to-buurt linkage; wire that lookup before enriching by postcode rather than by municipality.ENG) edition with the same data and English labels. Check the catalog for an ...ENG sibling if you want English topic names instead of mapping the Dutch ones.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".