---
title: "Real-time feed and presence"
---

# Real-time feed and presence

Subscribe to the organisation's ordered Context event feed over Server-Sent Events with resume, and publish presence in spaces and pair sessions.

The first-party apps learn that a source was re-indexed, a lens was shared or a pair session changed without polling. Govern keeps one ordered event feed per organisation and streams it to each viewer, filtered by what that viewer may read now. PostgreSQL is the only moving part: no broker is needed, so the feed works in an air-gapped, single-database installation.

Events carry ids, counts and short codes only: never a chunk, a file, a lens name, a query or a message. An app reads the content through the normal, authorised routes.

## Open a stream

```bash
curl -N -H "Authorization: Bearer $TOKEN" -H "Accept: text/event-stream" \
  "https://<your Govern host>/api/v1/context/events?spaceId=<optional>"
```

- `spaceId` limits the stream to one space (the caller must be able to read it, else `404`).
- Resume with the `Last-Event-ID` header (or `lastEventId`); a browser `EventSource` does it by itself.
- `422` for a malformed value, `429 context.events.too_many_connections` over the connection caps, `503` while the server shuts down.

### Stream format

| SSE `event` | `data` |
|---|---|
| `ready` | `{lastEventId, latestEventId, replaying, heartbeatSeconds, maxAgeSeconds}` |
| a feed type | `{id, type, spaceId?, spaceIds?, lensId?, sessionId?, subjectId?, actor?, at?, meta?, redacted?}` |
| `presence.updated`, `presence.left` | `{type, userId, spaceId, sessionId?, lensId?, state?, expiresAt?}` |
| `heartbeat` | `{at, latestEventId}` every 30 seconds |
| `error` | `{code, message, retryable, latestEventId?}`; the server closes the stream after it |

A `: keep-alive` comment is written every 10 seconds.

### Ordering and resume

- Event ids are per organisation, strictly increasing and gap-free in the feed. A stream delivers only the events the caller may read, so a client sees gaps in the ids it receives.
- Everyone sees the same total order, and no event is skipped. That order is the order in which events were relayed, which with several replicas can differ from the order the changes committed. Treat an event as "this changed, here are its ids" and reconcile with the object's `version` or by reading it.
- Reconnecting with the last id replays exactly what was missed, without loss or duplicates, within 1000 events and 24 hours. Further back, the stream sends `context.events.resume_gap`: reload your state and connect without an id.

### Error codes

| Code | Meaning |
|---|---|
| `context.events.slow_consumer` | The client did not keep up; reconnect with the last id |
| `context.events.access_revoked` | Access to the space or the sign-in ended (not retryable) |
| `context.events.resume_gap` | The events after `Last-Event-ID` are no longer available |
| `context.events.shutting_down`, `context.events.reconnect` | The server is stopping, or the stream reached its maximum age (15 minutes); reconnect with the last id |
| `context.events.authz_unavailable`, `context.events.internal` | Retryable |

## Who receives what

| Event family | Delivered when the viewer |
|---|---|
| `source.*`, `snapshot.created`, `handoff.created`, `memory.*`, `project.repositories_changed` | May read the space |
| `lens.*` | May see the lens; `spaceIds` lists only the lens's spaces the viewer may read |
| `session.*` | May read the session's space and takes part in the session |
| `agentfile.*` | Owns the personal file, or may read the node of any other file |
| `work.item.*` | May see the work item |
| `overlap.*` | Owns one of the two items |
| `share.*` | May see the lens |

Events of a restricted space carry `redacted: true`, the type and the ids only. Authorisation decisions are re-checked every 10 seconds (`KEYSTONE_CONTEXT_EVENTS_REFRESH`), so a revocation takes effect within that time; a stream bound to a lost space ends with `access_revoked`, and so does one whose sign-in stopped being valid. A permission removed from a role reaches a running stream when it reconnects, at the latest after 15 minutes.

### Feed types

`source.indexed`, `source.stale`, `source.deleted`, `snapshot.created`, `handoff.created`, `project.repositories_changed`, `lens.created`, `lens.updated`, `lens.shared`, `lens.unshared`, `lens.frozen`, `session.started`, `session.joined`, `session.left`, `session.ended`, `session.lens_changed`, `session.sources_changed`, `memory.created`, `memory.accepted`, `memory.superseded`, `memory.archived`, `agentfile.created`, `agentfile.updated`, `agentfile.pending`, `agentfile.rejected`, `agentfile.archived`, `agentfile.hold`, `work.item.declared`, `work.item.updated`, `work.item.finished`, `work.item.abandoned`, `overlap.opened`, `overlap.updated`, `overlap.ended`, and `share.requested`, `share.approved`, `share.rejected`, `share.cancelled`, `share.expired`, `share.refused`.

Session invitations publish no event: an invitee finds them with `GET /api/v1/context/sessions/invited`.

## Presence

```http
PUT /api/v1/context/presence
{"spaceId": "<id>", "sessionId": "<optional>", "lensId": "<optional>", "state": "active"}
```

The answer carries `expiresAt` and `ttlSeconds`; heartbeat every `ttlSeconds / 3`. `DELETE /api/v1/context/presence?spaceId=&sessionId=` leaves at once.

- `state` is a short token (`^[a-z][a-z0-9_-]{0,31}$`, default `active`), never free text.
- Presence is ephemeral: it is never audited and never stored in the feed. Entries expire 30 seconds after the last heartbeat (`KEYSTONE_CONTEXT_PRESENCE_TTL`) and the table is empty after a database failover; it refills within a heartbeat.
- The collaboration policy's `presence` switch applies (`403 context.collab_policy.capability_disabled`).

## Deployment notes

- A reverse proxy must not buffer the stream or time it out sooner than the keep-alive. The server sets `X-Accel-Buffering: no`; raise the proxy's read timeout above the heartbeat (30 seconds).
- Browsers allow about six HTTP/1.1 connections per origin: open one stream per app, not per component. HTTP/2 lifts the limit.
- Connection caps are per replica: 5 streams per user and 200 per organisation by default.
- If a replica's PostgreSQL LISTEN connection drops, delivery falls back to polling (about 7 seconds at worst) until it reconnects. Selfguard does not watch this fallback yet; the log line is "LISTEN connection lost".
- A malformed `KEYSTONE_CONTEXT_EVENTS_*` value turns only these routes into `503 context.events.unavailable`; retrieval is unaffected.

Metrics: `keystone_context_events_streams`, `keystone_context_events_published_total`, `keystone_context_presence_published_total`, `keystone_context_events_slow_consumers_total`, `keystone_context_events_pull_errors_total`, `keystone_context_events_skipped_total`.
