# 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 `withRun` call.
- Each of your own activities opens one AI Transport [step](https://ably.com/docs/ai-transport/concepts/runs.md#steps) 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](https://ably.com/docs/ai-transport/getting-started/vercel-ai-sdk.md); only the server-side execution model changes.
## Prerequisites
- Node.js 22 or later.
- An [Ably account](https://ably.com/sign-up) with an API key.
- An Anthropic API key, or any other model provider supported by Vercel AI SDK.
- The [Temporal CLI](https://learn.temporal.io/getting_started/typescript/dev_environment/) installed locally (`brew install temporal` on macOS).
## Set up the project
Install the dependencies:
### Shell
```
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/jsonwebtoken
```
This 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`:
### Json
```
{
"scripts": {
"dev": "next dev",
"worker": "tsx workflow/worker.ts"
}
}
```
Keep the Temporal client out of the Next.js browser bundle. In `next.config.mjs`:
### Javascript
```
/** @type {import('next').NextConfig} */
export default {
serverExternalPackages: ['@temporalio/client'],
};
```
Add your keys to `.env.local`. `next dev` generates `tsconfig.json` on first run.
### Shell
```
# 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](https://ably.com/docs/ai-transport/getting-started/authentication.md).
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](https://ably.com/docs/ai-transport/getting-started/channel-rules.md).
## 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.
#### Javascript
```
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`](https://ably.com/docs/ai-transport/api/javascript/temporal/workflow.md#with-run) 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.
#### Javascript
```
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({
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({
startToCloseTimeout: '5 minutes',
retry: { maximumAttempts: 5 },
});
export async function chatWorkflow(input: ChatWorkflowInput): Promise {
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.
#### Javascript
```
import { z } from 'zod';
import type { Tool } from 'ai';
export const tools: Record = {
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.
#### Javascript
```
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;
const makeAbly = () => new Ably.Realtime({ key: process.env.ABLY_API_KEY! });
interface StepInput {
ids: RunIdentity;
invocation: InvocationData;
}
export async function runInferenceStep(input: StepInput): Promise {
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 {
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 };
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 {
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.
#### Javascript
```
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.
### Javascript
```
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](https://ably.com/docs/ai-transport/getting-started/vercel-ai-sdk.md#create-the-chat-component). 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](https://ably.com/docs/ai-transport/getting-started/vercel-ai-sdk.md#wire-it-together). 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.
### Shell
```
# Terminal 1: Temporal dev server
temporal server start-dev --db-filename ai-transport-demo.db
```
### Shell
```
# Terminal 2: Temporal worker
npx tsx workflow/worker.ts
```
### Shell
```
# Terminal 3: Next.js
npm run dev
```
Open 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
1. `sendMessage({ text })` publishes the user input on the session and POSTs an [invocation](https://ably.com/docs/ai-transport/concepts/runs.md#invocations) to `/api/chat`, which starts a Temporal workflow with `workflowId = invocationId`.
2. `withRun` schedules the SDK's `openRun` activity, which calls [`session.createRun`](https://ably.com/docs/ai-transport/api/javascript/core/agent-session.md#create-run), publishes `ai-run-start`, and detaches without running any inference. The first `runInferenceStep` adopts the run with [`session.adoptRun`](https://ably.com/docs/ai-transport/api/javascript/core/agent-session.md#adopt-run), opens a step under `stepIdFor(invocationId)`, and pipes the LLM stream. When the model asks for a server tool, the workflow schedules `runToolStep` (which adopts the run, publishes the tool result, and detaches), then loops back into `runInferenceStep` for the follow-up inference, which publishes `ai-run-end`.
3. If an activity crashes, Temporal retries it under the same activity id. `stepIdFor` returns the same `stepId`, so the retry's `ai-step-start` supersedes the failed attempt's output on the session. The user sees only the retried step's output. The `getStockPrice` tool throws on odd prices to make this visible.
4. Cancels arrive on the session rather than through a Temporal signal. Each activity's own session routes them to [`run.abortSignal`](https://ably.com/docs/ai-transport/api/javascript/core/agent-session.md#run), 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](https://ably.com/docs/ai-transport/features/durable-execution.md#close-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](https://ably.com/docs/ai-transport/frameworks/temporal.md) shows how the two fit together, and [durable execution](https://ably.com/docs/ai-transport/features/durable-execution.md) generalises the pattern to any workflow engine.
## Explore next
- [Temporal framework](https://ably.com/docs/ai-transport/frameworks/temporal.md): scope, cancel routing, suspend-and-resume across workflows.
- [Durable execution](https://ably.com/docs/ai-transport/features/durable-execution.md): the pattern behind the code.
- [Steps](https://ably.com/docs/ai-transport/concepts/runs.md#steps): the retry unit inside a run.
- [Temporal workflow](https://ably.com/docs/ai-transport/api/javascript/temporal/workflow.md): `openRun`, `withRun`, and the `RunHandle` they return.
- [Temporal worker](https://ably.com/docs/ai-transport/api/javascript/temporal.md): `createAblyTransportPlugin`, and the `stepIdFor` helper that ties Temporal activity ids to AI Transport steps.
- [AgentSession API reference](https://ably.com/docs/ai-transport/api/javascript/core/agent-session.md): `adoptRun`, `createStep`, `detach`.
## Related Topics
- [Core SDK](https://ably.com/docs/ai-transport/getting-started/core-sdk.md): Build a streaming AI chat app using AI Transport's core React hooks. Full access to the conversation tree, branching, and pagination.
- [OpenAI](https://ably.com/docs/ai-transport/getting-started/openai.md): Build a streaming AI chat app with the OpenAI Responses API and Ably AI Transport. The ResponsesCodec streams model output over a durable session with multi-device sync and cancellation.
- [Vercel AI SDK](https://ably.com/docs/ai-transport/getting-started/vercel-ai-sdk.md): Build a streaming AI chat app with Vercel AI SDK and Ably AI Transport in a few minutes. Durable sessions, multi-device sync, and cancellation out of the box.
- [Vercel WDK](https://ably.com/docs/ai-transport/getting-started/vercel-wdk.md): Build a streaming AI chat app whose agent side runs as a Vercel Workflow inside your Next.js app. Each model call and tool is its own retryable WDK step; a retry supersedes the failed attempt on the session, and the user's stream never breaks.
## Documentation Index
To discover additional Ably documentation:
1. Fetch [llms.txt](https://ably.com/llms.txt) for the canonical list of available pages.
2. Identify relevant URLs from that index.
3. Fetch target pages as needed.
Avoid using assumed or outdated documentation paths.