Dieses Dokument gibt es nur auf Englisch.

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.

Quelle auf GitHub ansehen →