History and replay

Your users see the conversation when they come back to it. The channel holds it, and `history` pages backwards through it a batch at a time.

Every message on a conversation persists on the Ably channel that carries it: user inputs, agent output, and the run and step lifecycle around them. history walks the channel backwards from the point your transport attached, so a client can rebuild what it missed and an agent can assemble the context for a model call.

Diagram showing a client loading conversation history from the channel and paginating older messages on scroll

One call returns one batch:

JavaScript

1

const { events, exhausted } = await transport.history({ limit: 50 });

How it works

The cursor starts at the attach point and moves backwards. Each call returns the next older slice and leaves the cursor where it stopped, so repeated calls walk toward the start of the channel.

Five properties shape how you use it:

PropertyWhat it means for your code
Chronological within a batchevents is oldest-first, and each batch is older than the last, so a consumer prepends: all = [...batch.events, ...all].
Page granularlimit stops the walk once it is reached, after finishing the page it is on, so a batch may come back larger than the limit you asked for.
exhaustedTrue once the cursor reaches the channel's start. Further calls resolve empty.
Single-flightConcurrent calls serialise rather than interleaving, so a double-fired scroll handler cannot duplicate a page.
Shared decoderPaging uses the live stream's decoder, so a stream that spans the attach boundary is decoded once rather than twice.

History events are returned from this call only. They never reach your subscribe handlers, so a merge that runs on both has to be driven from both.

Page backwards on the client

Scroll-back is a loop over batches, prepending each one:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

// Client-side.
let exhausted = false;
let loading = false;

async function loadOlder() {
  if (exhausted || loading) return;
  loading = true;
  try {
    const batch = await transport.history({ limit: 30 });
    for (const event of batch.events) fold.apply(event);
    exhausted = batch.exhausted;
  } finally {
    loading = false;
  }
}

Guard on your own loading flag for the interface's sake. The call is single-flight underneath, so a second call while one is in flight waits rather than duplicating, but a spinner needs the flag anyway.

Pass a signal to abandon a walk when the user navigates away. The call rejects with OperationCancelled and the cursor stays where it was, so a later call carries on from the same place.

Assemble context on the agent

An agent that has no store of its own can page the channel for the conversation to feed the model:

JavaScript

1

2

3

4

5

6

7

8

// Agent-side.
const events = [];
let exhausted = false;
while (!exhausted && events.length < 500) {
  const batch = await transport.history({ limit: 100 });
  events.unshift(...batch.events);
  exhausted = batch.exhausted;
}

locateInput(eventId) finds the one input that woke this invocation directly, which is a different job from paging the conversation. It scans on a throwaway decoder so it never disturbs the live stream's state, and it reads roughly one page whatever the conversation's length, because the trigger is the newest message in that history.

Join your own store to the channel

Most applications that keep their own conversation store want both: the store for everything older than the channel's retention window, and the channel for what is live. You are responsible for making that join correct.

Your store ends somewhere, the channel's retained window starts somewhere, and the two overlap by an amount neither side knows. Walk too far and you render a message twice; stop too early and you leave a hole. Ordering the two halves is the same problem again, because your store's own ids are not the channel's serials.

What works today is to record, alongside each message you persist, the meta.serial of the wire message it came from. That gives you one comparable value on both sides: page the channel back until you reach a serial you already hold, drop that overlap, and prepend the rest.

With a durable session

A durable session does the walk and the join for you. It pages the channel behind a view, materialises the results into the conversation tree, and reveals them a window at a time:

JavaScript

1

2

// Client-side, with a durable session.
const { messages, hasOlder, loading, loadOlder } = useView({ limit: 30 });

loadOlder expands the window and resolves with the page it revealed, oldest-first, and the messages array updates in step. The untilAttach walk underneath accounts for every message between the historical window and the live stream, so the two meet without a gap. Branch structure comes back too: the tree reads the parent and fork-of headers off each message and rebuilds the forks rather than a flat list.

Seeding that view from a store you already have is database hydration, which reconciles the two at a join point you choose.

Edge cases and unhappy paths

  • Channel history is bounded by your retention window. A client attaching after messages have aged out of the retention window sees only what is left, and exhausted reports the start of the retained window rather than the start of the conversation.
  • A page fetch that keeps failing rejects the call with SessionHistoryFetchFailed after retries. The cursor is unmoved, so a retry resumes rather than restarting.
  • A single undecodable message is skipped and emitted on the transport's error stream. The rest of the batch is returned, so a decode failure loses one message rather than the page.
  • A client without history capability cannot page at all. Capability scoping is part of authentication.
  • A late joiner that arrives mid-stream reads the accumulated content of the in-flight message rather than a replay of every token, then receives the remaining appends live.
  • history requires connect first. Calling it on a transport that has not connected, or one that has closed, rejects.

FAQ

Do I need a database for chat history?

Not for AI Transport's own behaviour. The channel stores the conversation for the period configured by your retention window. Add a database when you need conversations to live longer than that window, or for search, analytics, and reporting.

Why did I get more events than my limit?

The walk stops once the limit is reached, but it finishes the page it is on first, so limit is a floor for when to stop rather than a cap on the batch. Size your rendering for a batch a little larger than you asked for.

Can I page forward?

No. history is backward-only, because the live subscription already delivers everything newer than the attach point. New messages arrive on subscribe with no fetch.

Does history include cancelled runs?

Yes. A cancelled message keeps the content it had, with a cancelled status, and its run's end event carries the reason.

Does paging disturb the live stream?

No. Paging shares the live decoder so a stream spanning the attach boundary decodes once, and history events are returned from the call rather than pushed to subscribe. locateInput goes further and uses a throwaway decoder, so it touches nothing.

  • Token streaming: how a streamed response is persisted and read back.
  • Reconnection and recovery: what the channel replays on its own, before you reach for history.
  • Runs and steps: the lifecycle events a history batch carries alongside messages.
  • Database hydration: the join point between your store and the channel, solved for you once the session holds the conversation.