A client reads events with a read key: a list for history, a server-sent event stream for live events. The routes
follow the Ambient protocol, version ambient/1; Pager is the reference
client. Base URL: https://citadel.albastov.com/v1/events.
Note. The examples need a read key (abk_); the example checker skips them.
Read keys
The owner mints a read key in the admin panel with a name (adam-mac) and a grant: every source, or a list of
sources. Every response and stream frame contains events of granted sources only. Dismissal state belongs to the
read key: two clients with the same key share dismissals; two keys do not.
A publish key on a read route fails with 403.
GET /v1/events/info
Returns the key's kind and grant. Accepts a publish key or a read key. Call once when a connection is added.
curl https://citadel.albastov.com/v1/events/info -H "Authorization: Bearer $CITADEL_EVENTS_READ_KEY"{"protocol":"ambient/1","name":"Albastar Citadel","key":"reader","reader":"adam-mac","sources":["github","narkis"],"features":["batch","search"],"docs":"https://citadel.albastov.com/v1/events/docs"}| Field | Value |
|---|---|
key | reader or publish |
reader | The read key's name; null for a publish key |
sources | Slugs the key reaches |
features | batch, search |
GET /v1/events
Lists events, newest first. With after, lists oldest first (catch-up).
| Parameter | Value |
|---|---|
source | Comma-separated slugs; default every granted source |
type | Exact type |
severity_min | Lowest severity: debug … critical |
q | Text search, at most 200 characters |
after | Cursor; events after it, oldest first |
before | Cursor; events before it, newest first |
limit | 1..500, default 100 |
sticky | open: sticky events this key has not dismissed |
{"events":[{"id":"evt_01JBX7Q2W8V5K3M9N4P6R2T8YC","cursor":"1734","source":"github","source_name":"GitHub","source_color":"#181717","source_icon":"github","type":"github.push","severity":"info","text":"site: 2 commits by Adam — Fix login redirect","detail":"1a2b3c4 Fix login redirect\n9f3c2e1 Add invoice export","subject":"site","url":"https://github.com/albastar/site/compare/1a2b3c4d5e6f...9f3c2e1aa0b1","occurred_at":"2026-10-31T10:01:00Z","received_at":"2026-10-31T10:01:02Z","display":null,"data":null,"dismissed_at":null}],"next_before":null,"latest_cursor":"1734"}next_before is the cursor for the next older page; null when the page holds fewer than limit events, and with
after. latest_cursor is the newest cursor
among granted sources; pass the value as after or Last-Event-ID to resume.
GET /v1/events/sources
Returns the granted sources as an array:
[{"slug":"github","name":"GitHub","color":"#181717","icon":"github","last_event_at":"2026-10-31T10:01:02Z","count_24h":14,"archived":false}]GET /v1/events/stream
A text/event-stream of new events. Optional ?source=a,b.
| Resume | Behaviour |
|---|---|
| No cursor | Live events from now |
Last-Event-ID: <cursor> or ?after=<cursor> | Events after the cursor first (at most 1000), then live events |
id: 1734
event: event
data: {…event…}
event: state
data: {"id":"evt_01JBX7Q2W8V5K3M9N4P6R2T8YC","dismissed_at":"2026-10-31T10:05:00Z"}
event: gap
data: {"after":"1200","skipped":2311,"resume":"3511"}
: keepalive| Frame | Meaning |
|---|---|
event | One event; id is its cursor |
state | The read key dismissed (dismissed_at set) or restored (dismissed_at: null) an event |
gap | More than 1000 events were pending: the skipped events after cursor after, up to and including cursor resume, were not sent; fetch them with GET /v1/events?after=<after> |
: keepalive | Comment every 20 s |
curl -N https://citadel.albastov.com/v1/events/stream -H "Authorization: Bearer $CITADEL_EVENTS_READ_KEY"Reconnecting
- On start, call
GET /v1/events/info, thenGET /v1/events?sticky=openfor open sticky events, thenGET /v1/events?limit=1forlatest_cursor. - Open the stream with
Last-Event-ID: <latest_cursor>. - Store the
idof everyeventframe. - Reconnect when 60 s pass without a byte, or when the connection closes. Wait 1 s, then double the wait up to 30 s, with jitter; reset the wait after a successful connection.
- Resume with
Last-Event-ID: <last stored id>. On agapframe, fetch the skipped range withGET /v1/events. - On
429 too_many_streams, close an unused stream of the same key, then retry.
POST /v1/events/{id}/dismiss and DELETE /v1/events/{id}/dismiss
POST sets this key's dismissal; DELETE clears it. Both are idempotent: a second POST keeps the first time. The
response is the event with dismissed_at. Each change sends a state frame to every stream of the same key.
curl -X POST https://citadel.albastov.com/v1/events/evt_01JBX7Q2W8V5K3M9N4P6R2T8YC/dismiss \
-H "Authorization: Bearer $CITADEL_EVENTS_READ_KEY"Ambient protocol
Citadel implements ambient/1 from the
Pager protocol. Paths are relative to the base
URL https://citadel.albastov.com/v1/events.
ambient/1 route | Citadel route | Key |
|---|---|---|
POST / | POST /v1/events | Publish |
POST /batch | POST /v1/events/batch | Publish |
GET / | GET /v1/events | Read |
GET /stream | GET /v1/events/stream | Read |
POST /{id}/dismiss, DELETE /{id}/dismiss | POST, DELETE /v1/events/{id}/dismiss | Read |
GET /sources | GET /v1/events/sources | Read |
GET /info | GET /v1/events/info | Either |
GET /docs | GET /v1/events/docs | None |
- Cursors are opaque strings, strictly increasing in hub order.
- Times are RFC 3339 UTC. Optional fields are
null. - Errors are JSON with
codeandmessage. - Readers give
data.panelmeaning:true, or an object withtitle,body,pinned,seconds(5..86400) andsound, raises a full Pager panel once per event.
Errors
| Status | Code | Cause |
|---|---|---|
400 | invalid_field | Bad parameter; meta.field names it |
400 | unknown_source | source names no source (keys granted every source) |
401 | invalid_key, events_key_required | Missing, unknown or revoked key |
403 | scope_missing, key_unbound | Not a read key |
403 | source_not_granted | source names a source outside the grant |
404 | not_found | No event with that id within the grant |
429 | too_many_streams | The key already holds 8 open streams |
429 | rate_limited | Over the key's rate; see Retry-After |