Usage
Use-case-driven recipes for the oparl CLI, which reads German council information
systems through OParl, their open standard API. Every municipality runs its own
server, so each recipe starts from a System or Body URL. The examples use the City of
Cologne’s server; substitute any endpoint from oparl endpoints.
SYSTEM=https://buergerinfo.stadt-koeln.de/oparl/system
BODY=https://buergerinfo.stadt-koeln.de/oparl/bodies/stadtverwaltung_koeln
Council systems differ in speed and in how completely they implement OParl. Start with one page (the default), and add filters one at a time.
Install
npm i -g @maschinenlesbar.org/oparl-cli
This installs the oparl command. All examples pipe JSON to jq,
which is optional.
Finding a server
1. Find a municipality’s endpoint
oparl endpoints --search köln | jq -r '.[] | "\(.title)\t\(.working)\t\(.url)"'
endpoints lists the public registry at dev.oparl.org, followed by a curated list of
servers the registry lacks (source says which). working is the result of the last
live check (checked, with the reason in problem); for a registry entry nobody has
checked, it is the registry’s own last fetch (fetched). A server that moved has
replacedBy, the new System URL. Search by place name or by a part of the URL (e.g.
--search ratsinfomanagement). The search covers the title, the URL and the note, and
ignores case and accents, so köln and koln match the same entries. Only when nothing
matches literally does it try the umlaut spellings, so koeln still finds Köln while an
ordinary word like Aue keeps its own meaning. When dev.oparl.org cannot be reached,
endpoints notes that on stderr and lists the curated servers alone, as of their last
check — --source curated does the same without trying the network.
2. All working endpoints on OParl 1.1
oparl endpoints --working --oparl-version 1.1 | jq -r '.[] | "\(.title)\t\(.systemName)"'
# Only the servers the registry doesn't list (no request is made)
oparl endpoints --source curated --working | jq -r '.[] | "\(.title)\t\(.url)"'
--oparl-version takes the short form (1.0, 1.1) or the whole version URI a System
reports (https://schema.oparl.org/1.1/); anything else is a usage error rather than an
empty result.
Not listed at all? Any System URL works: oparl system <url>. Council portals often link
their OParl interface, and many vendors use fixed paths (e.g. …/webservice/oparl/v1.1/system
on SD.NET, …/oparl/system on Session).
3. Check an endpoint before relying on it
oparl system "$SYSTEM" | jq '{name, oparlVersion, vendor, license}'
A System with a body URL is a live OParl server. Exit 1 with “not an OParl System”
means the URL is something else — often a portal page or a moved endpoint. Exit 1 with
“is an OParl System, but its body is …” means the URL is right and the server does not
publish the URL of its list of bodies; oparl get "$SYSTEM" shows what it sends instead.
Bodies and their lists
4. List the bodies on a server
oparl bodies "$SYSTEM" | jq -r '.data[] | "\(.name)\t\(.id)"'
Most servers host one body; regional providers host several municipalities.
5. See which lists a body offers
oparl get "$BODY" | jq -r 'to_entries[] | select(.key | test("^(organization|person|meeting|paper|agendaItem|consultation|consultations|file|files|membership|locationList|legislativeTermList)$")) | .key'
Every other URL a body carries — its own id, its system, its website — is not an
object list, which is why the recipe matches the list names instead of “looks like a URL”.
OParl 1.0 bodies link only organization, person, meeting and paper; some servers
use the plural consultations/files, which oparl list follows too.
Meetings, papers, committees
6. Recent papers (motions, inquiries, proposals)
oparl list paper "$BODY" --modified-since 2026-09-01 \
| jq -r '.data[] | select(.deleted != true) | "\(.date)\t\(.reference)\t\(.paperType // "")\t\(.name)"'
If a server ignores --modified-since, you get its first page unfiltered — compare the
modified field. Even when it honours the filter, the list holds every paper changed
since the date, including old papers edited since and deleted ones (deleted: true, with
empty fields), which the select drops. For new papers only, also compare date.
The filter is sent with every page: your value replaces the one a server put into its own
next link (several get the encoding wrong) or into a list URL, and it is sent again if
the server redirects the request elsewhere. So every page of the result is filtered alike.
Some servers answer a filter they cannot satisfy with an error instead of an empty list:
SD.NET RIM (Bremen, Essen and most other …/webservice/oparl/… endpoints) replies HTTP
404 when no object falls into the window, so the CLI exits 4 “not found” although
the list exists. Repeat the call without the filter: if that returns data, the window was
simply empty.
7. Meetings, several pages at once
oparl list meeting "$BODY" --max-pages 3 | jq -r '.data[] | "\(.start)\t\(.name)"'
The order is the server’s; some list the newest or future meetings first.
8. Continue where a list stopped
next=$(oparl list meeting "$BODY" | jq -r .next)
oparl get "$next" | jq -r '.data[] | .name'
next is null on the last page. It is also the way on when a walk gave up early: with
looped: true and a note on stderr (“stopped after page N …”), next still points at
the page after the last one fetched, so oparl get "$next" continues from there.
9. Committees and groups
oparl list organization "$BODY" --max-pages 0 | jq -r '.data[] | "\(.classification // "")\t\(.name)"'
10. Legislative terms
oparl list legislative-term "$BODY" | jq -r '.data[] | "\(.startDate)\t\(.name)"'
OParl 1.0 bodies embed their terms instead of linking a list; those come back with
pages: 0 and the date filters applied locally. Where such a term carries no
created/modified (ALLRIS 1.0 servers omit them), it is listed anyway and a note
says how many terms the filter could not be applied to.
11. Follow a reference
Objects point to each other by URL. Open a meeting’s agenda item or a paper’s consultation
with get:
paper=$(oparl list paper "$BODY" | jq -r '.data[0].id')
oparl get "$paper" | jq '{reference, name, consultation}'
Keeping a local copy in sync
Fetch everything once, then only what changed:
oparl list paper "$BODY" --max-pages 0 -o papers-full.json
oparl list paper "$BODY" --max-pages 0 --modified-since 2026-09-01T00:00:00+02:00 -o papers-delta.json
Deleted objects may appear with deleted: true — apply them to your copy as deletions.
Each object appears once per id, and where a page repeated it (a list that changes while
it is being walked does that), the delta holds the last copy the server sent, so an
object edited or deleted mid-walk is not kept stale. On large servers --max-pages 0 can
take a long time; raise --timeout rather than lowering it. An empty window can come back
as exit 4 on SD.NET servers — see recipe 6.
Global options
# A slow server: 5 minutes per request, no retries
oparl --timeout 300000 --max-retries 0 list meeting "$BODY"
# Compact JSON straight into jq
oparl --compact bodies "$SYSTEM" | jq -c '.data[]'
# Say who is calling (header values are ASCII or Latin-1; anything else exits 2)
oparl --user-agent "stadtdaten-bot (kontakt@example.de)" system "$SYSTEM"
See the README for the full table and the exit codes.