---
title: "User Events Stream"
method: GET
path: "/api/v1/users/me/events"
tags: ["user-events"]
---

# User Events Stream

`GET /api/v1/users/me/events`

Subscribe to the authenticated user's live event channel via SSE.

Forwards messages on ``user:{user_id}:events`` to the browser via the
per-instance ``UserEventsFanout``. Heartbeat every 15 s. The connection
closes when the client disconnects or the server shuts down; the FE
reconnects with jittered backoff and re-fetches snapshot state on each
(re)connect so a dropped event during a disconnect window is recovered
automatically.

No payload is required from the FE — the subscription is implicit in
opening the connection, scoped by the authenticated user.

## Response `200`

One JSON frame per `data:` line.

- union
  - CreditStateChangedEvent — Fired when a workspace credit total changes (reserve / commit / refund). Payload-less by design: the FE invalidates the credits query and refetches the authoritative state. Keeps the channel cheap (one short JSON per transition) and frees the publisher from needing to compute workspace totals at emit time.
    - `type` 'credit_state_changed'
  - EnrichmentStartedEvent — Fired when a kickoff launches a batch of enrichment row-workflows. The "started" half of the enrichment lifecycle, symmetric to ``EnrichmentStatusChangedEvent`` (the terminal half). Together they make the FE's in-flight set fully server-authoritative: it ADDS the carried rows here and DROPS each one on its terminal event, instead of optimistically inferring "started" from the kickoff HTTP response. That optimistic path held one entry per ``project_id:field_name`` and reset it on every new ``coordinator_workflow_id``, so concurrent per-row runs on the same column collapsed onto the latest one (only the most recent row showed enriching). Keying on ``coordinator_workflow_id`` here lets each run track independently. Carries the same coordinates the ``/in-flight`` payload exposes (``coordinator_workflow_id`` / ``field_name`` / per-row ``workflow_id`` + ``feature_id``) so the live-delta path and the resume-snapshot path build identical registry entries. ``launched_total`` is the fixed progress denominator (immune to waved fan-out), matching ``InFlightBatchResponse``.
    - `type` 'enrichment_started'
    - `project_id` string, uuid, required
    - `enrichment_id` string, uuid, required
    - `field_name` string, required
    - `coordinator_workflow_id` string, nullable
    - `launched_total` integer, required
    - `rows` EnrichmentStartedRow[], required
      - `workflow_id` string, required
      - `feature_id` string, required
  - EnrichmentStatusChangedEvent — Fired when one enrichment workflow reaches a terminal state. Unlike ``CreditStateChangedEvent`` this is payload-rich. It carries: - ``workflow_id`` — the client registry's key; routes the event to the cell/column it belongs to. - ``project_id`` / ``enrichment_id`` / ``feature_id`` — the coordinates, so an event for a workflow the client didn't dispatch in this session (e.g. after a reload, before reconcile) can still build a registry entry from the event alone. - ``value`` — on SUCCESS, the persisted ``EnrichRowResponse``-shaped dict (``{value, reasoning, citations, state}``) built from the SAME ``db_value`` written to the sandbox, so the live cell is byte-identical to what a refresh re-reads. ``None`` on FAILURE (no value was written). The terminal-status policy lives on the server (the workflow's own terminal state), not on the client — so the client can no longer drift a transient signal into a false terminal (the bug class this replaces).
    - `type` 'enrichment_status_changed'
    - `workflow_id` string, required
    - `project_id` string, uuid, required
    - `enrichment_id` string, uuid, required
    - `feature_id` string, required
    - `status` 'SUCCESS' | 'FAILURE', required
    - `value` object, nullable
  - ViewStateChangedEvent — Fired after a project view mutation commits (create / update / filter / delete / column change), over the same per-user channel. Payload-rich + version-stamped, mirroring ``EnrichmentStatusChangedEvent``: the FE applies the carried view(s) straight to its React Query cache without a refetch round-trip, and uses each view's ``version`` to discard stale or out-of-order events. Carries only the *affected* view(s) plus ``deletedViewIds`` (not the full list) so the FE merges rather than overwrites siblings. Fields are camelCase so the SSE endpoint can forward ``model_dump_json`` verbatim and the FE reads the same keys it reads off ``/views``.
    - `type` 'view_state_changed'
    - `projectId` string, uuid, required
    - `views` object[], required
    - `deletedViewIds` string[]
  - LayerStateChangedEvent — Fired after a layer metadata mutation commits (create / delete / rename / restyle / recolor), over the same per-user channel — the layer sibling of ``ViewStateChangedEvent``. Payload-rich + version-stamped: the FE applies the carried layer(s) straight to its React Query cache without a refetch round-trip, and uses each layer's ``metadata_version`` to discard stale or out-of-order events. Carries only the *affected* layer(s) plus ``deletedLayerIds`` so the FE merges rather than overwrites siblings. Unlike views, the nested layers serialize in their **snake_case** wire shape — ``LayerModel`` has no field aliases, and the FE reads the same snake_case keys off ``GET /projects/{id}/layers`` through ``layerMappers.fromApiModel``. So no ``field_serializer`` is needed here; the default dump is already the wire shape.
    - `type` 'layer_state_changed'
    - `projectId` string, uuid, required
    - `layers` LayerModel[]
      - `id` string, uuid, required
      - `project_id` string, uuid, required
      - `reference_layer_id` string, uuid, nullable
      - `reference_layer_ids` string[]
      - `name` string, required
      - `description` string, nullable
      - `style_config` object, required
      - `feature_count` integer, required
      - `total_size_bytes` integer, required
      - `data_version` integer
      - `schema_version` integer
      - `metadata_version` integer
      - `base_attributes` object, required
      - `feature_enrichments` EnrichmentModel[]
        - `id` string, uuid
        - `layer_id` string, uuid, nullable
        - `name` string
        - `description` string, nullable
        - `tool` string
        - `params` object
        - `dtype` string — Must be one of string, boolean, list[str], jsonb, int, float, categorical, url
        - `created_at` string, date-time, nullable
        - `updated_at` string, date-time, nullable
        - `credits_per_row` integer, required — Return the number of credits per row for this tool.
        - `is_configured` boolean, required — Check if the enrichment is configured with a valid tool. Returns: bool: True if tool is configured, False otherwise
        - `is_configuration_failed` boolean, required — Check if the enrichment's configuration failed. Returns: bool: True if configuration was attempted but failed
        - `is_data_source` boolean, required — Check if the enrichment is a data source. Returns: bool: True if the enrichment is a data source, False otherwise
      - `sandbox_relation_name` string, nullable
      - `render_mode` 'detail_always' | 'agg_at_low_zoom'
      - `display_kind` 'data' | 'boundary' — Whether the layer is a queryable dataset (``data``) or a styling-only overlay (``boundary``). Boundary layers paint on the map but suppress the per-layer table, column manager, and feature-detail panel — there is no per-row data worth surfacing.
      - `extent_bounds` ThumbnailBounds — Bounding box for project thumbnail map rendering. Represents the geographic extent of project features for generating static map thumbnails via Mapbox.
        - `min_lon` number, required
        - `min_lat` number, required
        - `max_lon` number, required
        - `max_lat` number, required
      - `has_drawable_geometry` boolean, nullable — Whether the layer holds geometry the map can draw. False marks a table-only upload; None means unrecorded — every layer created before this field, and every creation path that does not state it. Consumers must treat None as unknown, not as False.
      - `created_at` string, date-time, nullable, required
      - `updated_at` string, date-time, nullable, required
      - `columns` LayerColumn[], nullable — Producer-declared, resolved per-column metadata (id, key, provenance, display name, data type) — schema/lineage only. Visibility and ordering live on the view, seeded at register-layer time. None for legacy pre-cutover layers; the composer derives sensible defaults from physical introspection.
        - `id` string, required — Stable column identifier within a layer. Hashed from the layer id plus the column's kind-specific stable identity. Source and merged columns omit `key` so alias renames keep the same id; agent-derived columns include `key` to distinguish multiple computations anchored on the same source.
        - `key` string, required
        - `provenance` ColumnProvenance, required — Where a column came from and how to look up its canonical metadata. `source_column` is the canonical Overture column path (e.g. `names.primary`, not the agent's SQL alias `name`). The resolver uses it to look up the display label and to derive a rename-stable column id — making alias mismatches impossible by construction.
          - `kind` 'source' | 'merged' | 'enrichment' | 'system' | 'agent_derived', required — Provenance discriminator for a layer column. * `source` — column SELECTed directly from the canonical Overture table. * `merged` — column joined in from another canonical source table (e.g. `parcel_owner` on a building layer joined parcel data). * `enrichment` — populated by an enrichment runner, not the agent's SQL. (Reserved for the upcoming enrichment unification — not used yet.) * `system` — agent-facing hide-by-default escape for lineage columns merged for enrichment plumbing the user shouldn't see by default (e.g. parcel address fields plumbed onto a building layer for Owner Contact Info). Stays in the registered schema and can be surfaced later via `modify_columns`. * `agent_derived` — computed in the agent's SQL (`SUM(...) AS total`, `CASE WHEN ... END AS bucket`). Still requires source_table+source_column anchoring so view deltas survive a column rename.
          - `source_table` string, nullable — Canonical Overture table this column derives from. None for fully derived (composite) columns with no canonical lineage.
          - `source_column` string, nullable — Canonical column path on `source_table` (e.g. `names.primary`). Used by the resolver for display-name lookup and stable column ids — NOT the agent's SQL alias.
        - `display_name` string, required — Resolved user-facing column label. Unit-agnostic — does NOT include unit tokens like `(ft)`, `(m)`, `(m²)`, or `(acres)`. The FE composes the column header as `display_name + (unit)` where `unit` is the single source of truth for the unit suffix. See MAIA-1872.
        - `data_type` 'string' | 'number' | 'integer' | 'boolean' | 'date' | 'geometry' | 'array' | 'object' — Optional frontend type hint for a layer column.
        - `unit` string, nullable — Display-unit suffix declared by the agent at register-layer time. Single source of truth for the user-visible unit on this column — the FE composes the header as `display_name + (unit)` and renders cells as `value + unit`. Round-trips through the resolver verbatim. Numeric formatting (decimal places) is picked from a unit→format lookup; unknown units fall back to integer formatting + raw suffix so the agent can declare arbitrary unit strings without code changes. Null for non-unit columns (names, IDs, categorical text). Currency uses the `currency` data_type / semantic type, NOT this field.
        - `semantic_type` 'identifier' | 'name' | 'address_part' | 'address_full' | 'currency' | 'area' | 'height' | 'count' | 'category' | 'zoning' | 'date' | 'phone' | 'website' | 'percent' | 'generic' — What a column *means* — drives FE rendering and formatting. Independent from ``ColumnDataType`` (the storage type hint): a column can be ``data_type=number, semantic_type=currency`` (parcel ``landval``) or ``data_type=number, semantic_type=area`` (``area_acres``). The FE renderer keys off ``semantic_type``, not ``data_type``. Lives here (with the wire model) rather than on ``LayerKind`` because under the self-describing contract this rides on ``LayerColumn`` across the wire — ``LayerKind`` is the backend-only producer that hydrates it.
        - `role` 'stat' | 'other' — Narrow detail-panel placement signal, orthogonal to ``semantic_type``. The one signal that cannot be derived from ``semantic_type``: parcel ``parval`` and ``improvval`` are both ``CURRENCY``, but only ``parval`` is a headline stat. ``role`` carries that; ``semantic_type`` carries content/format. Ordering and visibility live on the *view*, not here.
        - `description` string, nullable — Human-readable column description surfaced in the table column metadata. Hydrated server-side (canonical column metadata or the agent's declaration), not persisted as a separate wire bag.
      - `data_restricted` boolean — Share-response-only signal that the layer's geometry is shown while every per-row attribute is withheld (parcel layers on anonymous share links). Always False on authenticated reads; set True solely by the shared-project layer builder. The FE renders a 'data unavailable in the public version' state in place of an empty grid.
      - `viz_withheld_column_keys` string[] — Keys of columns the MVT tile refuses to emit as a classed-visualization column, computed per column by ``column_withheld_from_viz`` and stamped by ``to_authenticated_api_format``. The picker subtracts these so it offers exactly what the tile will paint; re-deriving the rule client-side from a whole-layer flag over-counts and hides ordinary columns. Empty on share responses, which render no picker.
      - `title_template` string[] — Ordered column keys the FE concatenates to compose a feature's title (e.g. `[parcelnumb]`, `[primary_address_full]`). Sourced from the layer's `LayerKind`, empty for permissive kinds — the FE keeps its generic title fallback for the long tail.
      - `address_template` string[] — Ordered column keys the FE concatenates to compose a feature's address line. Sourced from the layer's `LayerKind`; may reference keys that aren't typed canonical columns (city, state_abbr, …). Empty for permissive kinds.
    - `deletedLayerIds` string[]
  - ProjectRenamedEvent — Fired when a project's name changes server-side, over the per-user channel. The trigger today is auto-title generation: a fire-and-forget task that generates a name several seconds into the turn, and can retry on a *later* message. By then the project is already ``ready`` and the FE's creating-project poller has stopped watching it, so without this event the new name only surfaces on the next projects refetch — a manual refresh in practice. Carries the coordinates the FE needs to patch the projects-list cache in place: ``projectId`` to locate the row, ``name`` for the display, and ``titleSource`` so the cached row matches what a refetch would read. camelCase wire shape, matching ``ViewStateChangedEvent`` / ``LayerStateChangedEvent``. Advisory only — a dropped publish degrades to the FE's reconnect-resync.
    - `type` 'project_renamed'
    - `projectId` string, uuid, required
    - `name` string, required
    - `titleSource` 'placeholder' | 'auto' | 'user', required — Provenance + lifecycle for the project's name field. Replaces an em-dash string heuristic (`" — " not in project.name`) that gated whether the sandbox auto-titler should rename a project from its skeleton name. Encoding the decision as a typed enum makes the check a single comparison and prevents future titlers that happen to produce em-dashes from silently disabling the branch. - PLACEHOLDER: title is initial scaffolding (sandbox skeleton's "Boulder County", initial scaffold name); eligible to be replaced by the next titler. - AUTO: title set by an LLM auto-titler (agent_service first-message rename or project setup using the user's plan name); final. - USER: title set by an explicit user rename via PUT /projects/{id}; final.
  - ChatSummarizationCompletedEvent — Fired when a chat's background ``summarize_workflow`` finishes persisting. Summarization runs as a fire-and-forget DBOS workflow after a turn crosses the context threshold; the turn's ``complete`` SSE frame (carrying ``summarization_task_id``) is the *enqueue* moment, ~50s before the summary actually generates + persists. The original turn's direct stream has closed by then, so this advisory event over the per-user fanout is how the client learns the summary is genuinely done and renders the "summarized" divider. Advisory only — the durable summary is the source of truth: on reload the FE reconstructs the divider from persisted history (``detectSummarization``), so a dropped publish degrades to "divider appears on next reload," never a stuck state. Carries only ``projectId`` (camelCase wire shape, matching ``ViewStateChangedEvent`` / ``LayerStateChangedEvent``): ``chats.project_id`` is UNIQUE (one chat per project), so the project anchors the chat and the FE needs no ``chat_id`` to route.
    - `type` 'chat_summarization_completed'
    - `projectId` string, uuid, required
  - ImportProgressEvent — Fired as a background address import advances, and once when it settles. One event type covers both halves of the import lifecycle — unlike enrichment, which needs a started/terminal pair because a kickoff fans out across many row workflows. An import is a single job, so ``status`` distinguishes in-flight from terminal and the client keys every frame on the job's ``id``. The job's ``id`` — never its ``project_id`` — is the client registry's key: a project can be imported into more than once, so a project-keyed frame from a previous attempt would be read as progress on its replacement. Carries the job row itself rather than a hand-picked set of scalars. The terminal frame is the *only* thing the badge renders before it self-dismisses — well inside the client's 30s reconcile window — so any field the frame omits is a field the user never sees on a settled import. Picking fields by hand meant the settled counts were left off and every finished import reported "0 of N rows imported"; a nested :class:`ImportJobSnapshot` makes the frame and the polled read the same shape by construction. The nested job serializes in its **snake_case** wire shape, like ``LayerStateChangedEvent``'s layers: the FE reads the same keys off ``GET /import/jobs/in-flight``, so one client mapper serves both channels. Advisory only for the fact of settling: ``import_jobs`` is the durable truth a reload re-reads, so a dropped publish degrades to the client's 30s reconcile poll.
    - `type` 'import_progress'
    - `job` ImportJobSnapshot, required — Everything a client is told about an import, and nothing else. One field list feeding both channels the client learns through — the polled read and the pushed progress frame. They were separate shapes once, and the terminal frame silently lacked the settled counts the row had written: the badge reported every finished import as "0 of N rows imported". Adding a reportable field here puts it on both channels or neither. Excludes the durable-only columns (``workspace_id``, ``user_id``, ``workflow_id``, ``working_set``) — the last is tens of KB of TOASTed JSONB and this shape is served from a list endpoint polled every 30s.
      - `id` string, uuid, required
      - `project_id` string, uuid, required
      - `status` 'pending' | 'running' | 'cancelling' | 'completed' | 'failed' — Lifecycle of one background import. - PENDING: row inserted, workflow enqueued, no chunk resolved yet - RUNNING: the workflow claimed the job and is resolving chunks - CANCELLING: the user asked to stop; the workflow observes this at the next chunk boundary and falls through to a normal finalize. Cancel is cooperative, so this is an in-flight state, not a terminal one — the partial commit still has to happen. - COMPLETED: finalized, whether every row resolved or only some - FAILED: finalized with nothing committed, or the workflow raised
      - `total_rows` integer
      - `resolved_rows` integer
      - `committed_rows` integer
      - `shortfall_rows` integer
      - `failure_reason` string, nullable
      - `created_at` string, date-time, nullable
      - `updated_at` string, date-time, nullable
  - MapFlyToEvent — Fired when the agent moves the map camera to a resolved feature, over the per-user channel — the reply to a "zoom to <address/place/parcel>" request. Carries the envelope ``bbox`` (``[minLng, minLat, maxLng, maxLat]``) of a feature already in the project's data; the FE ``fitBounds`` to it. ``projectId`` gates the move to the active project (the per-user stream is not project-scoped). camelCase wire shape, matching the sibling events. Fire-and-forget / advisory: a dropped publish just means the camera doesn't move — the agent's text reply still names where it went.
    - `type` 'map_fly_to'
    - `projectId` string, uuid, required
    - `bbox` unknown[], required
      - unknown
  - ExclusionStateChangedEvent — Fired after a project's excluded-row set changes (bulk exclude or restore commits), over the per-user channel. Exclusions are an always-on server-side predicate, so the client holds no filter state to patch — this event tells it the visible row set changed: invalidate the exclusions query and bump the exclusions revision, which purges the SSR grid and re-points tile URLs. It is the only channel through which an *agent-driven* exclusion reaches an open browser (the agent's removal no longer runs SQL whose ``affected_layers`` the client parses). ``version`` is the project's post-bump ``excl_v`` counter (``None`` when Redis was unavailable for the bump); the FE treats it as opaque freshness. camelCase wire shape, matching the sibling events. Advisory only — a dropped publish degrades to staleness bounded by the next refetch or reload; the durable ``row_exclusions`` table is the truth.
    - `type` 'exclusion_state_changed'
    - `projectId` string, uuid, required
    - `version` integer, nullable

---

[API](https://skmtc.net/maia-analytics/apis/maia-api.md) · [All operations](https://skmtc.net/maia-analytics/apis/maia-api/llms.txt) · [OpenAPI document](https://skmtc-service-staging.skmtc.workers.dev/v1/apis/maia-analytics/maia-api/revisions/fff2cd2dfbac/schema)
