Temporal
Temporal owns the durability of your agent's execution. AI Transport owns the durability of the conversation the user sees. The two compose without either owning the other's job.
Temporal runs your agent's loop as a workflow. Each stage of the loop is a Temporal activity that a worker executes and Temporal retries on failure. AI Transport publishes each activity's output as a Step inside a Run. When Temporal retries an activity, the retry lands on the channel under the same stepId and supersedes the failed attempt.
What Temporal brings
| Capability | Description |
|---|---|
| Workflow durability | The workflow's execution history persists in Temporal. A worker crash resumes the workflow on a different worker without losing progress. |
| Activity retries | Failed activities retry under configurable policies. The retry gets the same activityId, so Ably can supersede its predecessor. |
| Cancellation | A workflow cancel propagates as an AbortSignal inside each activity, which flows into the LLM call and stops it gracefully. |
| Observability | The Temporal Web UI shows activity attempts, retry counts, error traces, and workflow history. Pair it with the channel to see the whole turn. |
What AI Transport adds
| Capability | Description |
|---|---|
| Durable sessions | Tokens flow through an Ably channel that outlives any single connection. A client reconnects and resumes from where it left off. |
| Multi-device sync | Every device subscribed to the session sees the same conversation in realtime. |
| Bidirectional control | Cancel, steer, and interrupt the agent from any client. No separate control channel. |
| Retry supersede | When Temporal retries an activity, AgentRun.createStep({ stepId }) causes the retry's channel output to supersede the failed attempt's rather than append beside it. |
| Cancel routing on the channel | Cancels published to the Ably channel reach every activity's session directly, so a stop button in the browser aborts the LLM call inside the in-flight activity without a Temporal signal. |
| History and replay | Load the full conversation on reconnect, page refresh, or new device join. |
Where they connect
Each activity publishes its output as a single Step, using the Temporal activity id as that Step's stepId. That is the join between the two systems: Temporal's retryable unit and AI Transport's supersedable unit share one identifier.
Use stepIdFor from @ably/ai-transport/temporal to derive the stepId. It reads the activity id from the Temporal activity context and prefixes it with the Run's invocation id. Two workflows both have activityId === '1' for their first activity; the invocation-id prefix keeps their Steps distinct on the channel.
An inference activity:
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
import { Context } from '@temporalio/activity';
import Ably from 'ably';
import { streamText, convertToModelMessages, stepCountIs } from 'ai';
import { createAgentSession, vercelRunOutcome } from '@ably/ai-transport/vercel';
import { stepIdFor } from '@ably/ai-transport/temporal';
import { Invocation } from '@ably/ai-transport';
export async function runInferenceStep({ ids, invocation }) {
const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY });
const session = createAgentSession({ client: ably, channelName: invocation.sessionName });
await session.connect();
const run = session.adoptRun(
{ runId: ids.runId, invocationId: ids.invocationId, triggerEventId: ids.triggerEventId },
{ signal: Context.current().cancellationSignal },
);
await run.load();
while (run.view.hasOlder()) await run.view.loadOlder();
const step = run.createStep({ stepId: stepIdFor(ids.invocationId) });
await step.start();
const result = streamText({
model: myModel,
messages: await convertToModelMessages(run.view.getMessages().map((m) => m.message)),
abortSignal: run.abortSignal,
stopWhen: stepCountIs(1),
});
const pipeResult = await step.pipe(result.toUIMessageStream());
const outcome = await vercelRunOutcome(pipeResult, result.finishReason);
await step.end();
await session.detach();
ably.close();
return outcome;
}stopWhen: stepCountIs(1) prevents the Vercel AI SDK from running its own multi-step tool loop inside the activity. The workflow drives the loop instead: a first activity opens the Run, then one activity per inference (the first and every follow-up) and one activity per server tool call. This is what makes each unit retryable in isolation.
Open the Run in its own activity, separate from the first inference. That activity only calls createRun and run.start(); it publishes ai-run-start and returns the Run's ids without running the model. Two things follow from the split. An inference failure retries the inference alone and never re-opens the Run. And the Run's ids reach the workflow before any inference runs, so if the very first inference exhausts its retries your failure path still has the ids to end the Run rather than leaving it open. Pin the Run id to the Temporal workflowId in that opening activity so a fresh-process retry of it re-enters the same Run instead of opening a parallel one; the republished ai-run-start folds idempotently.
Route cancels through the channel
Cancels do not need a Temporal signal. A clientRun.cancel() in the browser publishes ai-cancel on the Ably channel. The activity's own AgentSession subscribes to that channel and routes the cancel to run.abortSignal via the SDK's built-in cancel routing:
Browser client │ clientRun.cancel() ▼ Ably channel │ ai-cancel published ▼ Activity's AgentSession (subscribes on adoptRun/createRun) │ matches by run-id ▼ run.abortSignal fires │ passed to streamText as abortSignal ▼ LLM call aborts · step.end() run.end({ reason: 'cancelled' })CopyCopied!
Because each activity constructs its own session and routes its own cancels, no long-running listener activity is needed for cancels. Temporal-level cancellation (a workflow cancel) still propagates via the activity's Context.current().cancellationSignal, which the code above passes into adoptRun as the runtime signal.
Suspend and resume across workflows
A suspend does not resume the current workflow. The suspending activity calls run.suspend(), publishes ai-run-suspend, and returns. The workflow ends. When the client posts a continuation invocation (a tool result or an approval response), the HTTP handler starts a fresh workflow. That workflow's first activity calls session.createRun(invocation, { invocationId }) with the new invocation id; the SDK sees the existing runId on the continuation input event and publishes ai-run-resume rather than ai-run-start.
workflowId = invocationId for every workflow: one workflow per HTTP POST. Continuations are new workflows on the same runId, not resumes of the original.
Scope and trade-offs
Temporal is intentionally focused on execution durability. By design, it does not model conversation state or route cancels between clients and the agent. AI Transport adds those without changing how you write workflows and activities. The workflow decides what runs and when; AI Transport decides what appears in the conversation and to whom.
Two boundaries to keep in mind:
- Durability applies at the Step boundary, not mid-chunk of an LLM stream. Temporal retries the whole activity. The activity's Step opens fresh under the same
stepIdand the retry's output supersedes the failed attempt. - Publish
run.end({ reason: 'error' })from your workflow's outermost catch when activity retries are truly exhausted. Otherwise the Run stays open on the channel and every observer's UI stays onstreaming.
Read next
- Durable execution: the framework-agnostic pattern.
- Steps: the retry unit Temporal activities map to.
- Vercel WDK: the same composition inside your Next.js app.
stepIdForAPI reference: the helper that produces a Temporal-safestepId.- AgentSession API reference:
adoptRun,createStep,detach, andend.