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

# Resume a stream reliably

> Survive disconnects and duplicates without losing or double-processing events.

Session streams are long-lived and delivery is at-least-once. A correct consumer needs three things: a durable cursor, deduplication by event ID, and settlement read from the log rather than from the connection.

This page is about reconnecting an event stream. It is not [native resume](/concepts/session-execution-graph#portable-continue-vs-native-resume) of a harness capsule.

<Warning>
  Plain HTTP against the Checkfu API needs no private package.
  The Checkfu CLI and TypeScript SDK are optional private-alpha artifacts; the HTTP requests work without them.
  Part of this flow is behind a rollout fence; a deployment that has not enabled it refuses the request rather than running it.
  Confirm [capability status](/getting-started/status) before making availability part of your application's contract.
</Warning>

## The three rules

<CardGroup cols={3}>
  <Card title="Order by seq" icon="list-ol">
    `seq` is the per-Session sequence. It is the cursor you persist and the order you process in.
  </Card>

  <Card title="Skip what you have" icon="fingerprint">
    Delivery is at-least-once. In one Session stream the cursor filters replays; across unordered consumers, deduplicate on the globally unique `id`.
  </Card>

  <Card title="Settle on events" icon="flag-checkered">
    A closed connection is not a finished workflow. Read both the status event and its `stop_reason` before deciding to stop following.
  </Card>
</CardGroup>

## Connecting and resuming

`CHECKFU_API_KEY` and `CHECKFU_WORKSPACE_ID` come from [Get access](/reference/access#environment-variables-used-by-the-guides); `CHECKFU_SESSION_ID` is the `sess_…` you are following. The TypeScript tab uses the [TypeScript SDK](/reference/typescript-sdk) with this client:

```ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { Checkfu } from "@checkfu/sdk"

const checkfu = new Checkfu({
  apiKey: process.env.CHECKFU_API_KEY,
})
```

With no cursor, the stream starts from the beginning of retained history,
then stays live. On reconnect, send the last sequence you **fully processed**
in `Last-Event-ID`. A disconnect is not a reason to resend the input or its wait
response: reconnecting observes the original work; it does not restart it.

<CodeGroup>
  ```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
  curl --no-buffer \
    "https://api.checkfu.com/v1/sessions/$CHECKFU_SESSION_ID/events/stream" \
    --header "Authorization: Bearer $CHECKFU_API_KEY" \
    --header "Checkfu-Version: 2026-08-31" \
    --header "Last-Event-ID: 12"
  ```

  ```ts TypeScript theme={"theme":{"light":"github-light","dark":"github-dark"}}
  // The SDK sets Last-Event-ID for you and reconnects transparently.
  for await (const event of checkfu.sessions.events.stream(sessionId, {
    lastEventId: 12,
  })) {
    console.log(event.seq, event.type)
  }
  ```
</CodeGroup>

Each SSE frame carries `seq` in `id:`, the event type in `event:`, and the full envelope in `data:`.

## Process each event exactly once

Delivery is at-least-once, so an event can reach your code again after a
reconnect or crash. Commit the event's local changes and cursor in the same
database transaction. This prevents duplicate local application of that event;
it does not guarantee exactly-once effects in another service.

The sample follows one sequential turn through its waits. For overlapping
Runs, follow each Run's recorded outcome instead of using Session idle as a
completion boundary for all work.

Use the TypeScript SDK's stream rather than writing your own SSE parser. It resumes from `Last-Event-ID`, honors `Retry-After`, bounds its reconnects, and cleans up the response body. The follower below keeps no cursor in memory: it reads the durable cursor, and each event is applied inside the same database transaction that advances it.

```ts path=follow-session.ts theme={"theme":{"light":"github-light","dark":"github-dark"}}
import { isCheckfuError, type Checkfu } from "@checkfu/sdk"

type FollowedEvent = {
  readonly seq: number
  readonly type: string
  readonly payload?: unknown
}

/** One transaction in your own database: both writes commit, or neither does. */
export interface CursorTransaction {
  /** Lock this Session's cursor until commit, creating its row if absent. */
  loadCursor(sessionId: string): Promise<number | undefined>
  saveCursor(sessionId: string, seq: number): Promise<void>
}

export interface CursorStore {
  transaction<T>(work: (tx: CursorTransaction) => Promise<T>): Promise<T>
}

const TERMINAL_SESSION_EVENTS = new Set([
  "session.status_completed",
  "session.status_failed",
  "session.status_canceled",
])

const stopReason = (payload: unknown) =>
  typeof payload === "object" && payload !== null && "stop_reason" in payload
    ? payload.stop_reason
    : undefined

export const stopsWorkflowFollow = (event: FollowedEvent) =>
  TERMINAL_SESSION_EVENTS.has(event.type) ||
  (event.type === "session.status_idle" &&
    stopReason(event.payload) !== "requires_action")

/** The stream closed without a settlement event; nothing failed, so follow again. */
export class StreamEndedError extends Error {}

/** Only an SDK iterator read can produce this reconnectable failure. */
export class RetryableStreamError extends Error {}

/** Follow again only when the stream read failed or ended before settlement. */
export const isRetryableStreamFailure = (error: unknown) =>
  error instanceof StreamEndedError ||
  error instanceof RetryableStreamError

export const followSession = async (
  checkfu: Pick<Checkfu, "sessions">,
  store: CursorStore,
  sessionId: string,
  apply: (tx: CursorTransaction, event: FollowedEvent) => Promise<void>,
): Promise<void> => {
  const start = (await store.transaction((tx) => tx.loadCursor(sessionId))) ?? 0
  const iterator = checkfu.sessions.events.stream(sessionId, {
    lastEventId: start,
  })[Symbol.asyncIterator]()
  let failed = false
  try {
    for (;;) {
      let next: Awaited<ReturnType<typeof iterator.next>>
      try {
        next = await iterator.next()
      } catch (error) {
        if (isCheckfuError(error) && error.retryable) {
          throw new RetryableStreamError("stream reconnect budget exhausted", { cause: error })
        }
        throw error
      }
      if (next.done) break
      const event = next.value
      await store.transaction(async (tx) => {
        // The cursor lock serializes concurrent followers before either applies an effect.
        if (event.seq <= ((await tx.loadCursor(sessionId)) ?? 0)) return
        await apply(tx, event)
        await tx.saveCursor(sessionId, event.seq)
      })
      if (stopsWorkflowFollow(event)) return
    }
  } catch (error) {
    failed = true
    throw error
  } finally {
    if (failed) {
      try {
        await iterator.return?.()
      } catch {
        // Preserve the primary failure if cleanup also fails.
      }
    } else {
      await iterator.return?.()
    }
  }
  throw new StreamEndedError("event stream ended before the Session settled")
}
```

Call `followSession` with your SDK client, your database, the Session ID, and an `apply` function that writes the event's local effect through `tx`.

Keep the cursor and applied state together across restarts. Before following
again, check that stored state for an already-recorded settlement. An absent
cursor starts this sample at zero and is appropriate for a new local
projection. If an existing projection loses its cursor, recover the matching
state and cursor or rebuild a fresh projection; do not replay external effects
from the beginning.

How the pieces behave under failure:

Your database adapter must serialize cursor updates for each Session, for example by creating the cursor row and locking it with `SELECT ... FOR UPDATE` in `loadCursor`. A transaction alone does not stop two followers from reading the same cursor and both applying the effect. Every follower must use the same locking rule.

* **Crash after the effect, before the cursor.** Both are in one transaction, so neither committed. The event replays and is applied once.
* **The cursor write fails.** The transaction rolls back the effect with it, and the error reaches your caller. Following again replays the same event.
* **Duplicate delivery.** The transaction re-reads the durable cursor, so an event at or below it is skipped without calling `apply`.
* **Your own handler or database fails.** The original error propagates without advancing the cursor, and `followSession` stops. Even a retryable `CheckfuError` raised by your application is not classified as a stream reconnect failure. Handle that failure at its application boundary.
* **The stream fails or ends early.** When `isRetryableStreamFailure(error)` is true, the SDK has already used its reconnect budget or the stream closed without a settlement event; wait, then call `followSession` again. `followSession` resolves only after a settlement event, never because a connection closed. It resumes from the durable cursor. A non-retryable `CheckfuError` (an invalid key, a missing Session) needs a fix, not a retry.
* **Leaving early.** Returning from the loop, or an exception inside it, closes the SDK iterator, which cancels the underlying request. A cleanup failure propagates when it is the only failure; if application or stream work already failed, that original failure takes precedence.
* **`requires_action`.** An idle boundary whose `stop_reason` is `requires_action` is intermediate, so the follower keeps going through the continuation.

### Effects outside your database

A database transaction cannot include a call to another service, so a local receipt written after a remote call is not atomic with it. For a remote effect, pick one:

* **The provider supports idempotency keys.** Send a key derived from the event, such as its `id`. A replay repeats the call, and the provider returns the first result instead of acting twice.
* **It does not.** In `apply`, record the intent (event `id`, target, request) in the same transaction as the cursor, and dispatch it from a separate worker after commit. That worker marks the intent done on success, and after a crash reconciles against the provider's own state before it sends anything again.

### Without the SDK

If you consume the stream directly, keep the same transaction rule and also handle what the SDK does for you: check the HTTP status before reading the body, retry only on `429`, `503`, and other `5xx` responses, honor and bound `Retry-After`, and resume with the last *committed* `seq` in `Last-Event-ID`. Use an SSE parser that follows the [event-stream format](https://html.spec.whatwg.org/dev/server-sent-events.html#the-event-stream-format): lines can end in CRLF, LF, or a lone CR, and a frame can be split across network reads, so splitting on `"\n\n"` is not enough. Cancel the response body when you stop reading.

## Live previews do not move the cursor

The stream is durable-only unless you repeat the `event_deltas` query parameter. An opted-in stream may also receive `event_start` and `event_delta` frames for live `agent.message` output, plus `event_start` for `agent.thinking`. These frames have no SSE `id:` line and no `seq`, are never replayed, and may be dropped. Do not save a cursor for them.

If the corresponding observation commits, the final persisted `agent.message` or `agent.thinking` event uses the preview's event ID. Reconcile the temporary rendering to that event, perform durable side effects from the final envelope, and advance `Last-Event-ID` only from its numeric `seq`. A failed, interrupted, or lease-lost turn can leave no matching final event, so discard unmatched previews when the turn settles. A non-SDK client may also clear them at an observable HTTP reconnect; the SDK reconnects transparently, so recreating its iterator is the observable reconnect boundary. The TypeScript SDK enforces cursor neutrality automatically when you pass `eventDeltas` to `events.stream`.

## Settlement

Use both the event type and `stop_reason` to decide whether your workflow follower can return:

| Event | Stop following? | Meaning |
| - | - | - |
| `session.status_idle` with `stop_reason: "requires_action"` | No | The original Run is waiting for a matching response or continuing after one |
| Any other `session.status_idle` | Yes | The turn ended and the Session can be driven again |
| `session.status_completed` | Yes | Explicit successful terminal outcome |
| `session.status_failed` | Yes | Terminal failure |
| `session.status_canceled` | Yes | Explicitly canceled |

These stop rules apply to the sequential follower above. An idle boundary does
not end the Session or prove that every overlapping Run has finished. Match
`run.completed`, `run.failed`, or `run.canceled` to the Run you are tracking.

`requires_action` and `session.status_waiting` are intermediate waits. A matching
approval decision, question answer, or tool result continues the same original
Run; it does not admit a replacement Run. Approval still requires current
authority, and an application-executed tool still needs its result. See
[Handle an approval](/guides/handle-an-approval).

<Warning>
  Do not infer settlement from a closed HTTP request or a dropped SSE connection. A connection can close for reasons that have nothing to do with the Run, and the Run keeps going. Read settlement from the log.
</Warning>

## Backpressure

A stalled consumer does not lose events. Checkfu keeps at most one internal page in flight and lets the rest wait in the durable log, so a slow reader applies backpressure rather than dropping data. Reconnecting from `Last-Event-ID` resumes from that log.

This means a consumer that falls far behind is fine. It also means the stream is not a substitute for a queue: if your processing is genuinely slow, read with the list endpoint on your own schedule instead.

## Catching up without streaming

`GET /v1/sessions/{id}/events` pages the same log. `limit` defaults to 100 and caps at 1000.

The list supports two mutually exclusive entry points. Pass a decimal event `seq` as `after` to begin immediately after that durable position—the same coordinate carried by SSE `Last-Event-ID`. Or start without `after` and continue an ordinary paginated walk by echoing each opaque `next_page` value as `page`. Never send `after` and `page` together, and never try to construct an opaque `page` token from a sequence.

Use it for backfill, reconciliation after downtime, or rebuilding a projection from scratch. The stream and the list return the same persisted envelopes.

## Next steps

<CardGroup cols={2}>
  <Card title="Events" icon="list-timeline" href="/reference/events">
    The envelope, the full event catalog, and the drive events you can post.
  </Card>

  <Card title="Fold a session into named views" icon="code-branch" href="/guides/project-a-session">
    Fold the stream into named views: messages, tool calls, ActionApprovals, the subagent tree, and the context ledger.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.