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 warns about that on stderr (a WARN record, of oparl.http for a network
failure, with the timeout hint where one applies) and lists the curated servers alone, as
of their last check — --source curated does the same without trying the network.
The search does not cover the names of the bodies on a shared server: one System can
host several municipalities (a data centre, a Verbandsgemeinde and its members), and the
list records only how many (bodyCount), under the title of one of them. Where a note
names them, the search finds them (Neuss and Mönchengladbach on the Düsseldorf server);
otherwise a place without a hit may still be on one of those servers. Look through their
bodies live — this sends a request to each shared server, so keep it to the region you need:
oparl endpoints --working --compact | jq -r '.[] | select((.bodyCount // 0) > 1) | "\(.bodyCount)\t\(.title)\t\(.url)"'
oparl bodies "<one of those URLs>" --compact | jq -r '.data[].name'
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 — on page 2 or later as on the first, and in
place of any stale value the redirect carries. So every page of the result is filtered
alike, and so is the -o file of a delta sync. A redirect to another host is never
followed: the walk ends there with a note, keeping the pages it has.
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 because
pages added nothing: with looped: true and a note on stderr (“stopped after page N: the
last 3 pages added no object …”), next still points at the page after the last one
fetched, so oparl get "$next" continues from there. When the note says the server’s next
link “points back to a page already fetched”, next is null: that link only leads round
the loop again.
oparl get returns the raw page; its own continuation is .links.next, which can be
passed to oparl get again as it is — a literal + in a date filter (Somacos servers
echo modified_since=…+00:00 unencoded) is sent as %2B, so the filter holds.
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. --max-pages 0 stops after 10,000
pages, in case a server’s next links never end; a note on stderr and in the file says
so, and next is where the list goes on (give a larger --max-pages to fetch more). 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. If a page after the first fails, the file
still holds the pages fetched before it, with next at the page that failed and a note,
but the command exits non-zero: treat such a file as incomplete.
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"
A start URL on plain http: to a remote host (oparl get http://ris.example/oparl/system)
gets one warning record on stderr per run (WARN [oparl.http] requests to ris.example are
sent unencrypted (http:, not https:)); loopback hosts and https: don’t. stdout and the exit code are unchanged.
See the README for the full table and the exit codes.