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.end() nor session.detach() closes 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.
Adopt an existing run
adoptRun(identity: AdoptIdentity, runtime?: RunRuntime<TOutput>): AdoptedRun<TOutput, TProjection, TMessage>Adopt an already-open Run by its identity so a fresh process can publish further Steps and lifecycle events for it. Returns synchronously and does no I/O; publishes nothing to the channel until AdoptedRun.load resolves. Use it from a step, tool, or cleanup activity that runs in a separate process from the one that opened the Run. See Durable execution.
1
2
3
4
5
const run = session.adoptRun(
{ runId, invocationId, triggerEventId },
{ signal: Context.current().cancellationSignal },
);
await run.load();Parameters
identityrequiredAdoptIdentityruntimeoptionalRunRuntimerunId and invocationId overrides do not apply here; identity comes from the identity argument.Returns
An AdoptedRun<TOutput, TProjection, TMessage> handle. Call load to resolve the Run's write context off the channel and adopt it for publishing.
AdoptedRun.load
load(options?: { timeoutMs?: number }): Promise<void>Resolve the Run's write context from the channel and adopt the Run for publishing in this process without emitting a fresh opening event. Awaits the Run's ai-run-start on the channel (paging history as needed), pins run.view to the triggering branch, and checks the Run's status: an active Run is adopted; a suspended or terminal Run rejects. Idempotent; a second call is a no-op.
Rejects with InvalidArgument when the Run is suspended (resume via createRun().start() on a continuation invocation) or terminal (read-only). Rejects with InputEventNotFound when the Run's ai-run-start is not observed within timeoutMs, which is a workflow-ordering error: the adopting activity ran before the opener published. This is retryable.
Parameters
options.timeoutMsoptionalNumberai-run-start before rejecting. Defaults to 30000.Returns
Promise<void>. Resolves once the Run is adopted for publishing. The returned AdoptedRun retains the full AgentRun publish surface (createStep, pipe, suspend, end) but omits start (the Run was opened elsewhere; publishing another opening event would corrupt its lifecycle).
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.
Create a step
createStep(options?: StepOptions): RunStep<TOutput>Create a RunStep: a re-attemptable unit of agent work within this Run. Use it when a retry of the same logical unit must supersede the failed attempt's channel output rather than append beside it, typically inside a workflow-engine activity. Returns synchronously and does no I/O; RunStep.start publishes the opening event.
options.stepId controls retry coalescing. Omit it for the common in-process case: the SDK assigns an invocation-scoped id, and an in-process retry after a 'failed' close reuses that id. Supply an explicit stepId when the same logical Step re-attempts in a separate process. Source it from the workflow engine's own stable per-activity id (a Temporal activity id, a Vercel WDK step id). See Durable execution.
1
2
3
4
const step = run.createStep({ stepId: stepIdFor(invocationId) });
await step.start();
await step.pipe(llmStream);
await step.end();The Run must be open first, via start or an adopting load. Only one Step may be active on a Run at a time; step.start() rejects if another Step is still open. If a Step is left open, run.end() auto-closes it.
Parameters
optionsoptionalStepOptionsReturns
A RunStep handle whose lifecycle mirrors the Run: call start() to publish ai-step-start, pipe() or send() to publish output, then end() to publish ai-step-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'.RunStep
The handle returned by AgentRun.createStep. A RunStep brackets one re-attemptable unit of agent output on the channel with an ai-step-start and an ai-step-end. Its stepId is stable across retries of the same Step: a retried ai-step-start under the same id supersedes the prior attempt's output instead of appending to it. See Steps.
Properties
stepIdStringabortSignalAbortSignalAbortSignal (the same instance as AgentRun.abortSignal); there is no per-Step abort. Fires when a cancel arrives for this Run.Start the step
start(): Promise<void>Publish ai-step-start, opening the Step for output. Call once, after the Run is open (via start or an adopting load) and before pipe or send. Idempotent; a second call is a no-op. Rejects if another Step is already active on the Run (only one Step may be open at a time), or if the Run has ended.
Pipe outputs
pipe(stream: ReadableStream<TOutput>, options?: PipeOptions<TOutput>): Promise<StreamResult>Pipe an output stream through the encoder to the channel, stamping every output with this Step's step-id and its attempt's start-serial. Otherwise identical to AgentRun.pipe: resolves when the stream completes, is cancelled, or errors. A stream error returns { reason: 'error' } rather than throwing, and marks the Step 'failed' when end closes it.
Parameters
streamrequiredReadableStream<TOutput>optionsoptionalPipeOptionsresolveWriteOptions merges over the Step's default headers, so a normal override leaves step-id intact.Returns
Promise<StreamResult>. Resolves when the stream ends. The reason classification matches AgentRun.pipe.
Send a discrete output
send(output: TOutput): Promise<void>Publish a single discrete output as one assistant message on the channel, stamped with this Step's step-id and its attempt's start-serial. Use it when the output is already resolved (a tool result, a data payload, a metadata event) rather than a streamed source. Each send mints its own codec-message-id, so N calls produce N assistant messages, not one. For streamed output from a long-running source, use pipe instead.
The Step must be active (started, not ended). Rejects otherwise. A publish failure throws.
Parameters
outputrequiredTOutputEnd the step
end(params?: StepEndParams): Promise<void>Publish ai-step-end, closing the Step. Idempotent; a second call is a no-op. Omit params to derive the reason: 'cancelled' if the Run was cancelled (its abortSignal fired), otherwise 'failed' if any pipe errored, otherwise 'complete'. Pass an explicit reason to override.
A Step terminal is not a Run terminal. Drive the Run to suspend or end afterwards. If a Step is left open, run.end() auto-closes it so observers are never stranded, but an explicit end() is clearer and lets you set the reason.
Parameters
paramsoptionalStepEndParamsSubscribe 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.
Detach the session
detach(): Promise<void>Unsubscribe from cancel messages, abort every active Run's controller (firing their abortSignal), detach the channel this session attached, and clean up. Publishes no Run terminal: any still-open Run is left as-is on the channel, to be resumed or cleaned up by another process. This is the escape hatch a durable in-flight activity uses to hand a Run off to the next activity mid-workflow. For a teardown that also closes open Runs, use end.
The detach is best-effort: a failure (for example, the channel is already FAILED) is swallowed and does not reject. Idempotent.
1
await session.detach();Returns
Promise<void>. Resolves once the detach completes. See Durable execution for when to prefer detach over end.
End the session
end(): Promise<void>Gracefully tear down the session. For every still-open Run this session owns, close its open Step (if any), then publish ai-run-end with reason: 'cancelled', then do everything detach does. A forgotten run.end() on a fire-and-forget turn still closes every observer's stream this way, rather than leaving it stuck on streaming.
An open Run ends 'cancelled'. Not 'complete' (that would falsely mark an unfinished turn as done), not 'suspend' (that would hang observers with no resumer; preserve-for-resume is detach's job), not 'error'. Use end as the normal teardown for a non-durable agent. A durable in-flight activity uses detach instead, to hand a still-open Run off to the next activity without terminating it.
1
await session.end();Returns
Promise<void>. Resolves once the terminals are published and the detach completes. Idempotent.
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.end();
}
return Response.json({ runId: run.runId, invocationId: run.invocationId });
}