AgentSession
The AgentSession is the server-side counterpart to ClientSession. It subscribes to the channel for cancel signals and creates AgentRun instances that publish lifecycle events, user messages, and streamed assistant output.
Construct one with createAgentSession from the core entry point. For Vercel UIMessage channels, use the pre-bound factory from @ably/ai-transport/vercel instead.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import * as Ably from 'ably';
import { createAgentSession, Invocation } from '@ably/ai-transport';
import { UIMessageCodec } from '@ably/ai-transport/vercel';
const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY });
const invocation = Invocation.fromJSON(await req.json());
const session = createAgentSession({
client: ably,
channelName: invocation.sessionName,
codec: UIMessageCodec,
});
await session.connect();
const run = session.createRun(invocation, { signal: req.signal });
await run.start();Properties
presenceAbly.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. See LiveObjects State.Create an agent session
function createAgentSession<TInput, TOutput, TProjection, TMessage>(options: AgentSessionOptions<TInput, TOutput, TProjection, TMessage>): AgentSession<TOutput, TProjection, TMessage>Construct an AgentSession bound to an Ably channel. The session does not attach until connect() resolves.
1
2
3
4
5
6
7
8
9
10
11
import * as Ably from 'ably';
import { createAgentSession } from '@ably/ai-transport';
import { UIMessageCodec } from '@ably/ai-transport/vercel';
const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY });
const session = createAgentSession({
client: ably,
channelName: 'conversation-42',
codec: UIMessageCodec,
});Parameters
clientrequiredAbly.Realtimesession.close() does not close the client.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. See LiveObjects State.historyPageSizeoptionalNumberrun.view pagination on this session. Independent of loadOlder's reveal limit: it tunes fetch cost, not reveal granularity. Defaults to 100.loggeroptionalLoggerSubscribe to non-fatal session errors with on('error') rather than a constructor option.
Returns
AgentSession<TOutput, TProjection, TMessage>. The session instance. Call connect() before createRun.
Connect the session
connect(): Promise<void>Attaches and subscribes to the channel backing the session. Idempotent: subsequent calls return the same promise. All AgentRun methods (start, pipe, suspend, end) throw InvalidArgument until connect() has been called.
1
await session.connect();Returns
Promise<void>. Resolves when the channel is attached and the session is ready to create runs.
Create a run
createRun(invocation: Invocation, runtime?: RunRuntime<TOutput>): AgentRun<TOutput, TProjection, TMessage>Create a new AgentRun for the input event named in the Invocation. Returns synchronously and publishes nothing to the channel until AgentRun.start is called. The run is registered for cancel routing immediately so early cancels fire the abortSignal.
1
2
const invocation = Invocation.fromJSON(await req.json());
const run = session.createRun(invocation, { signal: req.signal });Parameters
invocationrequiredInvocationInvocation carrying run identity and conversation context.runtimeoptionalRunRuntimeReturns
An AgentRun<TOutput, TProjection, TMessage> handle for publishing lifecycle events, user messages, and streamed output. See AgentRun interface below.
AgentRun
The handle returned by createRun. It extends the shared BaseRun read-model (runId, status, error, messages) with the agent's lifecycle surface.
Properties
runIdStringstatusRunStatuserrorAbly.ErrorInfo or Undefinedstatus is 'error'.messagesTMessage[]codecMessageId. The unit to persist. See Hydrate the conversation.invocationIdStringcreateRun call (one per HTTP request). Readable synchronously; the application returns it on the HTTP response. The agent stamps it on every event it publishes for this invocation.abortSignalAbortSignalAbortSignal scoped to this Run. Fires when a cancel event arrives.viewView<TMessage>View<TMessage> of the conversation branch this Run belongs to, from its triggering input back to the conversation root. Use it to reconstruct the conversation to feed the model. See Hydrate the conversation.locatedPromise<void>view.loadOlder(). start awaits it internally; await it directly only to read the trigger before deciding how to start.Start the run
start(): Promise<void>Wait until the Run's triggering input has been observed on the channel (see located), then publish the opening lifecycle event (ai-run-start, or ai-run-resume for a continuation). Must be called before pipe, suspend, or end.
There is no built-in deadline: start() does not time out waiting for the trigger. It rejects only if the run is cancelled or the session is closed before the trigger is observed. Race it against your own timeout if you need one.
Pipe the response stream
pipe(stream: ReadableStream<TOutput>, options?: PipeOptions<TOutput>): Promise<StreamResult>Pipe a ReadableStream of outputs through the encoder to the channel. Returns when the stream completes, is cancelled, or errors. Does NOT call end(); the caller must call end() after pipe() returns.
Parameters
streamrequiredReadableStream<TOutput>optionsoptionalPipeOptionsReturns
Promise<StreamResult>. Resolves when the stream ends. Pass result.reason to Run.end.
Suspend the run
suspend(): Promise<void>Publish the ai-run-suspend event to the channel, pausing the Run pending external input (a tool approval, a human-in-the-loop response). The Run is not terminal: RunInfo.status becomes 'suspended', and a continuation Invocation resumes it via ai-run-resume.
Use suspend instead of end when you want the run to come back. Use end only for terminal outcomes.
End the run
end(params: RunEndParams): Promise<void>Publish the ai-run-end event to the channel terminally and clean up. params is a RunEndParams object carrying the terminal reason and, when reason is 'error', an optional error. To pause a Run instead of ending it, use suspend.
Parameters
RunEndParams is discriminated on reason:
{ reason: 'complete' | 'cancelled' }: a non-error terminal reason that carries noerror.{ reason: 'error', error? }: the run ended in error.erroris an optionalAbly.ErrorInfoto surface to clients. Omit it to end in error without detail.
reasonrequiredRunEndReasonerroroptionalAbly.ErrorInforeason is 'error'.Subscribe to session errors
on(event: 'error', handler: (error: Ably.ErrorInfo) => void): () => voidSubscribe to non-fatal session-level errors not scoped to any run: channel continuity loss (a re-attach with resumed: false, or FAILED / SUSPENDED / DETACHED), cancel-listener or attach failures, and any run-scoped error whose run supplied no onError. Returns an unsubscribe function. Once the session is closed this is a no-op.
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 session error.Returns
() => void. An unsubscribe function. Call it to remove the listener.
Close the session
close(): Promise<void>Cancel active runs, detach the channel the session attached, and clear handlers. Local-state-only for run lifecycle; it does not end in-progress Runs on the wire. End each Run explicitly before closing the session.
Invocation
A value object wrapping the JSON body a client sends to the agent's HTTP endpoint to start a Run.
Build from JSON
Invocation.fromJSON(data: InvocationData): InvocationThe entry point used by agent handlers: parse the request body and pass it to Invocation.fromJSON, then hand the result to createRun.
1
2
3
4
import { Invocation } from '@ably/ai-transport';
const data = await req.json();
const invocation = Invocation.fromJSON(data);Parameters
datarequiredInvocationDataInvocationData wire shape.Hydrate the conversation
Two accessors expose conversation content, at different scopes:
run.messagesis all of this Run's own messages: its triggering input plus its streamed output (across any suspend and resume). This is the unit to persist, not the value to feed the model in a multi-turn conversation.run.viewis a read-only, leaf-pinnedViewof this Run's full branch, from its triggering input back to the conversation root. This is the value to feed the model.
run.view includes an ancestor turn only once its run has completed. An ancestor that is still active, suspended, cancelled, or errored is omitted, along with the input it replied to, so a dangling tool call from a concurrent or interrupted turn can't invalidate the prompt. The current run is always included, and an omitted ancestor reappears once it completes.
To rebuild the prior conversation for the model, drain run.view with loadOlder() for as much ancestor context as you want, then read getMessages():
1
2
3
4
5
6
7
8
// Rebuild the conversation from run.view before run.start(): draining pages in
// this run's triggering input (otherwise run.start() awaits it arriving live).
while (run.view.hasOlder()) {
await run.view.loadOlder();
}
const conversation = run.view.getMessages().map(({ message }) => message);
await run.start();For database-backed hydration, page run.view back only to the newest stored message with loadUntil instead of draining to the root.
RunEndReason
'complete' | 'cancelled' | 'error'. The terminal-reason discriminant: it is the reason field of the RunEndParams you pass to Run.end, the reason on StreamResult, and the value reflected on RunInfo.status once the Run terminates.
A Run that pauses for external input (tool approval, human-in-the-loop) uses Run.suspend instead of end, which publishes ai-run-suspend and leaves the Run alive at RunInfo.status === 'suspended'. A continuation Invocation resumes it via ai-run-resume.
Example
An HTTP handler that sets up the session, creates a Run, rebuilds the conversation from run.view, pipes the LLM stream, and ends the Run.
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
39
40
41
import * as Ably from 'ably';
import { createAgentSession, Invocation } from '@ably/ai-transport';
import { UIMessageCodec } from '@ably/ai-transport/vercel';
const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY });
export async function POST(req: Request) {
const invocation = Invocation.fromJSON(await req.json());
const session = createAgentSession({
client: ably,
channelName: invocation.sessionName,
codec: UIMessageCodec,
});
await session.connect();
const run = session.createRun(invocation, { signal: req.signal });
try {
// Rebuild the conversation from run.view before run.start(): draining pages
// in this run's triggering input (otherwise run.start() awaits it live).
while (run.view.hasOlder()) {
await run.view.loadOlder();
}
const conversation = run.view.getMessages().map(({ message }) => message);
await run.start();
const llmStream = await callMyLLM(conversation);
const result = await run.pipe(llmStream);
await run.end({ reason: result.reason });
} catch (err) {
await run.end({ reason: 'error' });
throw err;
} finally {
await session.close();
}
return Response.json({ runId: run.runId, invocationId: run.invocationId });
}