ClientSession
The ClientSession subscribes to an Ably channel, decodes incoming messages through a codec, and builds a conversation tree. It owns the channel attach and the cancel-publish path, exposes a default branch-aware View for rendering, and lets you derive additional views over the same tree.
Construct one with createClientSession from the core entry point. For Vercel UIMessage sessions, use the pre-bound factory from @ably/ai-transport/vercel instead.
1
2
3
4
5
6
7
8
9
10
11
12
13
import * as Ably from 'ably';
import { createClientSession } from '@ably/ai-transport';
import { createUIMessageCodec } from '@ably/ai-transport/vercel';
const ably = new Ably.Realtime({ authUrl: '/api/auth/token' });
const session = createClientSession({
client: ably,
channelName: 'conversation-42',
codec: createUIMessageCodec(),
});
await session.connect();Properties
treeTreeview in most cases; use tree for low-level inspection.viewViewpresenceAbly.RealtimePresenceenter, leave, get, subscribe). The session adds no semantics of its own (it is the same instance the channel exposes), and presence operations implicitly attach, so they work without first awaiting connect().objectRealtimeObjectLiveMap / LiveCounter state on the channel the session already uses; call get() to resolve the object. The session adds no semantics; it is the same instance the channel exposes. Operating on it requires the client to be constructed with the LiveObjects plugin from ably/liveobjects and the object modes to be requested via channelModes; without both, the underlying SDK throws.Create a client session
function createClientSession<TInput, TOutput, TProjection, TMessage>(options: ClientSessionOptions<TInput, TOutput, TProjection, TMessage>): ClientSession<TInput, TOutput, TProjection, TMessage>Construct a ClientSession bound to an Ably channel. The session does not attach to the channel until connect() resolves.
1
2
3
4
5
6
7
8
9
10
11
import * as Ably from 'ably';
import { createClientSession } from '@ably/ai-transport';
import { createUIMessageCodec } from '@ably/ai-transport/vercel';
const ably = new Ably.Realtime({ authUrl: '/api/auth/token' });
const session = createClientSession({
client: ably,
channelName: 'conversation-42',
codec: createUIMessageCodec(),
});Parameters
clientrequiredAbly.Realtimesession.close() does not close the client. The session's identity is read from this client's auth.clientId at publish time, stamped on the wire as the run-owner / input-owner id so other clients can attribute messages. A connection with no concrete clientId (anonymous, or a wildcard * token) publishes without one.channelNamerequiredStringcodecrequiredCodec<TInput, TOutput, TProjection, TMessage>channelModesoptionalAbly.ChannelMode[]OBJECT_MODES to use Ably LiveObjects via object. Omit to attach with the default mode set. The session requests the union, so extra modes never drop the modes AI Transport relies on.historyPageSizeoptionalNumberview.loadOlder(), shared by every view on the session. Independent of loadOlder's reveal limit: it tunes fetch cost rather than reveal granularity. Defaults to 100.loggeroptionalLoggerReturns
ClientSession<TInput, TOutput, TProjection, TMessage>. The session instance. Call connect() to attach before sending or cancelling.
Connect the session
connect(): Promise<void>Subscribe to the channel and implicitly attach. Idempotent: subsequent calls return the same promise.
All write methods on view and cancel throw InvalidArgument until connect() resolves.
1
await session.connect();Returns
Promise<void>. Resolves when the channel is attached and the session is ready for writes.
Create an additional view
createView(): ClientView<TInput, TMessage>Create an additional view over the same conversation tree. Each view has independent branch selections and pagination state.
The caller owns the returned view's lifecycle: call its close() when it is no longer needed, or session.close() closes it.
1
2
3
4
5
const secondaryView = session.createView();
secondaryView.branchSelection(messageId).select(1);
// the default view is unaffected
session.view.branchSelection(messageId).index; // 0Returns
ClientView<TInput, TMessage>. A new view with its own pagination window and branch selection state.
Cancel a run
cancel(runId: string): Promise<void>Publish a cancel signal for the specified run. The agent receives the cancel through its own channel subscription and ends the run with reason 'cancelled'.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const clientRun = await session.view.send({
kind: 'user-message',
message: {
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'Tell me a story' }],
},
});
// later, cancel from the UI.
// clientRun.cancel() works immediately, even before the agent's
// run-start has been observed (so clientRun.runId is still empty).
await clientRun.cancel();
// Or, when you already have a resolved runId (for example from a
// RunInfo in view.runs()):
// await session.cancel(someRunInfo.runId);Parameters
runIdrequiredStringClientRun.runId (after awaiting clientRun.started), or from a RunInfo.runId in view.runs().Returns
Promise<void>. Resolves once the cancel message has been published. The cancel is best-effort: if the agent has already ended the run, the cancel is a no-op.
ClientRun
The handle returned by view.send, regenerate, and edit. It extends the shared BaseRun read-model (runId, status, error, messages) with the client's control methods, including the run-scoped steer verb.
Properties
runIdStringai-run-start is observed; await started before reading it.statusRunStatuserrorAbly.ErrorInfo or Undefinedstatus is 'error'.messagesTMessage[]codecMessageId.startedPromise<void>ai-run-start (or ai-run-resume) is observed, the point at which runId is populated. No built-in deadline; race it against your own timeout.inputCodecMessageIdStringrunId.inputEventIdStringSteer the run
steer(input: TInput): SteerResultPublish a codec input event that targets this run. The steering message carries this run's run-id so the agent folds it into the active run rather than starting a new run. Pass the same shape view.send accepts, typically codec.createUserMessage(...). The SDK awaits runId internally, so this is safe to call as soon as the handle is returned. Once an ai-run-end has folded for this run the handle is dead and further steer() calls return immediately-rejected promises.
1
2
3
4
5
6
7
8
const { published, outcome } = activeRun.steer(createUIMessageCodec().createUserMessage({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'Also include vegan options.' }],
}));
const { serial } = await published;
const { consumed, runTerminalReason } = await outcome;Parameters
inputrequiredTInputcodec.createUserMessage for a follow-up user message.Returns
SteerResult. Two promises: published for the channel-publish acknowledgement, and outcome for the consumed determination resolved at the run's next terminal event.
Cancel the run
cancel(): Promise<void>Cancel this specific run. Keyed by inputCodecMessageId, which the client owns synchronously, so a cancel issued before the agent mints the runId is still honoured (the agent buffers it and fires it once its input-event watcher matches the trigger). Resolves once the cancel is published; it does not wait for started.
Build the invocation
toInvocation(): InvocationBuild the Invocation pointer for this run, carrying only inputEventId and the session's channel name. POST run.toInvocation().toJSON() to your agent endpoint to wake the agent; run identity lives on the channel rather than in the invocation body.
Subscribe to session errors
on(event: 'error', handler: (error: Ably.ErrorInfo) => void): () => voidSubscribe to non-fatal session errors. These indicate something went wrong but the session is still operational; examples are subscription callback failures and channel continuity loss.
1
2
3
4
5
6
const unsubscribe = session.on('error', (error) => {
console.error('Session error:', error.code, error.message);
});
// later, when the listener is no longer needed
unsubscribe();Parameters
eventrequired'error''error'.handlerrequiredFunctionErrorInfo for every non-fatal error.Returns
() => void. An unsubscribe function. Call it to remove the listener.
Close the session
close(): Promise<void>Tear down the session. Unsubscribe from the channel, close active streams, clear handlers, and prevent further operations.
close() is local-state-only. The server keeps streaming until its runs end on their own. To stop in-progress runs, call cancel for each before close().
1
2
3
4
5
6
const runIds = session.view.runs()
.filter((run) => run.status === 'active')
.map((run) => run.runId);
await Promise.all(runIds.map((runId) => session.cancel(runId)));
await session.close();Returns
Promise<void>. Resolves once the channel has been released.
Example
End-to-end usage covering construction, connect, send, and teardown.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import * as Ably from 'ably';
import { createClientSession } from '@ably/ai-transport';
import { createUIMessageCodec } from '@ably/ai-transport/vercel';
const ably = new Ably.Realtime({ authUrl: '/api/auth/token' });
const session = createClientSession({
client: ably,
channelName: 'conversation-42',
codec: createUIMessageCodec(),
});
await session.connect();
session.view.on('update', () => {
render(session.view.getMessages().map(({ message }) => message));
});
const clientRun = await session.view.send(createUIMessageCodec().createUserMessage({
id: crypto.randomUUID(),
role: 'user',
parts: [{ type: 'text', text: 'Plan a 3-day trip to Lisbon.' }],
}));
// The SDK doesn't POST. The application wakes the agent itself.
await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(clientRun.toInvocation().toJSON()),
});
// The agent mints the runId on the server, so clientRun.runId is empty
// until run-start is observed. Await `started`, then read it.
await clientRun.started;
const runId = clientRun.runId;
await session.close();
ably.close();