> ## Documentation Index
> Fetch the complete documentation index at: https://docs.12m.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Live thread list

> Combine the conversations endpoint with the events stream to render an inbox UI that updates in real time. Without mirroring our state into your DB.

Most integrations end up wanting the same UI: a list of recent threads
that updates as new replies land, with pagination as the user scrolls
back. The ergonomic way to build it on 12m is to combine two
endpoints. You don't keep a copy of our state.

## The pattern

```
GET /v1/identities/:handle/conversations         ← initial snapshot
GET /v1/identities/:handle/events?since=...      ← live deltas
GET /v1/identities/:handle/conversations?cursor=... ← scroll-back
```

Three primitives, three jobs. The first paints the screen. The second
patches rows as they change. The third pages backward when the user
scrolls past the bottom.

## Putting it together

```js theme={null}
// 1. Initial render.
const { conversations, nextCursor } = await fetch12m(
  `/v1/identities/${handle}/conversations?limit=50`,
);
const list = new Map(conversations.map((c) => [c.id, c]));
let scrollCursor = nextCursor;

// 2. Subscribe to deltas.
let eventCursor = 0;
while (mounted) {
  const r = await fetch12m(
    `/v1/identities/${handle}/events?since=${eventCursor}&timeoutMs=25000`,
  );
  for (const ev of r.events) {
    if (!ev.convId) continue;

    if (list.has(ev.convId)) {
      // Existing row — patch from the event.
      const row = list.get(ev.convId);
      row.lastEventAt = ev.ts;
      if (ev.data?.message?.snippet)  row.snippet  = ev.data.message.snippet;
      if (ev.data?.message?.subject)  row.subject  = ev.data.message.subject;
      if (ev.type === "email.received" || ev.type === "email.replied") {
        row.messageCount = (row.messageCount ?? 0) + 1;
      }
    } else {
      // New thread — fetch the row shape, prepend.
      const { conversation } = await fetch12m(
        `/v1/identities/${handle}/conversations/${ev.convId}`,
      );
      list.set(conversation.id, conversation);
    }
    render(Array.from(list.values()).sort((a, b) => b.lastEventAt - a.lastEventAt));
  }
  eventCursor = r.cursor;
}

// 3. Pagination — when the user scrolls past the bottom.
async function loadOlder() {
  if (!scrollCursor) return;
  const older = await fetch12m(
    `/v1/identities/${handle}/conversations?cursor=${scrollCursor}&limit=50`,
  );
  for (const c of older.conversations) list.set(c.id, c);
  scrollCursor = older.nextCursor;
}
```

That is the whole thing. \~30 lines, no client-side reducer, no
database, no replay logic.

## Why no refetch on every event

The event payload already carries the fields you need to update a row:
`subject`, `snippet`, the timestamps. The single-conv refetch is reserved
for two cases:

* **A new thread** — the convId isn't in your map yet, you need its
  full row shape (correspondent, label list, archived state).
* **You need a field the event doesn't carry** — labels were added,
  conversation was archived. Today these are dashboard-only mutations
  and the event stream doesn't broadcast them.

For the typical "render an inbox UI as new replies land" loop, refetch
is unnecessary >80% of the time.

## Why no streaming list endpoint

You might expect `/conversations` to support SSE or websockets so the
list itself "is the stream." We chose not to build that. One stream
(the events log) covers every live-update use case in the system, and
adding a per-resource SSE would multiply the surface area for no
expressive gain.

The mental model stays simple:

* The list endpoint answers "what's the snapshot?"
* The events stream answers "what just changed?"
* The single-conv endpoint answers "what's the current state of X?"

Three answers, three endpoints, no overlap.

## Filtering live

If your UI shows only replied threads, only archived, only a particular
correspondent — apply the filter on both ends. The list endpoint takes
filter query params. The events stream takes `types`:

```
GET /events?types=email.replied,email.no_reply&since=...
```

Filter the same way in both calls and your live list stays consistent.
Server-side filtering keeps your client small.

## What the list rows contain

Just enough to render a row, never the full timeline:

```json theme={null}
{
  "id":           "conv_dd6e...",
  "with":         "morgan@northwindrobotics.com",
  "subject":      "Quick intro — fleet rotation",
  "snippet":      "Friday 10 works...",
  "messageCount": 5,
  "archived":     false,
  "lastEventAt":  1777278929000,
  "createdAt":    1777200000000
}
```

If you want to render an "open thread" view on click, fetch the
single-conv endpoint at that point. It returns the full timeline as
`events[]`.
