# GroundTruthData > GroundTruthData is a paid MCP server that gives Claude, ChatGPT and other AI assistants cited, aggregated facts about South African places: crime, rainfall, population and boundaries, with source, licence and date on every number. GroundTruthData is operated by BI Branch (Pty) Ltd. It is a paid remote MCP server: bounded tools return aggregated, cited answers, never bulk data. Every figure carries its source, licence, retrieval date and dataset version. MCP endpoint: https://mcp.groundtruthdata.co.za/mcp. Each page below is also served as Markdown at the same path with a .md extension. --- # Introduction > GroundTruthData gives AI assistants cited, aggregated facts about South African places through the Model Context Protocol (MCP). GroundTruthData is a **remote MCP server**. Once connected, your assistant can call a small set of bounded tools to answer questions about South African places (crime, rainfall, population and boundaries) and every number it gets back carries its **source, licence, retrieval date and dataset version**. - [Quickstart](/docs/quickstart): Connect Claude, ChatGPT or Cursor in a few minutes. - [Authentication](/docs/authentication): OAuth sign-in for connectors, API keys for agents and scripts. - [Tools](/docs/tools): Each tool's inputs, caps, a cited example response and its errors. - [For agents](/docs/agents): Markdown twins of every page, llms.txt and llms-full.txt. - [Citations](/docs/citations): How provenance is attached to every number. ## What it is not - **Not a data download.** Tools return aggregated answers with caps on rows and areas. No bulk exports. - **Not personal data.** Sources are public statistics and boundaries; personal fields are dropped at extraction. - **Not advice.** Answers are evidence for your own judgement, with the paper trail attached. --- # How GroundTruthData works > A plain-English overview of where the numbers come from and how your AI assistant gets cited answers. GroundTruthData answers questions about **places in South Africa** (crime, population and rainfall for police stations, wards, municipalities and provinces) inside the AI assistant you already use. Every number comes with its source. ## What you do 1. Connect GroundTruthData to your assistant once (see the [Quickstart](/docs/quickstart)). 2. Ask a normal question, for example: *"How did murders change in Khayelitsha last year?"* 3. Your assistant looks up the figures with our tools and answers with a citation, in this shape (the figures below are placeholders, not real numbers; see the [Tool reference](/docs/tools) for real responses): > SAPS recorded **N murders** at *a named police station* in *a financial year*. > *Source: South African Police Service, Crime Statistics.* Note: these are crimes recorded by > police, not all crime. Figures cover the police station's area. ## Where the numbers come from 1. **Public sources only.** We use published, publicly available data: SAPS crime statistics, Municipal Demarcation Board ward and municipal boundaries, WorldPop population estimates, CHIRPS satellite rainfall and OpenStreetMap facility counts. We never get around logins, and we collect politely. See [Coverage](/coverage) for each source and its licence. 2. **No personal information.** We keep only approved fields. Names, ID numbers and contact details are dropped before anything is stored. 3. **Lined up on one map.** Each source uses its own map (police station areas, wards, satellite grids). We line them up so figures can be given per ward, municipality or province. Crime stays at police station and province level, because police areas do not match wards. 4. **Defined metrics.** Every figure we serve has a name, a clear definition and its caveats. Your assistant can ask what is available at any time. ## What you get back Every answer includes: - **Citations**: the source, publisher, licence, the date we retrieved it and the data version. - **Caveats**: for example, that a population figure is a modelled estimate, not a census. - **Summaries, not downloads**: totals, averages and comparisons, with limits on size. We do not offer bulk exports of the underlying data. See [Citations and provenance](/docs/citations) and [Tools](/docs/tools) for the details. ## Signing in, keys and plans - **Connectors** (Claude, ChatGPT) send you to groundtruthdata.co.za to sign in and approve access. See [Authentication](/docs/authentication). Connector sign-in is coming at launch; until then, use an API key. - **API keys** are for scripts and agents. Create them in your dashboard. A key is shown once; we store only a scrambled fingerprint of it. - **Plans** are flat monthly subscriptions with clear daily and monthly limits. No credits and no surprise overage charges. When you reach a limit, the tool says so and tells you when it resets. See [Plans and limits](/docs/limits). > **Evidence, not advice** > > GroundTruthData gives you sourced figures to support your own judgement. It is not legal, > financial or safety advice. --- # Quickstart > Connect GroundTruthData to Claude, ChatGPT, Cursor or any MCP client. The MCP endpoint is: ```txt https://mcp.groundtruthdata.co.za/mcp ``` You need an account and an active plan. [Create an account](/sign-in?mode=signup), [choose a plan](/pricing), then pick your client. **Connector (OAuth) sign-in is coming at launch; until then, use an API key** (see the Claude Code and Other MCP clients tabs). ### Claude (desktop & web) 1. Open **Settings → Connectors** and choose **Add custom connector**. 2. Name it `GroundTruthData` and paste the endpoint URL above. Leave the OAuth fields empty: Claude discovers our authorization server automatically. 3. Click **Connect**. You are sent to groundtruthdata.co.za to sign in and approve access. 4. In a new chat, enable the connector from the tools menu and ask, for example: *“Compare residential burglary in Stellenbosch and Drakenstein over the last three financial years, with sources.”* ### Claude Code Use OAuth (recommended, opens a browser to sign in): ```bash claude mcp add --transport http groundtruthdata https://mcp.groundtruthdata.co.za/mcp ``` Then run `/mcp` inside Claude Code and choose **Authenticate**. Or use an API key from [your dashboard](/app/keys) (good for CI and headless agents): ```bash claude mcp add --transport http groundtruthdata https://mcp.groundtruthdata.co.za/mcp \ --header "Authorization: Bearer $GTD_API_KEY" ``` ### ChatGPT 1. Open **Settings → Apps & Connectors**, enable developer mode if your plan requires it, and choose **Create**. 2. Paste the endpoint URL, choose **OAuth** as the authentication method and save. 3. Sign in at groundtruthdata.co.za when prompted, then enable the connector in a chat. ### Cursor Add the server to `.cursor/mcp.json` in your project (or `~/.cursor/mcp.json` for all projects). Keep the key in an environment variable so it never lands in the repository: ```json { "mcpServers": { "groundtruthdata": { "url": "https://mcp.groundtruthdata.co.za/mcp", "headers": { "Authorization": "Bearer ${env:GTD_API_KEY}" } } } } ``` Create the key in [your dashboard](/app/keys) and export it as `GTD_API_KEY` before starting Cursor. Then open **Settings → MCP**, check that `groundtruthdata` shows its five tools, and ask a question in Agent mode. ### Other MCP clients Any client that supports the Streamable HTTP transport works. Either let it discover OAuth from `https://mcp.groundtruthdata.co.za/.well-known/oauth-protected-resource`, or send an API key: ```json { "mcpServers": { "groundtruthdata": { "type": "http", "url": "https://mcp.groundtruthdata.co.za/mcp", "headers": { "Authorization": "Bearer gtd_test_example" } } } } ``` Replace `gtd_test_example` with your own key. ## Check it works Ask your assistant to call `describe_catalog` (free, it does not count against your plan). It lists the metrics, geographies and per-call limits. Then see the [Tool reference](/docs/tools). > **Keep keys secret** > > API keys are shown once. Store them in your secrets manager or environment, never in a repository. > Revoke a key from the dashboard the moment you suspect it leaked. --- # Authentication > OAuth 2.1 for connectors, Bearer API keys for agents and scripts. ## OAuth 2.1 (connectors) **Connector sign-in is coming at launch; until then, use an [API key](#api-keys).** Claude and ChatGPT connectors use OAuth. GroundTruthData's website is the **authorization server**; the MCP endpoint is the **protected resource**. | | URL | |---|---| | Protected resource metadata | `https://mcp.groundtruthdata.co.za/.well-known/oauth-protected-resource` | | Authorization server metadata | `https://groundtruthdata.co.za/.well-known/oauth-authorization-server/api/auth` | | Authorize | `https://groundtruthdata.co.za/api/auth/oauth2/authorize` | | Token | `https://groundtruthdata.co.za/api/auth/oauth2/token` | | JWKS | `https://groundtruthdata.co.za/api/auth/jwks` | - Clients identify themselves with a **Client ID Metadata Document** (an HTTPS URL as `client_id`). Dynamic Client Registration is not enabled. - Authorization code flow with **PKCE (S256)** only. - Access tokens are short-lived JWTs whose audience is the MCP endpoint. ## API keys Create keys in [Dashboard → API keys](/app/keys). Send them as a Bearer token: ```http Authorization: Bearer gtd_test_example ``` - `gtd_live_…` keys count against your plan. `gtd_test_…` keys are for development. - We store only a SHA-256 hash; a lost key cannot be recovered, only replaced. - Each plan has a maximum number of active keys. --- # Plans and limits > How daily and monthly limits, per-call caps and limit errors work. Every plan has **hard limits**: tool calls per UTC day and per UTC calendar month, plus per-call caps on rows and areas. There are no credits and no overage charges. See [pricing](/pricing) for the numbers. - Each tool call counts as one call, whether you use OAuth or an API key. - When a limit is reached, tools return an error explaining which limit and when it resets. Your assistant will usually relay this to you. - We email you at 80% and 100% of your monthly limit. - Usage is visible in [Dashboard → Usage](/app/usage). --- # For agents > Machine-readable ways to read these docs and what the server expects of an agent. ## Read the docs as Markdown - Every docs page is also served as clean Markdown at its path plus `.md`, for example `/docs/quickstart.md` and `/docs/tools/query_metrics.md`. The index is `/docs.md`. - [`/llms.txt`](/llms.txt) lists every docs page in the llms.txt format. [`/llms-full.txt`](/llms-full.txt) has the full text of all pages in one file. - Both files and the tool reference are generated at build time from these pages and the live tool schemas. If a page and the server disagree, the server is right and the page is a bug: tell us at info@groundtruthdata.co.za. ## How an agent should call the tools 1. Call `describe_catalog` first. It is free and lists metrics, geographies and limits. 2. Turn place names into ids with `resolve_location`. Never guess an `area_id`. If the status is `ambiguous`, ask the user, or call again with `area_type`. 3. Call `query_metrics` or `compare_areas`. Responses are capped; do not try to page through everything. 4. Quote the `citations` and `caveats` with every number. Use `get_provenance` for the licence and method behind a metric. Errors say what went wrong and how to fix it. See the [Tool reference](/docs/tools). --- # Tool reference > The bounded tools the GroundTruthData MCP server exposes. Five read-only tools, generated from the live MCP tool schemas. Recommended order: `describe_catalog`, then `resolve_location`, then `query_metrics` or `compare_areas`, then `get_provenance`. Never guess an `area_id`. | Tool | Purpose | Counts against your plan | |---|---|---| | [`describe_catalog`](/docs/tools/describe_catalog) | List the metrics, geographies, time coverage and per-call limits this server offers. | no (free) | | [`resolve_location`](/docs/tools/resolve_location) | Turn a place name or a lat/lon point into canonical area_ids (max 5 candidates). | yes | | [`query_metrics`](/docs/tools/query_metrics) | Get aggregated, cited metric values for up to 25 areas (max 200 rows). | yes | | [`compare_areas`](/docs/tools/compare_areas) | Compare 2-5 areas on up to 5 metrics: value, rank among them (1 = highest) and the national percentile among all areas of the same type. | yes | | [`get_provenance`](/docs/tools/get_provenance) | Where a metric comes from: publisher, licence, retrieval date, dataset version, method and lineage. | no (free) | ## Bounds on every response Responses are aggregated and capped. There is no bulk export. Maximums: max metrics per call: 5, max areas per call: 25, max rows per call: 200, compare areas min: 2, compare areas max: 5, max location candidates: 5. See [Plans and limits](/docs/limits). ## Errors shared by all tools | Code | Meaning | Fix | |---|---|---| | `UNAUTHENTICATED` | No valid credential on the request (or the account no longer exists). | Send the header `Authorization: Bearer YOUR_KEY` with a current API key, or reconnect with OAuth. | | `SUBSCRIPTION_REQUIRED` | 402. The account has no active plan, so no call is allowed. | Choose a plan on the pricing page. | | `RATE_LIMITED` | 429. Too many calls in a short burst, a plan quota is used up, or too many failed calls today. The message says which, and when it resets. | Wait for the time given, space out calls, or upgrade. | | `LIMIT_EXCEEDED` | The request is over a per-call cap (areas or rows) of your plan. | Split the request, narrow the years or set `time_grain` to `year`. | | `UNKNOWN_TOOL` | The tool name is not one of the five tools. | Use a name from this page. | | `INVALID_ARGUMENT` | An input is missing, malformed or inconsistent. | Read the message: it names the argument and the correction. | | `UNKNOWN_METRIC` | The metric id is not in the catalogue. | Call `describe_catalog` for valid ids. | | `UNKNOWN_AREA` | The area_id is not known. | Call `resolve_location` with a place name; never guess ids. | | `GEOGRAPHY_NOT_ALLOWED` | The metric is not published for that type of area. | Use an area type the message lists, for example via `resolve_location` with `area_type`. | | `NO_DATA` | The metric has no data loaded. | Call `describe_catalog` to see which metrics have coverage. | | `UNAVAILABLE` | The data store is not available right now. | Retry in a few minutes. | | `PROVENANCE_MISSING` | A value could not be cited, so none was returned. This is a server-side data issue. | Report it to info@groundtruthdata.co.za. | Each tool page lists its own errors with the exact text the server returns. --- # describe_catalog > List the metrics, geographies, time coverage and per-call limits this server offers. List the metrics, geographies, time coverage and per-call limits this server offers. Call this first. Free (does not count against your plan). **Counts against your plan:** no, free. **Read-only:** yes, nothing you send is stored or changes any data. ## Inputs This tool takes no arguments. ## Caps No row or area caps apply: the response is a fixed-size summary. ## Response Every response also carries `citations`, `caveats` and `dataset_version` ([Citations](/docs/citations)). | Field | Type | Description | |---|---|---| | `citations` | `object[]` | | | `caveats` | `string[]` | | | `dataset_version` | `string` | | | `metrics` | `object[]` | | | `geographies` | `object[]` | | | `limits` | `object` | | | `how_to` | `string[]` | | **`metrics[]`** (each item) | Field | Type | Description | |---|---|---| | `id` | `string` | | | `label` | `string` | | | `description` | `string` | | | `unit` | `string` | | | `kind` | `string` | | | `spatial_aggregation` | `string` | | | `native_geographies` | `string[]` | | | `allowed_geographies` | `string[]` | | | `time_grain` | `string` | | | `time_rollups` | `object` | | | `period_format` | `string` | | | `first_period` | `string` | | | `last_period` | `string` | | | `available_ids` | `string[]` | Concrete ids for metric families | | `family_members` | `object[]` | | | `caveats` | `string[]` | | **`geographies[]`** (each item) | Field | Type | Description | |---|---|---| | `area_type` | `string` | | | `label` | `string` | | | `id_format` | `string` | | | `example` | `string` | | **`metrics.family_members[]`** (each item) | Field | Type | Description | |---|---|---| | `id` | `string` | | | `label` | `string` | | | `is_aggregate` | `boolean` | SAPS roll-up category: never add it to its component offences | ## Examples > **Fixture data** > > These responses were produced by the real tool against the synthetic test dataset (`fixture-2026.11.05`), so the numbers are illustrative, not real statistics. Production responses cite the real sources. ### Everything the server offers Request: ```json {} ``` Response: (lists shortened) ```json { "citations": [ { "source_id": "worldpop_population", "name": "[FIXTURE] WorldPop Constrained Population 100 m (R2025A)", "publisher": "WorldPop, University of Southampton", "url": "https://www.worldpop.org/", "licence": "CC-BY-4.0", "commercial_use": "yes", "attribution": "WorldPop (www.worldpop.org), University of Southampton. CC BY 4.0.", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" }, { "source_id": "mdb_wards_2026", "name": "[FIXTURE] MDB Wards 2026", "publisher": "Municipal Demarcation Board (MDB)", "url": "https://www.demarcation.org.za/", "licence": "MDB custom terms", "commercial_use": "unclear", "attribution": "Source: Municipal Demarcation Board (MDB)", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" }, { "source_id": "chirps_rainfall", "name": "[FIXTURE] CHIRPS v3.0 Monthly Rainfall", "publisher": "Climate Hazards Center, UC Santa Barbara", "url": "https://www.chc.ucsb.edu/data/chirps3", "licence": "CC-BY-4.0", "commercial_use": "yes", "attribution": "Funk et al., CHIRPS v3.0, Climate Hazards Center, UCSB.", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" }, { "source_id": "saps_crime_stats", "name": "[FIXTURE] SAPS Crime Statistics", "publisher": "South African Police Service (SAPS)", "url": "https://www.saps.gov.za/services/crimestats.php", "licence": "SAPS website copyright (no open licence)", "commercial_use": "unclear", "attribution": "Source: South African Police Service, Crime Statistics", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" }, { "source_id": "osm_geofabrik_za", "name": "[FIXTURE] OpenStreetMap South Africa (Geofabrik) facility points", "publisher": "OpenStreetMap contributors; extract by Geofabrik GmbH", "url": "https://download.geofabrik.de/africa/south-africa-latest.osm.pbf", "licence": "ODbL-1.0", "commercial_use": "yes", "attribution": "© OpenStreetMap contributors. Data under the Open Database Licence (ODbL 1.0).", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" }, { "source_id": "treasury_municipal_finance", "name": "[FIXTURE] National Treasury Municipal Money", "publisher": "National Treasury", "url": "https://municipaldata.treasury.gov.za/api/cubes", "licence": "National Treasury Municipal Money Terms of Use v1.0", "commercial_use": "unclear", "attribution": "National Treasury, Municipal Money (municipalmoney.gov.za)", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" } ], "caveats": [ "FIXTURE DATA: these numbers are synthetic test values, not real statistics. Do not use them for decisions.", "Licence note (mdb_wards_2026): MDB custom terms; commercial use 'unclear'. Served only as derived aggregates with attribution: Source: Municipal Demarcation Board (MDB)", "Licence note (saps_crime_stats): SAPS website copyright (no open licence); commercial use 'unclear'. Served only as derived aggregates with attribution: Source: South African Police Service, Crime Statistics", "Licence note (treasury_municipal_finance): National Treasury Municipal Money Terms of Use v1.0; commercial use 'unclear'. Served only as derived aggregates with attribution: National Treasury, Municipal Money (municipalmoney.gov.za)" ], "dataset_version": "fixture-2026.11.05", "metrics": [ { "id": "population_total", "label": "Total population", "description": "Estimated resident population of the area for a year, from WorldPop 100 m gridded population summed over the area polygon. Use for \"how many people live in X\".", "unit": "people", "kind": "count", "spatial_aggregation": "sum", "native_geographies": [ "ward", "local_municipality", "district", "province" ], "allowed_geographies": [ "ward", "local_municipality", "district", "province" ], "time_grain": "year", "time_rollups": {}, "period_format": "YYYY", "first_period": "2020", "last_period": "2025", "available_ids": [], "family_members": [], "caveats": [ "Modelled estimate (WorldPop constrained gridded population), not a census count.", "Zonal sum of 100 m cells; small wards can be off by a few percent at boundaries." ] }, { "id": "population_density_km2", "label": "Population density", "description": "Estimated people per square kilometre (population_total / area_km2). A ratio: combining several areas uses the area-weighted mean, which equals total people / total area.", "unit": "people/km2", "kind": "ratio", "spatial_aggregation": "area_weighted_mean", "native_geographies": [ "ward", "local_municipality", "district", "province" ], "allowed_geographies": [ "ward", "local_municipality", "district", "province" ], "time_grain": "year", "time_rollups": {}, "period_format": "YYYY", "first_period": "2020", "last_period": "2025", "available_ids": [], "family_members": [], "caveats": [ "Derived from a modelled population estimate divided by polygon area.", "Density over a large rural municipality hides strong local variation." ] } ], "geographies": [ { "area_type": "province", "label": "Province", "id_format": "prov:", "example": "prov:WC" }, { "area_type": "district", "label": "District / metropolitan municipality", "id_format": "dm:", "example": "dm:DC1" } ], "limits": { "max_metrics_per_call": 5, "max_areas_per_call": 25, "max_rows_per_call": 200, "compare_areas_min": 2, "compare_areas_max": 5, "max_location_candidates": 5 }, "how_to": [ "1. resolve_location('') to get an area_id (never guess ids).", "2. query_metrics(metrics=[...], area_ids=[...], year_from, year_to)." ] } ``` Provenance in this example: source `worldpop_population`, licence “CC-BY-4.0”, retrieved 2026-11-05, dataset_version `fixture-2026.11.05`. ## Errors Errors read `: . Fix: `. Shared codes are on the [Tool reference](/docs/tools) page. This tool has no input of its own to get wrong. --- # resolve_location > Turn a place name or a lat/lon point into canonical area_ids (max 5 candidates). Turn a place name or a lat/lon point into canonical area_ids (max 5 candidates). If status is 'ambiguous', do NOT pick one: ask the user or call again with area_type. **Counts against your plan:** yes, one call. **Read-only:** yes, nothing you send is stored or changes any data. ## Inputs | Name | Type | Required | Default | Description | |---|---|---|---|---| | `query` | `string` | no | none | Place name, e.g. 'Matzikama', 'Kenilworth', 'West Coast'. | | `lat` | `number` | no | none | WGS84 latitude (with lon) | | `lon` | `number` | no | none | WGS84 longitude (with lat) | | `area_type` | `"province" / "district" / "local_municipality" / "ward" / "police_station"` | no | none | Restrict to one geography type | ## Caps | Cap | Value | |---|---| | Candidates returned | 5 | Your plan can set lower caps. See [Plans and limits](/docs/limits). A request over a cap is refused with `LIMIT_EXCEEDED`; nothing is truncated silently. ## Response Every response also carries `citations`, `caveats` and `dataset_version` ([Citations](/docs/citations)). | Field | Type | Description | |---|---|---| | `citations` | `object[]` | | | `caveats` | `string[]` | | | `dataset_version` | `string` | | | `status` | `"resolved" / "ambiguous" / "not_found"` | | | `resolved_area_id` | `string` | | | `candidates` | `object[]` | | | `message` | `string` | | **`candidates[]`** (each item) | Field | Type | Description | |---|---|---| | `area_id` | `string` | | | `name` | `string` | | | `area_type` | `string` | | | `province_code` | `string` | | | `parent_id` | `string` | | | `parent_name` | `string` | | | `match` | `"exact" / "prefix" / "contains" / "point_in_polygon"` | | ## Examples > **Fixture data** > > These responses were produced by the real tool against the synthetic test dataset (`fixture-2026.11.05`), so the numbers are illustrative, not real statistics. Production responses cite the real sources. ### A unique match Request: ```json { "query": "Matzikama" } ``` Response: ```json { "citations": [ { "source_id": "mdb_wards_2026", "name": "[FIXTURE] MDB Wards 2026", "publisher": "Municipal Demarcation Board (MDB)", "url": "https://www.demarcation.org.za/", "licence": "MDB custom terms", "commercial_use": "unclear", "attribution": "Source: Municipal Demarcation Board (MDB)", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" } ], "caveats": [ "FIXTURE DATA: these numbers are synthetic test values, not real statistics. Do not use them for decisions.", "Licence note (mdb_wards_2026): MDB custom terms; commercial use 'unclear'. Served only as derived aggregates with attribution: Source: Municipal Demarcation Board (MDB)" ], "dataset_version": "fixture-2026.11.05", "status": "resolved", "resolved_area_id": "lm:WC011", "candidates": [ { "area_id": "lm:WC011", "name": "Matzikama", "area_type": "local_municipality", "province_code": "WC", "parent_id": "dm:DC1", "parent_name": "West Coast", "match": "exact" } ], "message": "Resolved to Matzikama (local_municipality)." } ``` Provenance in this example: source `mdb_wards_2026`, licence “MDB custom terms”, retrieved 2026-11-05, dataset_version `fixture-2026.11.05`. ### An ambiguous name (two police stations are called Kenilworth) Request: ```json { "query": "Kenilworth", "area_type": "police_station" } ``` Response: ```json { "citations": [ { "source_id": "saps_crime_stats", "name": "[FIXTURE] SAPS Crime Statistics", "publisher": "South African Police Service (SAPS)", "url": "https://www.saps.gov.za/services/crimestats.php", "licence": "SAPS website copyright (no open licence)", "commercial_use": "unclear", "attribution": "Source: South African Police Service, Crime Statistics", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" } ], "caveats": [ "FIXTURE DATA: these numbers are synthetic test values, not real statistics. Do not use them for decisions.", "Licence note (saps_crime_stats): SAPS website copyright (no open licence); commercial use 'unclear'. Served only as derived aggregates with attribution: Source: South African Police Service, Crime Statistics" ], "dataset_version": "fixture-2026.11.05", "status": "ambiguous", "resolved_area_id": null, "candidates": [ { "area_id": "saps:kenilworth-gt", "name": "Kenilworth", "area_type": "police_station", "province_code": "GT", "parent_id": "prov:GT", "parent_name": "Gauteng", "match": "exact" }, { "area_id": "saps:kenilworth-wc", "name": "Kenilworth", "area_type": "police_station", "province_code": "WC", "parent_id": "prov:WC", "parent_name": "Western Cape", "match": "exact" } ], "message": "'Kenilworth' matches several places; no guess was made. Ask the user which one they mean, or call again with area_type or a more specific name." } ``` Provenance in this example: source `saps_crime_stats`, licence “SAPS website copyright (no open licence)”, retrieved 2026-11-05, dataset_version `fixture-2026.11.05`. ## Errors Errors read `: . Fix: `. Shared codes are on the [Tool reference](/docs/tools) page. | Request | Error | |---|---| | `{}` | `INVALID_ARGUMENT: Give a place name (query) or a point (lat and lon). Fix: e.g. resolve_location(query='Matzikama') or resolve_location(lat=-31.6, lon=18.5).` | | `{"lat": -31.0}` | `INVALID_ARGUMENT: lat and lon must be given together. Fix: Pass both lat and lon.` | --- # query_metrics > Get aggregated, cited metric values for up to 25 areas (max 200 rows). Get aggregated, cited metric values for up to 25 areas (max 200 rows). Values are at the metric's native geography; means and ratios are never summed. **Counts against your plan:** yes, one call. **Read-only:** yes, nothing you send is stored or changes any data. ## Inputs | Name | Type | Required | Default | Description | |---|---|---|---|---| | `metrics` | `string[]` | yes | none | 1-5 metric ids from describe_catalog, e.g. ['population_total', 'crime_count:murder'] | | `area_ids` | `string[]` | yes | none | 1-25 area_ids from resolve_location | | `year_from` | `integer` | no | none | First year (financial years start in this year). Default: latest available | | `year_to` | `integer` | no | none | Last year (inclusive) | | `time_grain` | `"native" / "year"` | no | `"native"` | 'year' aggregates monthly metrics to annual totals | | `combine_areas` | `boolean` | no | `false` | Return one combined value per period across the areas (sum for counts, area-weighted mean for means/ratios). Areas must share a type. | ## Caps | Cap | Value | |---|---| | Metrics per call | 5 | | Areas per call | 25 | | Rows per call | 200 | Your plan can set lower caps. See [Plans and limits](/docs/limits). A request over a cap is refused with `LIMIT_EXCEEDED`; nothing is truncated silently. ## Response Every response also carries `citations`, `caveats` and `dataset_version` ([Citations](/docs/citations)). | Field | Type | Description | |---|---|---| | `citations` | `object[]` | | | `caveats` | `string[]` | | | `dataset_version` | `string` | | | `rows` | `object[]` | | | `row_count` | `integer` | | | `request` | `object` | | | `limits` | `object` | | **`rows[]`** (each item) | Field | Type | Description | |---|---|---| | `metric_id` | `string` | | | `area_id` | `string` | | | `area_name` | `string` | | | `area_type` | `string` | | | `period` | `string` | | | `value` | `number` | | | `unit` | `string` | | | `is_estimate` | `boolean` | | | `method` | `string` | | | `source_ids` | `string[]` | Sources behind this value (see citations) | | `n_periods` | `integer` | Native periods aggregated into this row | | `n_areas` | `integer` | Native areas aggregated into this row | **`request`** | Field | Type | Description | |---|---|---| | `year_from` | `integer` | | | `year_to` | `integer` | | | `metrics` | `string[]` | | | `area_ids` | `string[]` | | | `time_grain` | `"native" / "year"` | | | `combine_areas` | `boolean` | | ## Examples > **Fixture data** > > These responses were produced by the real tool against the synthetic test dataset (`fixture-2026.11.05`), so the numbers are illustrative, not real statistics. Production responses cite the real sources. ### Population of one municipality in one year Request: ```json { "metrics": [ "population_total" ], "area_ids": [ "lm:WC011" ], "year_from": 2025, "year_to": 2025 } ``` Response: ```json { "citations": [ { "source_id": "worldpop_population", "name": "[FIXTURE] WorldPop Constrained Population 100 m (R2025A)", "publisher": "WorldPop, University of Southampton", "url": "https://www.worldpop.org/", "licence": "CC-BY-4.0", "commercial_use": "yes", "attribution": "WorldPop (www.worldpop.org), University of Southampton. CC BY 4.0.", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" } ], "caveats": [ "FIXTURE DATA: these numbers are synthetic test values, not real statistics. Do not use them for decisions.", "Modelled estimate (WorldPop constrained gridded population), not a census count.", "Zonal sum of 100 m cells; small wards can be off by a few percent at boundaries.", "population_total values are estimates (method: zonal_sum)." ], "dataset_version": "fixture-2026.11.05", "rows": [ { "metric_id": "population_total", "area_id": "lm:WC011", "area_name": "Matzikama", "area_type": "local_municipality", "period": "2025", "value": 21270.0, "unit": "people", "is_estimate": true, "method": "zonal_sum", "source_ids": [ "worldpop_population" ], "n_periods": 1, "n_areas": 1 } ], "row_count": 1, "request": { "year_from": 2025, "year_to": 2025, "metrics": [ "population_total" ], "area_ids": [ "lm:WC011" ], "time_grain": "native", "combine_areas": false }, "limits": { "max_metrics_per_call": 5, "max_areas_per_call": 25, "max_rows_per_call": 200, "compare_areas_min": 2, "compare_areas_max": 5, "max_location_candidates": 5 } } ``` Provenance in this example: source `worldpop_population`, licence “CC-BY-4.0”, retrieved 2026-11-05, dataset_version `fixture-2026.11.05`. ### A count rolled up to a province (murders, financial year 2024/25) Request: ```json { "metrics": [ "crime_count:murder" ], "area_ids": [ "prov:WC" ], "year_from": 2024, "year_to": 2024 } ``` Response: ```json { "citations": [ { "source_id": "saps_crime_stats", "name": "[FIXTURE] SAPS Crime Statistics", "publisher": "South African Police Service (SAPS)", "url": "https://www.saps.gov.za/services/crimestats.php", "licence": "SAPS website copyright (no open licence)", "commercial_use": "unclear", "attribution": "Source: South African Police Service, Crime Statistics", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" } ], "caveats": [ "FIXTURE DATA: these numbers are synthetic test values, not real statistics. Do not use them for decisions.", "Counts are crimes recorded by SAPS (reported to police), not victimisation; under-reporting varies by crime type and place.", "Native geography is the SAPS police station precinct, which does not align with ward or municipal boundaries.", "Do not add roll-up categories to their component categories.", "crime_count:murder for province is a sum of native police_station values.", "Licence note (saps_crime_stats): SAPS website copyright (no open licence); commercial use 'unclear'. Served only as derived aggregates with attribution: Source: South African Police Service, Crime Statistics" ], "dataset_version": "fixture-2026.11.05", "rows": [ { "metric_id": "crime_count:murder", "area_id": "prov:WC", "area_name": "Western Cape", "area_type": "province", "period": "FY2024/25", "value": 25.0, "unit": "count", "is_estimate": false, "method": "rollup_sum", "source_ids": [ "saps_crime_stats" ], "n_periods": 1, "n_areas": 3 } ], "row_count": 1, "request": { "year_from": 2024, "year_to": 2024, "metrics": [ "crime_count:murder" ], "area_ids": [ "prov:WC" ], "time_grain": "native", "combine_areas": false }, "limits": { "max_metrics_per_call": 5, "max_areas_per_call": 25, "max_rows_per_call": 200, "compare_areas_min": 2, "compare_areas_max": 5, "max_location_candidates": 5 } } ``` Provenance in this example: source `saps_crime_stats`, licence “SAPS website copyright (no open licence)”, retrieved 2026-11-05, dataset_version `fixture-2026.11.05`. ## Errors Errors read `: . Fix: `. Shared codes are on the [Tool reference](/docs/tools) page. | Request | Error | |---|---| | `{"metrics": ["no_such_metric"], "area_ids": ["lm:WC011"]}` | `UNKNOWN_METRIC: Unknown metric 'no_such_metric'. Fix: Use one of ['population_total', 'population_density_km2', 'rainfall_monthly_mm', 'rainfall_annual_mm', 'osm_school_count', 'osm_hospital_count', 'osm_clinic_count', 'osm_pharmacy_count', 'osm_police_station_count', 'osm_fuel_station_count', 'osm_supermarket_count', 'operating_revenue', 'operating_expenditure', 'property_rates_revenue', 'property_rates_revenue_per_capita', 'capital_budget', 'audit_outcome', 'unauthorised_irregular_fruitless_wasteful', 'cash_coverage_months', 'debtor_collection_rate'] or a family member such as ['crime_count:burglary_residential', 'crime_count:common_assault', 'crime_count:contact_crime', 'crime_count:murder']. Call describe_catalog for the full list.` | | `{"metrics": ["population_total"], "area_ids": ["lm:NOPE"]}` | `UNKNOWN_AREA: Unknown area_id(s): ['lm:NOPE']. Fix: Call resolve_location with a place name to get valid area_ids (formats: prov:WC, dm:DC1, lm:WC011, ward:10101001, saps:).` | | `{"metrics": ["population_total"], "area_ids": ["lm:WC011"], "year_from": 2026, "year_to": 2020}` | `INVALID_ARGUMENT: year_from (2026) is after year_to (2020). Fix: Swap them or pass a single year in both.` | --- # compare_areas > Compare 2-5 areas on up to 5 metrics: value, rank among them (1 = highest) and the national percentile among all areas of the same type. Compare 2-5 areas on up to 5 metrics: value, rank among them (1 = highest) and the national percentile among all areas of the same type. **Counts against your plan:** yes, one call. **Read-only:** yes, nothing you send is stored or changes any data. ## Inputs | Name | Type | Required | Default | Description | |---|---|---|---|---| | `area_ids` | `string[]` | yes | none | 2-5 area_ids of the same type | | `metrics` | `string[]` | yes | none | 1-5 metric ids | | `year` | `integer` | no | none | Year to compare. Default: latest year with data for every area | ## Caps | Cap | Value | |---|---| | Areas per call (min) | 2 | | Areas per call (max) | 5 | | Metrics per call | 5 | Your plan can set lower caps. See [Plans and limits](/docs/limits). A request over a cap is refused with `LIMIT_EXCEEDED`; nothing is truncated silently. ## Response Every response also carries `citations`, `caveats` and `dataset_version` ([Citations](/docs/citations)). | Field | Type | Description | |---|---|---| | `citations` | `object[]` | | | `caveats` | `string[]` | | | `dataset_version` | `string` | | | `comparisons` | `object[]` | | **`comparisons[]`** (each item) | Field | Type | Description | |---|---|---| | `metric_id` | `string` | | | `unit` | `string` | | | `period` | `string` | | | `area_type` | `string` | | | `spatial_aggregation` | `string` | | | `entries` | `object[]` | | **`comparisons.entries[]`** (each item) | Field | Type | Description | |---|---|---| | `area_id` | `string` | | | `area_name` | `string` | | | `value` | `number` | | | `rank` | `integer` | 1 = highest value among the compared areas | | `national_percentile` | `number` | 0-100: share of all areas of this type nationally with a lower value | | `n_peers` | `integer` | | ## Examples > **Fixture data** > > These responses were produced by the real tool against the synthetic test dataset (`fixture-2026.11.05`), so the numbers are illustrative, not real statistics. Production responses cite the real sources. ### Three police stations on one metric Request: ```json { "area_ids": [ "saps:vredendal", "saps:clanwilliam", "saps:vanderbijlpark" ], "metrics": [ "crime_count:murder" ] } ``` Response: ```json { "citations": [ { "source_id": "saps_crime_stats", "name": "[FIXTURE] SAPS Crime Statistics", "publisher": "South African Police Service (SAPS)", "url": "https://www.saps.gov.za/services/crimestats.php", "licence": "SAPS website copyright (no open licence)", "commercial_use": "unclear", "attribution": "Source: South African Police Service, Crime Statistics", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" } ], "caveats": [ "FIXTURE DATA: these numbers are synthetic test values, not real statistics. Do not use them for decisions.", "Counts are crimes recorded by SAPS (reported to police), not victimisation; under-reporting varies by crime type and place.", "Native geography is the SAPS police station precinct, which does not align with ward or municipal boundaries.", "Do not add roll-up categories to their component categories.", "national_percentile ranks each area among all areas of the same type in the loaded data for that period (100 = highest).", "Licence note (saps_crime_stats): SAPS website copyright (no open licence); commercial use 'unclear'. Served only as derived aggregates with attribution: Source: South African Police Service, Crime Statistics" ], "dataset_version": "fixture-2026.11.05", "comparisons": [ { "metric_id": "crime_count:murder", "unit": "count", "period": "FY2024/25", "area_type": "police_station", "spatial_aggregation": "sum", "entries": [ { "area_id": "saps:vredendal", "area_name": "Vredendal", "value": 14.0, "rank": 2, "national_percentile": 75.0, "n_peers": 5 }, { "area_id": "saps:clanwilliam", "area_name": "Clanwilliam", "value": 9.0, "rank": 3, "national_percentile": 50.0, "n_peers": 5 }, { "area_id": "saps:vanderbijlpark", "area_name": "Vanderbijlpark", "value": 61.0, "rank": 1, "national_percentile": 100.0, "n_peers": 5 } ] } ] } ``` Provenance in this example: source `saps_crime_stats`, licence “SAPS website copyright (no open licence)”, retrieved 2026-11-05, dataset_version `fixture-2026.11.05`. ## Errors Errors read `: . Fix: `. Shared codes are on the [Tool reference](/docs/tools) page. | Request | Error | |---|---| | `{"area_ids": ["saps:vredendal"], "metrics": ["crime_count:murder"]}` | `INVALID_ARGUMENT: area_ids: List should have at least 2 items after validation, not 1. Fix: Correct the compare_areas arguments. Limits: {'max_metrics_per_call': 5, 'max_areas_per_call': 25, 'max_rows_per_call': 200, 'compare_areas_min': 2, 'compare_areas_max': 5, 'max_location_candidates': 5}. Call describe_catalog to see valid metrics and geographies.` | --- # get_provenance > Where a metric comes from: publisher, licence, retrieval date, dataset version, method and lineage. Where a metric comes from: publisher, licence, retrieval date, dataset version, method and lineage. Free (does not count against your plan). **Counts against your plan:** no, free. **Read-only:** yes, nothing you send is stored or changes any data. ## Inputs | Name | Type | Required | Default | Description | |---|---|---|---|---| | `metric_id` | `string` | yes | none | Metric id, e.g. 'rainfall_annual_mm' or 'crime_count:murder' | ## Caps No row or area caps apply: the response is a fixed-size summary. ## Response Every response also carries `citations`, `caveats` and `dataset_version` ([Citations](/docs/citations)). | Field | Type | Description | |---|---|---| | `citations` | `object[]` | | | `caveats` | `string[]` | | | `dataset_version` | `string` | | | `metric` | `object` | | | `lineage` | `string[]` | | | `sources` | `object[]` | | **`metric`** | Field | Type | Description | |---|---|---| | `id` | `string` | | | `label` | `string` | | | `description` | `string` | | | `unit` | `string` | | | `kind` | `string` | | | `spatial_aggregation` | `string` | | | `native_geographies` | `string[]` | | | `allowed_geographies` | `string[]` | | | `time_grain` | `string` | | | `time_rollups` | `object` | | | `period_format` | `string` | | | `first_period` | `string` | | | `last_period` | `string` | | | `available_ids` | `string[]` | Concrete ids for metric families | | `family_members` | `object[]` | | | `caveats` | `string[]` | | **`sources[]`** (each item) | Field | Type | Description | |---|---|---| | `source_id` | `string` | | | `name` | `string` | | | `publisher` | `string` | | | `url` | `string` | | | `licence` | `string` | | | `commercial_use` | `string` | | | `attribution` | `string` | | | `retrieved_at` | `string` | | | `dataset_version` | `string` | | **`metric.family_members[]`** (each item) | Field | Type | Description | |---|---|---| | `id` | `string` | | | `label` | `string` | | | `is_aggregate` | `boolean` | SAPS roll-up category: never add it to its component offences | ## Examples > **Fixture data** > > These responses were produced by the real tool against the synthetic test dataset (`fixture-2026.11.05`), so the numbers are illustrative, not real statistics. Production responses cite the real sources. ### Where a rainfall metric comes from Request: ```json { "metric_id": "rainfall_annual_mm" } ``` Response: (lists shortened) ```json { "citations": [ { "source_id": "chirps_rainfall", "name": "[FIXTURE] CHIRPS v3.0 Monthly Rainfall", "publisher": "Climate Hazards Center, UC Santa Barbara", "url": "https://www.chc.ucsb.edu/data/chirps3", "licence": "CC-BY-4.0", "commercial_use": "yes", "attribution": "Funk et al., CHIRPS v3.0, Climate Hazards Center, UCSB.", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" } ], "caveats": [ "FIXTURE DATA: these numbers are synthetic test values, not real statistics. Do not use them for decisions.", "Satellite-derived estimate (CHIRPS ~5 km grid), averaged over the area; not a rain-gauge reading.", "rainfall_annual_mm values are modelled or satellite estimates." ], "dataset_version": "fixture-2026.11.05", "metric": { "id": "rainfall_annual_mm", "label": "Annual rainfall", "description": "Total rainfall in the calendar year (mm), averaged over the area. Areas combine by area-weighted mean, never by adding.", "unit": "mm", "kind": "mean", "spatial_aggregation": "area_weighted_mean", "native_geographies": [ "ward", "local_municipality", "district", "province" ], "allowed_geographies": [ "ward", "local_municipality", "district", "province" ], "time_grain": "year", "time_rollups": {}, "period_format": "YYYY", "first_period": "2020", "last_period": "2025", "available_ids": [], "family_members": [], "caveats": [ "Satellite-derived estimate (CHIRPS ~5 km grid), averaged over the area; not a rain-gauge reading." ] }, "lineage": [ "Raw files from Climate Hazards Center, UC Santa Barbara are retrieved by the ingest job and stored unchanged with retrieval date and checksum.", "Cleaned and conformed to the MDB 2026 ward/municipality/province and SAPS precinct geography in DuckDB (transform/).", "Stored in gold `fact` at native geography ['ward', 'local_municipality', 'district', 'province'] (methods: zonal_mean).", "Served by the semantic layer: combining areas uses area_weighted_mean." ], "sources": [ { "source_id": "chirps_rainfall", "name": "[FIXTURE] CHIRPS v3.0 Monthly Rainfall", "publisher": "Climate Hazards Center, UC Santa Barbara", "url": "https://www.chc.ucsb.edu/data/chirps3", "licence": "CC-BY-4.0", "commercial_use": "yes", "attribution": "Funk et al., CHIRPS v3.0, Climate Hazards Center, UCSB.", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" } ] } ``` Provenance in this example: source `chirps_rainfall`, licence “CC-BY-4.0”, retrieved 2026-11-05, dataset_version `fixture-2026.11.05`. ## Errors Errors read `: . Fix: `. Shared codes are on the [Tool reference](/docs/tools) page. | Request | Error | |---|---| | `{"metric_id": "no_such_metric"}` | `UNKNOWN_METRIC: Unknown metric 'no_such_metric'. Fix: Use one of ['population_total', 'population_density_km2', 'rainfall_monthly_mm', 'rainfall_annual_mm', 'crime_count:{category}', 'osm_school_count', 'osm_hospital_count', 'osm_clinic_count', 'osm_pharmacy_count', 'osm_police_station_count', 'osm_fuel_station_count', 'osm_supermarket_count', 'operating_revenue', 'operating_expenditure', 'property_rates_revenue', 'property_rates_revenue_per_capita', 'capital_budget', 'audit_outcome', 'unauthorised_irregular_fruitless_wasteful', 'cash_coverage_months', 'debtor_collection_rate']. Call describe_catalog for details.` | --- # Citations and provenance > Every number carries its source, licence, retrieval date and dataset version. Every tool response carries three things next to the data: `citations`, `caveats` and `dataset_version`. Show the citations and caveats whenever you quote a number. ## The citation object | Field | Meaning | |---|---| | `source_id` | Stable id of the source, the same one used on the [coverage](/coverage) page | | `name` | Dataset name | | `publisher` | Who publishes the data | | `url` | Where the publisher describes it (can be null) | | `licence` | The licence as recorded, even when it is unclear | | `commercial_use` | Whether the licence allows commercial use: `yes`, `no` or `unclear` | | `attribution` | The text to print when you publish the number | | `retrieved_at` | The date we retrieved the data | | `dataset_version` | The version of the dataset behind the number | ## A real response This is a citation exactly as the `query_metrics` tool returned it for the population of one municipality. It was produced against the synthetic test dataset, so the name says `[FIXTURE]` and the value is not a real statistic; production responses name the real source. ```json { "source_id": "worldpop_population", "name": "[FIXTURE] WorldPop Constrained Population 100 m (R2025A)", "publisher": "WorldPop, University of Southampton", "url": "https://www.worldpop.org/", "licence": "CC-BY-4.0", "commercial_use": "yes", "attribution": "WorldPop (www.worldpop.org), University of Southampton. CC BY 4.0.", "retrieved_at": "2026-11-05", "dataset_version": "fixture-2026.11.05" } ``` Each row of a result lists the `source_ids` behind it, so you can tell which citation belongs to which value. When a value was converted between geographies, `is_estimate` is `true` and `method` says how. See [Tool reference](/docs/tools) for full responses and `get_provenance` for a metric's publisher, licence, method and lineage.