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.

JavaScript

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.RealtimePresence
The Ably presence object for the session's channel. Use it to see which clients are connected, for example to detect whether the requesting user is still online (enter, 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().
objectRealtimeObject
The Ably LiveObjects API for the session's channel. Use it to read and write shared LiveMap / 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.

JavaScript

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.Realtime
The Ably Realtime client. The caller owns its lifecycle; neither session.end() nor session.detach() closes the client.
channelNamerequiredString
The channel to publish to. The session owns this channel; do not also resolve it elsewhere with conflicting options.
codecrequiredCodec<TInput, TOutput, TProjection, TMessage>
The codec used to encode events and messages.
channelModesoptionalAbly.ChannelMode[]
Extra channel modes to request on top of the modes AI Transport always needs. Pass 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.
historyPageSizeoptionalNumber
Wire-message limit fetched per channel-history round trip, used by every run.view pagination on this session. Independent of loadOlder's reveal limit: it tunes fetch cost, not reveal granularity. Defaults to 100.
loggeroptionalLogger
Logger instance for diagnostic output.

Subscribe 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.

JavaScript

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.

JavaScript

1

2

const invocation = Invocation.fromJSON(await req.json());
const run = session.createRun(invocation, { signal: req.signal });

Parameters

invocationrequiredInvocation
The Invocation carrying run identity and conversation context.
runtimeoptionalRunRuntime
Per-run hooks and an external abort signal.

Returns

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.

JavaScript

1

2

3

4

5

const run = session.adoptRun(
  { runId, invocationId, triggerEventId },
  { signal: Context.current().cancellationSignal },
);
await run.load();

Parameters

identityrequiredAdoptIdentity
The Run's identity, threaded across the process boundary by the workflow that opened it.
runtimeoptionalRunRuntime
Per-Run hooks and an external abort signal. runId 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.timeoutMsoptionalNumber
How long to wait for the Run's ai-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

runIdString
The Run's unique identifier. Known synchronously on the agent (it mints the id for a fresh run, or reads it off the triggering input event for a continuation).
statusRunStatus
The Run's lifecycle status, read live off the tree.
errorAbly.ErrorInfo or Undefined
The terminal error, present exactly when status is 'error'.
messagesTMessage[]
All of this Run's messages: its triggering input followed by its streamed output (across any suspend and resume), deduplicated by codecMessageId. The unit to persist. See Hydrate the conversation.
invocationIdString
The invocation id minted by the agent for this createRun 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.
abortSignalAbortSignal
AbortSignal scoped to this Run. Fires when a cancel event arrives.
viewView<TMessage>
A read-only 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>
Resolves once the Run's triggering input (named in its invocation) has been observed on the channel, whether through the live subscription or by paging history with 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>
The output stream from your LLM call.
optionsoptionalPipeOptions
Branching and per-output hooks.

Returns

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.

JavaScript

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

optionsoptionalStepOptions
Step configuration.

Returns

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 no error.
  • { reason: 'error', error? }: the run ended in error. error is an optional Ably.ErrorInfo to surface to clients. Omit it to end in error without detail.
reasonrequiredRunEndReason
The terminal reason.
erroroptionalAbly.ErrorInfo
The terminal error to surface to clients. Allowed only when reason 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

stepIdString
This Step's id. Stable across retry attempts of the same Step.
abortSignalAbortSignal
The Run's AbortSignal (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>
The output stream from your LLM call.
optionsoptionalPipeOptions
Per-stream overrides. A per-output resolveWriteOptions 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

outputrequiredTOutput
The single codec output to publish.

End 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

paramsoptionalStepEndParams
Step-end configuration.

Subscribe to session errors

on(event: 'error', handler: (error: Ably.ErrorInfo) => void): () => void

Subscribe 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.

JavaScript

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'
The event to subscribe to. Currently only 'error'.
handlerrequiredFunction
Called with an ErrorInfo 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.

JavaScript

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.

JavaScript

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): Invocation

The entry point used by agent handlers: parse the request body and pass it to Invocation.fromJSON, then hand the result to createRun.

JavaScript

1

2

3

4

import { Invocation } from '@ably/ai-transport';

const data = await req.json();
const invocation = Invocation.fromJSON(data);

Parameters

datarequiredInvocationData
The parsed JSON request body matching the InvocationData wire shape.

Hydrate the conversation

Two accessors expose conversation content, at different scopes:

  • run.messages is 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.view is a read-only, leaf-pinned View of 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():

JavaScript

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.

JavaScript

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 });
}