diff --git a/src/data/nav/aitransport.ts b/src/data/nav/aitransport.ts
index 78e929b457..ebabea2a1a 100644
--- a/src/data/nav/aitransport.ts
+++ b/src/data/nav/aitransport.ts
@@ -27,6 +27,10 @@ export default {
name: 'Vercel AI SDK',
link: '/docs/ai-transport/getting-started/vercel-ai-sdk',
},
+ {
+ name: 'Vercel WDK',
+ link: '/docs/ai-transport/getting-started/vercel-wdk',
+ },
{
name: 'Temporal',
link: '/docs/ai-transport/getting-started/temporal',
@@ -114,6 +118,10 @@ export default {
name: 'Vercel AI SDK Core',
link: '/docs/ai-transport/frameworks/vercel-ai-sdk-core',
},
+ {
+ name: 'Vercel WDK',
+ link: '/docs/ai-transport/frameworks/vercel-wdk',
+ },
{
name: 'Temporal',
link: '/docs/ai-transport/frameworks/temporal',
diff --git a/src/images/content/diagrams/ait-frameworks-vercel-wdk-cancel.png b/src/images/content/diagrams/ait-frameworks-vercel-wdk-cancel.png
new file mode 100644
index 0000000000..87766a1a7f
Binary files /dev/null and b/src/images/content/diagrams/ait-frameworks-vercel-wdk-cancel.png differ
diff --git a/src/images/content/diagrams/ait-frameworks-vercel-wdk.png b/src/images/content/diagrams/ait-frameworks-vercel-wdk.png
new file mode 100644
index 0000000000..f84f08b18a
Binary files /dev/null and b/src/images/content/diagrams/ait-frameworks-vercel-wdk.png differ
diff --git a/src/pages/docs/ai-transport/api/javascript/core/agent-session.mdx b/src/pages/docs/ai-transport/api/javascript/core/agent-session.mdx
index f4d6a2a291..d30be29ea1 100644
--- a/src/pages/docs/ai-transport/api/javascript/core/agent-session.mdx
+++ b/src/pages/docs/ai-transport/api/javascript/core/agent-session.mdx
@@ -292,7 +292,7 @@ Pipe a `ReadableStream` of outputs through the encoder to the channel. Returns w
Create a [`RunStep`](#step): 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`](#step-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](/docs/ai-transport/frameworks/temporal) activity id, a Vercel Workflow DevKit step id). See [Durable execution](/docs/ai-transport/features/durable-execution).
+`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](/docs/ai-transport/frameworks/temporal) activity id, a [Vercel WDK](/docs/ai-transport/frameworks/vercel-wdk) step id). See [Durable execution](/docs/ai-transport/features/durable-execution).
```javascript
@@ -422,7 +422,7 @@ The Step must be active (started, not ended). Rejects otherwise. A publish failu
{`end(params?: StepEndParams): Promise`}
-Publish `ai-step-end`, closing the Step. Idempotent; a second call is a no-op. Omit `params` to derive the reason from the Step's piped output: `'failed'` if any [`pipe`](#step-pipe) errored, otherwise `'complete'`. Pass an explicit `reason` to override.
+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`](#step-pipe) errored, otherwise `'complete'`. Pass an explicit `reason` to override.
A Step terminal is not a Run terminal. Drive the Run to [`suspend`](#run-suspend) or [`end`](#run-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.
@@ -440,7 +440,7 @@ A Step terminal is not a Run terminal. Drive the Run to [`suspend`](#run-suspend
| Property | Description | Type |
| --- | --- | --- |
-| reason | The terminal reason. Omit to derive it from the Step's piped output: `'failed'` if any `pipe` errored, otherwise `'complete'`. Pass an explicit value to override. | `'complete' \| 'failed'` |
+| reason | The terminal reason. Omit to derive it: `'cancelled'` if the Run was cancelled (its `abortSignal` fired), otherwise `'failed'` if any `pipe` errored, otherwise `'complete'`. Pass an explicit value to override. | `'complete' \| 'failed' \| 'cancelled'` |
diff --git a/src/pages/docs/ai-transport/api/javascript/vercel/run-outcome.mdx b/src/pages/docs/ai-transport/api/javascript/vercel/run-outcome.mdx
index 5d7bd5d4b5..46245cf41e 100644
--- a/src/pages/docs/ai-transport/api/javascript/vercel/run-outcome.mdx
+++ b/src/pages/docs/ai-transport/api/javascript/vercel/run-outcome.mdx
@@ -130,7 +130,7 @@ export async function POST(req: Request) {
await run.end(outcome);
}
} finally {
- await session.close();
+ await session.end();
}
return Response.json({ invocationId: run.invocationId });
diff --git a/src/pages/docs/ai-transport/concepts/connections.mdx b/src/pages/docs/ai-transport/concepts/connections.mdx
index a9c10ef8c4..3bee445b1e 100644
--- a/src/pages/docs/ai-transport/concepts/connections.mdx
+++ b/src/pages/docs/ai-transport/concepts/connections.mdx
@@ -27,7 +27,7 @@ The two connection types differ in what they own and how long they live:
| `ClientSession` | Long-lived (the lifetime of the user's app or tab). | Channel subscription, the read path into the tree, default `view`, cancel publish path. | The channel itself, the Ably client, the conversation state. |
| `AgentSession` | Typically short-lived (one HTTP handler invocation). | Channel attach for cancel routing, the write path for Run lifecycle events and the streamed response. | Long-lived state, retry coordination across invocations. |
-Both expose `connect()`, `close()`, and the SDK's lifecycle gate: methods that publish or subscribe throw `InvalidArgument` until `connect()` resolves.
+Both expose `connect()` and the SDK's lifecycle gate: methods that publish or subscribe throw `InvalidArgument` until `connect()` resolves. They differ on teardown: a `ClientSession` disposes with `close()`, an `AgentSession` with `end()` (or `detach()` to hand an in-flight Run to another process).
## What the connection layer requires
@@ -37,11 +37,11 @@ Both expose `connect()`, `close()`, and the SDK's lifecycle gate: methods that p
| Independence | Each connection has its own subscription, branch selections, and pagination window. A second `ClientSession` in another tab doesn't see the first one's branch state. |
| Cancel routing | Cancels are channel publishes that name a `runId`. Both `ClientSession.cancel(runId)` and the agent's `Run.abortSignal` rely on the connection being attached to the right channel. |
| Codec binding | The codec is wired into the connection at construction. Switching codecs means a new connection. |
-| Clean teardown | `close()` releases the subscription and clears handlers locally. It does not end Runs on the wire. Active Runs on the channel keep running until the agent ends them. |
+| Clean teardown | Disposal releases the subscription and clears handlers locally. `ClientSession.close()` and `AgentSession.detach()` do not end Runs on the wire; active Runs keep running until the agent ends them. `AgentSession.end()` first closes the agent's own open Runs as `'cancelled'`, then detaches. |
## Understand connection lifecycle
-A connection moves through four stages. Construct it with `createClientSession` or `createAgentSession`. Call `connect()` to subscribe to the channel. On a `ClientSession`, write through `view.send`, `view.regenerate`, and `view.edit`, cancel through `cancel(runId)`, derive views with `createView()`, and subscribe to non-fatal errors with `on('error', ...)`. Tear it down with `close()`.
+A connection moves through four stages. Construct it with `createClientSession` or `createAgentSession`. Call `connect()` to subscribe to the channel. On a `ClientSession`, write through `view.send`, `view.regenerate`, and `view.edit`, cancel through `cancel(runId)`, derive views with `createView()`, and subscribe to non-fatal errors with `on('error', ...)`. Tear it down with `close()` on a `ClientSession`, or `end()` (or `detach()`) on an `AgentSession`.
`connect()` is the gate. Every operation that touches the channel throws `InvalidArgument` until the connect promise resolves. `connect()` is also idempotent: subsequent calls return the same promise, useful when a component mounts twice or a retry path re-runs initialisation.
diff --git a/src/pages/docs/ai-transport/concepts/index.mdx b/src/pages/docs/ai-transport/concepts/index.mdx
index 8731533dd6..8c9e104cac 100644
--- a/src/pages/docs/ai-transport/concepts/index.mdx
+++ b/src/pages/docs/ai-transport/concepts/index.mdx
@@ -33,7 +33,7 @@ A session contains many Runs. Runs coexist on the same session and have independ
A [Step](/docs/ai-transport/concepts/steps) is one re-attemptable unit of agent output inside a Run: a single inference, a single tool result, a single scheduled activity. A retry under the same `stepId` supersedes the failed attempt on the channel rather than appending beside it.
-Steps are what make Runs safe to execute inside a durable workflow engine such as [Temporal](/docs/ai-transport/frameworks/temporal). See [Durable execution](/docs/ai-transport/features/durable-execution).
+Steps are what make Runs safe to execute inside a durable workflow engine such as [Temporal](/docs/ai-transport/frameworks/temporal) or [Vercel WDK](/docs/ai-transport/frameworks/vercel-wdk). See [Durable execution](/docs/ai-transport/features/durable-execution).
## An Invocation triggers a Run
diff --git a/src/pages/docs/ai-transport/concepts/steps.mdx b/src/pages/docs/ai-transport/concepts/steps.mdx
index 9adc56a59f..125abde670 100644
--- a/src/pages/docs/ai-transport/concepts/steps.mdx
+++ b/src/pages/docs/ai-transport/concepts/steps.mdx
@@ -2,12 +2,12 @@
title: "Steps"
meta_description: "Understand Steps in AI Transport: the sub-bracket within a Run that carries one publishable unit of agent output. Steps have their own start and end lifecycle, an addressable stepId, and supersede-on-retry semantics."
meta_keywords: "AI Transport, Step, RunStep, createStep, stepId, supersede, run structure, retry"
-intro: "A Step is a bracket around one publishable unit of agent output inside a Run. It has its own start and end lifecycle on the channel, an addressable stepId, and a terminal reason of complete or failed."
+intro: "A Step is a bracket around one publishable unit of agent output inside a Run. It has its own start and end lifecycle on the channel, an addressable stepId, and a terminal reason of complete, failed, or cancelled."
---
A [Run](/docs/ai-transport/concepts/runs) does not publish output directly. It publishes through Steps. A Step is the inner bracket a Run uses to group one contiguous unit of output: the tokens for one LLM inference, the payload for one tool result, or any other burst of channel writes the agent code frames as a single unit. Every output message an agent publishes carries a `step-id` that names the Step it belongs to.
-A Step has its own lifecycle events on the channel (`ai-step-start` and `ai-step-end`), its own addressable identity (`stepId`), and its own terminal reason (`'complete'` or `'failed'`). It is to the Run what the Run is to the Session: a nested bracket with a stable identity and a defined start and end, carrying a portion of the outer bracket's output.
+A Step has its own lifecycle events on the channel (`ai-step-start` and `ai-step-end`), its own addressable identity (`stepId`), and its own terminal reason (`'complete'`, `'failed'`, or `'cancelled'`). It is to the Run what the Run is to the Session: a nested bracket with a stable identity and a defined start and end, carrying a portion of the outer bracket's output.
Two properties of the Step layer sit on top of that structural role. Every Step in a Run is ordered relative to the others (exactly one Step is active on a Run at a time). And two `ai-step-start` events under the same `stepId` coalesce: the later one supersedes the earlier one's output rather than appending beside it, which is what makes a Step the safe boundary for a retry.
@@ -41,7 +41,7 @@ Two kinds of Steps sit on a Run:
| Property | Why it matters |
| --- | --- |
-| Stable stepId | A retry's `ai-step-start` must land under the same `stepId` as the failed attempt. Otherwise the retry's output publishes under a fresh id and both attempts remain in the conversation. Supply the workflow engine's own stable id ([Temporal](/docs/ai-transport/frameworks/temporal) activity id, Vercel Workflow DevKit step id) on cross-process retries. |
+| Stable stepId | A retry's `ai-step-start` must land under the same `stepId` as the failed attempt. Otherwise the retry's output publishes under a fresh id and both attempts remain in the conversation. Supply the workflow engine's own stable id ([Temporal](/docs/ai-transport/frameworks/temporal) activity id, [Vercel WDK](/docs/ai-transport/frameworks/vercel-wdk) step id) on cross-process retries. |
| Supersede on retry | When two attempts share a `stepId`, the message that was published later wins. The client's View surfaces only the winning attempt. |
| One Step at a time | Exactly one Step may be active on a Run. [`RunStep.start`](/docs/ai-transport/api/javascript/core/agent-session#step-start) rejects if another Step is still open. |
| Terminal auto-close | If agent code forgets to close a Step, [`AgentRun.end`](/docs/ai-transport/api/javascript/core/agent-session#run-end) auto-closes the open Step before publishing the Run terminal. No observer's UI is stranded on `streaming`. |
@@ -94,4 +94,5 @@ Omit `stepId` for the common in-process case. The SDK assigns an invocation-scop
- [Runs](/docs/ai-transport/concepts/runs): the outer bracket a Step lives in.
- [Durable execution](/docs/ai-transport/features/durable-execution): the pattern that makes cross-process Step retries safe.
- [Temporal](/docs/ai-transport/frameworks/temporal): the reference workflow-engine integration.
+- [Vercel WDK](/docs/ai-transport/frameworks/vercel-wdk): the same pattern inside your Next.js app.
- [RunStep API reference](/docs/ai-transport/api/javascript/core/agent-session#step): the surface `createStep` returns.
diff --git a/src/pages/docs/ai-transport/features/agent-presence.mdx b/src/pages/docs/ai-transport/features/agent-presence.mdx
index 771211ab1e..4566fc9c20 100644
--- a/src/pages/docs/ai-transport/features/agent-presence.mdx
+++ b/src/pages/docs/ai-transport/features/agent-presence.mdx
@@ -52,7 +52,7 @@ app.post('/api/chat', async (req, res) => {
await run.end({ reason });
await session.presence.leave();
- await session.close();
+ await session.end();
res.json({ ok: true });
});
```
diff --git a/src/pages/docs/ai-transport/features/branching.mdx b/src/pages/docs/ai-transport/features/branching.mdx
index f86dce5151..2c242349f9 100644
--- a/src/pages/docs/ai-transport/features/branching.mdx
+++ b/src/pages/docs/ai-transport/features/branching.mdx
@@ -143,7 +143,7 @@ try {
await run.end({ reason: 'error' });
throw err;
} finally {
- await session.close();
+ await session.end();
}
```
diff --git a/src/pages/docs/ai-transport/features/chain-of-thought.mdx b/src/pages/docs/ai-transport/features/chain-of-thought.mdx
index 78d627f54a..7744a7c081 100644
--- a/src/pages/docs/ai-transport/features/chain-of-thought.mdx
+++ b/src/pages/docs/ai-transport/features/chain-of-thought.mdx
@@ -42,7 +42,7 @@ app.post('/api/chat', async (req, res) => {
const { reason } = await run.pipe(result.toUIMessageStream());
await run.end({ reason });
- await session.close();
+ await session.end();
res.json({ ok: true });
});
```
diff --git a/src/pages/docs/ai-transport/features/concurrent-turns.mdx b/src/pages/docs/ai-transport/features/concurrent-turns.mdx
index a642eef77a..bdb5fc7bb4 100644
--- a/src/pages/docs/ai-transport/features/concurrent-turns.mdx
+++ b/src/pages/docs/ai-transport/features/concurrent-turns.mdx
@@ -61,7 +61,7 @@ app.post('/api/chat', async (req, res) => {
const { reason } = await run.pipe(result.toUIMessageStream());
await run.end({ reason });
- await session.close();
+ await session.end();
res.json({ ok: true });
});
```
diff --git a/src/pages/docs/ai-transport/features/durable-execution.mdx b/src/pages/docs/ai-transport/features/durable-execution.mdx
index fceef5a482..08425a2320 100644
--- a/src/pages/docs/ai-transport/features/durable-execution.mdx
+++ b/src/pages/docs/ai-transport/features/durable-execution.mdx
@@ -1,13 +1,13 @@
---
title: "Durable execution"
meta_description: "Run AI Transport agents inside a durable workflow engine. Adopt an in-flight Run from a fresh process, retry a failed Step under a stable stepId, and let the retry supersede the failed attempt on the channel."
-meta_keywords: "AI Transport, durable execution, Temporal, Vercel Workflow SDK, adoptRun, createStep, stepId, retry, supersede"
+meta_keywords: "AI Transport, durable execution, Temporal, Vercel Workflow Development Kit, Vercel WDK, adoptRun, createStep, stepId, retry, supersede"
intro: "Your agent turn survives a mid-flight process crash. AI Transport's Runs and Steps let a fresh process adopt the in-flight Run, retry the failed Step under the same stepId, and supersede the dead attempt's channel output cleanly."
---
An agent turn is often more than one HTTP request's worth of work. An LLM call, a tool execution, a follow-up call, sometimes a suspend and a later resume. Each stage is a candidate for failure: a serverless cold start, a container redeploy, a spot instance eviction. Durable execution keeps the whole turn safe by pushing each stage into a workflow engine's activity and using AI Transport's [Steps](/docs/ai-transport/concepts/steps) to keep the channel clean.
-AI Transport is not itself a durable execution engine. It works with any engine that gives each retryable activity a stable identifier, including [Temporal](/docs/ai-transport/frameworks/temporal), Vercel Workflow DevKit, Inngest, and trigger.dev.
+AI Transport is not itself a durable execution engine. It works with any engine that gives each retryable activity a stable identifier, including [Temporal](/docs/ai-transport/frameworks/temporal), [Vercel WDK](/docs/ai-transport/frameworks/vercel-wdk), Inngest, and trigger.dev.

@@ -52,7 +52,7 @@ async function runToolStep({ runId, invocationId, triggerEventId, activityId, to
## Retry a Step under a stable stepId
-For a retry to supersede the failed attempt, both attempts must publish under the same `stepId`. Pass the workflow engine's own per-activity id as the `stepId` option to [`run.createStep()`](/docs/ai-transport/api/javascript/core/agent-session#create-step). Any id that the engine keeps stable across retries of the same activity is a valid source. In Temporal, that is the activity id. In Vercel Workflow DevKit, it is `getStepMetadata().stepId`.
+For a retry to supersede the failed attempt, both attempts must publish under the same `stepId`. Pass the workflow engine's own per-activity id as the `stepId` option to [`run.createStep()`](/docs/ai-transport/api/javascript/core/agent-session#create-step). Any id that the engine keeps stable across retries of the same activity is a valid source. In Temporal, that is the activity id. In [Vercel WDK](/docs/ai-transport/frameworks/vercel-wdk), it is `getStepMetadata().stepId`.
Inside a single process, leave `stepId` out. The SDK picks one for you and reuses it if the previous Step ended `failed`, so an in-process retry supersedes without any extra bookkeeping.
@@ -72,7 +72,7 @@ Do not call [`session.end()`](/docs/ai-transport/api/javascript/core/agent-sessi
Cancels arrive on the Ably channel, not through a workflow signal. Every `AgentSession` subscribes to the channel on connect, so a cancel published by the client fires [`run.abortSignal`](/docs/ai-transport/api/javascript/core/agent-session#run) inside the activity that currently holds the Run. Pass that signal into the LLM call as its `abortSignal` and the model call stops with the Step ending `'cancelled'`. No listener activity or workflow signal is required.
-Each activity constructs its own `Ably.Realtime` client and its own `AgentSession`. Sharing session objects (instances) across activities is not supported; each activity should connect to the underlying Ably AI Transport session through its own session object. Sharing a Realtime client is only possible when activities run in the same process; workflow engines that isolate each activity in its own process, such as Temporal and Vercel Workflow DevKit, give each activity a fresh client regardless. Where a client is shared, no activity may close a channel that another Run still uses.
+Each activity constructs its own `Ably.Realtime` client and its own `AgentSession`. Sharing session objects (instances) across activities is not supported; each activity should connect to the underlying Ably AI Transport session through its own session object. Sharing a Realtime client is only possible when activities run in the same process; workflow engines that isolate each activity in its own process, such as Temporal and Vercel WDK, give each activity a fresh client regardless. Where a client is shared, no activity may close a channel that another Run still uses.
## Edge cases
@@ -96,7 +96,7 @@ No. The workflow engine retries. AI Transport's role is to keep the channel clea
Any engine that gives each retryable unit of work an id it keeps stable across retries of that unit and unique across different units. AI Transport passes that id to [`createStep({ stepId })`](/docs/ai-transport/api/javascript/core/agent-session#create-step), so a retry lands under the same `stepId` and supersedes the failed attempt. What differs per engine is where that id comes from:
- Temporal: the activity id. The [`stepIdFor`](/docs/ai-transport/api/javascript/temporal) helper reads it from `Context.current().info.activityId` and prefixes it with the Run's invocation id. Temporal is the reference integration, with a [framework page](/docs/ai-transport/frameworks/temporal).
-- Vercel Workflow DevKit: `getStepMetadata().stepId`.
+- Vercel WDK: `getStepMetadata().stepId`, already stable across retries and unique across workflow runs, so it passes straight to `createStep` with no helper. Integration documented on a [framework page](/docs/ai-transport/frameworks/vercel-wdk).
- Inngest and trigger.dev: the stable id each exposes for a step or task. Dedicated helpers are [on the roadmap](/docs/ai-transport/roadmap); until then, read that id and pass it to `createStep({ stepId })` yourself.
An engine that does not expose a retry-stable id cannot supersede: derive one that is stable, or the retry double-publishes.
diff --git a/src/pages/docs/ai-transport/features/push-notifications.mdx b/src/pages/docs/ai-transport/features/push-notifications.mdx
index 95846dc347..fc9834d5bb 100644
--- a/src/pages/docs/ai-transport/features/push-notifications.mdx
+++ b/src/pages/docs/ai-transport/features/push-notifications.mdx
@@ -59,7 +59,7 @@ app.post('/api/chat', async (req, res) => {
const { reason } = await run.pipe(result.toUIMessageStream())
await run.end({ reason })
- await session.close()
+ await session.end()
if (reason === 'complete') {
// Publish a push-eligible event on a notification channel
diff --git a/src/pages/docs/ai-transport/features/token-streaming.mdx b/src/pages/docs/ai-transport/features/token-streaming.mdx
index 85dcbe4e8e..52e2b792fe 100644
--- a/src/pages/docs/ai-transport/features/token-streaming.mdx
+++ b/src/pages/docs/ai-transport/features/token-streaming.mdx
@@ -100,7 +100,7 @@ const result = streamText({
const { reason } = await run.pipe(result.toUIMessageStream());
await run.end({ reason });
-await session.close();
+await session.end();
```
diff --git a/src/pages/docs/ai-transport/features/tool-calling.mdx b/src/pages/docs/ai-transport/features/tool-calling.mdx
index 4159f8738d..f14a716b05 100644
--- a/src/pages/docs/ai-transport/features/tool-calling.mdx
+++ b/src/pages/docs/ai-transport/features/tool-calling.mdx
@@ -130,7 +130,7 @@ A user who starts a tool-assisted workflow on a laptop continues it on a phone w
## Durable tool execution
-When the agent runs inside a workflow engine such as [Temporal](/docs/ai-transport/frameworks/temporal), each tool execution can be its own retryable activity. Wrap the tool call in [`AgentRun.createStep({ stepId })`](/docs/ai-transport/api/javascript/core/agent-session#create-step) and publish the result via [`RunStep.send`](/docs/ai-transport/api/javascript/core/agent-session#step-send). A retry of the same tool activity re-enters `createStep` with the same `stepId`, so the retry's tool result supersedes the failed attempt on the channel rather than appending beside it. See [Durable execution](/docs/ai-transport/features/durable-execution).
+When the agent runs inside a workflow engine such as [Temporal](/docs/ai-transport/frameworks/temporal) or [Vercel WDK](/docs/ai-transport/frameworks/vercel-wdk), each tool execution can be its own retryable activity. Wrap the tool call in [`AgentRun.createStep({ stepId })`](/docs/ai-transport/api/javascript/core/agent-session#create-step) and publish the result via [`RunStep.send`](/docs/ai-transport/api/javascript/core/agent-session#step-send). A retry of the same tool activity re-enters `createStep` with the same `stepId`, so the retry's tool result supersedes the failed attempt on the channel rather than appending beside it. See [Durable execution](/docs/ai-transport/features/durable-execution).
## Edge cases and unhappy paths
diff --git a/src/pages/docs/ai-transport/frameworks/temporal.mdx b/src/pages/docs/ai-transport/frameworks/temporal.mdx
index d725a16cd3..85d73d44a4 100644
--- a/src/pages/docs/ai-transport/frameworks/temporal.mdx
+++ b/src/pages/docs/ai-transport/frameworks/temporal.mdx
@@ -100,7 +100,7 @@ Activity's AgentSession (subscribes on adoptRun/createRun)
run.abortSignal fires
│ passed to streamText as abortSignal
▼
-LLM call aborts · step.end({ reason: 'cancelled' })
+LLM call aborts · step.end()
run.end({ reason: 'cancelled' })
```
@@ -125,5 +125,6 @@ Two boundaries to keep in mind:
- [Durable execution](/docs/ai-transport/features/durable-execution): the framework-agnostic pattern.
- [Steps](/docs/ai-transport/concepts/steps): the retry unit Temporal activities map to.
+- [Vercel WDK](/docs/ai-transport/frameworks/vercel-wdk): the same composition inside your Next.js app.
- [`stepIdFor` API reference](/docs/ai-transport/api/javascript/temporal): the helper that produces a Temporal-safe `stepId`.
- [AgentSession API reference](/docs/ai-transport/api/javascript/core/agent-session): `adoptRun`, `createStep`, `detach`, and `end`.
diff --git a/src/pages/docs/ai-transport/frameworks/vercel-ai-sdk-core.mdx b/src/pages/docs/ai-transport/frameworks/vercel-ai-sdk-core.mdx
index 5fe54f17e5..d406bd3fa2 100644
--- a/src/pages/docs/ai-transport/frameworks/vercel-ai-sdk-core.mdx
+++ b/src/pages/docs/ai-transport/frameworks/vercel-ai-sdk-core.mdx
@@ -100,7 +100,7 @@ export async function POST(req) {
} else {
await run.end(outcome);
}
- await session.close();
+ await session.end();
});
return Response.json({ runId: run.runId, invocationId: run.invocationId });
diff --git a/src/pages/docs/ai-transport/frameworks/vercel-wdk.mdx b/src/pages/docs/ai-transport/frameworks/vercel-wdk.mdx
new file mode 100644
index 0000000000..7e874bbc7d
--- /dev/null
+++ b/src/pages/docs/ai-transport/frameworks/vercel-wdk.mdx
@@ -0,0 +1,154 @@
+---
+title: "Vercel Workflow Development Kit"
+meta_description: "How Ably AI Transport composes with Vercel Workflow Development Kit. Open the Run and each model call as their own WDK steps, WDK step ids as AI Transport Step ids, retries supersede on the channel, and cancels route through Ably rather than workflow signals."
+meta_keywords: "AI Transport, Vercel Workflow Development Kit, Vercel WDK, WDK, durable execution, workflow, use step, getStepMetadata, adoptRun, createStep, retry, tool calls, stripToolExecutes, pendingToolCalls"
+intro: "Vercel WDK makes your agent's execution durable without leaving your Next.js app. AI Transport makes the conversation durable for every device watching it. Each owns its half of the job."
+---
+
+[Vercel Workflow Development Kit](https://useworkflow.dev/) (WDK) runs your agent's loop as a workflow compiled into the same app that serves your agent route. Every interaction with the session is its own `'use step'` function, a WDK step that WDK runs as its own process and re-runs on failure. AI Transport publishes each model call and each tool result as a [Step](/docs/ai-transport/concepts/steps) inside a [Run](/docs/ai-transport/concepts/runs). When WDK retries a step, the retry lands on the channel under the same `stepId` and supersedes the failed attempt.
+
+
+
+
+
+## What Vercel WDK brings
+
+Vercel WDK handles execution: it persists a workflow's progress, retries a failed WDK step, and runs it all inside your Next.js app.
+
+| Capability | Description |
+| --- | --- |
+| Workflow durability | The workflow's progress persists as a journal of completed WDK steps. A crash or redeploy resumes the workflow with every finished step's result replayed, not re-executed. |
+| Step retries | A failed WDK step re-runs as a fresh process, up to three retries by default. Tune the count per step with `maxRetries`, control backoff by throwing `RetryableError` with a `retryAfter`, or fail immediately with `FatalError`. The retry keeps the same WDK step id, so Ably can supersede its predecessor. |
+| In-app runtime | Workflows compile into your Next.js app through the `'use workflow'` and `'use step'` directives. No separate cluster or worker fleet; `next dev` runs workflows locally with zero configuration. |
+| Observability | `npx workflow web` opens an inspector showing runs, steps, attempts, and errors. Pair it with the channel to see the whole turn. |
+
+## What AI Transport adds
+
+AI Transport layers a durable session over that execution:
+
+| 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 WDK retries a step, [`AgentRun.createStep({ stepId })`](/docs/ai-transport/api/javascript/core/agent-session#create-step) 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 step's session directly, so a stop button in the browser aborts the LLM call inside the in-flight WDK step. |
+| History and replay | Load the full conversation on reconnect, page refresh, or new device join. |
+
+## Where they connect
+
+A WDK step that produces agent output publishes it as a single Step, using the WDK step id as that Step's `stepId`. That is the join between the two systems: WDK's retryable unit and AI Transport's supersedable unit share one identifier.
+
+Read the id with [`getStepMetadata()`](https://useworkflow.dev/docs/api-reference/workflow/get-step-metadata) from the `workflow` package, inside the step. WDK step ids are stable across retries of the same step and unique across workflow runs, so they pass straight into `createStep` with no prefixing.
+
+An inference step:
+
+
+```javascript
+import * as Ably from 'ably';
+import { getStepMetadata } from 'workflow';
+import { streamText, convertToModelMessages, stepCountIs } from 'ai';
+import { anthropic } from '@ai-sdk/anthropic';
+import { createAgentSession, vercelRunOutcome } from '@ably/ai-transport/vercel';
+
+export async function runInference(invocation, ids) {
+ 'use step';
+ 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,
+ });
+ await run.load();
+ while (run.view.hasOlder()) await run.view.loadOlder();
+
+ const step = run.createStep({ stepId: getStepMetadata().stepId });
+ await step.start();
+
+ const result = streamText({
+ model: anthropic('claude-sonnet-4-20250514'),
+ 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;
+}
+```
+
+
+Open the Step before reading the view. A retry re-enters this WDK step with the same `stepId`, so its `step.start()` re-emits `ai-step-start` under a higher channel serial that supersedes the failed attempt. `step.start()` folds that superseding start into the read model behind `run.view` before it resolves, so once you have awaited it the view already reflects the supersede and you build the prompt from the winning attempt alone. Read `run.view` before opening the Step and the half-streamed response the retry is replacing trails the prompt as an assistant prefill, which real providers reject. The [getting-started guide](/docs/ai-transport/getting-started/vercel-wdk#define-steps) opens the Step before reading the view for this reason.
+
+`stopWhen: stepCountIs(1)` prevents the Vercel AI SDK from running its own multi-step tool loop inside the WDK step. The workflow drives the loop instead: a step that opens the Run, a step for the first inference, then one step per server tool call and one per follow-up inference. This is what makes each unit retryable in isolation, and it shapes how tool calls route.
+
+## Open the Run in its own step
+
+Opening the Run is its own WDK step, separate from the first inference. `openRun` calls `createRun` and `run.start()`, publishing `ai-run-start` for a fresh turn or `ai-run-resume` for a continuation, and returns the Run's ids without running the model. Every model call, the first and each follow-up alike, is a separate `runInference` step that adopts that open Run.
+
+The split follows the [durable execution](/docs/ai-transport/features/durable-execution) rule that each interaction with the session channel is its own retryable unit, and it earns two properties:
+
+- An inference failure retries the inference alone. It never re-opens the Run, because opening happened in a different step that already succeeded.
+- The Run's ids reach the workflow before any inference runs. If the first inference later exhausts its retries, your failure path still has the ids to end the Run rather than leaving it stranded open.
+
+Pin the Run id to the workflow's replay-stable id (for example `run:${workflowRunId}` from `getWorkflowMetadata()`) so a retry of `openRun` re-enters the same Run. Its republished `ai-run-start` folds idempotently onto the existing Run, first start wins, so the retry never opens a parallel Run. A continuation ignores the override and resumes the Run its trigger names.
+
+## Handle tool calls
+
+The workflow owns the tool loop, not the Vercel AI SDK's internal one. Every model call runs with server `execute`s removed by `stripToolExecutes`, so `streamText` emits its tool-call parts and stops rather than running tools inline. Each inference then reads the calls the model left pending with `pendingToolCalls` and routes them by how each one resolves:
+
+| Tool kind | How the model call sees it | How the driver resolves it |
+| --- | --- | --- |
+| Server tool | An `execute` in your registry, stripped for the model call | The workflow schedules one tool step (`runTool`) per call. The step runs `execute`, publishes the result as a `tool-output-available` message under its own Step, then a follow-up inference feeds it back to the model. |
+| Client tool | No `execute`; the browser owns the work | The inference finds no server call to dispatch, so the Run suspends. The client runs the tool and posts the result, which starts a continuation. |
+| Approval-gated tool | A `needsApproval` predicate that `stripToolExecutes` preserves, with its `execute` stripped for the model call like any server tool | The model emits a `tool-approval-request` part and the Run suspends for the user's decision. Once approved, the call runs as a server tool in its own tool step. |
+
+`pendingToolCalls` reports the fresh calls the model just emitted, the parts in `input-available` state. It does not classify them; your registry does, by whether the named tool has an `execute`. A call with one is the workflow's to run as a tool step; a call without one belongs to the client, so the Run [suspends](#suspend-resume) and the continuation carries the answer back. A tool step throws if its tool has no `execute`, so the classification and the dispatch stay in agreement.
+
+Approvals resolve on a separate helper. A continuation triggered by a `tool-approval-response` dispatches the approved call directly with `approvedPendingToolCalls`, rather than replaying the approval through the model. Feeding an approved-then-answered tool pair back through `streamText` is unreliable on real providers, so the driver runs the approved call itself and lets the next inference summarize the result. `approvedPendingToolCalls` excludes a denied approval, so a denied call never reaches the tool path.
+
+Each inference ends with [`vercelRunOutcome`](/docs/ai-transport/api/javascript/vercel/run-outcome), which maps the stream's `finishReason` to a terminal or to a pause when the model stopped on tool calls. The step publishes `ai-run-end` or `ai-run-suspend` inline for a terminal outcome, but for a server-tool outcome it publishes nothing and leaves the Run open for the next tool step to adopt.
+
+## Route cancels through the channel
+
+Cancels do not travel through a workflow-level signal. Mid-flight control between a client and a running step is the transport's job, so a `clientRun.cancel()` in the browser publishes `ai-cancel` on the Ably channel. The in-flight WDK step's own `AgentSession` subscribes to that channel and routes the cancel to [`run.abortSignal`](/docs/ai-transport/api/javascript/core/agent-session#run) via the SDK's built-in cancel routing:
+
+
+
+Because each WDK step constructs its own session and routes its own cancels, `run.abortSignal` fires in whichever step is subscribed when the cancel lands. An inference step passes that signal into its model call, aborts, and publishes the `cancelled` terminal inline, and the workflow stops scheduling further steps. A cancel that lands while a tool step is in flight fires that step's `run.abortSignal` too; wire it into any long-running tool work so the tool stops rather than running to completion.
+
+## Suspend and resume across workflows
+
+The WDK step holding the Run calls `run.suspend()`, publishes `ai-run-suspend`, and returns; the workflow completes. When the client posts a continuation invocation (a tool result or an approval response), the agent route starts a fresh workflow. That workflow's `openRun` step calls `createRun` as usual; the SDK reads the existing `runId` the continuation's trigger names and publishes `ai-run-resume` rather than `ai-run-start`.
+
+The invocation is a poke and a pointer. Its HTTP body carries no session content, only the id of the channel event the client just published; the agent reads that event from the channel to learn what to do. One workflow run per HTTP POST, so continuations are new workflow runs on the same AI Transport `runId`, not resumes of the original workflow.
+
+WDK's own hooks can hold a workflow open while it waits for external input. The design in the [getting-started walkthrough](/docs/ai-transport/getting-started/vercel-wdk) ends the workflow instead, because the continuation arrives as a fresh POST from the AI Transport client and its trigger already points at the channel event that resumes the Run. The client stays identical to the [Vercel AI SDK integration](/docs/ai-transport/frameworks/vercel-ai-sdk-ui), with no workflow-aware code on the browser side.
+
+## Scope and trade-offs
+
+Vercel WDK is intentionally focused on execution durability. By design, it does not model conversation state or route signals between clients and a running step. AI Transport adds both without changing how you write workflows. The workflow decides what runs and when; AI Transport decides what appears in the conversation and to whom.
+
+Boundaries to keep in mind:
+
+- The workflow function is a deterministic orchestrator. WDK re-executes it on every wake-up, replaying recorded step results, and rejects I/O inside it. All channel work happens inside steps.
+- Only serializable values cross the workflow-to-step boundary. An `Ably.Realtime` client or an `AgentSession` cannot be passed between steps. Each step constructs its own and reconstructs Run state from the channel with `adoptRun` and `load`.
+- Durability applies at the step boundary, not mid-chunk of an LLM stream. WDK re-runs the whole step; its Step opens fresh under the same `stepId` and the retry's output supersedes the failed attempt.
+- Publish `run.end({ reason: 'error' })` from the workflow's catch when a step exhausts its retries, and give that cleanup step `maxRetries = 0` so its own failure cannot cascade. The catch holds the Run's ids only once `openRun` has returned them, so a turn whose `openRun` fails every attempt strands no Run: nothing was opened. Alert on persistent `openRun` failure so a turn that never starts does not pass silently.
+
+## Read next
+
+- [Get started with Vercel WDK](/docs/ai-transport/getting-started/vercel-wdk): the full walkthrough.
+- [Durable execution](/docs/ai-transport/features/durable-execution): the framework-agnostic pattern.
+- [Steps](/docs/ai-transport/concepts/steps): the unit a WDK step publishes.
+- [AgentSession API reference](/docs/ai-transport/api/javascript/core/agent-session): `adoptRun`, `createStep`, `detach`, and `end`.
diff --git a/src/pages/docs/ai-transport/getting-started/core-sdk.mdx b/src/pages/docs/ai-transport/getting-started/core-sdk.mdx
index ec1ba78388..cc4c68b0dd 100644
--- a/src/pages/docs/ai-transport/getting-started/core-sdk.mdx
+++ b/src/pages/docs/ai-transport/getting-started/core-sdk.mdx
@@ -98,7 +98,7 @@ export async function POST(req) {
await run.end({ reason: 'error' });
throw err;
} finally {
- session.close();
+ session.end();
}
});
diff --git a/src/pages/docs/ai-transport/getting-started/vercel-ai-sdk.mdx b/src/pages/docs/ai-transport/getting-started/vercel-ai-sdk.mdx
index d3834eb5a2..7e03121b48 100644
--- a/src/pages/docs/ai-transport/getting-started/vercel-ai-sdk.mdx
+++ b/src/pages/docs/ai-transport/getting-started/vercel-ai-sdk.mdx
@@ -108,7 +108,7 @@ export async function POST(req) {
await run.end({ reason: 'error' });
throw err;
} finally {
- session.close();
+ session.end();
}
});
diff --git a/src/pages/docs/ai-transport/getting-started/vercel-wdk.mdx b/src/pages/docs/ai-transport/getting-started/vercel-wdk.mdx
new file mode 100644
index 0000000000..f79b0d2584
--- /dev/null
+++ b/src/pages/docs/ai-transport/getting-started/vercel-wdk.mdx
@@ -0,0 +1,404 @@
+---
+title: "Get started with Vercel Workflow Development Kit"
+meta_description: "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 channel, and the user's stream never breaks."
+meta_keywords: "AI Transport, Vercel Workflow Development Kit, Vercel WDK, WDK, durable execution, workflow, use step, getStepMetadata, adoptRun, createStep, Next.js, Ably"
+intro: "Build a Next.js chat app whose agent side runs as a Vercel Workflow in the same deployment. The workflow opens the Run and runs each model call as its own durable WDK step; a retry supersedes the failed attempt on the channel, and the user's stream never breaks."
+---
+
+## What you build
+
+A Next.js chat app where:
+
+- Every user turn starts a [Vercel Workflow Development Kit](https://useworkflow.dev/) (WDK) workflow inside the same Next.js deployment. The workflow drives the agent loop as a sequence of WDK steps, each running as its own process.
+- The workflow opens the Run in one step, then runs each model call and each tool as its own step. Every model response and tool result is published as an AI Transport [Step](/docs/ai-transport/concepts/steps), identified by the WDK step id.
+- When a step crashes, WDK retries it as a fresh process under the same step id, so the retry's channel output supersedes the failed attempt's and the user sees one clean result, not a duplicate.
+- The client experience is identical to the [Vercel AI SDK getting-started](/docs/ai-transport/getting-started/vercel-ai-sdk); 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.
+
+No separate workflow server or CLI install is needed. The workflow runtime ships in the `workflow` package and runs inside `next dev`.
+
+## Install dependencies
+
+Install the AI Transport SDK, the Vercel AI SDK, the workflow runtime, and Next.js:
+
+
+```shell
+npm install @ably/ai-transport ably ai @ai-sdk/react @ai-sdk/anthropic workflow zod \
+ next react react-dom
+```
+
+
+## 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. See [Set up authentication](/docs/ai-transport/getting-started/authentication) for the full setup.
+
+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. See [Configure the channel rule](/docs/ai-transport/getting-started/channel-rules) for the dashboard, Control API, and CLI steps.
+
+
+
+## Configure Next.js for workflows
+
+Wrap your Next.js config with `withWorkflow`. It installs the bundler transforms for the `'use workflow'` and `'use step'` directives and serves the workflow runtime's handler endpoints under `/.well-known/workflow/v1/*`.
+
+
+```typescript
+import type { NextConfig } from 'next';
+import { withWorkflow } from 'workflow/next';
+
+const nextConfig: NextConfig = {};
+
+export default withWorkflow(nextConfig);
+```
+
+
+In local development the runtime stores run state in `.workflow-data/`; add that directory to `.gitignore`.
+
+## Build the agent
+
+The agent side is three files under `app/workflows/`: the workflow that orchestrates the turn, the server tool it calls, and the steps that publish to the channel. Create them next.
+
+### Workflow
+
+Create `app/workflows/turn.ts`. The workflow is the deterministic orchestrator: it holds the Run's identity and does no channel I/O. `openRun` opens the Run; `runInference` runs each model call, the first and every follow-up. While an inference reports fresh server-tool calls, the workflow dispatches one `runTool` per call, then loops a follow-up `runInference`. Every terminal outcome has already been published on the wire by the step that produced it; the workflow only decides whether to schedule more steps.
+
+
+```typescript
+import { getWorkflowMetadata } from 'workflow';
+import type { InvocationData } from '@ably/ai-transport';
+import { failRun, openRun, runInference, runTool, type TurnIds } from './steps';
+
+export async function chatWorkflow(invocation: InvocationData): Promise {
+ 'use workflow';
+ const { workflowRunId } = getWorkflowMetadata();
+
+ let ids: TurnIds | undefined;
+ try {
+ ids = await openRun(invocation, workflowRunId);
+ let outcome = await runInference(invocation, ids);
+
+ while (outcome.kind === 'server-tools') {
+ for (const toolCall of outcome.serverToolCalls) {
+ await runTool(invocation, ids, toolCall);
+ }
+ outcome = await runInference(invocation, ids);
+ }
+ } catch (error) {
+ // Retries exhausted: if a Run was opened, end it in error so observers unstick.
+ if (ids) {
+ const message = error instanceof Error ? error.message : String(error);
+ try {
+ await failRun(invocation, ids, message);
+ } catch {
+ /* best-effort */
+ }
+ }
+ throw error;
+ }
+}
+```
+
+
+A `'use workflow'` function must stay deterministic. WDK re-executes it on every wake-up, replaying each awaited step's recorded result instead of re-running it, so anything nondeterministic, and all channel I/O, belongs inside the steps. Reading `getWorkflowMetadata()` here is safe because `workflowRunId` is stable across replays.
+
+### Tool
+
+Create `app/workflows/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 WDK retry the tool step in the inspector and watch the retry's re-rolled output supersede the failed attempt on the Ably channel.
+
+
+```typescript
+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 so you can watch the retry re-roll it.
+ 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 };
+ },
+ },
+};
+```
+
+
+### Steps
+
+Create `app/workflows/steps.ts`. Each `'use step'` function runs as a separate process, a fresh invocation with no shared memory, so every step constructs its own Ably client and `AgentSession` and reconstructs Run state from the channel. Only serializable values cross the workflow-to-step boundary; the client and session never do.
+
+Two structural rules keep the cross-process lifecycle sound. The step that produces an outcome publishes the matching Run lifecycle event in the same session it streamed with. And failure paths detach rather than end, leaving the Run open on the wire so a WDK retry can adopt it and publish a superseding attempt under the same `stepId`.
+
+
+```typescript
+import * as Ably from 'ably';
+import { convertToModelMessages, stepCountIs, streamText } from 'ai';
+import { getStepMetadata } from 'workflow';
+import { anthropic } from '@ai-sdk/anthropic';
+import { Invocation, type InvocationData } from '@ably/ai-transport';
+import {
+ createAgentSession,
+ pendingToolCalls,
+ stripToolExecutes,
+ vercelRunOutcome,
+} from '@ably/ai-transport/vercel';
+import { tools } from './tools';
+
+export interface TurnIds {
+ runId: string;
+ invocationId: string;
+ triggerEventId: string;
+}
+
+export interface ToolCallInfo {
+ toolCallId: string;
+ toolName: string;
+ input: unknown;
+}
+
+// server-tools is the only non-terminal kind; the other kinds are already published.
+export type InferenceOutcome =
+ | { kind: 'complete' }
+ | { kind: 'suspend' }
+ | { kind: 'cancelled' }
+ | { kind: 'error'; errorMessage: string }
+ | { kind: 'server-tools'; serverToolCalls: ToolCallInfo[] };
+
+type AgentSession = ReturnType;
+type AgentRun = ReturnType | ReturnType;
+
+// A fresh session per step. detach (not end) leaves any open Run on the channel
+// for the next step, or for a retry to adopt.
+async function withAgentSession(
+ channelName: string,
+ body: (session: AgentSession) => Promise,
+): Promise {
+ const client = new Ably.Realtime({ key: process.env.ABLY_API_KEY! });
+ const session = createAgentSession({ client, channelName });
+ try {
+ await session.connect();
+ return await body(session);
+ } finally {
+ try {
+ await session.detach();
+ } catch {
+ /* best-effort */
+ }
+ client.close();
+ }
+}
+
+// Open the Run and return its ids. No model call here; the first inference is its own step.
+export async function openRun(invocationData: InvocationData, workflowRunId: string): Promise {
+ 'use step';
+ const invocation = Invocation.fromJSON(invocationData);
+ const invocationId = `inv:${workflowRunId}`;
+ return withAgentSession(invocation.sessionName, async (session) => {
+ // Pin the ids to the replay-stable workflow run id so a retry re-enters the same Run.
+ const run = session.createRun(invocation, { runId: `run:${workflowRunId}`, invocationId });
+
+ // Drain history so the trigger folds in, then open (or resume) the Run.
+ while (run.view.hasOlder()) await run.view.loadOlder();
+ await run.start();
+
+ return { runId: run.runId, invocationId, triggerEventId: invocation.inputEventId };
+ });
+}
+
+// Adopt the open Run, run one model call as a Step, and publish its terminal.
+export async function runInference(invocationData: InvocationData, ids: TurnIds): Promise {
+ 'use step';
+ const invocation = Invocation.fromJSON(invocationData);
+ return withAgentSession(invocation.sessionName, async (session) => {
+ const { stepId } = getStepMetadata();
+ const run = session.adoptRun(ids);
+ await run.load({ timeoutMs: 15_000 });
+ while (run.view.hasOlder()) await run.view.loadOlder();
+
+ const step = run.createStep({ stepId });
+ await step.start();
+
+ const conversation = run.view.getMessages().map((entry) => entry.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(outcome.reason === 'error' ? { reason: 'failed' } : undefined);
+
+ let inference: InferenceOutcome;
+ if (outcome.reason === 'error') {
+ inference = { kind: 'error', errorMessage: outcome.error.message };
+ } else if (outcome.reason === 'cancelled') {
+ inference = { kind: 'cancelled' };
+ } else if (outcome.reason === 'complete') {
+ inference = { kind: 'complete' };
+ } else {
+ // Server tools (execute in the registry) run as steps; anything else suspends.
+ const serverToolCalls = pendingToolCalls(run.messages)
+ .filter((call) => typeof tools[call.toolName]?.execute === 'function')
+ .map((call) => ({ toolCallId: call.toolCallId, toolName: call.toolName, input: call.input }));
+ inference = serverToolCalls.length > 0 ? { kind: 'server-tools', serverToolCalls } : { kind: 'suspend' };
+ }
+
+ await publishTerminal(run, inference);
+ return inference;
+ });
+}
+
+// Execute one server tool and publish its result as a Step. A throw retries under the same stepId.
+export async function runTool(invocationData: InvocationData, ids: TurnIds, toolCall: ToolCallInfo): Promise {
+ 'use step';
+ const invocation = Invocation.fromJSON(invocationData);
+ await withAgentSession(invocation.sessionName, async (session) => {
+ const run = session.adoptRun(ids);
+ await run.load({ timeoutMs: 15_000 });
+
+ const step = run.createStep({ stepId: getStepMetadata().stepId });
+ await step.start();
+ // Look the tool up by the model-provided name and narrow it to the callable shape.
+ const tool = tools[toolCall.toolName] as { execute?: (input: unknown) => Promise };
+ if (!tool?.execute) throw new Error(`tool '${toolCall.toolName}' has no execute`);
+ const output = await tool.execute(toolCall.input);
+ await step.send({ type: 'tool-output-available', toolCallId: toolCall.toolCallId, output });
+ await step.end();
+ });
+}
+
+// The workflow's failure terminal, run from its catch when a step exhausts retries.
+// A load() rejection means the Run is already gone; nothing to clean up.
+export async function failRun(invocationData: InvocationData, ids: TurnIds, errorMessage: string): Promise {
+ 'use step';
+ const invocation = Invocation.fromJSON(invocationData);
+ await withAgentSession(invocation.sessionName, async (session) => {
+ const run = session.adoptRun(ids);
+ try {
+ await run.load({ timeoutMs: 15_000 });
+ } catch {
+ return;
+ }
+ await run.end({ reason: 'error', error: new Ably.ErrorInfo(errorMessage, 104000, 500) });
+ });
+}
+
+// Cleanup is best-effort and must not cascade: one attempt, no retries.
+failRun.maxRetries = 0;
+
+// Publish the lifecycle event the outcome implies; server-tools publishes nothing.
+async function publishTerminal(run: AgentRun, outcome: InferenceOutcome): Promise {
+ switch (outcome.kind) {
+ case 'server-tools':
+ return;
+ case 'suspend':
+ await run.suspend();
+ return;
+ case 'error':
+ await run.end({ reason: 'error', error: new Ably.ErrorInfo(outcome.errorMessage, 104000, 500) });
+ return;
+ default:
+ await run.end({ reason: outcome.kind });
+ }
+}
+```
+
+
+`openRun` opens the Run and returns its ids without calling the model. Splitting it from the first inference keeps each retryable on its own: an inference retry never re-opens the Run, and the ids reach the workflow before any inference, so `failRun` can still end the Run if an inference later exhausts its retries.
+
+`runInference` adopts the Run and runs one model call. `stripToolExecutes` and `stopWhen: stepCountIs(1)` keep the Vercel AI SDK from running its own tool loop, so the model emits its tool-call parts and stops; the workflow drives the loop. `runInference` then routes the pending calls: a tool with a server `execute` becomes a `runTool` step; anything else suspends the Run for the client to answer. This guide uses a single server tool; the [framework page](/docs/ai-transport/frameworks/vercel-wdk#tool-calls) and [tool calling](/docs/ai-transport/features/tool-calling) cover client-executed and approval-gated tools.
+
+## Create the agent route
+
+Create `app/api/chat/route.ts`. The route starts a workflow and returns immediately; the reply reaches the client over the channel, so the response body is informational only.
+
+
+```typescript
+import { start } from 'workflow/api';
+import type { InvocationData } from '@ably/ai-transport';
+import { chatWorkflow } from '../../workflows/turn';
+
+export async function POST(req: Request): Promise {
+ const invocation = (await req.json()) as InvocationData;
+ const run = await start(chatWorkflow, [invocation]);
+ return Response.json({ workflowRunId: run.runId });
+}
+```
+
+
+`run.runId` here is the WDK workflow run id, not the AI Transport `runId`; `openRun` mints that on the channel. A continuation POST (a tool result, a regenerate, a suspend resume) starts a fresh workflow the same way, and its `openRun` step resumes the existing Run.
+
+## Create the chat component
+
+The client is identical to the [Vercel AI SDK getting-started](/docs/ai-transport/getting-started/vercel-ai-sdk#create-the-chat-component). The `ChatTransport` POSTs to `/api/chat`; whether the server side is a single `streamText` call or a WDK workflow is invisible to the client.
+
+## Wire it together
+
+The page wrapper is identical to the [Vercel AI SDK getting-started](/docs/ai-transport/getting-started/vercel-ai-sdk#wire-it-together). Use the same `Providers` and `ChatTransportProvider` setup.
+
+## Run the app
+
+One process runs everything. Next.js serves the app, and the workflow runtime executes workflows and steps inside it:
+
+
+```shell
+npm run dev
+```
+
+
+To watch workflows execute, open the inspector in a second terminal:
+
+
+```shell
+npx workflow web
+```
+
+
+Open the app at `http://localhost:3000`. Every user turn appears as a new workflow run in the inspector; each step's output is a Step on the Ably channel. Ask for a stock price a few times: about half the tool steps fail and retry, re-rolling the price, and the conversation settles on the retried output with no duplicate.
+
+## What is happening
+
+1. `sendMessage({ text })` publishes the user input on the channel and POSTs an [Invocation](/docs/ai-transport/concepts/invocations) to `/api/chat`, which starts a WDK workflow.
+2. `openRun` calls [`session.createRun`](/docs/ai-transport/api/javascript/core/agent-session#create-run) with the Run id pinned to the workflow run id, drains history so the trigger folds in, and publishes `ai-run-start` with `run.start()`. It returns the Run's ids and runs no model. The workflow then calls `runInference`, which adopts the Run with [`session.adoptRun`](/docs/ai-transport/api/javascript/core/agent-session#adopt-run), opens a Step under `getStepMetadata().stepId`, streams the model response, and publishes the outcome's terminal inline. When the model asks for a server tool, the workflow schedules `runTool` per call (each adopts the Run and publishes the result as its own Step), then loops back into `runInference` for the follow-up, which publishes `ai-run-end`.
+3. If a step throws, WDK re-runs it as a fresh process under the same step id. `getStepMetadata().stepId` is stable across retries, so the retry's `ai-step-start` supersedes the failed attempt's channel output. The user sees only the retried Step's output. The `getStockPrice` tool throws on odd prices to make this visible on the tool step.
+4. Cancels arrive on the channel, not through a workflow API. The in-flight step's own session routes them to [`run.abortSignal`](/docs/ai-transport/api/javascript/core/agent-session#run), which flows into the LLM call.
+
+This guide's happy path publishes each Run terminal inside the step that produced it. When a step exhausts its retries instead, the workflow's catch schedules `failRun` to publish `run.end({ reason: 'error' })` so every observer's UI unsticks; `maxRetries = 0` keeps that final write to a single attempt. The catch holds the Run's ids only once `openRun` has returned them, so a turn whose `openRun` fails every attempt opened no Run to strand. See [Durable execution](/docs/ai-transport/features/durable-execution#close-once) for the pattern.
+
+## Understand the architecture
+
+Vercel WDK owns execution durability: process crashes, step retries, workflow progress. AI Transport owns conversation state: what appears on the channel, how retries reconcile, how clients observe. See [Vercel WDK](/docs/ai-transport/frameworks/vercel-wdk) for the composition model and [Durable execution](/docs/ai-transport/features/durable-execution) for the framework-agnostic pattern.
+
+## Explore next
+
+- [Vercel WDK framework](/docs/ai-transport/frameworks/vercel-wdk): scope, cancel routing, suspend-and-resume across workflows.
+- [Durable execution](/docs/ai-transport/features/durable-execution): the pattern behind the code.
+- [Tool calling](/docs/ai-transport/features/tool-calling): server-executed, client-executed, and approval-gated tools.
+- [Steps](/docs/ai-transport/concepts/steps): the unit a WDK step publishes.
+- [AgentSession API reference](/docs/ai-transport/api/javascript/core/agent-session): `adoptRun`, `createStep`, `detach`.
diff --git a/src/pages/docs/ai-transport/going-to-production.mdx b/src/pages/docs/ai-transport/going-to-production.mdx
index 520ea9bcb8..953a297766 100644
--- a/src/pages/docs/ai-transport/going-to-production.mdx
+++ b/src/pages/docs/ai-transport/going-to-production.mdx
@@ -78,7 +78,7 @@ A few patterns that come up:
## Durable execution
-If an agent turn spans multiple stages (inference, tools, follow-up inference) or exceeds a serverless function's runtime budget, run the agent inside a workflow engine such as [Temporal](/docs/ai-transport/frameworks/temporal). Each stage becomes its own retryable activity. AI Transport's [`AgentSession.adoptRun`](/docs/ai-transport/api/javascript/core/agent-session#adopt-run) plus [`AgentRun.createStep({ stepId })`](/docs/ai-transport/api/javascript/core/agent-session#create-step) let a retry of the same stage supersede the failed attempt's channel output rather than append beside it. See [Durable execution](/docs/ai-transport/features/durable-execution).
+If an agent turn spans multiple stages (inference, tools, follow-up inference) or exceeds a serverless function's runtime budget, run the agent inside a workflow engine such as [Temporal](/docs/ai-transport/frameworks/temporal) or [Vercel WDK](/docs/ai-transport/frameworks/vercel-wdk). Each stage becomes its own retryable activity. AI Transport's [`AgentSession.adoptRun`](/docs/ai-transport/api/javascript/core/agent-session#adopt-run) plus [`AgentRun.createStep({ stepId })`](/docs/ai-transport/api/javascript/core/agent-session#create-step) let a retry of the same stage supersede the failed attempt's channel output rather than append beside it. See [Durable execution](/docs/ai-transport/features/durable-execution).
When a turn exhausts its retries, your failure path needs to adopt the Run and publish `run.end({ reason: 'error' })`. Otherwise the Run stays open on the channel and every observer's UI stays on `streaming`.
diff --git a/src/pages/docs/ai-transport/internals/wire-protocol.mdx b/src/pages/docs/ai-transport/internals/wire-protocol.mdx
index 387fcb2f4f..9bc56d859c 100644
--- a/src/pages/docs/ai-transport/internals/wire-protocol.mdx
+++ b/src/pages/docs/ai-transport/internals/wire-protocol.mdx
@@ -73,7 +73,7 @@ Transport headers carry the routing and identity that the transport layer reads.
| `step-id` | Step correlation. Stable across retry attempts of the same [Step](/docs/ai-transport/concepts/steps): a retry publishes `ai-step-start` under the same `step-id` and the retry's output supersedes the failed attempt. Set on `ai-step-start`, `ai-step-end`, and every `ai-output` published inside the Step. |
| `step-client-id` | The Ably `clientId` attributed to a Step. Sticky across Steps in a Run by default; a mid-run steer that incorporates a fresh input mid-run overrides it. |
| `start-serial` | The Ably serial of the Step's own `ai-step-start`. Stamped on every `ai-output` inside the Step so the tree can elect the latest attempt when two attempts share a `step-id`. |
-| `step-reason` | The reason a Step ended. Present on `ai-step-end`. One of `complete`, `failed`. |
+| `step-reason` | The reason a Step ended. Present on `ai-step-end`. One of `complete`, `failed`, `cancelled`. |
| `error-code` | Numeric error code on `ai-run-end` with `run-reason: error`. |
| `error-message` | Human-readable error message on `ai-run-end` with `run-reason: error`. |
@@ -126,7 +126,7 @@ AI Transport uses nine Ably message names. The decoder dispatches on the Ably me
| `ai-run-resume` | Agent | Run lifecycle. The agent publishes this when a continuation Invocation re-activates a Run (tool-result follow-up, suspended-Run resume), instead of a second `ai-run-start`. The agent detects the continuation by reading the existing `run-id` off the triggering input event's wire headers; a fresh input event omits `run-id` and produces `ai-run-start`. |
| `ai-run-end` | Agent | Run lifecycle. Closes the Run terminally with one of `complete`, `cancelled`, `error`. |
| `ai-step-start` | Agent | [Step](/docs/ai-transport/concepts/steps) lifecycle. Opens a bracket for one re-attemptable unit of output. A fresh `ai-step-start` under an existing `step-id` supersedes the prior attempt. |
-| `ai-step-end` | Agent | Step lifecycle. Closes the Step with `step-reason: complete` or `step-reason: failed`. A Step terminal is not a Run terminal; the Run stays active for the next Step or for its own `ai-run-end` / `ai-run-suspend`. |
+| `ai-step-end` | Agent | Step lifecycle. Closes the Step with `step-reason: complete`, `step-reason: failed`, or `step-reason: cancelled`. A Step terminal is not a Run terminal; the Run stays active for the next Step or for its own `ai-run-end` / `ai-run-suspend`. |
| `ai-cancel` | Client | Cancel intent, targeting a specific `run-id`. The agent matches against its registered Runs and fires the matching `AbortSignal`. |
## Content messages
diff --git a/src/pages/docs/ai-transport/roadmap.mdx b/src/pages/docs/ai-transport/roadmap.mdx
index dc690210a4..ddbbad96ae 100644
--- a/src/pages/docs/ai-transport/roadmap.mdx
+++ b/src/pages/docs/ai-transport/roadmap.mdx
@@ -22,7 +22,7 @@ Available today.
| Client-side tool calls and approval gates | Call [client tools](/docs/ai-transport/features/tool-calling) over the session, with [human-in-the-loop](/docs/ai-transport/features/human-in-the-loop) approval where you need it. |
| Drop-in framework integration | A drop-in transport for the [Vercel AI SDK](/docs/ai-transport/getting-started/vercel-ai-sdk), plus the [Core SDK](/docs/ai-transport/getting-started/core-sdk) for everything else. |
| Shared live state and presence | [Agent presence](/docs/ai-transport/features/agent-presence) and [shared session state](/docs/ai-transport/features/liveobjects) exposed directly through the SDK. |
-| [Durable execution](/docs/ai-transport/features/durable-execution) | Pair durable sessions with a workflow engine such as [Temporal](/docs/ai-transport/frameworks/temporal), so a mid-flight process crash retries the failed Step cleanly instead of stranding the turn. |
+| [Durable execution](/docs/ai-transport/features/durable-execution) | Pair durable sessions with a workflow engine such as [Temporal](/docs/ai-transport/frameworks/temporal) or [Vercel WDK](/docs/ai-transport/frameworks/vercel-wdk), so a mid-flight process crash retries the failed Step cleanly instead of stranding the turn. |
| Enterprise-ready platform | SOC 2 Type II and HIPAA, on Ably's realtime [infrastructure](/docs/ai-transport/concepts/infrastructure). |
## Now