Get started with Temporal
Build a Next.js chat app whose agent side runs inside a Temporal workflow. Each step is one retryable activity; a retry supersedes the failed attempt on the session, and the user's stream never breaks.
What you build
A Next.js chat app where:
- Every user turn starts a Temporal workflow. The workflow drives the agent loop as a sequence of activities.
- The SDK's worker plugin registers the framing activities, so the workflow opens and closes the run with one
withRuncall. - Each of your own activities opens one AI Transport step with the Temporal activity id as the
stepId. - A crashed activity is retried by Temporal under the same activity id, so the retry's output supersedes the failed attempt's on the session.
- The client experience is identical to the Vercel AI SDK getting-started; only the server-side execution model changes.
Prerequisites
- Node.js 22 or later.
- An Ably account with an API key.
- An Anthropic API key, or any other model provider supported by Vercel AI SDK.
- The Temporal CLI installed locally (
brew install temporalon macOS).
Set up the project
Install the dependencies:
npm install @ably/ai-transport@^0.8.0 ably ai@^6 \
@ai-sdk/react@^3 @ai-sdk/anthropic@^3 \
@temporalio/client @temporalio/worker @temporalio/workflow @temporalio/activity \
next react react-dom zod jsonwebtoken dotenv
npm install -D tsx typescript @types/node @types/react @types/react-dom @types/jsonwebtokenThis is a standard Next.js app with one addition: the Temporal worker runs as a separate Node process. Two pieces of config make that work.
Add a script to run the worker. In package.json:
1
2
3
4
5
6
{
"scripts": {
"dev": "next dev",
"worker": "tsx workflow/worker.ts"
}
}Keep the Temporal client out of the Next.js browser bundle. In next.config.mjs:
1
2
3
4
/** @type {import('next').NextConfig} */
export default {
serverExternalPackages: ['@temporalio/client'],
};Add your keys to .env.local. next dev generates tsconfig.json on first run.
# Ably API key in "keyName:keySecret" form, from your Ably dashboard.
ABLY_API_KEY=
# Anthropic API key used by the worker's inference activity.
ANTHROPIC_API_KEY=Set up authentication
Create an auth endpoint at /api/auth/token that returns an Ably JWT to the client. The endpoint validates the user and signs a token with their client ID and the channel capabilities they need, as described in Set up authentication.
The client below uses authUrl: '/api/auth/token' to fetch tokens from this endpoint.
Configure the channel rule
AI Transport streams each response by appending tokens to a single channel message. That requires the Message annotations, updates, deletes, and appends channel rule (mutableMessages) on the namespace your conversations live on.
In your Ably dashboard, enable Message annotations, updates, deletes, and appends on the conversations namespace, using the dashboard, Control API, or CLI.
Build the worker
The Temporal worker is the agent side of the app. It lives in a workflow/ package: shared types, the workflow definition, one server tool, the activities that publish to the session, and the worker entrypoint that hosts them. Create these five files.
Framing is everything that brackets a turn: opening the run, publishing its terminal, suspending it, and closing it on a failure path. The SDK ships those four activities and a worker plugin that registers them, so you write only the inference and tool activities.
Shared types
Create workflow/shared.ts. These types travel between the API route, the workflow, and the activities. Keep them plain data with no runtime side effects. Temporal loads workflow bundles in an isolated sandbox that has no access to Ably, the ai SDK, or crypto at import time.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import type { InvocationData } from '@ably/ai-transport';
export interface ChatWorkflowInput {
invocation: InvocationData;
}
export interface ToolCallInfo {
toolCallId: string;
toolName: string;
input: unknown;
}
// The inference step either finishes the turn or asks to run a server tool.
export type InferenceOutcome =
| { kind: 'done' }
| { kind: 'server-tools'; toolCalls: ToolCallInfo[] };
export const TASK_QUEUE = 'ai-transport-demo';Workflow
Create workflow/workflows.ts. withRun opens the run and hands back a handle carrying its ids. The workflow then drives every inference, the first and each follow-up, through the same runInferenceStep activity, scheduling a server-tool activity in between whenever the model calls a tool.
Opening the run is its own activity, separate from the first inference, so the two are independently retryable: an inference failure retries the inference alone and never re-opens the run. withRun schedules that activity for you.
Import withRun from @ably/ai-transport/temporal/workflow. Every call on it schedules one of the framing activities the worker plugin registered, and the Ably client and the session stay on the worker side, which is why Temporal's workflow sandbox can load it.
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
import { proxyActivities } from '@temporalio/workflow';
import { withRun } from '@ably/ai-transport/temporal/workflow';
import type { ChatWorkflowInput } from './shared.js';
import type * as activities from './activities.js';
const { runInferenceStep } = proxyActivities<typeof activities>({
startToCloseTimeout: '5 minutes',
retry: { maximumAttempts: 3 },
});
// getStockPrice throws on odd prices (~half the time), so give the tool
// activity enough attempts to show Temporal retrying it.
const { runToolStep } = proxyActivities<typeof activities>({
startToCloseTimeout: '5 minutes',
retry: { maximumAttempts: 5 },
});
export async function chatWorkflow(input: ChatWorkflowInput): Promise<void> {
await withRun(input.invocation, async (run) => {
const ids = run.ids;
let outcome = await runInferenceStep({ ids, invocation: input.invocation });
while (outcome.kind === 'server-tools') {
for (const toolCall of outcome.toolCalls) {
await runToolStep({ ids, invocation: input.invocation, toolCall });
}
outcome = await runInferenceStep({ ids, invocation: input.invocation });
}
});
}withRun pins the run id to the Temporal workflow id, so a fresh-process retry re-enters the same run instead of opening a parallel one. The route below starts every workflow with workflowId = invocationId, so the default is the right id here.
If the body throws, withRun makes a best-effort attempt to end the run as error so every subscribed client's UI unsticks. That attempt runs in a non-cancellable scope, so a cancelled or terminated workflow still closes its run.
Tool
Create workflow/tools.ts with one server tool. It generates a whole-dollar price and throws when the price is odd, about half the time, so you can watch Temporal retry the activity in the Web UI and see the retry's re-rolled output supersede the failed attempt on the session.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { z } from 'zod';
import type { Tool } from 'ai';
export const tools: Record<string, Tool> = {
getStockPrice: {
description: 'Get the current stock price for a ticker symbol.',
inputSchema: z.object({
symbol: z.string().describe('The ticker symbol, for example "AAPL"'),
}),
execute: async ({ symbol }: { symbol: string }) => {
// Intentionally flaky: throws on an odd price (~half the time) and
// succeeds on an even one. The retry re-rolls the price.
const priceUSD = Math.round(50 + Math.random() * 500);
if (priceUSD % 2 !== 0) {
throw new Error(`stock price service returned an odd price (${priceUSD}), retry me`);
}
return { symbol, priceUSD };
},
},
};Activities
Create workflow/activities.ts. These are the two activities you own: runInferenceStep and runToolStep. Each one constructs its own Ably.Realtime and AgentSession, adopts the open run, and publishes a step whose stepId is derived from the Temporal activity id. Each detaches when done, leaving the run open for the next to adopt. Retries re-enter the same code with the same activity id, so a retried step lands under the same stepId and supersedes the failed attempt's output.
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import { Context } from '@temporalio/activity';
import Ably from 'ably';
import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { Invocation, type AgentRun, type InvocationData, type RunIdentity } from '@ably/ai-transport';
import {
createAgentSession,
pendingToolCalls,
stripToolExecutes,
vercelRunOutcome,
} from '@ably/ai-transport/vercel';
import type { VercelOutput, VercelProjection } from '@ably/ai-transport/vercel';
import { stepIdFor } from '@ably/ai-transport/temporal';
import type { InferenceOutcome, ToolCallInfo } from './shared.js';
import { tools } from './tools.js';
type VercelAgentRun = AgentRun<VercelOutput, VercelProjection, UIMessage>;
const makeAbly = () => new Ably.Realtime({ key: process.env.ABLY_API_KEY! });
interface StepInput {
ids: RunIdentity;
invocation: InvocationData;
}
export async function runInferenceStep(input: StepInput): Promise<InferenceOutcome> {
const ably = makeAbly();
const session = createAgentSession({ client: ably, channelName: input.invocation.sessionName });
try {
await session.connect();
const run = session.adoptRun(Invocation.fromJSON(input.invocation), input.ids, {
signal: Context.current().cancellationSignal,
});
await run.load();
while (run.view.hasOlder()) await run.view.loadOlder();
const outcome = await runInference(run, stepIdFor(input.ids.invocationId));
await session.detach();
return outcome;
} finally {
ably.close();
}
}
export async function runToolStep(input: StepInput & { toolCall: ToolCallInfo }): Promise<void> {
const ably = makeAbly();
const session = createAgentSession({ client: ably, channelName: input.invocation.sessionName });
try {
await session.connect();
const run = session.adoptRun(Invocation.fromJSON(input.invocation), input.ids, {
signal: Context.current().cancellationSignal,
});
await run.load();
const step = run.createStep({ stepId: stepIdFor(input.ids.invocationId) });
await step.start();
const tool = tools[input.toolCall.toolName] as { execute: (input: unknown) => Promise<unknown> };
const output = await tool.execute(input.toolCall.input);
await step.send({
type: 'tool-output-available',
toolCallId: input.toolCall.toolCallId,
output,
});
await step.end();
await session.detach();
} finally {
ably.close();
}
}
async function runInference(run: VercelAgentRun, stepId: string): Promise<InferenceOutcome> {
const step = run.createStep({ stepId });
await step.start();
const conversation = run.view.getMessages().map((m) => m.message);
const result = streamText({
model: anthropic('claude-sonnet-4-20250514'),
messages: await convertToModelMessages(conversation),
tools: stripToolExecutes(tools),
abortSignal: run.abortSignal,
// The workflow drives the loop; this call runs one step only.
stopWhen: stepCountIs(1),
});
const pipeResult = await step.pipe(result.toUIMessageStream());
const outcome = await vercelRunOutcome(pipeResult, result.finishReason);
await step.end();
if (outcome.reason === 'complete' || outcome.reason === 'cancelled') {
await run.end({ reason: outcome.reason });
return { kind: 'done' };
}
if (outcome.reason === 'error') {
await run.end({
reason: 'error',
error: new Ably.ErrorInfo(outcome.error.message, 104000, 500),
});
return { kind: 'done' };
}
// The model asked for a server tool: leave the run open for runToolStep to adopt.
const toolCalls = pendingToolCalls(run.messages)
.filter((call) => typeof tools[call.toolName]?.execute === 'function')
.map((call) => ({ toolCallId: call.toolCallId, toolName: call.toolName, input: call.input }));
return { kind: 'server-tools', toolCalls };
}stopWhen: stepCountIs(1) prevents the Vercel AI SDK from running its own multi-step tool loop inside a single activity. The workflow drives the loop instead, one activity at a time, so each unit is retryable in isolation.
Worker entrypoint
Create workflow/worker.ts. The worker hosts the workflow and its activities on a single task queue. createAblyTransportPlugin adds the framing activities to the two you wrote, so withRun has something to schedule. Your own activities still go in activities; the plugin adds to them.
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
import path from 'node:path';
import { config as loadDotenv } from 'dotenv';
import Ably from 'ably';
import { NativeConnection, Worker } from '@temporalio/worker';
import { createAblyTransportPlugin } from '@ably/ai-transport/temporal';
import { createUIMessageCodec } from '@ably/ai-transport/vercel';
import * as activities from './activities.js';
import { TASK_QUEUE } from './shared.js';
// tsx does not auto-load .env.local the way `next dev` does.
loadDotenv({ path: path.resolve(__dirname, '../.env.local') });
async function main() {
const connection = await NativeConnection.connect({ address: 'localhost:7233' });
const worker = await Worker.create({
connection,
namespace: 'default',
taskQueue: TASK_QUEUE,
workflowsPath: require.resolve('./workflows'),
activities,
plugins: [
createAblyTransportPlugin({
codec: createUIMessageCodec(),
createClient: () => new Ably.Realtime({ key: process.env.ABLY_API_KEY! }),
}),
],
});
console.log(`worker listening on ${TASK_QUEUE}`);
await worker.run();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});Create the agent route
On the agent, create app/api/chat/route.ts. The route starts a Temporal workflow and returns immediately with the ids the client observes.
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
import { Client, Connection } from '@temporalio/client';
import type { InvocationData } from '@ably/ai-transport';
import type { ChatWorkflowInput } from '../../../workflow/shared';
import { TASK_QUEUE } from '../../../workflow/shared';
let cachedTemporal: Client | undefined;
async function temporalClient() {
if (cachedTemporal) return cachedTemporal;
const connection = await Connection.connect({ address: 'localhost:7233' });
cachedTemporal = new Client({ connection, namespace: 'default' });
return cachedTemporal;
}
export async function POST(req: Request) {
const invocation = (await req.json()) as InvocationData;
const invocationId = crypto.randomUUID();
const client = await temporalClient();
const args: [ChatWorkflowInput] = [{ invocation }];
await client.workflow.start('chatWorkflow', {
workflowId: invocationId,
taskQueue: TASK_QUEUE,
args,
});
return Response.json({ invocationId });
}workflowId = invocationId gives every HTTP POST its own workflow, and it is the id withRun picks up as the run's invocation id. A continuation POST (a tool result, a regenerate, a suspend resume) starts a fresh workflow on the same runId, because AI Transport reads the existing run id off the resuming event's headers.
Create the chat component
The client is identical to the Vercel AI SDK getting-started. The ChatTransport POSTs to /api/chat; whether the server side is a single streamText call or a Temporal workflow is invisible to the client.
Wire it together
The page wrapper is identical to the Vercel AI SDK getting-started. Use the same Providers and ChatTransportProvider setup.
Run the app
Open three terminals. --db-filename persists Temporal state across restarts, so a workflow you inspect in the Web UI survives a machine reboot.
# Terminal 1: Temporal dev server
temporal server start-dev --db-filename ai-transport-demo.db# Terminal 2: Temporal worker
npx tsx workflow/worker.ts# Terminal 3: Next.js
npm run devOpen the app at http://localhost:3000. Open the Temporal Web UI at http://localhost:8233. Every user turn appears as a new workflow in the UI; each activity is one step on the session.
What happens when you send a message
sendMessage({ text })publishes the user input on the session and POSTs an invocation to/api/chat, which starts a Temporal workflow withworkflowId = invocationId.withRunschedules the SDK'sopenRunactivity, which callssession.createRun, publishesai-run-start, and detaches without running any inference. The firstrunInferenceStepadopts the run withsession.adoptRun, opens a step understepIdFor(invocationId), and pipes the LLM stream. When the model asks for a server tool, the workflow schedulesrunToolStep(which adopts the run, publishes the tool result, and detaches), then loops back intorunInferenceStepfor the follow-up inference, which publishesai-run-end.- If an activity crashes, Temporal retries it under the same activity id.
stepIdForreturns the samestepId, so the retry'sai-step-startsupersedes the failed attempt's output on the session. The user sees only the retried step's output. ThegetStockPricetool throws on odd prices to make this visible. - Cancels arrive on the session rather than through a Temporal signal. Each activity's own session routes them to
run.abortSignal, which flows into the LLM call.
This guide's happy path ends the run inside the final inference activity, which is the cheap way to do it: that activity already has the run loaded, so its run.end costs no extra adopt.
When a turn exhausts its retries with the run still open, withRun closes the run once on the way out, ending it as error so every observer's UI unsticks. That is why opening the run is its own activity: the run's ids reach the workflow before any inference runs, so the cleanup path always has the ids to end the run, even when the very first inference exhausts its retries.
The cleanup is best-effort. It gets one attempt with a short timeout, it no-ops when the run has already finished or is parked suspended, and it fires only on a throw. A body that returns without publishing a terminal leaves the run open.
Understand the architecture
Temporal owns execution durability: worker crashes, activity retries, workflow history. AI Transport owns conversation state: what appears on the session, how retries reconcile, how clients observe. The Temporal composition model shows how the two fit together, and durable execution generalises the pattern to any workflow engine.
Explore next
- Temporal framework: scope, cancel routing, suspend-and-resume across workflows.
- Durable execution: the pattern behind the code.
- Steps: the retry unit inside a run.
- Temporal workflow:
openRun,withRun, and theRunHandlethey return. - Temporal worker:
createAblyTransportPlugin, and thestepIdForhelper that ties Temporal activity ids to AI Transport steps. - AgentSession API reference:
adoptRun,createStep,detach.