diff --git a/src/data/nav/aitransport.ts b/src/data/nav/aitransport.ts
index 197c20cc4f..2de51d8161 100644
--- a/src/data/nav/aitransport.ts
+++ b/src/data/nav/aitransport.ts
@@ -13,6 +13,36 @@ export default {
link: '/docs/ai-transport',
index: true,
},
+ {
+ name: 'Why AI Transport',
+ link: '/docs/ai-transport/why-ai-transport',
+ },
+ {
+ name: 'How it works',
+ pages: [
+ {
+ name: 'Overview',
+ link: '/docs/ai-transport/how-it-works',
+ index: true,
+ },
+ {
+ name: 'Sessions',
+ link: '/docs/ai-transport/how-it-works/sessions',
+ },
+ {
+ name: 'Transport',
+ link: '/docs/ai-transport/how-it-works/transport',
+ },
+ {
+ name: 'Turns',
+ link: '/docs/ai-transport/how-it-works/turns',
+ },
+ ],
+ },
+ {
+ name: 'Authentication',
+ link: '/docs/ai-transport/authentication',
+ },
{
name: 'Getting started',
pages: [
@@ -20,175 +50,176 @@ export default {
name: 'Anthropic',
link: '/docs/ai-transport/getting-started/anthropic',
},
- {
- name: 'OpenAI',
- link: '/docs/ai-transport/getting-started/openai',
- },
{
name: 'Vercel AI SDK',
link: '/docs/ai-transport/getting-started/vercel-ai-sdk',
},
{
- name: 'LangGraph',
- link: '/docs/ai-transport/getting-started/langgraph',
+ name: 'Custom integration',
+ link: '/docs/ai-transport/getting-started/custom',
},
],
},
{
- name: 'Token streaming',
+ name: 'Framework guides',
pages: [
{
- name: 'Overview',
- link: '/docs/ai-transport/token-streaming',
- index: true,
- },
- {
- name: 'Message per response',
- link: '/docs/ai-transport/token-streaming/message-per-response',
+ name: 'Anthropic',
+ link: '/docs/ai-transport/framework-guides/anthropic',
},
{
- name: 'Message per token',
- link: '/docs/ai-transport/token-streaming/message-per-token',
+ name: 'Vercel AI SDK',
+ link: '/docs/ai-transport/framework-guides/vercel-ai-sdk',
},
{
- name: 'Token streaming limits',
- link: '/docs/ai-transport/token-streaming/token-rate-limits',
+ name: 'Custom integration',
+ link: '/docs/ai-transport/framework-guides/custom',
},
],
},
{
- name: 'Sessions & Identity',
+ name: 'Features',
pages: [
{
- name: 'Overview',
- link: '/docs/ai-transport/sessions-identity',
+ name: 'Token streaming',
+ link: '/docs/ai-transport/features/token-streaming',
+ },
+ {
+ name: 'Reconnection and recovery',
+ link: '/docs/ai-transport/features/reconnection-and-recovery',
+ },
+ {
+ name: 'Tool calls',
+ link: '/docs/ai-transport/features/tool-calls',
+ },
+ {
+ name: 'Human-in-the-loop',
+ link: '/docs/ai-transport/features/human-in-the-loop',
+ },
+ {
+ name: 'Chain of thought',
+ link: '/docs/ai-transport/features/chain-of-thought',
+ },
+ {
+ name: 'Citations',
+ link: '/docs/ai-transport/features/citations',
},
{
- name: 'Identifying users and agents',
- link: '/docs/ai-transport/sessions-identity/identifying-users-and-agents',
+ name: 'Cancel',
+ link: '/docs/ai-transport/features/cancel',
},
{
- name: 'Online status',
- link: '/docs/ai-transport/sessions-identity/online-status',
+ name: 'Steer mid-stream',
+ link: '/docs/ai-transport/features/steer-mid-stream',
+ },
+ {
+ name: 'History and replay',
+ link: '/docs/ai-transport/features/history-and-replay',
+ },
+ {
+ name: 'Agent presence and health',
+ link: '/docs/ai-transport/features/agent-presence-and-health',
},
{
name: 'Push notifications',
- link: '/docs/ai-transport/sessions-identity/push-notifications',
+ link: '/docs/ai-transport/features/push-notifications',
+ },
+ {
+ name: 'Multi-device sessions',
+ link: '/docs/ai-transport/features/multi-device-sessions',
},
{
- name: 'Resuming sessions',
- link: '/docs/ai-transport/sessions-identity/resuming-sessions',
+ name: 'Multi-user sessions',
+ link: '/docs/ai-transport/features/multi-user-sessions',
},
],
},
{
- name: 'Messaging',
+ name: 'Use cases and demos',
pages: [
{
- name: 'Accepting user input',
- link: '/docs/ai-transport/messaging/accepting-user-input',
+ name: 'Support chat',
+ link: '/docs/ai-transport/use-cases/support-chat',
},
+ ],
+ },
+ {
+ name: 'Examples',
+ link: '/docs/ai-transport/examples',
+ },
+ {
+ name: 'Going to production',
+ pages: [
{
- name: 'Tool calls',
- link: '/docs/ai-transport/messaging/tool-calls',
+ name: 'Pricing and cost control',
+ link: '/docs/ai-transport/going-to-production/pricing-and-cost-control',
},
{
- name: 'Human-in-the-loop',
- link: '/docs/ai-transport/messaging/human-in-the-loop',
+ name: 'Limits',
+ link: '/docs/ai-transport/going-to-production/limits',
},
{
- name: 'Chain of thought',
- link: '/docs/ai-transport/messaging/chain-of-thought',
+ name: 'Compliance',
+ link: '/docs/ai-transport/going-to-production/compliance',
},
{
- name: 'Citations',
- link: '/docs/ai-transport/messaging/citations',
+ name: 'Production checklist',
+ link: '/docs/ai-transport/going-to-production/production-checklist',
},
{
- name: 'Completion and cancellation',
- link: '/docs/ai-transport/messaging/completion-and-cancellation',
+ name: 'Monitoring and observability',
+ link: '/docs/ai-transport/going-to-production/monitoring-and-observability',
},
],
},
{
- name: 'Guides',
- expand: true,
+ name: 'API reference',
pages: [
{
- name: 'Anthropic',
- pages: [
- {
- name: 'Message per response',
- link: '/docs/ai-transport/guides/anthropic/anthropic-message-per-response',
- },
- {
- name: 'Message per token',
- link: '/docs/ai-transport/guides/anthropic/anthropic-message-per-token',
- },
- {
- name: 'Human-in-the-loop',
- link: '/docs/ai-transport/guides/anthropic/anthropic-human-in-the-loop',
- },
- {
- name: 'Citations',
- link: '/docs/ai-transport/guides/anthropic/anthropic-citations',
- },
- ],
- },
- {
- name: 'OpenAI',
- pages: [
- {
- name: 'Message per response',
- link: '/docs/ai-transport/guides/openai/openai-message-per-response',
- },
- {
- name: 'Message per token',
- link: '/docs/ai-transport/guides/openai/openai-message-per-token',
- },
- {
- name: 'Human-in-the-loop',
- link: '/docs/ai-transport/guides/openai/openai-human-in-the-loop',
- },
- {
- name: 'Citations',
- link: '/docs/ai-transport/guides/openai/openai-citations',
- },
- ],
- },
- {
- name: 'LangGraph',
- pages: [
- {
- name: 'Message per response',
- link: '/docs/ai-transport/guides/langgraph/langgraph-message-per-response',
- },
- {
- name: 'Message per token',
- link: '/docs/ai-transport/guides/langgraph/langgraph-message-per-token',
- },
- {
- name: 'Human-in-the-loop',
- link: '/docs/ai-transport/guides/langgraph/langgraph-human-in-the-loop',
- },
- ],
+ name: 'Client transport API',
+ link: '/docs/ai-transport/api-reference/client-transport-api',
},
{
- name: 'Vercel AI SDK',
- pages: [
- {
- name: 'Message per response',
- link: '/docs/ai-transport/guides/vercel-ai-sdk/vercel-message-per-response',
- },
- {
- name: 'Message per token',
- link: '/docs/ai-transport/guides/vercel-ai-sdk/vercel-message-per-token',
- },
- {
- name: 'Human-in-the-loop',
- link: '/docs/ai-transport/guides/vercel-ai-sdk/vercel-human-in-the-loop',
- },
- ],
+ name: 'Server transport API',
+ link: '/docs/ai-transport/api-reference/server-transport-api',
+ },
+ {
+ name: 'React hooks',
+ link: '/docs/ai-transport/api-reference/react-hooks',
+ },
+ {
+ name: 'Codec API',
+ link: '/docs/ai-transport/api-reference/codec-api',
+ },
+ {
+ name: 'Error codes',
+ link: '/docs/ai-transport/api-reference/error-codes',
+ },
+ ],
+ },
+ {
+ name: 'Internals',
+ pages: [
+ {
+ name: 'Overview',
+ link: '/docs/ai-transport/internals',
+ index: true,
+ },
+ {
+ name: 'Codec architecture',
+ link: '/docs/ai-transport/internals/codec-architecture',
+ },
+ {
+ name: 'Wire protocol',
+ link: '/docs/ai-transport/internals/wire-protocol',
+ },
+ {
+ name: 'Transport patterns',
+ link: '/docs/ai-transport/internals/transport-patterns',
+ },
+ {
+ name: 'Event mapping',
+ link: '/docs/ai-transport/internals/event-mapping',
},
],
},
diff --git a/src/pages/docs/ai-transport/api-reference/client-transport-api.mdx b/src/pages/docs/ai-transport/api-reference/client-transport-api.mdx
new file mode 100644
index 0000000000..f561c8ee70
--- /dev/null
+++ b/src/pages/docs/ai-transport/api-reference/client-transport-api.mdx
@@ -0,0 +1,6 @@
+---
+title: "Client transport API"
+meta_description: "API reference for the AI Transport client SDK, including connection management, message handling, and event subscriptions."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/api-reference/codec-api.mdx b/src/pages/docs/ai-transport/api-reference/codec-api.mdx
new file mode 100644
index 0000000000..e17b326b12
--- /dev/null
+++ b/src/pages/docs/ai-transport/api-reference/codec-api.mdx
@@ -0,0 +1,6 @@
+---
+title: "Codec API"
+meta_description: "API reference for AI Transport codecs, including message encoding, decoding, and custom codec implementation."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/api-reference/error-codes.mdx b/src/pages/docs/ai-transport/api-reference/error-codes.mdx
new file mode 100644
index 0000000000..b81b719c4f
--- /dev/null
+++ b/src/pages/docs/ai-transport/api-reference/error-codes.mdx
@@ -0,0 +1,6 @@
+---
+title: "Error codes"
+meta_description: "Reference for AI Transport error codes, including descriptions, causes, and recommended resolution steps."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/api-reference/react-hooks.mdx b/src/pages/docs/ai-transport/api-reference/react-hooks.mdx
new file mode 100644
index 0000000000..10a0f47563
--- /dev/null
+++ b/src/pages/docs/ai-transport/api-reference/react-hooks.mdx
@@ -0,0 +1,6 @@
+---
+title: "React hooks"
+meta_description: "API reference for AI Transport React hooks, including useTransport, useSession, and useStream."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/api-reference/server-transport-api.mdx b/src/pages/docs/ai-transport/api-reference/server-transport-api.mdx
new file mode 100644
index 0000000000..0a5a64a2c4
--- /dev/null
+++ b/src/pages/docs/ai-transport/api-reference/server-transport-api.mdx
@@ -0,0 +1,6 @@
+---
+title: "Server transport API"
+meta_description: "API reference for the AI Transport server SDK, including stream management, tool call handling, and session lifecycle."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/authentication.mdx b/src/pages/docs/ai-transport/authentication.mdx
new file mode 100644
index 0000000000..d9b102b2e8
--- /dev/null
+++ b/src/pages/docs/ai-transport/authentication.mdx
@@ -0,0 +1,8 @@
+---
+title: "Authentication"
+meta_description: "Configure authentication for AI Transport, including token-based auth, identifying users and agents, and securing sessions."
+redirect_from:
+ - /docs/ai-transport/sessions-identity/identifying-users-and-agents
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/examples/index.mdx b/src/pages/docs/ai-transport/examples/index.mdx
new file mode 100644
index 0000000000..a9556a72ac
--- /dev/null
+++ b/src/pages/docs/ai-transport/examples/index.mdx
@@ -0,0 +1,6 @@
+---
+title: "Examples"
+meta_description: "Browse AI Transport code examples and sample applications for common integration patterns."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/features/agent-presence-and-health.mdx b/src/pages/docs/ai-transport/features/agent-presence-and-health.mdx
new file mode 100644
index 0000000000..15ffc1cc36
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/agent-presence-and-health.mdx
@@ -0,0 +1,8 @@
+---
+title: "Agent presence and health"
+meta_description: "Monitor AI agent online status and health using AI Transport's presence features."
+redirect_from:
+ - /docs/ai-transport/sessions-identity/online-status
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/features/cancel.mdx b/src/pages/docs/ai-transport/features/cancel.mdx
new file mode 100644
index 0000000000..51c33e69dc
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/cancel.mdx
@@ -0,0 +1,8 @@
+---
+title: "Cancel"
+meta_description: "Cancel in-progress AI agent responses using AI Transport, enabling users to stop generation mid-stream."
+redirect_from:
+ - /docs/ai-transport/messaging/completion-and-cancellation
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/features/chain-of-thought.mdx b/src/pages/docs/ai-transport/features/chain-of-thought.mdx
new file mode 100644
index 0000000000..5643967ecd
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/chain-of-thought.mdx
@@ -0,0 +1,8 @@
+---
+title: "Chain of thought"
+meta_description: "Stream chain-of-thought reasoning from AI agents to clients in realtime using AI Transport."
+redirect_from:
+ - /docs/ai-transport/messaging/chain-of-thought
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/features/citations.mdx b/src/pages/docs/ai-transport/features/citations.mdx
new file mode 100644
index 0000000000..9defa9f1dc
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/citations.mdx
@@ -0,0 +1,12 @@
+---
+title: "Citations"
+meta_description: "Display source citations from AI agents in realtime using AI Transport, with support for inline and end-of-response citation formats."
+redirect_from:
+ - /docs/ai-transport/messaging/citations
+ - /docs/ai-transport/guides/anthropic/anthropic-citations
+ - /docs/ai-transport/guides/openai/openai-citations
+ - /docs/guides/ai-transport/anthropic/anthropic-citations
+ - /docs/guides/ai-transport/openai/openai-citations
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/features/history-and-replay.mdx b/src/pages/docs/ai-transport/features/history-and-replay.mdx
new file mode 100644
index 0000000000..ea079eb05a
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/history-and-replay.mdx
@@ -0,0 +1,6 @@
+---
+title: "History and replay"
+meta_description: "Access conversation history and replay past interactions using AI Transport's history and replay features."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/features/human-in-the-loop.mdx b/src/pages/docs/ai-transport/features/human-in-the-loop.mdx
new file mode 100644
index 0000000000..03378e1cbf
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/human-in-the-loop.mdx
@@ -0,0 +1,14 @@
+---
+title: "Human-in-the-loop"
+meta_description: "Implement human-in-the-loop workflows with AI Transport, allowing users to approve, reject, or modify agent actions in realtime."
+redirect_from:
+ - /docs/ai-transport/messaging/human-in-the-loop
+ - /docs/ai-transport/guides/anthropic/anthropic-human-in-the-loop
+ - /docs/ai-transport/guides/openai/openai-human-in-the-loop
+ - /docs/ai-transport/guides/langgraph/langgraph-human-in-the-loop
+ - /docs/ai-transport/guides/vercel-ai-sdk/vercel-human-in-the-loop
+ - /docs/guides/ai-transport/anthropic/anthropic-human-in-the-loop
+ - /docs/guides/ai-transport/openai/openai-human-in-the-loop
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/features/multi-device-sessions.mdx b/src/pages/docs/ai-transport/features/multi-device-sessions.mdx
new file mode 100644
index 0000000000..2d392e28bb
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/multi-device-sessions.mdx
@@ -0,0 +1,6 @@
+---
+title: "Multi-device sessions"
+meta_description: "Continue AI Transport sessions across multiple devices with synchronized state and seamless handoff."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/features/multi-user-sessions.mdx b/src/pages/docs/ai-transport/features/multi-user-sessions.mdx
new file mode 100644
index 0000000000..4bc7021739
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/multi-user-sessions.mdx
@@ -0,0 +1,6 @@
+---
+title: "Multi-user sessions"
+meta_description: "Enable multiple users to participate in the same AI Transport session with shared context and collaborative interactions."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/features/push-notifications.mdx b/src/pages/docs/ai-transport/features/push-notifications.mdx
new file mode 100644
index 0000000000..b8016169e4
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/push-notifications.mdx
@@ -0,0 +1,8 @@
+---
+title: "Push notifications"
+meta_description: "Send push notifications to users when AI agents complete tasks or need attention, using AI Transport's notification support."
+redirect_from:
+ - /docs/ai-transport/sessions-identity/push-notifications
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/features/reconnection-and-recovery.mdx b/src/pages/docs/ai-transport/features/reconnection-and-recovery.mdx
new file mode 100644
index 0000000000..c70f810287
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/reconnection-and-recovery.mdx
@@ -0,0 +1,8 @@
+---
+title: "Reconnection and recovery"
+meta_description: "Handle network disruptions gracefully with AI Transport's built-in reconnection and recovery mechanisms."
+redirect_from:
+ - /docs/ai-transport/sessions-identity/resuming-sessions
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/features/steer-mid-stream.mdx b/src/pages/docs/ai-transport/features/steer-mid-stream.mdx
new file mode 100644
index 0000000000..1aeac568cf
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/steer-mid-stream.mdx
@@ -0,0 +1,6 @@
+---
+title: "Steer mid-stream"
+meta_description: "Allow users to redirect AI agent responses mid-stream using AI Transport's steer mid-stream feature."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/features/token-streaming.mdx b/src/pages/docs/ai-transport/features/token-streaming.mdx
new file mode 100644
index 0000000000..6bcabbbb72
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/token-streaming.mdx
@@ -0,0 +1,34 @@
+---
+title: "Token streaming"
+meta_description: "Stream AI-generated tokens to clients in realtime using AI Transport, with support for message-per-response and message-per-token patterns."
+redirect_from:
+ - /docs/ai-transport/token-streaming
+ - /docs/ai-transport/token-streaming/message-per-response
+ - /docs/ai-transport/token-streaming/message-per-token
+ - /docs/ai-transport/guides/anthropic/anthropic-message-per-response
+ - /docs/ai-transport/guides/anthropic/anthropic-message-per-token
+ - /docs/ai-transport/guides/openai/openai-message-per-response
+ - /docs/ai-transport/guides/openai/openai-message-per-token
+ - /docs/ai-transport/guides/langgraph/langgraph-message-per-response
+ - /docs/ai-transport/guides/langgraph/langgraph-message-per-token
+ - /docs/ai-transport/guides/vercel-ai-sdk/vercel-message-per-response
+ - /docs/ai-transport/guides/vercel-ai-sdk/vercel-message-per-token
+ - /docs/guides/ai-transport/anthropic-message-per-response
+ - /docs/guides/ai-transport/anthropic/anthropic-message-per-response
+ - /docs/guides/ai-transport/anthropic-message-per-token
+ - /docs/guides/ai-transport/anthropic/anthropic-message-per-token
+ - /docs/guides/ai-transport/openai-message-per-response
+ - /docs/guides/ai-transport/openai/openai-message-per-response
+ - /docs/guides/ai-transport/openai-message-per-token
+ - /docs/guides/ai-transport/openai/openai-message-per-token
+ - /docs/guides/ai-transport/langgraph-message-per-response
+ - /docs/guides/ai-transport/langgraph/langgraph-message-per-response
+ - /docs/guides/ai-transport/langgraph-message-per-token
+ - /docs/guides/ai-transport/langgraph/langgraph-message-per-token
+ - /docs/guides/ai-transport/vercel-message-per-response
+ - /docs/guides/ai-transport/vercel-ai-sdk/vercel-message-per-response
+ - /docs/guides/ai-transport/vercel-message-per-token
+ - /docs/guides/ai-transport/vercel-ai-sdk/vercel-message-per-token
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/features/tool-calls.mdx b/src/pages/docs/ai-transport/features/tool-calls.mdx
new file mode 100644
index 0000000000..8525f667c3
--- /dev/null
+++ b/src/pages/docs/ai-transport/features/tool-calls.mdx
@@ -0,0 +1,8 @@
+---
+title: "Tool calls"
+meta_description: "Handle AI tool calls in realtime with AI Transport, enabling agents to invoke external tools and return results during a conversation."
+redirect_from:
+ - /docs/ai-transport/messaging/tool-calls
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/framework-guides/anthropic.mdx b/src/pages/docs/ai-transport/framework-guides/anthropic.mdx
new file mode 100644
index 0000000000..4dae480dcc
--- /dev/null
+++ b/src/pages/docs/ai-transport/framework-guides/anthropic.mdx
@@ -0,0 +1,6 @@
+---
+title: "Anthropic integration guide"
+meta_description: "Integrate AI Transport with Anthropic Claude, including server-side setup, client configuration, and streaming patterns."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/framework-guides/custom.mdx b/src/pages/docs/ai-transport/framework-guides/custom.mdx
new file mode 100644
index 0000000000..f108a6735e
--- /dev/null
+++ b/src/pages/docs/ai-transport/framework-guides/custom.mdx
@@ -0,0 +1,6 @@
+---
+title: "Custom integration"
+meta_description: "Build a custom AI Transport integration with any AI framework, using the low-level transport API directly."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/framework-guides/vercel-ai-sdk.mdx b/src/pages/docs/ai-transport/framework-guides/vercel-ai-sdk.mdx
new file mode 100644
index 0000000000..b8220e2481
--- /dev/null
+++ b/src/pages/docs/ai-transport/framework-guides/vercel-ai-sdk.mdx
@@ -0,0 +1,6 @@
+---
+title: "Vercel AI SDK integration guide"
+meta_description: "Integrate AI Transport with the Vercel AI SDK, including server-side setup, client configuration, and streaming patterns."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/getting-started/anthropic.mdx b/src/pages/docs/ai-transport/getting-started/anthropic.mdx
index 8f0e6c2a66..7ee2ae6651 100644
--- a/src/pages/docs/ai-transport/getting-started/anthropic.mdx
+++ b/src/pages/docs/ai-transport/getting-started/anthropic.mdx
@@ -1,628 +1,9 @@
---
-title: "Getting started with Anthropic"
-meta_description: "Build a realtime AI agent with Anthropic Claude that streams tokens over Ably, handles tool calls with human-in-the-loop approval, and authenticates users with verified identities."
-meta_keywords: "AI Transport Anthropic, Ably AI agent, token streaming Claude, realtime AI, LLM streaming, AI agent tutorial, human in the loop, HITL, tool calls, Anthropic Messages API"
+title: "Get started with Anthropic"
+meta_description: "Build your first AI Transport application using the Anthropic Claude SDK, with realtime token streaming and bidirectional communication."
redirect_from:
- /docs/ai-transport/getting-started
- /docs/ai-transport/getting-started/javascript
---
-This guide will get you started with Ably AI Transport using Anthropic's Messages API.
-
-You'll learn how to authenticate users with verified identities, stream tokens from an agent to clients in realtime, and implement human-in-the-loop approval for tool calls. The agent uses Anthropic's Claude model with a `send_email` tool that requires user approval before execution.
-
-
-
-## Prerequisites
-
-1. [Sign up](https://ably.com/signup) for an Ably account.
-
-2. Create a [new app](https://ably.com/accounts/any/apps/new), and create your first API key in the **API Keys** tab of the dashboard.
-
-3. Your API key will need the `publish`, `subscribe`, and `message-update-own` capabilities.
-
-4. Enable message appends for the channel:
- 1. Go to the **Settings** tab of your app in the dashboard.
- 2. Under **Rules**, click **Add new rule**.
- 3. Enter `ai` as the channel namespace.
- 4. Check **Message annotations, updates, deletes, and appends**.
- 5. Click **Create channel rule** to save.
-
-5. Install any current LTS version of [Node.js](https://nodejs.org/en).
-
-6. Get an [Anthropic API key](https://console.anthropic.com/settings/keys).
-
-## Step 1: Project setup
-
-Create a new directory for your project and initialize it:
-
-
-```shell
-mkdir ai-agent-demo && cd ai-agent-demo
-npm init -y && npm pkg set type=module
-```
-
-
-Install the required dependencies:
-
-
-```shell
-npm install ably @anthropic-ai/sdk jsonwebtoken express
-npm install -D typescript @types/node @types/express @types/jsonwebtoken
-```
-
-
-Create a TypeScript configuration file:
-
-
-```shell
-npx tsc --init
-```
-
-
-Create a `.env` file in your project root and add your API keys:
-
-
-```shell
-echo "ABLY_API_KEY={{API_KEY}}" > .env
-echo "ANTHROPIC_API_KEY=your_anthropic_api_key" >> .env
-```
-
-
-## Step 2: Authenticate users
-
-Users authenticate with Ably using [token authentication](/docs/auth/token). Your server generates signed JWTs that establish a verified identity for each user. Agents can trust this identity because only your server can issue valid tokens.
-
-Create a file called `auth-server.ts` with an endpoint that generates signed JWTs:
-
-
-```typescript
-import express from 'express';
-import jwt from 'jsonwebtoken';
-
-const app = express();
-
-const apiKey = process.env.ABLY_API_KEY;
-if (!apiKey) {
- throw new Error('ABLY_API_KEY environment variable is required');
-}
-
-const [keyName, keySecret] = apiKey.split(':');
-if (!keyName || !keySecret) {
- throw new Error('ABLY_API_KEY must be in format "keyName:keySecret"');
-}
-
-app.get('/api/auth/token', (req, res) => {
- // In production, authenticate the user and get their ID from your session
- const userId = 'user-123';
-
- const token = jwt.sign({
- 'x-ably-clientId': userId,
- 'ably.channel.*': 'user'
- }, keySecret, {
- algorithm: 'HS256',
- keyid: keyName,
- expiresIn: '1h'
- });
-
- res.type('application/jwt').send(token);
-});
-
-app.listen(3001, () => {
- console.log('Auth server running on http://localhost:3001');
-});
-```
-
-
-
-
-The JWT includes two claims:
-- `x-ably-clientId`: Establishes a verified identity that appears on all messages the user publishes.
-- `ably.channel.*`: Assigns a role that agents can use to distinguish users from other agents on the channel.
-
-
-
-## Step 3: Create the agent
-
-The agent runs in a trusted server environment and uses [API key authentication](/docs/auth#basic-authentication). It subscribes to a channel to receive user prompts, processes them with Anthropic's Claude model, and streams responses back using the [message-per-response](/docs/ai-transport/token-streaming/message-per-response) pattern. When Claude requests a tool call, the agent pauses to request human approval before executing.
-
-Create a file called `agent.ts` with the setup, tool definition, and human-in-the-loop helpers:
-
-
-```typescript
-import * as Ably from 'ably';
-import Anthropic from '@anthropic-ai/sdk';
-
-const apiKey = process.env.ABLY_API_KEY;
-if (!apiKey) {
- throw new Error('ABLY_API_KEY environment variable is required');
-}
-
-const anthropic = new Anthropic();
-
-const realtime = new Ably.Realtime({
- key: apiKey,
- clientId: 'ai-agent',
- echoMessages: false,
-});
-
-const channel = realtime.channels.get('ai:conversation');
-
-// Define a tool that requires human approval
-const tools: Anthropic.Tool[] = [
- {
- name: 'send_email',
- description: 'Send an email to a recipient. Always requires human approval.',
- input_schema: {
- type: 'object' as const,
- properties: {
- to: { type: 'string', description: 'Recipient email address' },
- subject: { type: 'string', description: 'Email subject line' },
- body: { type: 'string', description: 'Email body content' },
- },
- required: ['to', 'subject', 'body'],
- },
- },
-];
-
-// Track pending approval requests
-const pendingApprovals = new Map void>();
-
-// Listen for approval responses from users
-await channel.subscribe('approval-response', (message: Ably.Message) => {
- const toolCallId = message.extras?.headers?.toolCallId;
- const resolve = pendingApprovals.get(toolCallId);
- if (resolve) {
- pendingApprovals.delete(toolCallId);
- resolve(message.data.decision);
- }
-});
-
-// Request human approval for a tool call via the channel
-function requestApproval(
- toolCallId: string,
- toolName: string,
- toolInput: Record,
-): Promise {
- return new Promise((resolve) => {
- pendingApprovals.set(toolCallId, resolve);
- channel.publish({
- name: 'approval-request',
- data: { name: toolName, arguments: toolInput },
- extras: { headers: { toolCallId } },
- });
- console.log(`Awaiting approval for ${toolName} (${toolCallId})`);
- });
-}
-
-// Execute a tool after approval
-function executeTool(name: string, input: Record) {
- if (name === 'send_email') {
- console.log(`Sending email to ${input.to}: ${input.subject}`);
- return { success: true, message: `Email sent to ${input.to}` };
- }
- return { error: `Unknown tool: ${name}` };
-}
-```
-
-
-The agent publishes `approval-request` messages to the channel when a tool call is detected, then waits for a matching `approval-response` correlated by `toolCallId`. The `executeTool` function simulates the email action. In production, replace this with actual email delivery logic.
-
-
-
-Add the streaming function to `agent.ts`. This streams Anthropic response tokens to Ably using `channel.appendMessage()`, while tracking any tool call the model requests:
-
-
-```typescript
-// Stream Anthropic response tokens to Ably, returning tool call info if any
-async function streamToAbly(
- messages: Anthropic.MessageParam[],
- serial: string,
- includeTools: boolean,
-) {
- const stream = await anthropic.messages.create({
- model: 'claude-sonnet-4-5',
- max_tokens: 1024,
- messages,
- ...(includeTools ? { tools } : {}),
- stream: true,
- });
-
- let textBlockIndex: number | null = null;
- let currentToolUse: { id: string; name: string; index: number } | null = null;
- let toolInput = '';
- let stopReason: string | null = null;
- const assistantContent: Anthropic.ContentBlockParam[] = [];
- let accumulatedText = '';
-
- for await (const event of stream) {
- switch (event.type) {
- case 'content_block_start':
- if (event.content_block.type === 'text') {
- textBlockIndex = event.index;
- accumulatedText = '';
- } else if (event.content_block.type === 'tool_use') {
- currentToolUse = {
- id: event.content_block.id,
- name: event.content_block.name,
- index: event.index,
- };
- toolInput = '';
- }
- break;
-
- case 'content_block_delta':
- if (event.delta.type === 'text_delta' && event.index === textBlockIndex) {
- channel.appendMessage({ serial, data: event.delta.text });
- accumulatedText += event.delta.text;
- } else if (event.delta.type === 'input_json_delta') {
- toolInput += event.delta.partial_json;
- }
- break;
-
- case 'content_block_stop':
- if (event.index === textBlockIndex && accumulatedText) {
- assistantContent.push({ type: 'text', text: accumulatedText });
- textBlockIndex = null;
- }
- if (currentToolUse && event.index === currentToolUse.index) {
- assistantContent.push({
- type: 'tool_use',
- id: currentToolUse.id,
- name: currentToolUse.name,
- input: JSON.parse(toolInput),
- });
- currentToolUse = null;
- }
- break;
-
- case 'message_delta':
- stopReason = event.delta.stop_reason ?? null;
- break;
- }
- }
-
- return { stopReason, assistantContent };
-}
-```
-
-
-The function filters for `text_delta` events and appends each token to the Ably message. It also tracks `tool_use` content blocks and accumulates their JSON input. The `stopReason` indicates whether the model finished normally (`end_turn`) or wants to call a tool (`tool_use`).
-
-Add the prompt handler to the end of `agent.ts`. This ties everything together, streaming the initial response and handling tool calls with HITL approval:
-
-
-```typescript
-// Handle incoming user prompts
-await channel.subscribe('user-input', async (message: Ably.Message) => {
- const { promptId, text } = message.data as { promptId: string; text: string };
- const userId = message.clientId;
- const role = message.extras?.userClaim;
-
- console.log(`Received prompt from ${userId} (role: ${role}): ${text}`);
-
- if (role !== 'user') {
- console.log('Ignoring message from non-user');
- return;
- }
-
- // Create the initial Ably message for streaming
- const response = await channel.publish({
- name: 'agent-response',
- data: '',
- extras: { headers: { promptId } },
- });
-
- const serial = response.serials[0];
- if (!serial) {
- console.error('No serial returned from publish');
- return;
- }
-
- // Stream the response from Anthropic
- const messages: Anthropic.MessageParam[] = [{ role: 'user', content: text }];
- const { stopReason, assistantContent } = await streamToAbly(messages, serial, true);
-
- // Handle tool call with human-in-the-loop approval
- if (stopReason === 'tool_use') {
- const toolCall = assistantContent.find(
- (c): c is Anthropic.ToolUseBlockParam => c.type === 'tool_use',
- );
-
- if (toolCall) {
- const decision = await requestApproval(
- toolCall.id,
- toolCall.name,
- toolCall.input as Record,
- );
-
- let toolResult: Record;
- if (decision === 'approved') {
- toolResult = executeTool(toolCall.name, toolCall.input as Record);
- } else {
- toolResult = { error: 'The user rejected this action' };
- }
-
- // Continue the conversation with the tool result
- const followUpMessages: Anthropic.MessageParam[] = [
- ...messages,
- { role: 'assistant', content: assistantContent },
- {
- role: 'user',
- content: [
- {
- type: 'tool_result',
- tool_use_id: toolCall.id,
- content: JSON.stringify(toolResult),
- },
- ],
- },
- ];
-
- // Stream the follow-up response, appending to the same message
- channel.appendMessage({ serial, data: '\n\n' });
- await streamToAbly(followUpMessages, serial, false);
- }
- }
-
- // Signal completion
- await channel.publish({
- name: 'agent-response-complete',
- extras: { headers: { promptId } },
- });
-
- console.log(`Completed response for prompt ${promptId}`);
-});
-
-console.log('Agent is listening for prompts...');
-```
-
-
-The prompt handler:
-1. Verifies the sender has the `user` role.
-2. Creates an initial Ably message and captures its `serial` for appending.
-3. Streams the Anthropic response, appending text tokens in realtime.
-4. If the model requests a tool call, publishes an `approval-request` and waits for the user's decision.
-5. After approval, executes the tool and streams a follow-up response appended to the same message.
-
-## Step 4: Create the client
-
-The client uses an [`authCallback`](/docs/auth/token#auth-callback) to obtain a signed JWT from your auth server. The `clientId` from the token is automatically attached to all messages the client publishes.
-
-Create a file called `client.ts` with the connection setup and token streaming subscription:
-
-
-```typescript
-import * as Ably from 'ably';
-import crypto from 'crypto';
-import * as readline from 'readline';
-
-const realtime = new Ably.Realtime({
- authCallback: async (
- _tokenParams: Ably.TokenParams,
- callback: (error: Ably.ErrorInfo | string | null, token: Ably.TokenDetails | Ably.TokenRequest | string | null) => void
- ) => {
- try {
- const response = await fetch('http://localhost:3001/api/auth/token');
- const token = await response.text();
- callback(null, token);
- } catch (error) {
- callback(error instanceof Error ? error.message : String(error), null);
- }
- }
-});
-
-realtime.connection.on('connected', () => {
- console.log('Connected to Ably as', realtime.auth.clientId);
-});
-
-const channel = realtime.channels.get('ai:conversation');
-const pendingPrompts = new Map void>();
-
-const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
-});
-
-// Subscribe to streamed agent responses
-await channel.subscribe('agent-response', (message: Ably.Message) => {
- const promptId = message.extras?.headers?.promptId;
- if (!promptId) return;
-
- switch (message.action) {
- case 'message.create':
- break;
- case 'message.append':
- // Write each new token as it arrives
- process.stdout.write(message.data || '');
- break;
- case 'message.update':
- // Full response after reconnection
- console.log(message.data || '');
- break;
- }
-});
-```
-
-
-The client subscribes to `agent-response` messages and handles different [message actions](/docs/ai-transport/token-streaming/message-per-response):
-- `message.create`: A new response has started.
-- `message.append`: A token has been appended. Each token is written directly to the terminal as it arrives.
-- `message.update`: The full response content, received after reconnection.
-
-Add the human-in-the-loop approval handler to `client.ts`. When the agent requests approval for a tool call, the client displays the details and prompts the user:
-
-
-```typescript
-// Subscribe to approval requests for human-in-the-loop
-await channel.subscribe('approval-request', async (message: Ably.Message) => {
- const { name, arguments: args } = message.data;
- const toolCallId = message.extras?.headers?.toolCallId;
-
- console.log(`\n\nAgent wants to execute: ${name}`);
- console.log(`Arguments: ${JSON.stringify(args, null, 2)}`);
-
- const answer = await new Promise((resolve) => {
- rl.question('Approve? (yes/no): ', resolve);
- });
-
- const decision = answer.toLowerCase() === 'yes' ? 'approved' : 'rejected';
-
- await channel.publish({
- name: 'approval-response',
- data: { decision },
- extras: { headers: { toolCallId } },
- });
-
- console.log(`Decision sent: ${decision}\n`);
-});
-```
-
-
-## Step 5: Send user prompts
-
-Each prompt includes a unique `promptId` to correlate responses. The user's `clientId` is automatically attached to the message by Ably.
-
-Add the following to the end of `client.ts`:
-
-
-```typescript
-// Subscribe to completion signals
-await channel.subscribe('agent-response-complete', (message: Ably.Message) => {
- const promptId = message.extras?.headers?.promptId;
- if (!promptId) return;
-
- console.log('\n');
- const resolve = pendingPrompts.get(promptId);
- if (resolve) {
- pendingPrompts.delete(promptId);
- resolve();
- }
-});
-
-async function sendPrompt(text: string): Promise {
- const promptId = crypto.randomUUID();
-
- const completionPromise = new Promise((resolve) => {
- pendingPrompts.set(promptId, resolve);
- });
-
- await channel.publish('user-input', {
- promptId,
- text,
- });
-
- await completionPromise;
-}
-
-function askQuestion() {
- rl.question('Enter a prompt (or "quit" to exit): ', async (text) => {
- if (text.toLowerCase() === 'quit') {
- rl.close();
- realtime.close();
- return;
- }
-
- await sendPrompt(text);
- askQuestion();
- });
-}
-
-askQuestion();
-```
-
-
-## Step 6: Run the example
-
-Open three terminal windows to run the auth server, agent, and client.
-
-Terminal 1: Start the auth server
-
-
-```shell
-npx tsx --env-file=.env auth-server.ts
-```
-
-
-You should see:
-
-
-```text
-Auth server running on http://localhost:3001
-```
-
-
-Terminal 2: Start the agent
-
-
-```shell
-npx tsx --env-file=.env agent.ts
-```
-
-
-You should see:
-
-
-```text
-Agent is listening for prompts...
-```
-
-
-Terminal 3: Run the client
-
-
-```shell
-npx tsx --env-file=.env client.ts
-```
-
-
-Try entering different prompts. For a regular response without tool calls:
-
-
-```text
-Enter a prompt (or "quit" to exit): What is the capital of France?
-
-The capital of France is Paris.
-
-Enter a prompt (or "quit" to exit):
-```
-
-
-For a response that triggers a tool call with human-in-the-loop approval:
-
-
-```text
-Enter a prompt (or "quit" to exit): Send an email to alice@example.com saying hello
-
-Agent wants to execute: send_email
-Arguments: {
- "to": "alice@example.com",
- "subject": "Hello",
- "body": "Hello Alice!"
-}
-Approve? (yes/no): yes
-Decision sent: approved
-
-I've sent the email to alice@example.com with the subject "Hello".
-
-Enter a prompt (or "quit" to exit):
-```
-
-
-## Next steps
-
-Continue exploring AI Transport features:
-
-* Learn about [token streaming patterns](/docs/ai-transport/token-streaming) including [message-per-response](/docs/ai-transport/token-streaming/message-per-response) and [message-per-token](/docs/ai-transport/token-streaming/message-per-token).
-* Understand [user input](/docs/ai-transport/messaging/accepting-user-input) patterns for handling prompts and correlating responses.
-* Explore [identifying users and agents](/docs/ai-transport/sessions-identity/identifying-users-and-agents) for more advanced authentication scenarios.
-* Implement more advanced [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) workflows with role-based authorization.
-* Stream [tool call](/docs/ai-transport/messaging/tool-calls) information to build generative UI experiences.
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/getting-started/custom.mdx b/src/pages/docs/ai-transport/getting-started/custom.mdx
new file mode 100644
index 0000000000..bc4ff56549
--- /dev/null
+++ b/src/pages/docs/ai-transport/getting-started/custom.mdx
@@ -0,0 +1,6 @@
+---
+title: "Get started with a custom integration"
+meta_description: "Build your first AI Transport application using a custom integration, with realtime token streaming and bidirectional communication."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/getting-started/langgraph.mdx b/src/pages/docs/ai-transport/getting-started/langgraph.mdx
deleted file mode 100644
index 66c05c7fad..0000000000
--- a/src/pages/docs/ai-transport/getting-started/langgraph.mdx
+++ /dev/null
@@ -1,607 +0,0 @@
----
-title: "Getting started with LangGraph"
-meta_description: "Build a realtime AI agent with LangGraph that streams tokens over Ably, handles tool calls with human-in-the-loop approval, and authenticates users with verified identities."
-meta_keywords: "AI Transport LangGraph, Ably AI agent, token streaming LangChain, realtime AI, LLM streaming, AI agent tutorial, human in the loop, HITL, tool calls, LangGraph streaming"
----
-
-This guide will get you started with Ably AI Transport using LangGraph.
-
-You'll learn how to authenticate users with verified identities, stream tokens from an agent to clients in realtime, and implement human-in-the-loop approval for tool calls. The agent uses LangGraph with a `send_email` tool that requires user approval before execution.
-
-
-
-## Prerequisites
-
-1. [Sign up](https://ably.com/signup) for an Ably account.
-
-2. Create a [new app](https://ably.com/accounts/any/apps/new), and create your first API key in the **API Keys** tab of the dashboard.
-
-3. Your API key will need the `publish`, `subscribe`, and `message-update-own` capabilities.
-
-4. Enable message appends for the channel:
- 1. Go to the **Settings** tab of your app in the dashboard.
- 2. Under **Rules**, click **Add new rule**.
- 3. Enter `ai` as the channel namespace.
- 4. Check **Message annotations, updates, deletes, and appends**.
- 5. Click **Create channel rule** to save.
-
-5. Install any current LTS version of [Node.js](https://nodejs.org/en).
-
-6. Get an [Anthropic API key](https://console.anthropic.com/settings/keys).
-
-
-
-## Step 1: Project setup
-
-Create a new directory for your project and initialize it:
-
-
-```shell
-mkdir ai-agent-demo && cd ai-agent-demo
-npm init -y && npm pkg set type=module
-```
-
-
-Install the required dependencies:
-
-
-```shell
-npm install ably @langchain/langgraph@^0.2 @langchain/anthropic@^0.3 @langchain/core@^0.3 zod jsonwebtoken express
-npm install -D typescript @types/node @types/express @types/jsonwebtoken
-```
-
-
-Create a TypeScript configuration file:
-
-
-```shell
-npx tsc --init
-```
-
-
-Create a `.env` file in your project root and add your API keys:
-
-
-```shell
-echo "ABLY_API_KEY={{API_KEY}}" > .env
-echo "ANTHROPIC_API_KEY=your_anthropic_api_key" >> .env
-```
-
-
-## Step 2: Authenticate users
-
-Users authenticate with Ably using [token authentication](/docs/auth/token). Your server generates signed JWTs that establish a verified identity for each user. Agents can trust this identity because only your server can issue valid tokens.
-
-Create a file called `auth-server.ts` with an endpoint that generates signed JWTs:
-
-
-```typescript
-import express from 'express';
-import jwt from 'jsonwebtoken';
-
-const app = express();
-
-const apiKey = process.env.ABLY_API_KEY;
-if (!apiKey) {
- throw new Error('ABLY_API_KEY environment variable is required');
-}
-
-const [keyName, keySecret] = apiKey.split(':');
-if (!keyName || !keySecret) {
- throw new Error('ABLY_API_KEY must be in format "keyName:keySecret"');
-}
-
-app.get('/api/auth/token', (req, res) => {
- // In production, authenticate the user and get their ID from your session
- const userId = 'user-123';
-
- const token = jwt.sign({
- 'x-ably-clientId': userId,
- 'ably.channel.*': 'user'
- }, keySecret, {
- algorithm: 'HS256',
- keyid: keyName,
- expiresIn: '1h'
- });
-
- res.type('application/jwt').send(token);
-});
-
-app.listen(3001, () => {
- console.log('Auth server running on http://localhost:3001');
-});
-```
-
-
-
-
-The JWT includes two claims:
-- `x-ably-clientId`: Establishes a verified identity that appears on all messages the user publishes.
-- `ably.channel.*`: Assigns a role that agents can use to distinguish users from other agents on the channel.
-
-
-
-## Step 3: Create the agent
-
-The agent runs in a trusted server environment and uses [API key authentication](/docs/auth#basic-authentication). It subscribes to a channel to receive user prompts, processes them with a LangGraph graph that uses Claude, and streams responses back using the [message-per-response](/docs/ai-transport/token-streaming/message-per-response) pattern. When the model requests a tool call, the agent pauses to request human approval before executing.
-
-Create a file called `agent.ts` with the setup, tool definition, and human-in-the-loop helpers:
-
-
-```typescript
-import * as Ably from 'ably';
-import { ChatAnthropic } from '@langchain/anthropic';
-import { tool } from '@langchain/core/tools';
-import { StateGraph, Annotation, START, END } from '@langchain/langgraph';
-import { AIMessage } from '@langchain/core/messages';
-import { z } from 'zod';
-
-const apiKey = process.env.ABLY_API_KEY;
-if (!apiKey) {
- throw new Error('ABLY_API_KEY environment variable is required');
-}
-
-const realtime = new Ably.Realtime({
- key: apiKey,
- clientId: 'ai-agent',
- echoMessages: false,
-});
-
-const channel = realtime.channels.get('ai:conversation');
-
-// Define a tool that requires human approval
-const sendEmail = tool(
- async ({ to, subject, body }) => {
- console.log(`Sending email to ${to}: ${subject}`);
- return JSON.stringify({ success: true, message: `Email sent to ${to}` });
- },
- {
- name: 'send_email',
- description: 'Send an email to a recipient. Always requires human approval.',
- schema: z.object({
- to: z.string().describe('Recipient email address'),
- subject: z.string().describe('Email subject line'),
- body: z.string().describe('Email body content'),
- }),
- },
-);
-
-// Initialize the model with tools
-const model = new ChatAnthropic({ model: 'claude-sonnet-4-5' });
-const modelWithTools = model.bindTools([sendEmail]);
-
-// Track pending approval requests
-const pendingApprovals = new Map void>();
-
-// Listen for approval responses from users
-await channel.subscribe('approval-response', (message: Ably.Message) => {
- const toolCallId = message.extras?.headers?.toolCallId;
- const resolve = pendingApprovals.get(toolCallId);
- if (resolve) {
- pendingApprovals.delete(toolCallId);
- resolve(message.data.decision);
- }
-});
-
-// Request human approval for a tool call via the channel
-function requestApproval(
- toolCallId: string,
- toolName: string,
- toolInput: Record,
-): Promise {
- return new Promise((resolve) => {
- pendingApprovals.set(toolCallId, resolve);
- channel.publish({
- name: 'approval-request',
- data: { name: toolName, arguments: toolInput },
- extras: { headers: { toolCallId } },
- });
- console.log(`Awaiting approval for ${toolName} (${toolCallId})`);
- });
-}
-```
-
-
-The agent publishes `approval-request` messages to the channel when a tool call is detected, then waits for a matching `approval-response` correlated by `toolCallId`. The `sendEmail` tool simulates the email action. In production, replace this with actual email delivery logic.
-
-
-
-Add the streaming function to `agent.ts`. This streams LangGraph response tokens to Ably using `channel.appendMessage()`, while tracking any tool call the model requests:
-
-
-```typescript
-// Stream LangGraph response tokens to Ably
-async function streamToAbly(
- messages: any[],
- serial: string,
- useTools: boolean,
-) {
- const StateAnnotation = Annotation.Root({
- messages: Annotation({
- reducer: (x, y) => x.concat(y),
- default: () => [],
- }),
- });
-
- const graph = new StateGraph(StateAnnotation)
- .addNode('agent', async (state) => {
- const response = await (useTools ? modelWithTools : model).invoke(state.messages);
- return { messages: [response] };
- })
- .addEdge(START, 'agent')
- .addEdge('agent', END);
-
- const app = graph.compile();
-
- const stream = await app.stream(
- { messages },
- { streamMode: 'messages' },
- );
-
- let toolCallId: string | undefined;
- let toolCallName: string | undefined;
- let toolCallArgsStr = '';
-
- for await (const [messageChunk, metadata] of stream) {
- const content = messageChunk?.content;
-
- // Stream text tokens to Ably
- // Content may be a string or an array of content blocks (Anthropic format)
- if (typeof content === 'string' && content) {
- channel.appendMessage({ serial, data: content });
- } else if (Array.isArray(content)) {
- for (const block of content) {
- if (block.type === 'text' && block.text) {
- channel.appendMessage({ serial, data: block.text });
- }
- }
- }
-
- // Accumulate tool call info from streaming chunks
- for (const chunk of messageChunk?.tool_call_chunks ?? []) {
- if (chunk.id) toolCallId = chunk.id;
- if (chunk.name) toolCallName = chunk.name;
- if (chunk.args) toolCallArgsStr += chunk.args;
- }
- }
-
- const toolCallDetected = toolCallId && toolCallName
- ? { id: toolCallId, name: toolCallName, args: toolCallArgsStr ? JSON.parse(toolCallArgsStr) : {} }
- : null;
-
- return { hasToolCall: !!toolCallDetected, toolCallInfo: toolCallDetected };
-}
-```
-
-
-The function creates a LangGraph state graph and streams message chunks. It appends text content to the Ably message, handling both string and array content blocks (Anthropic streams content as arrays of content blocks). Tool call arguments arrive incrementally via `tool_call_chunks` and are accumulated as a string, then parsed as JSON after the stream completes.
-
-Add the prompt handler to the end of `agent.ts`. This ties everything together, streaming the initial response and handling tool calls with HITL approval:
-
-
-```typescript
-// Handle incoming user prompts
-await channel.subscribe('user-input', async (message: Ably.Message) => {
- const { promptId, text } = message.data as { promptId: string; text: string };
- const userId = message.clientId;
- const role = message.extras?.userClaim;
-
- console.log(`Received prompt from ${userId} (role: ${role}): ${text}`);
-
- if (role !== 'user') {
- console.log('Ignoring message from non-user');
- return;
- }
-
- // Create the initial Ably message for streaming
- const response = await channel.publish({
- name: 'agent-response',
- data: '',
- extras: { headers: { promptId } },
- });
-
- const serial = response.serials[0];
- if (!serial) {
- console.error('No serial returned from publish');
- return;
- }
-
- // Stream the response from LangGraph
- const messages = [{ role: 'user', content: text }];
- const { hasToolCall, toolCallInfo } = await streamToAbly(messages, serial, true);
-
- // Handle tool call with human-in-the-loop approval
- if (hasToolCall && toolCallInfo) {
- const decision = await requestApproval(
- toolCallInfo.id,
- toolCallInfo.name,
- toolCallInfo.args,
- );
-
- let toolResult: string;
- if (decision === 'approved') {
- const result = await sendEmail.invoke(toolCallInfo.args);
- toolResult = result;
- } else {
- toolResult = JSON.stringify({ error: 'The user rejected this action' });
- }
-
- // Stream follow-up response with the tool result
- channel.appendMessage({ serial, data: '\n\n' });
-
- const followUpMessages = [
- { role: 'user', content: text },
- new AIMessage({
- content: '',
- tool_calls: [{
- id: toolCallInfo.id,
- name: toolCallInfo.name,
- args: toolCallInfo.args,
- }],
- }),
- { role: 'tool', content: toolResult, tool_call_id: toolCallInfo.id },
- ];
-
- await streamToAbly(followUpMessages, serial, false);
- }
-
- // Signal completion
- await channel.publish({
- name: 'agent-response-complete',
- extras: { headers: { promptId } },
- });
-
- console.log(`Completed response for prompt ${promptId}`);
-});
-
-console.log('Agent is listening for prompts...');
-```
-
-
-The prompt handler:
-1. Verifies the sender has the `user` role.
-2. Creates an initial Ably message and captures its `serial` for appending.
-3. Streams the LangGraph response, appending text tokens in realtime.
-4. If the model requests a tool call, publishes an `approval-request` and waits for the user's decision.
-5. After approval, executes the tool and streams a follow-up response appended to the same message.
-
-## Step 4: Create the client
-
-The client uses an [`authCallback`](/docs/auth/token#auth-callback) to obtain a signed JWT from your auth server. The `clientId` from the token is automatically attached to all messages the client publishes.
-
-Create a file called `client.ts` with the connection setup and token streaming subscription:
-
-
-```typescript
-import * as Ably from 'ably';
-import crypto from 'crypto';
-import * as readline from 'readline';
-
-const realtime = new Ably.Realtime({
- authCallback: async (
- _tokenParams: Ably.TokenParams,
- callback: (error: Ably.ErrorInfo | string | null, token: Ably.TokenDetails | Ably.TokenRequest | string | null) => void
- ) => {
- try {
- const response = await fetch('http://localhost:3001/api/auth/token');
- const token = await response.text();
- callback(null, token);
- } catch (error) {
- callback(error instanceof Error ? error.message : String(error), null);
- }
- }
-});
-
-realtime.connection.on('connected', () => {
- console.log('Connected to Ably as', realtime.auth.clientId);
-});
-
-const channel = realtime.channels.get('ai:conversation');
-const pendingPrompts = new Map void>();
-
-const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
-});
-
-// Subscribe to streamed agent responses
-await channel.subscribe('agent-response', (message: Ably.Message) => {
- switch (message.action) {
- case 'message.create':
- break;
- case 'message.append':
- // Write each new token as it arrives
- process.stdout.write(message.data || '');
- break;
- case 'message.update':
- // Full response after reconnection
- console.log(message.data || '');
- break;
- }
-});
-```
-
-
-The client subscribes to `agent-response` messages and handles different [message actions](/docs/ai-transport/token-streaming/message-per-response):
-- `message.create`: A new response has started.
-- `message.append`: A token has been appended. Each token is written directly to the terminal as it arrives.
-- `message.update`: The full response content, received after reconnection.
-
-Add the human-in-the-loop approval handler to `client.ts`. When the agent requests approval for a tool call, the client displays the details and prompts the user:
-
-
-```typescript
-// Subscribe to approval requests for human-in-the-loop
-await channel.subscribe('approval-request', async (message: Ably.Message) => {
- const { name, arguments: args } = message.data;
- const toolCallId = message.extras?.headers?.toolCallId;
-
- console.log(`\n\nAgent wants to execute: ${name}`);
- console.log(`Arguments: ${JSON.stringify(args, null, 2)}`);
-
- const answer = await new Promise((resolve) => {
- rl.question('Approve? (yes/no): ', resolve);
- });
-
- const decision = answer.toLowerCase() === 'yes' ? 'approved' : 'rejected';
-
- await channel.publish({
- name: 'approval-response',
- data: { decision },
- extras: { headers: { toolCallId } },
- });
-
- console.log(`Decision sent: ${decision}\n`);
-});
-```
-
-
-## Step 5: Send user prompts
-
-Each prompt includes a unique `promptId` to correlate responses. The user's `clientId` is automatically attached to the message by Ably.
-
-Add the following to the end of `client.ts`:
-
-
-```typescript
-// Subscribe to completion signals
-await channel.subscribe('agent-response-complete', (message: Ably.Message) => {
- const promptId = message.extras?.headers?.promptId;
- if (!promptId) return;
-
- console.log('\n');
- const resolve = pendingPrompts.get(promptId);
- if (resolve) {
- pendingPrompts.delete(promptId);
- resolve();
- }
-});
-
-async function sendPrompt(text: string): Promise {
- const promptId = crypto.randomUUID();
-
- const completionPromise = new Promise((resolve) => {
- pendingPrompts.set(promptId, resolve);
- });
-
- await channel.publish('user-input', {
- promptId,
- text,
- });
-
- await completionPromise;
-}
-
-function askQuestion() {
- rl.question('Enter a prompt (or "quit" to exit): ', async (text) => {
- if (text.toLowerCase() === 'quit') {
- rl.close();
- realtime.close();
- return;
- }
-
- await sendPrompt(text);
- askQuestion();
- });
-}
-
-askQuestion();
-```
-
-
-## Step 6: Run the example
-
-Open three terminal windows to run the auth server, agent, and client.
-
-Terminal 1: Start the auth server
-
-
-```shell
-npx tsx --env-file=.env auth-server.ts
-```
-
-
-You should see:
-
-
-```text
-Auth server running on http://localhost:3001
-```
-
-
-Terminal 2: Start the agent
-
-
-```shell
-npx tsx --env-file=.env agent.ts
-```
-
-
-You should see:
-
-
-```text
-Agent is listening for prompts...
-```
-
-
-Terminal 3: Run the client
-
-
-```shell
-npx tsx --env-file=.env client.ts
-```
-
-
-Try entering different prompts. For a regular response without tool calls:
-
-
-```text
-Enter a prompt (or "quit" to exit): What is the capital of France?
-
-The capital of France is Paris.
-
-Enter a prompt (or "quit" to exit):
-```
-
-
-For a response that triggers a tool call with human-in-the-loop approval:
-
-
-```text
-Enter a prompt (or "quit" to exit): Send an email to alice@example.com saying hello
-
-Agent wants to execute: send_email
-Arguments: {
- "to": "alice@example.com",
- "subject": "Hello",
- "body": "Hello Alice!"
-}
-Approve? (yes/no): yes
-Decision sent: approved
-
-I've sent the email to alice@example.com with the subject "Hello".
-
-Enter a prompt (or "quit" to exit):
-```
-
-
-## Next steps
-
-Continue exploring AI Transport features:
-
-* Learn about [token streaming patterns](/docs/ai-transport/token-streaming) including [message-per-response](/docs/ai-transport/token-streaming/message-per-response) and [message-per-token](/docs/ai-transport/token-streaming/message-per-token).
-* Understand [user input](/docs/ai-transport/messaging/accepting-user-input) patterns for handling prompts and correlating responses.
-* Explore [identifying users and agents](/docs/ai-transport/sessions-identity/identifying-users-and-agents) for more advanced authentication scenarios.
-* Implement more advanced [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) workflows with role-based authorization.
-* Stream [tool call](/docs/ai-transport/messaging/tool-calls) information to build generative UI experiences.
diff --git a/src/pages/docs/ai-transport/getting-started/openai.mdx b/src/pages/docs/ai-transport/getting-started/openai.mdx
deleted file mode 100644
index c437b2e554..0000000000
--- a/src/pages/docs/ai-transport/getting-started/openai.mdx
+++ /dev/null
@@ -1,610 +0,0 @@
----
-title: "Getting started with OpenAI"
-meta_description: "Build a realtime AI agent with OpenAI that streams tokens over Ably, handles tool calls with human-in-the-loop approval, and authenticates users with verified identities."
-meta_keywords: "AI Transport OpenAI, Ably AI agent, token streaming GPT, realtime AI, LLM streaming, AI agent tutorial, human in the loop, HITL, tool calls, OpenAI Responses API"
----
-
-This guide will get you started with Ably AI Transport using OpenAI's Responses API.
-
-You'll learn how to authenticate users with verified identities, stream tokens from an agent to clients in realtime, and implement human-in-the-loop approval for tool calls. The agent uses OpenAI's GPT model with a `send_email` tool that requires user approval before execution.
-
-
-
-## Prerequisites
-
-1. [Sign up](https://ably.com/signup) for an Ably account.
-
-2. Create a [new app](https://ably.com/accounts/any/apps/new), and create your first API key in the **API Keys** tab of the dashboard.
-
-3. Your API key will need the `publish`, `subscribe`, and `message-update-own` capabilities.
-
-4. Enable message appends for the channel:
- 1. Go to the **Settings** tab of your app in the dashboard.
- 2. Under **Rules**, click **Add new rule**.
- 3. Enter `ai` as the channel namespace.
- 4. Check **Message annotations, updates, deletes, and appends**.
- 5. Click **Create channel rule** to save.
-
-5. Install any current LTS version of [Node.js](https://nodejs.org/en).
-
-6. Get an [OpenAI API key](https://platform.openai.com/api-keys).
-
-## Step 1: Project setup
-
-Create a new directory for your project and initialize it:
-
-
-```shell
-mkdir ai-agent-demo && cd ai-agent-demo
-npm init -y && npm pkg set type=module
-```
-
-
-Install the required dependencies:
-
-
-```shell
-npm install ably openai jsonwebtoken express
-npm install -D typescript @types/node @types/express @types/jsonwebtoken
-```
-
-
-Create a TypeScript configuration file:
-
-
-```shell
-npx tsc --init
-```
-
-
-Create a `.env` file in your project root and add your API keys:
-
-
-```shell
-echo "ABLY_API_KEY={{API_KEY}}" > .env
-echo "OPENAI_API_KEY=your_openai_api_key" >> .env
-```
-
-
-## Step 2: Authenticate users
-
-Users authenticate with Ably using [token authentication](/docs/auth/token). Your server generates signed JWTs that establish a verified identity for each user. Agents can trust this identity because only your server can issue valid tokens.
-
-Create a file called `auth-server.ts` with an endpoint that generates signed JWTs:
-
-
-```typescript
-import express from 'express';
-import jwt from 'jsonwebtoken';
-
-const app = express();
-
-const apiKey = process.env.ABLY_API_KEY;
-if (!apiKey) {
- throw new Error('ABLY_API_KEY environment variable is required');
-}
-
-const [keyName, keySecret] = apiKey.split(':');
-if (!keyName || !keySecret) {
- throw new Error('ABLY_API_KEY must be in format "keyName:keySecret"');
-}
-
-app.get('/api/auth/token', (req, res) => {
- // In production, authenticate the user and get their ID from your session
- const userId = 'user-123';
-
- const token = jwt.sign({
- 'x-ably-clientId': userId,
- 'ably.channel.*': 'user'
- }, keySecret, {
- algorithm: 'HS256',
- keyid: keyName,
- expiresIn: '1h'
- });
-
- res.type('application/jwt').send(token);
-});
-
-app.listen(3001, () => {
- console.log('Auth server running on http://localhost:3001');
-});
-```
-
-
-
-
-The JWT includes two claims:
-- `x-ably-clientId`: Establishes a verified identity that appears on all messages the user publishes.
-- `ably.channel.*`: Assigns a role that agents can use to distinguish users from other agents on the channel.
-
-
-
-## Step 3: Create the agent
-
-The agent runs in a trusted server environment and uses [API key authentication](/docs/auth#basic-authentication). It subscribes to a channel to receive user prompts, processes them with OpenAI's Responses API, and streams responses back using the [message-per-response](/docs/ai-transport/token-streaming/message-per-response) pattern. When the model requests a tool call, the agent pauses to request human approval before executing.
-
-Create a file called `agent.ts` with the setup, tool definition, and human-in-the-loop helpers:
-
-
-```typescript
-import * as Ably from 'ably';
-import OpenAI from 'openai';
-
-const apiKey = process.env.ABLY_API_KEY;
-if (!apiKey) {
- throw new Error('ABLY_API_KEY environment variable is required');
-}
-
-const openai = new OpenAI();
-
-const realtime = new Ably.Realtime({
- key: apiKey,
- clientId: 'ai-agent',
- echoMessages: false,
-});
-
-const channel = realtime.channels.get('ai:conversation');
-
-// Define a tool that requires human approval
-const tools: OpenAI.Responses.Tool[] = [
- {
- type: 'function',
- name: 'send_email',
- description: 'Send an email to a recipient. Always requires human approval.',
- parameters: {
- type: 'object',
- properties: {
- to: { type: 'string', description: 'Recipient email address' },
- subject: { type: 'string', description: 'Email subject line' },
- body: { type: 'string', description: 'Email body content' },
- },
- required: ['to', 'subject', 'body'],
- },
- },
-];
-
-// Track pending approval requests
-const pendingApprovals = new Map void>();
-
-// Listen for approval responses from users
-await channel.subscribe('approval-response', (message: Ably.Message) => {
- const toolCallId = message.extras?.headers?.toolCallId;
- const resolve = pendingApprovals.get(toolCallId);
- if (resolve) {
- pendingApprovals.delete(toolCallId);
- resolve(message.data.decision);
- }
-});
-
-// Request human approval for a tool call via the channel
-function requestApproval(
- toolCallId: string,
- toolName: string,
- toolInput: Record,
-): Promise {
- return new Promise((resolve) => {
- pendingApprovals.set(toolCallId, resolve);
- channel.publish({
- name: 'approval-request',
- data: { name: toolName, arguments: toolInput },
- extras: { headers: { toolCallId } },
- });
- console.log(`Awaiting approval for ${toolName} (${toolCallId})`);
- });
-}
-
-// Execute a tool after approval
-function executeTool(name: string, input: Record) {
- if (name === 'send_email') {
- console.log(`Sending email to ${input.to}: ${input.subject}`);
- return { success: true, message: `Email sent to ${input.to}` };
- }
- return { error: `Unknown tool: ${name}` };
-}
-```
-
-
-The agent publishes `approval-request` messages to the channel when a tool call is detected, then waits for a matching `approval-response` correlated by `toolCallId`. The `executeTool` function simulates the email action. In production, replace this with actual email delivery logic.
-
-
-
-Add the streaming function to `agent.ts`. This streams OpenAI response tokens to Ably using `channel.appendMessage()`, while tracking any tool call the model requests:
-
-
-```typescript
-// Stream OpenAI response tokens to Ably, returning tool call info if any
-async function streamToAbly(
- input: OpenAI.Responses.ResponseInput,
- serial: string,
-) {
- const stream = await openai.responses.create({
- model: 'gpt-4o',
- input,
- tools,
- stream: true,
- });
-
- let messageItemId: string | null = null;
- let functionCallItem: { id: string; callId: string; name: string } | null = null;
- let functionArgs = '';
- let hasToolCall = false;
-
- for await (const event of stream) {
- switch (event.type) {
- case 'response.output_item.added':
- if (event.item.type === 'message') {
- messageItemId = event.item.id;
- } else if (event.item.type === 'function_call') {
- functionCallItem = {
- id: event.item.id,
- callId: event.item.call_id,
- name: event.item.name,
- };
- functionArgs = '';
- hasToolCall = true;
- }
- break;
-
- case 'response.output_text.delta':
- if (event.item_id === messageItemId) {
- channel.appendMessage({ serial, data: event.delta });
- }
- break;
-
- case 'response.function_call_arguments.delta':
- functionArgs += event.delta;
- break;
-
- case 'response.completed':
- break;
- }
- }
-
- return {
- hasToolCall,
- functionCallItem,
- functionArgs,
- };
-}
-```
-
-
-The function filters for `response.output_text.delta` events and appends each token to the Ably message. It also tracks `function_call` output items and accumulates their JSON arguments. The `hasToolCall` flag indicates whether the model wants to call a tool.
-
-Add the prompt handler to the end of `agent.ts`. This ties everything together, streaming the initial response and handling tool calls with HITL approval:
-
-
-```typescript
-// Handle incoming user prompts
-await channel.subscribe('user-input', async (message: Ably.Message) => {
- const { promptId, text } = message.data as { promptId: string; text: string };
- const userId = message.clientId;
- const role = message.extras?.userClaim;
-
- console.log(`Received prompt from ${userId} (role: ${role}): ${text}`);
-
- if (role !== 'user') {
- console.log('Ignoring message from non-user');
- return;
- }
-
- // Create the initial Ably message for streaming
- const response = await channel.publish({
- name: 'agent-response',
- data: '',
- extras: { headers: { promptId } },
- });
-
- const serial = response.serials[0];
- if (!serial) {
- console.error('No serial returned from publish');
- return;
- }
-
- // Stream the response from OpenAI
- const input: OpenAI.Responses.ResponseInput = [
- { role: 'user', content: text },
- ];
-
- const { hasToolCall, functionCallItem, functionArgs } = await streamToAbly(input, serial);
-
- // Handle tool call with human-in-the-loop approval
- if (hasToolCall && functionCallItem) {
- const parsedArgs = JSON.parse(functionArgs);
-
- const decision = await requestApproval(
- functionCallItem.callId,
- functionCallItem.name,
- parsedArgs,
- );
-
- let toolResult: Record;
- if (decision === 'approved') {
- toolResult = executeTool(functionCallItem.name, parsedArgs);
- } else {
- toolResult = { error: 'The user rejected this action' };
- }
-
- // Continue the conversation with the tool result
- const followUpInput: OpenAI.Responses.ResponseInput = [
- { role: 'user', content: text },
- {
- type: 'function_call',
- id: functionCallItem.id,
- call_id: functionCallItem.callId,
- name: functionCallItem.name,
- arguments: functionArgs,
- },
- {
- type: 'function_call_output',
- call_id: functionCallItem.callId,
- output: JSON.stringify(toolResult),
- },
- ];
-
- // Stream the follow-up response, appending to the same message
- channel.appendMessage({ serial, data: '\n\n' });
- await streamToAbly(followUpInput, serial);
- }
-
- // Signal completion
- await channel.publish({
- name: 'agent-response-complete',
- extras: { headers: { promptId } },
- });
-
- console.log(`Completed response for prompt ${promptId}`);
-});
-
-console.log('Agent is listening for prompts...');
-```
-
-
-The prompt handler:
-1. Verifies the sender has the `user` role.
-2. Creates an initial Ably message and captures its `serial` for appending.
-3. Streams the OpenAI response, appending text tokens in realtime.
-4. If the model requests a tool call, publishes an `approval-request` and waits for the user's decision.
-5. After approval, executes the tool and streams a follow-up response appended to the same message.
-
-## Step 4: Create the client
-
-The client uses an [`authCallback`](/docs/auth/token#auth-callback) to obtain a signed JWT from your auth server. The `clientId` from the token is automatically attached to all messages the client publishes.
-
-Create a file called `client.ts` with the connection setup and token streaming subscription:
-
-
-```typescript
-import * as Ably from 'ably';
-import crypto from 'crypto';
-import * as readline from 'readline';
-
-const realtime = new Ably.Realtime({
- authCallback: async (
- _tokenParams: Ably.TokenParams,
- callback: (error: Ably.ErrorInfo | string | null, token: Ably.TokenDetails | Ably.TokenRequest | string | null) => void
- ) => {
- try {
- const response = await fetch('http://localhost:3001/api/auth/token');
- const token = await response.text();
- callback(null, token);
- } catch (error) {
- callback(error instanceof Error ? error.message : String(error), null);
- }
- }
-});
-
-realtime.connection.on('connected', () => {
- console.log('Connected to Ably as', realtime.auth.clientId);
-});
-
-const channel = realtime.channels.get('ai:conversation');
-const pendingPrompts = new Map void>();
-
-const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
-});
-
-// Subscribe to streamed agent responses
-await channel.subscribe('agent-response', (message: Ably.Message) => {
- const promptId = message.extras?.headers?.promptId;
- if (!promptId) return;
-
- switch (message.action) {
- case 'message.create':
- break;
- case 'message.append':
- // Write each new token as it arrives
- process.stdout.write(message.data || '');
- break;
- case 'message.update':
- // Full response after reconnection
- console.log(message.data || '');
- break;
- }
-});
-```
-
-
-The client subscribes to `agent-response` messages and handles different [message actions](/docs/ai-transport/token-streaming/message-per-response):
-- `message.create`: A new response has started.
-- `message.append`: A token has been appended. Each token is written directly to the terminal as it arrives.
-- `message.update`: The full response content, received after reconnection.
-
-Add the human-in-the-loop approval handler to `client.ts`. When the agent requests approval for a tool call, the client displays the details and prompts the user:
-
-
-```typescript
-// Subscribe to approval requests for human-in-the-loop
-await channel.subscribe('approval-request', async (message: Ably.Message) => {
- const { name, arguments: args } = message.data;
- const toolCallId = message.extras?.headers?.toolCallId;
-
- console.log(`\n\nAgent wants to execute: ${name}`);
- console.log(`Arguments: ${JSON.stringify(args, null, 2)}`);
-
- const answer = await new Promise((resolve) => {
- rl.question('Approve? (yes/no): ', resolve);
- });
-
- const decision = answer.toLowerCase() === 'yes' ? 'approved' : 'rejected';
-
- await channel.publish({
- name: 'approval-response',
- data: { decision },
- extras: { headers: { toolCallId } },
- });
-
- console.log(`Decision sent: ${decision}\n`);
-});
-```
-
-
-## Step 5: Send user prompts
-
-Each prompt includes a unique `promptId` to correlate responses. The user's `clientId` is automatically attached to the message by Ably.
-
-Add the following to the end of `client.ts`:
-
-
-```typescript
-// Subscribe to completion signals
-await channel.subscribe('agent-response-complete', (message: Ably.Message) => {
- const promptId = message.extras?.headers?.promptId;
- if (!promptId) return;
-
- console.log('\n');
- const resolve = pendingPrompts.get(promptId);
- if (resolve) {
- pendingPrompts.delete(promptId);
- resolve();
- }
-});
-
-async function sendPrompt(text: string): Promise {
- const promptId = crypto.randomUUID();
-
- const completionPromise = new Promise((resolve) => {
- pendingPrompts.set(promptId, resolve);
- });
-
- await channel.publish('user-input', {
- promptId,
- text,
- });
-
- await completionPromise;
-}
-
-function askQuestion() {
- rl.question('Enter a prompt (or "quit" to exit): ', async (text) => {
- if (text.toLowerCase() === 'quit') {
- rl.close();
- realtime.close();
- return;
- }
-
- await sendPrompt(text);
- askQuestion();
- });
-}
-
-askQuestion();
-```
-
-
-## Step 6: Run the example
-
-Open three terminal windows to run the auth server, agent, and client.
-
-Terminal 1: Start the auth server
-
-
-```shell
-npx tsx --env-file=.env auth-server.ts
-```
-
-
-You should see:
-
-
-```text
-Auth server running on http://localhost:3001
-```
-
-
-Terminal 2: Start the agent
-
-
-```shell
-npx tsx --env-file=.env agent.ts
-```
-
-
-You should see:
-
-
-```text
-Agent is listening for prompts...
-```
-
-
-Terminal 3: Run the client
-
-
-```shell
-npx tsx --env-file=.env client.ts
-```
-
-
-Try entering different prompts. For a regular response without tool calls:
-
-
-```text
-Enter a prompt (or "quit" to exit): What is the capital of France?
-
-The capital of France is Paris.
-
-Enter a prompt (or "quit" to exit):
-```
-
-
-For a response that triggers a tool call with human-in-the-loop approval:
-
-
-```text
-Enter a prompt (or "quit" to exit): Send an email to alice@example.com saying hello
-
-Agent wants to execute: send_email
-Arguments: {
- "to": "alice@example.com",
- "subject": "Hello",
- "body": "Hello Alice!"
-}
-Approve? (yes/no): yes
-Decision sent: approved
-
-I've sent the email to alice@example.com with the subject "Hello".
-
-Enter a prompt (or "quit" to exit):
-```
-
-
-## Next steps
-
-Continue exploring AI Transport features:
-
-* Learn about [token streaming patterns](/docs/ai-transport/token-streaming) including [message-per-response](/docs/ai-transport/token-streaming/message-per-response) and [message-per-token](/docs/ai-transport/token-streaming/message-per-token).
-* Understand [user input](/docs/ai-transport/messaging/accepting-user-input) patterns for handling prompts and correlating responses.
-* Explore [identifying users and agents](/docs/ai-transport/sessions-identity/identifying-users-and-agents) for more advanced authentication scenarios.
-* Implement more advanced [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) workflows with role-based authorization.
-* Stream [tool call](/docs/ai-transport/messaging/tool-calls) information to build generative UI experiences.
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 a4770f3a66..0498cc8e35 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
@@ -1,587 +1,6 @@
---
-title: "Getting started with Vercel AI SDK"
-meta_description: "Build a realtime AI agent with the Vercel AI SDK that streams tokens over Ably, handles tool calls with human-in-the-loop approval, and authenticates users with verified identities."
-meta_keywords: "AI Transport Vercel AI SDK, Ably AI agent, token streaming, realtime AI, LLM streaming, AI agent tutorial, human in the loop, HITL, tool calls, streamText"
+title: "Get started with Vercel AI SDK"
+meta_description: "Build your first AI Transport application using the Vercel AI SDK, with realtime token streaming and bidirectional communication."
---
-This guide will get you started with Ably AI Transport using the Vercel AI SDK.
-
-You'll learn how to authenticate users with verified identities, stream tokens from an agent to clients in realtime, and implement human-in-the-loop approval for tool calls. The agent uses the Vercel AI SDK with a `send_email` tool that requires user approval before execution.
-
-
-
-## Prerequisites
-
-1. [Sign up](https://ably.com/signup) for an Ably account.
-
-2. Create a [new app](https://ably.com/accounts/any/apps/new), and create your first API key in the **API Keys** tab of the dashboard.
-
-3. Your API key will need the `publish`, `subscribe`, and `message-update-own` capabilities.
-
-4. Enable message appends for the channel:
- 1. Go to the **Settings** tab of your app in the dashboard.
- 2. Under **Rules**, click **Add new rule**.
- 3. Enter `ai` as the channel namespace.
- 4. Check **Message annotations, updates, deletes, and appends**.
- 5. Click **Create channel rule** to save.
-
-5. Install any current LTS version of [Node.js](https://nodejs.org/en).
-
-6. Get an API key for your chosen model provider. This guide uses OpenAI via the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway), but you can use any [supported provider](https://ai-sdk.dev/providers/ai-sdk-providers).
-
-## Step 1: Project setup
-
-Create a new directory for your project and initialize it:
-
-
-```shell
-mkdir ai-agent-demo && cd ai-agent-demo
-npm init -y && npm pkg set type=module
-```
-
-
-Install the required dependencies:
-
-
-```shell
-npm install ably ai@^6 zod jsonwebtoken express
-npm install -D typescript @types/node @types/express @types/jsonwebtoken
-```
-
-
-Create a TypeScript configuration file:
-
-
-```shell
-npx tsc --init
-```
-
-
-Create a `.env` file in your project root and add your API keys:
-
-
-```shell
-echo "ABLY_API_KEY={{API_KEY}}" > .env
-echo "AI_GATEWAY_API_KEY=your_ai_gateway_api_key" >> .env
-```
-
-
-
-
-## Step 2: Authenticate users
-
-Users authenticate with Ably using [token authentication](/docs/auth/token). Your server generates signed JWTs that establish a verified identity for each user. Agents can trust this identity because only your server can issue valid tokens.
-
-Create a file called `auth-server.ts` with an endpoint that generates signed JWTs:
-
-
-```typescript
-import express from 'express';
-import jwt from 'jsonwebtoken';
-
-const app = express();
-
-const apiKey = process.env.ABLY_API_KEY;
-if (!apiKey) {
- throw new Error('ABLY_API_KEY environment variable is required');
-}
-
-const [keyName, keySecret] = apiKey.split(':');
-if (!keyName || !keySecret) {
- throw new Error('ABLY_API_KEY must be in format "keyName:keySecret"');
-}
-
-app.get('/api/auth/token', (req, res) => {
- // In production, authenticate the user and get their ID from your session
- const userId = 'user-123';
-
- const token = jwt.sign({
- 'x-ably-clientId': userId,
- 'ably.channel.*': 'user'
- }, keySecret, {
- algorithm: 'HS256',
- keyid: keyName,
- expiresIn: '1h'
- });
-
- res.type('application/jwt').send(token);
-});
-
-app.listen(3001, () => {
- console.log('Auth server running on http://localhost:3001');
-});
-```
-
-
-
-
-The JWT includes two claims:
-- `x-ably-clientId`: Establishes a verified identity that appears on all messages the user publishes.
-- `ably.channel.*`: Assigns a role that agents can use to distinguish users from other agents on the channel.
-
-
-
-## Step 3: Create the agent
-
-The agent runs in a trusted server environment and uses [API key authentication](/docs/auth#basic-authentication). It subscribes to a channel to receive user prompts, processes them with the Vercel AI SDK's `streamText`, and streams responses back using the [message-per-response](/docs/ai-transport/token-streaming/message-per-response) pattern. When the model requests a tool call, the agent pauses to request human approval before executing.
-
-Create a file called `agent.ts` with the setup, tool definition, and human-in-the-loop helpers:
-
-
-```typescript
-import * as Ably from 'ably';
-import { streamText, tool } from 'ai';
-import { z } from 'zod';
-
-const apiKey = process.env.ABLY_API_KEY;
-if (!apiKey) {
- throw new Error('ABLY_API_KEY environment variable is required');
-}
-
-const realtime = new Ably.Realtime({
- key: apiKey,
- clientId: 'ai-agent',
- echoMessages: false,
-});
-
-const channel = realtime.channels.get('ai:conversation');
-
-// Define a tool that requires human approval
-const sendEmailTool = tool({
- description: 'Send an email to a recipient. Always requires human approval.',
- inputSchema: z.object({
- to: z.string().describe('Recipient email address'),
- subject: z.string().describe('Email subject line'),
- body: z.string().describe('Email body content'),
- }),
-});
-
-// Track pending approval requests
-const pendingApprovals = new Map void>();
-
-// Listen for approval responses from users
-await channel.subscribe('approval-response', (message: Ably.Message) => {
- const toolCallId = message.extras?.headers?.toolCallId;
- const resolve = pendingApprovals.get(toolCallId);
- if (resolve) {
- pendingApprovals.delete(toolCallId);
- resolve(message.data.decision);
- }
-});
-
-// Request human approval for a tool call via the channel
-function requestApproval(
- toolCallId: string,
- toolName: string,
- toolInput: Record,
-): Promise {
- return new Promise((resolve) => {
- pendingApprovals.set(toolCallId, resolve);
- channel.publish({
- name: 'approval-request',
- data: { name: toolName, arguments: toolInput },
- extras: { headers: { toolCallId } },
- });
- console.log(`Awaiting approval for ${toolName} (${toolCallId})`);
- });
-}
-
-// Execute a tool after approval
-function executeTool(name: string, input: Record) {
- if (name === 'send_email') {
- console.log(`Sending email to ${input.to}: ${input.subject}`);
- return { success: true, message: `Email sent to ${input.to}` };
- }
- return { error: `Unknown tool: ${name}` };
-}
-```
-
-
-The agent publishes `approval-request` messages to the channel when a tool call is detected, then waits for a matching `approval-response` correlated by `toolCallId`. The `executeTool` function simulates the email action. In production, replace this with actual email delivery logic.
-
-
-
-Add the streaming function to `agent.ts`. This streams response tokens to Ably using `channel.appendMessage()`, while tracking any tool call the model requests:
-
-
-```typescript
-// Stream AI response tokens to Ably, returning tool call info if any
-async function streamToAbly(
- options: { prompt: string } | { messages: any[] },
- serial: string,
-) {
- const result = streamText({
- model: 'openai/gpt-4o',
- tools: { send_email: sendEmailTool },
- ...options,
- });
-
- let toolCallDetected: { toolCallId: string; toolName: string; args: Record } | null = null;
- let lastAppend: Promise | undefined;
-
- for await (const event of result.fullStream) {
- switch (event.type) {
- case 'text-delta':
- lastAppend = channel.appendMessage({ serial, data: event.text });
- break;
-
- case 'tool-call':
- toolCallDetected = {
- toolCallId: event.toolCallId,
- toolName: event.toolName,
- args: event.input as Record,
- };
- break;
- }
- }
-
- // Ensure the last appended token is delivered before signaling completion
- await lastAppend;
-
- return { toolCallDetected };
-}
-```
-
-
-The function iterates over `fullStream` events from `streamText`. It appends each `text-delta` token to the Ably message using `appendMessage` and captures `tool-call` events. The `toolCallDetected` object is returned so the prompt handler can process tool calls with HITL approval.
-
-Add the prompt handler to the end of `agent.ts`. This ties everything together, streaming the initial response and handling tool calls with HITL approval:
-
-
-```typescript
-// Handle incoming user prompts
-await channel.subscribe('user-input', async (message: Ably.Message) => {
- const { promptId, text } = message.data as { promptId: string; text: string };
- const userId = message.clientId;
- const role = message.extras?.userClaim;
-
- console.log(`Received prompt from ${userId} (role: ${role}): ${text}`);
-
- if (role !== 'user') {
- console.log('Ignoring message from non-user');
- return;
- }
-
- // Create the initial Ably message for streaming
- const response = await channel.publish({
- name: 'agent-response',
- data: '',
- extras: { headers: { promptId } },
- });
-
- const serial = response.serials[0];
- if (!serial) {
- console.error('No serial returned from publish');
- return;
- }
-
- // Stream the response
- const { toolCallDetected } = await streamToAbly({ prompt: text }, serial);
-
- // Handle tool call with human-in-the-loop approval
- if (toolCallDetected) {
- const decision = await requestApproval(
- toolCallDetected.toolCallId,
- toolCallDetected.toolName,
- toolCallDetected.args,
- );
-
- let toolResult: { type: string; value?: unknown; reason?: string };
- if (decision === 'approved') {
- toolResult = { type: 'json', value: executeTool(toolCallDetected.toolName, toolCallDetected.args) };
- } else {
- toolResult = { type: 'execution-denied', reason: 'The user rejected this action' };
- }
-
- // Stream follow-up response with the tool result
- channel.appendMessage({ serial, data: '\n\n' });
-
- await streamToAbly({
- messages: [
- { role: 'user', content: text },
- {
- role: 'assistant',
- content: [
- {
- type: 'tool-call',
- toolCallId: toolCallDetected.toolCallId,
- toolName: toolCallDetected.toolName,
- input: toolCallDetected.args,
- },
- ],
- },
- {
- role: 'tool',
- content: [
- {
- type: 'tool-result',
- toolCallId: toolCallDetected.toolCallId,
- toolName: toolCallDetected.toolName,
- output: toolResult,
- },
- ],
- },
- ],
- }, serial);
- }
-
- // Signal completion
- await channel.publish({
- name: 'agent-response-complete',
- extras: { headers: { promptId } },
- });
-
- console.log(`Completed response for prompt ${promptId}`);
-});
-
-console.log('Agent is listening for prompts...');
-```
-
-
-The prompt handler:
-1. Verifies the sender has the `user` role.
-2. Creates an initial Ably message and captures its `serial` for appending.
-3. Streams the response, appending text tokens in realtime.
-4. If the model requests a tool call, publishes an `approval-request` and waits for the user's decision.
-5. After approval, executes the tool and streams a follow-up response appended to the same message.
-
-## Step 4: Create the client
-
-The client uses an [`authCallback`](/docs/auth/token#auth-callback) to obtain a signed JWT from your auth server. The `clientId` from the token is automatically attached to all messages the client publishes.
-
-Create a file called `client.ts` with the connection setup and token streaming subscription:
-
-
-```typescript
-import * as Ably from 'ably';
-import crypto from 'crypto';
-import * as readline from 'readline';
-
-const realtime = new Ably.Realtime({
- authCallback: async (
- _tokenParams: Ably.TokenParams,
- callback: (error: Ably.ErrorInfo | string | null, token: Ably.TokenDetails | Ably.TokenRequest | string | null) => void
- ) => {
- try {
- const response = await fetch('http://localhost:3001/api/auth/token');
- const token = await response.text();
- callback(null, token);
- } catch (error) {
- callback(error instanceof Error ? error.message : String(error), null);
- }
- }
-});
-
-realtime.connection.on('connected', () => {
- console.log('Connected to Ably as', realtime.auth.clientId);
-});
-
-const channel = realtime.channels.get('ai:conversation');
-const pendingPrompts = new Map void>();
-
-const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
-});
-
-// Subscribe to streamed agent responses
-await channel.subscribe('agent-response', (message: Ably.Message) => {
- switch (message.action) {
- case 'message.create':
- break;
- case 'message.append':
- // Write each new token as it arrives
- process.stdout.write(message.data || '');
- break;
- case 'message.update':
- // Full response after reconnection
- console.log(message.data || '');
- break;
- }
-});
-```
-
-
-The client subscribes to `agent-response` messages and handles different [message actions](/docs/ai-transport/token-streaming/message-per-response):
-- `message.create`: A new response has started.
-- `message.append`: A token has been appended. Each token is written directly to the terminal as it arrives.
-- `message.update`: The full response content, received after reconnection.
-
-Add the human-in-the-loop approval handler to `client.ts`. When the agent requests approval for a tool call, the client displays the details and prompts the user:
-
-
-```typescript
-// Subscribe to approval requests for human-in-the-loop
-await channel.subscribe('approval-request', async (message: Ably.Message) => {
- const { name, arguments: args } = message.data;
- const toolCallId = message.extras?.headers?.toolCallId;
-
- console.log(`\n\nAgent wants to execute: ${name}`);
- console.log(`Arguments: ${JSON.stringify(args, null, 2)}`);
-
- const answer = await new Promise((resolve) => {
- rl.question('Approve? (yes/no): ', resolve);
- });
-
- const decision = answer.toLowerCase() === 'yes' ? 'approved' : 'rejected';
-
- await channel.publish({
- name: 'approval-response',
- data: { decision },
- extras: { headers: { toolCallId } },
- });
-
- console.log(`Decision sent: ${decision}\n`);
-});
-```
-
-
-## Step 5: Send user prompts
-
-Each prompt includes a unique `promptId` to correlate responses. The user's `clientId` is automatically attached to the message by Ably.
-
-Add the following to the end of `client.ts`:
-
-
-```typescript
-// Subscribe to completion signals
-await channel.subscribe('agent-response-complete', (message: Ably.Message) => {
- const promptId = message.extras?.headers?.promptId;
- if (!promptId) return;
-
- console.log('\n');
- const resolve = pendingPrompts.get(promptId);
- if (resolve) {
- pendingPrompts.delete(promptId);
- resolve();
- }
-});
-
-async function sendPrompt(text: string): Promise {
- const promptId = crypto.randomUUID();
-
- const completionPromise = new Promise((resolve) => {
- pendingPrompts.set(promptId, resolve);
- });
-
- await channel.publish('user-input', {
- promptId,
- text,
- });
-
- await completionPromise;
-}
-
-function askQuestion() {
- rl.question('Enter a prompt (or "quit" to exit): ', async (text) => {
- if (text.toLowerCase() === 'quit') {
- rl.close();
- realtime.close();
- return;
- }
-
- await sendPrompt(text);
- askQuestion();
- });
-}
-
-askQuestion();
-```
-
-
-## Step 6: Run the example
-
-Open three terminal windows to run the auth server, agent, and client.
-
-Terminal 1: Start the auth server
-
-
-```shell
-npx tsx --env-file=.env auth-server.ts
-```
-
-
-You should see:
-
-
-```text
-Auth server running on http://localhost:3001
-```
-
-
-Terminal 2: Start the agent
-
-
-```shell
-npx tsx --env-file=.env agent.ts
-```
-
-
-You should see:
-
-
-```text
-Agent is listening for prompts...
-```
-
-
-Terminal 3: Run the client
-
-
-```shell
-npx tsx --env-file=.env client.ts
-```
-
-
-Try entering different prompts. For a regular response without tool calls:
-
-
-```text
-Enter a prompt (or "quit" to exit): What is the capital of France?
-
-The capital of France is Paris.
-
-Enter a prompt (or "quit" to exit):
-```
-
-
-For a response that triggers a tool call with human-in-the-loop approval:
-
-
-```text
-Enter a prompt (or "quit" to exit): Send an email to alice@example.com saying hello
-
-Agent wants to execute: send_email
-Arguments: {
- "to": "alice@example.com",
- "subject": "Hello",
- "body": "Hello Alice!"
-}
-Approve? (yes/no): yes
-Decision sent: approved
-
-I've sent the email to alice@example.com with the subject "Hello".
-
-Enter a prompt (or "quit" to exit):
-```
-
-
-## Next steps
-
-Continue exploring AI Transport features:
-
-* Learn about [token streaming patterns](/docs/ai-transport/token-streaming) including [message-per-response](/docs/ai-transport/token-streaming/message-per-response) and [message-per-token](/docs/ai-transport/token-streaming/message-per-token).
-* Understand [user input](/docs/ai-transport/messaging/accepting-user-input) patterns for handling prompts and correlating responses.
-* Explore [identifying users and agents](/docs/ai-transport/sessions-identity/identifying-users-and-agents) for more advanced authentication scenarios.
-* Implement more advanced [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) workflows with role-based authorization.
-* Stream [tool call](/docs/ai-transport/messaging/tool-calls) information to build generative UI experiences.
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/going-to-production/compliance.mdx b/src/pages/docs/ai-transport/going-to-production/compliance.mdx
new file mode 100644
index 0000000000..4aa7a9c960
--- /dev/null
+++ b/src/pages/docs/ai-transport/going-to-production/compliance.mdx
@@ -0,0 +1,6 @@
+---
+title: "Compliance"
+meta_description: "Learn about AI Transport compliance certifications, data residency options, and security features."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/going-to-production/limits.mdx b/src/pages/docs/ai-transport/going-to-production/limits.mdx
new file mode 100644
index 0000000000..145d4f3a0b
--- /dev/null
+++ b/src/pages/docs/ai-transport/going-to-production/limits.mdx
@@ -0,0 +1,8 @@
+---
+title: "Limits"
+meta_description: "Understand AI Transport rate limits, connection limits, and message size limits for production applications."
+redirect_from:
+ - /docs/ai-transport/token-streaming/token-rate-limits
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/going-to-production/monitoring-and-observability.mdx b/src/pages/docs/ai-transport/going-to-production/monitoring-and-observability.mdx
new file mode 100644
index 0000000000..5c091e1a80
--- /dev/null
+++ b/src/pages/docs/ai-transport/going-to-production/monitoring-and-observability.mdx
@@ -0,0 +1,6 @@
+---
+title: "Monitoring and observability"
+meta_description: "Monitor your AI Transport application with built-in metrics, logging, and observability integrations."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/going-to-production/pricing-and-cost-control.mdx b/src/pages/docs/ai-transport/going-to-production/pricing-and-cost-control.mdx
new file mode 100644
index 0000000000..e77c8da44a
--- /dev/null
+++ b/src/pages/docs/ai-transport/going-to-production/pricing-and-cost-control.mdx
@@ -0,0 +1,6 @@
+---
+title: "Pricing and cost control"
+meta_description: "Understand AI Transport pricing and learn how to control costs with rate limiting, session management, and usage monitoring."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/going-to-production/production-checklist.mdx b/src/pages/docs/ai-transport/going-to-production/production-checklist.mdx
new file mode 100644
index 0000000000..e1176d4635
--- /dev/null
+++ b/src/pages/docs/ai-transport/going-to-production/production-checklist.mdx
@@ -0,0 +1,6 @@
+---
+title: "Production checklist"
+meta_description: "Follow the AI Transport production checklist to ensure your application is ready for launch."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/guides/anthropic/anthropic-citations.mdx b/src/pages/docs/ai-transport/guides/anthropic/anthropic-citations.mdx
deleted file mode 100644
index 467abe1676..0000000000
--- a/src/pages/docs/ai-transport/guides/anthropic/anthropic-citations.mdx
+++ /dev/null
@@ -1,566 +0,0 @@
----
-title: "Guide: Attach citations to Anthropic responses using message annotations"
-meta_description: "Attach source citations to AI responses from the Anthropic Messages API using Ably message annotations."
-meta_keywords: "AI, citations, Anthropic, Claude, Messages API, AI transport, Ably, realtime, message annotations, source attribution"
-redirect_from:
- - /docs/guides/ai-transport/anthropic/anthropic-citations
----
-
-This guide shows you how to attach source citations to AI responses from Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) using Ably [message annotations](/docs/messages/annotations). When Anthropic provides citations from documents or search results, you can publish them as annotations on Ably messages, enabling clients to display source references alongside AI responses in realtime.
-
-Attaching citations to AI responses enables your users to see the original sources that were used in the generated response, explore topics in depth, and properly attribute the source content creators. Citations provide explicit traceability between the generated response and the information sources that were used when generating them.
-
-Ably [message annotations](/docs/messages/annotations) let you separate citation metadata from response content, display citation summaries updated in realtime, and retrieve detailed citation data on demand.
-
-
-
-## Prerequisites
-
-To follow this guide, you need:
-- Node.js 20 or higher
-- An Anthropic API key
-- An Ably API key
-
-Useful links:
-- [Anthropic Citations documentation](https://docs.anthropic.com/en/docs/build-with-claude/citations)
-- [Ably JavaScript SDK getting started](/docs/getting-started/javascript)
-
-Create a new Node project, which will contain the agent and client code:
-
-
-```shell
-mkdir ably-anthropic-citations && cd ably-anthropic-citations
-npm init -y
-```
-
-
-Install the required packages using NPM:
-
-
-```shell
-npm install @anthropic-ai/sdk@^0.71 ably@^2
-```
-
-
-
-
-Export your Anthropic API key to the environment, which will be used later in the guide by the Anthropic SDK:
-
-
-```shell
-export ANTHROPIC_API_KEY="your_api_key_here"
-```
-
-
-## Step 1: Enable message annotations
-
-Message annotations require "Message annotations, updates, deletes and appends" to be enabled in a [rule](/docs/channels#rules) associated with the channel.
-
-
-
-To enable the rule:
-
-1. Go to the [Ably dashboard](https://www.ably.com/dashboard) and select your app.
-2. Navigate to the "Configuration" > "Rules" section from the left-hand navigation bar.
-3. Choose "Add new rule".
-4. Enter a channel name or namespace pattern (e.g. `ai` for all channels starting with `ai:`).
-5. Select the "Message annotations, updates, deletes and appends" option from the list.
-6. Click "Create rule".
-
-The examples in this guide use the `ai:` namespace prefix, which assumes you have configured the rule for `ai:*`.
-
-
-
-## Step 2: Get a response with citations from Anthropic
-
-Initialize an Anthropic client and use the [Messages API](https://docs.anthropic.com/en/api/messages) with citations enabled. Anthropic supports citations from documents, PDFs, and search results.
-
-Create a new file `agent.mjs` with the following contents:
-
-
-```javascript
-import Ably from 'ably';
-import Anthropic from '@anthropic-ai/sdk';
-
-// Initialize Anthropic client
-const anthropic = new Anthropic();
-
-// Create a response with citations enabled
-async function getAnthropicResponseWithCitations(question, documentContent) {
- const response = await anthropic.messages.create({
- model: "claude-sonnet-4-5",
- max_tokens: 1024,
- messages: [
- {
- role: "user",
- content: [
- {
- type: "document",
- source: {
- type: "text",
- media_type: "text/plain",
- data: documentContent
- },
- title: "Source Document",
- citations: { enabled: true }
- },
- {
- type: "text",
- text: question
- }
- ]
- }
- ]
- });
-
- console.log(JSON.stringify(response, null, 2));
-}
-
-// Usage example
-const document = `The James Webb Space Telescope (JWST) launched on December 25, 2021.
-It is the largest optical telescope in space and is designed to conduct infrared astronomy.
-The telescope's first full-color images were released on July 12, 2022, revealing unprecedented
-details of distant galaxies, nebulae, and exoplanet atmospheres.`;
-
-getAnthropicResponseWithCitations(
- "What are the latest discoveries from the James Webb Space Telescope?",
- document
-);
-```
-
-
-### Understand Anthropic citation responses
-
-When citations are enabled, Anthropic's Messages API returns responses with multiple text blocks. Each text block can include a `citations` array containing references to specific locations in the source documents.
-
-The following example shows the response structure when citations are included:
-
-
-```json
-{
- "content": [
- {
- "type": "text",
- "text": "The James Webb Space Telescope launched on "
- },
- {
- "type": "text",
- "text": "December 25, 2021",
- "citations": [{
- "type": "char_location",
- "cited_text": "The James Webb Space Telescope (JWST) launched on December 25, 2021.",
- "document_index": 0,
- "document_title": "Source Document",
- "start_char_index": 0,
- "end_char_index": 68
- }]
- },
- {
- "type": "text",
- "text": ". Its first full-color images were released on "
- },
- {
- "type": "text",
- "text": "July 12, 2022",
- "citations": [{
- "type": "char_location",
- "cited_text": "The telescope's first full-color images were released on July 12, 2022",
- "document_index": 0,
- "document_title": "Source Document",
- "start_char_index": 185,
- "end_char_index": 255
- }]
- }
- ]
-}
-```
-
-
-Each citation includes:
-
-- `type`: The citation type (`char_location` for plain text, `page_location` for PDFs, `content_block_location` for custom content, or `search_result_location` for search results).
-- `cited_text`: The exact text being cited from the source.
-- `document_index`: The index of the source document (0-indexed).
-- `document_title`: The title of the source document.
-- Location fields: Character indices, page numbers, or block indices depending on the citation type.
-
-
-
-## Step 3: Publish response and citations to Ably
-
-Publish the AI response as an Ably message, then publish each citation as a message annotation referencing the response message's `serial`.
-
-### Initialize the Ably client
-
-Add the Ably import and client initialization to your `agent.mjs` file:
-
-
-```javascript
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- echoMessages: false
-});
-
-// Create a channel for publishing AI responses
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-```
-
-
-The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish messages with low latency.
-
-
-
-### Publish response and citations
-
-Add a `processResponse` function to extract the full text and citations from the Anthropic response, then publish them to Ably. Update `getAnthropicResponseWithCitations` to call it by replacing the `console.log` line with `await processResponse(response);`:
-
-
-```javascript
-// Process response and publish to Ably
-async function processResponse(response) {
- let fullText = '';
- const citations = [];
- let currentOffset = 0;
-
- // Extract text and citations from response
- for (const block of response.content) {
- if (block.type === 'text') {
- const text = block.text;
-
- if (block.citations) {
- for (const citation of block.citations) {
- citations.push({
- ...citation,
- // Track position in the full response text
- responseStartOffset: currentOffset,
- responseEndOffset: currentOffset + text.length
- });
- }
- }
-
- fullText += text;
- currentOffset += text.length;
- }
- }
-
- // Publish the AI response message
- const { serials: [msgSerial] } = await channel.publish('response', fullText);
- console.log('Published response with serial:', msgSerial);
-
- // Publish each citation as an annotation
- for (const citation of citations) {
- let sourceDomain;
- try {
- sourceDomain = citation.source ? new URL(citation.source).hostname : citation.document_title;
- } catch {
- sourceDomain = citation.document_title || 'document';
- }
-
- await channel.annotations.publish(msgSerial, {
- type: 'citations:multiple.v1',
- name: sourceDomain,
- data: {
- title: citation.document_title,
- citedText: citation.cited_text,
- citationType: citation.type,
- startOffset: citation.responseStartOffset,
- endOffset: citation.responseEndOffset,
- documentIndex: citation.document_index,
- ...(citation.start_char_index !== undefined && {
- startCharIndex: citation.start_char_index,
- endCharIndex: citation.end_char_index
- }),
- ...(citation.start_page_number !== undefined && {
- startPageNumber: citation.start_page_number,
- endPageNumber: citation.end_page_number
- })
- }
- });
- }
-
- console.log(`Published ${citations.length} citation(s)`);
-}
-```
-
-
-This implementation:
-
-- Extracts the full response text by concatenating all text blocks
-- Tracks the position of each citation within the full response
-- Publishes the response as a single Ably message and captures its `serial`
-- Publishes each citation as an annotation using the [`multiple.v1`](/docs/messages/annotations#multiple) summarization method
-- Uses the source domain as the annotation `name` for grouping in summaries
-
-
-
-Run the publisher to see responses and citations published to Ably:
-
-
-```shell
-node agent.mjs
-```
-
-
-## Step 4: Subscribe to citation summaries
-
-Create a subscriber that receives AI responses and citation summaries in realtime.
-
-Create a new file `client.mjs` with the following contents:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({ key: '{{API_KEY}}' });
-
-// Get the same channel used by the publisher
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses
-const responses = new Map();
-
-// Subscribe to receive messages and summaries
-await channel.subscribe((message) => {
- switch (message.action) {
- case 'message.create':
- console.log('\n[New response]');
- console.log('Serial:', message.serial);
- console.log('Content:', message.data);
- responses.set(message.serial, { content: message.data, citations: {} });
- break;
-
- case 'message.summary':
- const citationsSummary = message.annotations?.summary['citations:multiple.v1'];
- if (citationsSummary) {
- console.log('\n[Citation summary updated]');
- for (const [source, data] of Object.entries(citationsSummary)) {
- console.log(` ${source}: ${data.total} citation(s)`);
- }
- }
- break;
- }
-});
-
-console.log('Subscriber ready, waiting for responses and citations...');
-```
-
-
-Run the subscriber in a separate terminal:
-
-
-```shell
-node client.mjs
-```
-
-
-With the subscriber running, run the publisher in another terminal. You'll see the response appear followed by citation summary updates showing counts grouped by source document.
-
-## Step 5: Subscribe to individual citations
-
-To access the full citation data for rendering source links or inline markers, subscribe to individual annotation events.
-
-Create a new file `citation-client.mjs` with the following contents:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({ key: '{{API_KEY}}' });
-
-// Get the channel with annotation subscription enabled
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}', {
- modes: ['SUBSCRIBE', 'ANNOTATION_SUBSCRIBE']
-});
-
-// Track responses and their citations
-const responses = new Map();
-
-// Subscribe to messages
-await channel.subscribe((message) => {
- if (message.action === 'message.create') {
- console.log('\n[New response]');
- console.log('Serial:', message.serial);
- console.log('Content:', message.data);
- responses.set(message.serial, { content: message.data, citations: [] });
- }
-});
-
-// Subscribe to individual citation annotations
-await channel.annotations.subscribe((annotation) => {
- if (annotation.action === 'annotation.create' &&
- annotation.type === 'citations:multiple.v1') {
- const { title, citedText, citationType, documentIndex } = annotation.data;
-
- console.log('\n[Citation received]');
- console.log(` Source: ${title}`);
- console.log(` Type: ${citationType}`);
- console.log(` Document index: ${documentIndex}`);
- console.log(` Cited text: "${citedText}"`);
-
- // Store citation for the response
- const response = responses.get(annotation.messageSerial);
- if (response) {
- response.citations.push(annotation.data);
- }
- }
-});
-
-console.log('Subscriber ready, waiting for responses and citations...');
-```
-
-
-Run the citation subscriber:
-
-
-```shell
-node citation-client.mjs
-```
-
-
-This subscriber receives the full citation data as each annotation arrives, enabling you to:
-
-- Display the source document title
-- Show the exact text that was cited from each source
-- Highlight cited portions of the response text using the offset positions
-
-## Step 6: Combine with streaming responses
-
-You can combine citations with the [message-per-response](/docs/ai-transport/token-streaming/message-per-response) streaming pattern. Since Anthropic includes `citations_delta` events when streaming, you can publish citations as annotations while the response is still being streamed.
-
-
-```javascript
-import Anthropic from '@anthropic-ai/sdk';
-import Ably from 'ably';
-
-const anthropic = new Anthropic();
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- echoMessages: false
-});
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track state for streaming
-let msgSerial = null;
-let currentBlockIndex = null;
-let currentOffset = 0;
-
-// Process streaming events
-async function processStreamEvent(event) {
- switch (event.type) {
- case 'message_start':
- // Publish initial empty message
- const result = await channel.publish({ name: 'response', data: '' });
- msgSerial = result.serials[0];
- currentOffset = 0;
- break;
-
- case 'content_block_start':
- if (event.content_block.type === 'text') {
- currentBlockIndex = event.index;
- }
- break;
-
- case 'content_block_delta':
- if (event.index === currentBlockIndex) {
- if (event.delta.type === 'text_delta') {
- // Append text token
- channel.appendMessage({ serial: msgSerial, data: event.delta.text });
- currentOffset += event.delta.text.length;
- } else if (event.delta.type === 'citations_delta') {
- // Publish citation annotation
- const citation = event.delta.citation;
- let sourceDomain;
- try {
- sourceDomain = new URL(citation.source || '').hostname;
- } catch {
- sourceDomain = citation.document_title || 'document';
- }
-
- await channel.annotations.publish(msgSerial, {
- type: 'citations:multiple.v1',
- name: sourceDomain,
- data: {
- title: citation.document_title,
- citedText: citation.cited_text,
- citationType: citation.type,
- documentIndex: citation.document_index
- }
- });
- }
- }
- break;
-
- case 'message_stop':
- console.log('Stream completed!');
- break;
- }
-}
-
-// Stream response with citations
-async function streamWithCitations(question, documentContent) {
- const stream = await anthropic.messages.create({
- model: "claude-sonnet-4-5",
- max_tokens: 1024,
- stream: true,
- messages: [
- {
- role: "user",
- content: [
- {
- type: "document",
- source: {
- type: "text",
- media_type: "text/plain",
- data: documentContent
- },
- title: "Source Document",
- citations: { enabled: true }
- },
- {
- type: "text",
- text: question
- }
- ]
- }
- ]
- });
-
- for await (const event of stream) {
- await processStreamEvent(event);
- }
-}
-
-// Example usage
-const document = "The James Webb Space Telescope (JWST) launched on December 25, 2021. It is the largest optical telescope in space and is designed to conduct infrared astronomy. The telescope's first full-color images were released on July 12, 2022, revealing unprecedented details of distant galaxies, nebulae, and exoplanet atmospheres.";
-
-await streamWithCitations("What are the latest discoveries from the James Webb Space Telescope?", document);
-```
-
-
-
-
-## Next steps
-
-- Learn more about [citations and message annotations](/docs/ai-transport/messaging/citations)
-- Explore [annotation summaries](/docs/messages/annotations#annotation-summaries) for displaying citation counts
-- Understand how to [retrieve annotations on demand](/docs/messages/annotations#rest-api) via the REST API
-- Combine with [message-per-response streaming](/docs/ai-transport/token-streaming/message-per-response) for live token delivery
diff --git a/src/pages/docs/ai-transport/guides/anthropic/anthropic-human-in-the-loop.mdx b/src/pages/docs/ai-transport/guides/anthropic/anthropic-human-in-the-loop.mdx
deleted file mode 100644
index 5be5036d54..0000000000
--- a/src/pages/docs/ai-transport/guides/anthropic/anthropic-human-in-the-loop.mdx
+++ /dev/null
@@ -1,417 +0,0 @@
----
-title: "Guide: Human-in-the-loop approval with Anthropic"
-meta_description: "Implement human approval workflows for AI agent tool calls using Anthropic and Ably with role-based access control."
-meta_keywords: "AI, human in the loop, HITL, Anthropic, Claude, tool use, approval workflow, AI transport, Ably, realtime, RBAC"
-redirect_from:
- - /docs/guides/ai-transport/anthropic/anthropic-human-in-the-loop
----
-
-This guide shows you how to implement a human-in-the-loop (HITL) approval workflow for AI agent tool calls using Anthropic and Ably. The agent requests human approval before executing sensitive operations, with role-based access control to verify approvers have sufficient permissions.
-
-When the model calls a tool that requires human approval, the tool implementation itself handles the approval check before executing. Rather than executing immediately, the tool publishes an `approval-request` message to an Ably channel, waits for an `approval-response` from a human approver, verifies the approver has the required role using [claims embedded in their JWT token](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims), and only then executes the action. The model calls the tool as normal, and the approval logic lives inside the tool's implementation.
-
-
-
-## Prerequisites
-
-To follow this guide, you need:
-- Node.js 20 or higher
-- An Anthropic API key
-- An Ably API key
-
-Useful links:
-- [Anthropic tool use guide](https://docs.anthropic.com/en/docs/build-with-claude/tool-use/overview)
-- [Ably JavaScript SDK getting started](/docs/getting-started/javascript)
-
-Create a new Node project, which will contain the agent, client, and server code:
-
-
-```shell
-mkdir ably-anthropic-hitl-example && cd ably-anthropic-hitl-example
-npm init -y
-```
-
-
-Install the required packages using NPM:
-
-
-```shell
-npm install @anthropic-ai/sdk@^0.71 ably@^2 express jsonwebtoken
-```
-
-
-
-
-Export your API keys to the environment:
-
-
-```shell
-export ANTHROPIC_API_KEY="your_anthropic_api_key_here"
-export ABLY_API_KEY="your_ably_api_key_here"
-```
-
-
-## Step 1: Initialize the agent
-
-Set up the agent that will call Anthropic and request human approval for sensitive operations. This example uses a `publish_blog_post` tool that requires authorization before execution.
-
-Initialize the Anthropic and Ably clients, and create a channel for communication between the agent and human approvers.
-
-Add the following to a new file called `agent.mjs`:
-
-
-```javascript
-import Anthropic from '@anthropic-ai/sdk';
-import Ably from 'ably';
-
-const anthropic = new Anthropic();
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: process.env.ABLY_API_KEY,
- echoMessages: false
-});
-
-// Wait for connection to be established
-await realtime.connection.once('connected');
-
-// Create a channel for HITL communication
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track pending approval requests
-const pendingApprovals = new Map();
-
-// Function that executes the approved action
-async function publishBlogPost(args) {
- const { title } = args;
- console.log(`Publishing blog post: ${title}`);
- // In production, this would call your CMS API
- return { published: true, title };
-}
-```
-
-
-
-
-Tools that modify data, access sensitive resources, or perform actions with business impact are good candidates for HITL approval workflows.
-
-## Step 2: Request human approval
-
-When the model returns a tool use block, publish an approval request to the channel and wait for a human decision. The tool use ID is passed in the message headers to correlate requests with responses.
-
-Add the approval request function to `agent.mjs`:
-
-
-```javascript
-async function requestHumanApproval(toolUse) {
- const approvalPromise = new Promise((resolve, reject) => {
- pendingApprovals.set(toolUse.id, { toolUse, resolve, reject });
- });
-
- await channel.publish({
- name: 'approval-request',
- data: {
- tool: toolUse.name,
- arguments: toolUse.input
- },
- extras: {
- headers: {
- toolCallId: toolUse.id
- }
- }
- });
-
- console.log(`Approval request sent for: ${toolUse.name}`);
- return approvalPromise;
-}
-```
-
-
-The `toolUse.id` provided by Anthropic correlates the approval request with the response, enabling the agent to handle multiple concurrent approval flows.
-
-## Step 3: Subscribe to approval responses
-
-Set up a subscription to receive approval decisions from human users. When a response arrives, verify the approver has sufficient permissions using role-based access control before resolving the pending promise.
-
-Add the subscription handler to `agent.mjs`:
-
-
-```javascript
-async function subscribeApprovalResponses() {
- // Define role hierarchy from lowest to highest privilege
- const roleHierarchy = ['editor', 'publisher', 'admin'];
-
- // Define minimum role required for each tool
- const approvalPolicies = {
- publish_blog_post: { minRole: 'publisher' }
- };
-
- function canApprove(approverRole, requiredRole) {
- const approverLevel = roleHierarchy.indexOf(approverRole);
- const requiredLevel = roleHierarchy.indexOf(requiredRole);
- return approverLevel >= requiredLevel;
- }
-
- await channel.subscribe('approval-response', async (message) => {
- const { decision } = message.data;
- const toolCallId = message.extras?.headers?.toolCallId;
- const pending = pendingApprovals.get(toolCallId);
-
- if (!pending) {
- console.log(`No pending approval for tool call: ${toolCallId}`);
- return;
- }
-
- const policy = approvalPolicies[pending.toolUse.name];
- // Get the trusted role from the JWT user claim
- const approverRole = message.extras?.userClaim;
-
- // Verify the approver's role meets the minimum required
- if (!canApprove(approverRole, policy.minRole)) {
- console.log(`Insufficient role: ${approverRole} < ${policy.minRole}`);
- pending.reject(new Error(
- `Approver role '${approverRole}' insufficient for required '${policy.minRole}'`
- ));
- pendingApprovals.delete(toolCallId);
- return;
- }
-
- // Process the decision
- if (decision === 'approved') {
- console.log(`Approved by ${approverRole}`);
- pending.resolve({ approved: true, approverRole });
- } else {
- console.log(`Rejected by ${approverRole}`);
- pending.reject(new Error(`Action rejected by ${approverRole}`));
- }
- pendingApprovals.delete(toolCallId);
- });
-}
-```
-
-
-The `message.extras.userClaim` contains the role embedded in the approver's JWT token, providing a trusted source for authorization decisions. See [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) for details on embedding claims in tokens. This ensures only users with sufficient privileges can approve sensitive operations.
-
-## Step 4: Process tool calls
-
-Create a function to process tool use blocks by requesting approval and executing the action if approved.
-
-Add the tool processing function to `agent.mjs`:
-
-
-```javascript
-async function processToolUse(toolUse) {
- if (toolUse.name === 'publish_blog_post') {
- // requestHumanApproval returns a promise that resolves when the human
- // approves the tool use, or rejects if the human explicitly rejects
- // the tool call or the approver's role is insufficient.
- await requestHumanApproval(toolUse);
- return await publishBlogPost(toolUse.input);
- }
- throw new Error(`Unknown tool: ${toolUse.name}`);
-}
-```
-
-
-The function awaits approval before executing. If the approver rejects or has insufficient permissions, the promise rejects and the tool is not executed.
-
-## Step 5: Run the agent
-
-Create the main agent loop that sends prompts to the model and processes any tool use blocks that require approval.
-
-Add the agent runner to `agent.mjs`:
-
-
-```javascript
-async function runAgent(prompt) {
- await subscribeApprovalResponses();
-
- console.log(`User: ${prompt}`);
-
- const response = await anthropic.messages.create({
- model: 'claude-sonnet-4-5',
- max_tokens: 1024,
- tools: [
- {
- name: 'publish_blog_post',
- description: 'Publish a blog post to the website. Requires human approval.',
- input_schema: {
- type: 'object',
- properties: {
- title: {
- type: 'string',
- description: 'Title of the blog post to publish'
- }
- },
- required: ['title']
- }
- }
- ],
- messages: [{ role: 'user', content: prompt }]
- });
-
- const toolUseBlocks = response.content.filter(block => block.type === 'tool_use');
-
- for (const toolUse of toolUseBlocks) {
- console.log(`Tool use: ${toolUse.name}`);
- try {
- const result = await processToolUse(toolUse);
- console.log('Result:', result);
- } catch (err) {
- console.error('Tool use failed:', err.message);
- }
- }
-}
-
-runAgent("Publish the blog post called 'Introducing our new API'");
-```
-
-
-## Step 6: Create the authentication server
-
-The authentication server issues JWT tokens with embedded role claims. The role claim is trusted by Ably and included in messages, enabling secure role-based authorization.
-
-Add the following to a new file called `server.mjs`:
-
-
-```javascript
-import express from 'express';
-import jwt from 'jsonwebtoken';
-
-const app = express();
-
-// Mock authentication - replace with your actual auth logic
-function authenticateUser(req, res, next) {
- // In production, verify the user's session/credentials
- req.user = { id: 'user123', role: 'publisher' };
- next();
-}
-
-// Return claims to embed in the JWT
-function getJWTClaims(user) {
- return {
- 'ably.channel.*': user.role
- };
-}
-
-app.get('/api/auth/token', authenticateUser, (req, res) => {
- const [keyName, keySecret] = process.env.ABLY_API_KEY.split(':');
-
- const token = jwt.sign(getJWTClaims(req.user), keySecret, {
- algorithm: 'HS256',
- keyid: keyName,
- expiresIn: '1h'
- });
-
- res.type('application/jwt').send(token);
-});
-
-app.listen(3001, () => {
- console.log('Auth server running on http://localhost:3001');
-});
-```
-
-
-The `ably.channel.*` claim embeds the user's role in the JWT. When the user publishes messages, this claim is available as `message.extras.userClaim`, providing a trusted source for authorization.
-
-Run the server:
-
-
-```shell
-node server.mjs
-```
-
-
-## Step 7: Create the approval client
-
-The approval client receives approval requests and allows humans to approve or reject them. It authenticates via the server to obtain a JWT with the user's role.
-
-Add the following to a new file called `client.mjs`:
-
-
-```javascript
-import Ably from 'ably';
-import readline from 'readline';
-
-const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout
-});
-
-const realtime = new Ably.Realtime({
- authCallback: async (tokenParams, callback) => {
- try {
- const response = await fetch('http://localhost:3001/api/auth/token');
- const token = await response.text();
- callback(null, token);
- } catch (error) {
- callback(error, null);
- }
- }
-});
-
-realtime.connection.on('connected', () => {
- console.log('Connected to Ably');
- console.log('Waiting for approval requests...\n');
-});
-
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-await channel.subscribe('approval-request', (message) => {
- const request = message.data;
-
- console.log('\n========================================');
- console.log('APPROVAL REQUEST');
- console.log('========================================');
- console.log(`Tool: ${request.tool}`);
- console.log(`Arguments: ${JSON.stringify(request.arguments, null, 2)}`);
- console.log('========================================');
-
- rl.question('Approve this action? (y/n): ', async (answer) => {
- const decision = answer.toLowerCase() === 'y' ? 'approved' : 'rejected';
-
- await channel.publish({
- name: 'approval-response',
- data: { decision },
- extras: {
- headers: {
- toolCallId: message.extras?.headers?.toolCallId
- }
- }
- });
-
- console.log(`Decision sent: ${decision}\n`);
- });
-});
-```
-
-
-Run the client in a separate terminal:
-
-
-```shell
-node client.mjs
-```
-
-
-With the server, client, and agent running, the workflow proceeds as follows:
-
-1. The agent sends a prompt to the model that triggers a tool use
-2. The agent publishes an approval request to the channel
-3. The client displays the request and prompts the user
-4. The user approves or rejects the request
-5. The agent verifies the approver's role meets the minimum requirement
-6. If approved and authorized, the agent executes the tool
-
-## Next steps
-
-- Learn more about [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) patterns and verification strategies
-- Explore [identifying users and agents](/docs/ai-transport/sessions-identity/identifying-users-and-agents) for secure identity verification
-- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI-enabled applications
-- Learn about [tool calls](/docs/ai-transport/messaging/tool-calls) for agent-to-agent communication
diff --git a/src/pages/docs/ai-transport/guides/anthropic/anthropic-message-per-response.mdx b/src/pages/docs/ai-transport/guides/anthropic/anthropic-message-per-response.mdx
deleted file mode 100644
index 3f9bc5fd22..0000000000
--- a/src/pages/docs/ai-transport/guides/anthropic/anthropic-message-per-response.mdx
+++ /dev/null
@@ -1,1052 +0,0 @@
----
-title: "Guide: Stream Anthropic responses using the message-per-response pattern"
-meta_description: "Stream tokens from the Anthropic Messages API over Ably in realtime using message appends."
-meta_keywords: "AI, token streaming, Anthropic, Claude, Messages API, AI transport, Ably, realtime, message appends"
-redirect_from:
- - /docs/guides/ai-transport/anthropic-message-per-response
- - /docs/guides/ai-transport/anthropic/anthropic-message-per-response
----
-
-This guide shows you how to stream AI responses from Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) over Ably using the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response). Specifically, it appends each response token to a single Ably message, creating a complete AI response that grows incrementally while delivering tokens in realtime.
-
-Using Ably to distribute tokens from the Anthropic SDK enables you to broadcast AI responses to thousands of concurrent subscribers with reliable message delivery and ordering guarantees. This approach stores each complete response as a single message in channel history, making it easy to retrieve conversation history without processing thousands of individual token messages.
-
-
-
-## Prerequisites
-
-
-Node.js 20 or higher is required.
-
-
-Python 3.8 or higher is required.
-
-
-Java 8 or higher is required.
-
-
-Xcode 15 or higher is required.
-
-
-You also need:
-- An Anthropic API key
-- An Ably API key
-
-Useful links:
-- [Anthropic API documentation](https://docs.anthropic.com/en/api)
-- [Token streaming overview](/docs/ai-transport/token-streaming)
-- [AI Transport overview](/docs/ai-transport)
-
-### Agent setup
-
-
-Create a new Node project for the agent code:
-
-
-```shell
-mkdir ably-anthropic-agent && cd ably-anthropic-agent
-npm init -y
-npm install @anthropic-ai/sdk ably
-```
-
-
-
-
-Create a new directory and install the required packages:
-
-
-```shell
-mkdir ably-anthropic-agent && cd ably-anthropic-agent
-pip install anthropic ably
-```
-
-
-
-
-Create a new project and add the required dependencies.
-
-For Maven, add to your `pom.xml`:
-
-
-```xml
-
-
- com.anthropic
- anthropic-java
- 2.15.0
-
-
- io.ably
- ably-java
- 1.6.1
-
-
-```
-
-
-For Gradle, add to your `build.gradle`:
-
-
-```text
-dependencies {
- implementation 'com.anthropic:anthropic-java:2.15.0'
- implementation 'io.ably:ably-java:1.6.1'
-}
-```
-
-
-
-Export your Anthropic API key to the environment:
-
-
-```shell
-export ANTHROPIC_API_KEY="your_api_key_here"
-```
-
-
-### Client setup
-
-
-Create a new Node project for the client code, or use the same project as the agent if both are JavaScript:
-
-
-```shell
-mkdir ably-anthropic-client && cd ably-anthropic-client
-npm init -y
-npm install ably
-```
-
-
-
-
-Add the Ably SDK to your iOS or macOS project using Swift Package Manager. In Xcode, go to File > Add Package Dependencies and add:
-
-
-```text
-https://github.com/ably/ably-cocoa
-```
-
-
-Or add it to your `Package.swift`:
-
-
-```client_swift
-dependencies: [
- .package(url: "https://github.com/ably/ably-cocoa", from: "1.2.0")
-]
-```
-
-
-
-
-Add the Ably Java SDK to your `pom.xml`:
-
-
-```xml
-
- io.ably
- ably-java
- 1.6.1
-
-```
-
-
-For Gradle, add to your `build.gradle`:
-
-
-```text
-implementation 'io.ably:ably-java:1.6.1'
-```
-
-
-
-
-
-## Step 1: Enable message appends
-
-Message append functionality requires "Message annotations, updates, deletes and appends" to be enabled in a [rule](/docs/channels#rules) associated with the channel.
-
-
-
-To enable the rule:
-
-1. Go to the [Ably dashboard](https://www.ably.com/dashboard) and select your app.
-2. Navigate to the "Configuration" > "Rules" section from the left-hand navigation bar.
-3. Choose "Add new rule".
-4. Enter a channel name or namespace pattern (e.g. `ai` for all channels starting with `ai:`).
-5. Select the "Message annotations, updates, deletes and appends" option from the list.
-6. Click "Create rule".
-
-The examples in this guide use the `ai:` namespace prefix, which assumes you have configured the rule for `ai:*`.
-
-
-
-## Step 2: Get a streamed response from Anthropic
-
-Initialize an Anthropic client and use the [Messages API](https://docs.anthropic.com/en/api/messages) to stream model output as a series of events.
-
-
-In your `ably-anthropic-agent` directory, create a new file called `agent.mjs``agent.py` with the following contents:
-
-
-In your agent project, create a new file called `Agent.java` with the following contents:
-
-
-
-```agent_javascript
-import Anthropic from '@anthropic-ai/sdk';
-
-// Initialize Anthropic client
-const anthropic = new Anthropic();
-
-// Process each streaming event
-async function processEvent(event) {
- console.log(JSON.stringify(event));
- // This function is updated in the next sections
-}
-
-// Create streaming response from Anthropic
-async function streamAnthropicResponse(prompt) {
- const stream = await anthropic.messages.create({
- model: "claude-sonnet-4-5",
- max_tokens: 1024,
- messages: [{ role: "user", content: prompt }],
- stream: true,
- });
-
- // Iterate through streaming events
- for await (const event of stream) {
- await processEvent(event);
- }
-}
-
-// Usage example
-streamAnthropicResponse("Tell me a short joke");
-```
-
-```agent_python
-import asyncio
-import anthropic
-
-# Initialize Anthropic client
-client = anthropic.AsyncAnthropic()
-
-# Process each streaming event
-async def process_event(event):
- print(event)
- # This function is updated in the next sections
-
-# Create streaming response from Anthropic
-async def stream_anthropic_response(prompt: str):
- async with client.messages.stream(
- model="claude-sonnet-4-5",
- max_tokens=1024,
- messages=[{"role": "user", "content": prompt}],
- ) as stream:
- async for event in stream:
- await process_event(event)
-
-# Usage example
-asyncio.run(stream_anthropic_response("Tell me a short joke"))
-```
-
-```agent_java
-import com.anthropic.client.AnthropicClient;
-import com.anthropic.client.okhttp.AnthropicOkHttpClient;
-import com.anthropic.core.http.StreamResponse;
-import com.anthropic.models.messages.*;
-
-public class Agent {
- // Initialize Anthropic client
- private static final AnthropicClient client = AnthropicOkHttpClient.fromEnv();
-
- // Process each streaming event
- private static void processEvent(RawMessageStreamEvent event) {
- System.out.println(event);
- // This method is updated in the next sections
- }
-
- // Create streaming response from Anthropic
- public static void streamAnthropicResponse(String prompt) {
- MessageCreateParams params = MessageCreateParams.builder()
- .model(Model.CLAUDE_SONNET_4_5)
- .maxTokens(1024)
- .addUserMessage(prompt)
- .build();
-
- try (StreamResponse stream =
- client.messages().createStreaming(params)) {
- stream.stream().forEach(Agent::processEvent);
- }
- }
-
- public static void main(String[] args) {
- streamAnthropicResponse("Tell me a short joke");
- }
-}
-```
-
-
-### Understand Anthropic streaming events
-
-Anthropic's Messages API [streams](https://docs.anthropic.com/en/api/messages-streaming) model output as a series of events when you set `stream: true`. Each streamed event includes a `type` property which describes the event type. A complete text response can be constructed from the following event types:
-
-- [`message_start`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Signals the start of a response. Contains a `message` object with an `id` to correlate subsequent events.
-
-- [`content_block_start`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Indicates the start of a new content block. For text responses, the `content_block` will have `type: "text"`; other types may be specified, such as `"thinking"` for internal reasoning tokens. The `index` indicates the position of this item in the message's `content` array.
-
-- [`content_block_delta`](https://platform.claude.com/docs/en/build-with-claude/streaming#content-block-delta-types): Contains a single text delta in the `delta.text` field. If `delta.type === "text_delta"` the delta contains model response text; other types may be specified, such as `"thinking_delta"` for internal reasoning tokens. Use the `index` to correlate deltas relating to a specific content block.
-
-- [`content_block_stop`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Signals completion of a content block. Contains the `index` that identifies the content block.
-
-- [`message_delta`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Contains additional message-level metadata that may be streamed incrementally. Includes a [`delta.stop_reason`](https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons) which indicates why the model successfully completed its response generation.
-
-- [`message_stop`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Signals the end of the response.
-
-The following example shows the event sequence received when streaming a response:
-
-
-```json
-// 1. Message starts
-{"type":"message_start","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_012zEkenyT6heaYSDvDEDdXm","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}}
-
-// 2. Content block starts
-{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
-
-// 3. Text tokens stream in as delta events
-{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Why"}}
-{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" don't scientists trust atoms?\n\nBecause"}}
-{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" they make up everything!"}}
-
-// 4. Content block completes
-{"type":"content_block_stop","index":0}
-
-// 5. Message delta (usage stats)
-{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":17}}
-
-// 6. Message completes
-{"type":"message_stop"}
-```
-
-
-
-
-## Step 3: Publish streaming tokens to Ably
-
-Publish Anthropic streaming events to Ably using message appends to reliably and scalably distribute them to subscribers.
-
-Each AI response is stored as a single Ably message that grows as tokens are appended.
-
-### Initialize the Ably client
-
-Add the Ably client initialization to your agent file:
-
-
-```agent_javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- echoMessages: false
-});
-
-// Create a channel for publishing streamed AI responses
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-```
-
-```agent_python
-from ably import AblyRealtime
-
-# Initialize Ably Realtime client
-realtime = AblyRealtime(key='{{API_KEY}}', transport_params={'echo': 'false'})
-
-# Create a channel for publishing streamed AI responses
-channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}')
-```
-
-```agent_java
-import io.ably.lib.realtime.AblyRealtime;
-import io.ably.lib.realtime.Channel;
-import io.ably.lib.types.ClientOptions;
-
-// Initialize Ably Realtime client
-ClientOptions options = new ClientOptions("{{API_KEY}}");
-options.echoMessages = false;
-AblyRealtime realtime = new AblyRealtime(options);
-
-// Create a channel for publishing streamed AI responses
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-```
-
-
-The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish tokens at high message rates with low latency.
-
-
-
-### Publish initial message and append tokens
-
-When a new response begins, publish an initial message to create it. Ably assigns a [`serial`](/docs/messages#properties) identifier to the message. Use this `serial` to append each token to the message as it arrives from the Anthropic model.
-
-
-
-Update your agent file to publish the initial message and append tokens:
-
-
-```agent_javascript
-// Track state across events
-let msgSerial = null;
-let textBlockIndex = null;
-
-// Process each streaming event and publish to Ably
-async function processEvent(event) {
- switch (event.type) {
- case 'message_start':
- // Publish initial empty message when response starts
- const result = await channel.publish({
- name: 'response',
- data: ''
- });
-
- // Capture the message serial for appending tokens
- msgSerial = result.serials[0];
- break;
-
- case 'content_block_start':
- // Capture text block index when a text content block is added
- if (event.content_block.type === 'text') {
- textBlockIndex = event.index;
- }
- break;
-
- case 'content_block_delta':
- // Append tokens from text deltas only
- if (event.index === textBlockIndex && event.delta.type === 'text_delta' && msgSerial) {
- channel.appendMessage({
- serial: msgSerial,
- data: event.delta.text
- });
- }
- break;
-
- case 'message_stop':
- console.log('Stream completed!');
- break;
- }
-}
-```
-
-```agent_python
-from ably.types.message import Message
-
-# Track state across events
-msg_serial = None
-text_block_index = None
-
-# Process each streaming event and publish to Ably
-async def process_event(event):
- global msg_serial, text_block_index
-
- if event.type == 'message_start':
- # Publish initial empty message when response starts
- result = await channel.publish('response', '')
-
- # Capture the message serial for appending tokens
- msg_serial = result.serials[0]
-
- elif event.type == 'content_block_start':
- # Capture text block index when a text content block is added
- if event.content_block.type == 'text':
- text_block_index = event.index
-
- elif event.type == 'content_block_delta':
- # Append tokens from text deltas only
- if (event.index == text_block_index and
- hasattr(event.delta, 'text') and
- msg_serial):
- await channel.append_message(
- Message(serial=msg_serial, data=event.delta.text)
- )
-
- elif event.type == 'message_stop':
- print('Stream completed!')
-```
-
-```agent_java
-import io.ably.lib.types.Message;
-
-// Track state across events
-private static String msgSerial = null;
-private static Long textBlockIndex = null;
-
-// Process each streaming event and publish to Ably
-private static void processEvent(RawMessageStreamEvent event) throws AblyException {
- if (event.isMessageStart()) {
- // Publish initial empty message when response starts
- Message message = new Message("response", "");
- channel.publish(message, new Callback() {
- @Override
- public void onSuccess(PublishResult result) {
- // Capture the message serial for appending tokens
- msgSerial = result.serials[0];
- }
- @Override
- public void onError(ErrorInfo reason) {
- System.err.println("Publish failed: " + reason.message);
- }
- });
-
- } else if (event.isContentBlockStart()) {
- // Capture text block index when a text content block is added
- RawContentBlockStartEvent blockStart = event.asContentBlockStart();
- if (blockStart.contentBlock().isText()) {
- textBlockIndex = blockStart.index();
- }
-
- } else if (event.isContentBlockDelta()) {
- // Append tokens from text deltas only
- RawContentBlockDeltaEvent delta = event.asContentBlockDelta();
- if (delta.index() == textBlockIndex &&
- delta.delta().isText() &&
- msgSerial != null) {
- String text = delta.delta().asText().text();
- Message message = new Message();
- message.data = text;
- message.serial = msgSerial;
- channel.appendMessage(message);
- }
-
- } else if (event.isMessageStop()) {
- System.out.println("Stream completed!");
- }
-}
-```
-
-
-This implementation:
-
-- Publishes an initial empty message when the response begins and captures the `serial`
-- Filters for `content_block_delta` events with `text_delta` type from text content blocks
-- Appends each token to the original message
-
-
-
-
-
-
-
-Run the publisher to see tokens streaming to Ably:
-
-
-
-```shell
-cd ably-anthropic-agent
-node agent.mjs
-```
-
-
-
-
-
-```shell
-cd ably-anthropic-agent
-python agent.py
-```
-
-
-
-
-
-```shell
-mvn compile exec:java -Dexec.mainClass="Agent"
-```
-
-
-
-## Step 4: Subscribe to streaming tokens
-
-Create a subscriber that receives the streaming tokens from Ably and reconstructs the response in realtime.
-
-
-In your `ably-anthropic-client`client project directory, create a new file called `client.mjs``Client.java` with the following contents:
-
-
-Add the following code to your iOS or macOS app:
-
-
-
-```client_javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({ key: '{{API_KEY}}' });
-
-// Get the same channel used by the publisher
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by message serial
-const responses = new Map();
-
-// Subscribe to receive messages
-await channel.subscribe((message) => {
- switch (message.action) {
- case 'message.create':
- // New response started
- console.log('\n[Response started]', message.serial);
- responses.set(message.serial, message.data);
- break;
-
- case 'message.append':
- // Append token to existing response
- const current = responses.get(message.serial) || '';
- responses.set(message.serial, current + message.data);
-
- // Display token as it arrives
- process.stdout.write(message.data);
- break;
-
- case 'message.update':
- // Replace entire response content
- responses.set(message.serial, message.data);
- console.log('\n[Response updated with full content]');
- break;
- }
-});
-
-console.log('Subscriber ready, waiting for tokens...');
-```
-
-{/* Swift example test harness
-ID: anthropic-message-per-response-1
-To verify: copy this comment into a Swift file, paste the example code into the function body, run `swift build`
-
-@MainActor
-func example_anthropic_message_per_response_1() async throws {
- // --- example code starts here ---
-*/}
-```client_swift
-import Ably
-
-// Initialize Ably Realtime client
-let realtime = ARTRealtime(key: "{{API_KEY}}")
-
-// Get the same channel used by the publisher
-let channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}")
-
-// Track responses by message serial
-var responses: [String: String] = [:]
-
-// Subscribe to receive messages and wait for the channel to attach
-try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
- channel.subscribe(attachCallback: { error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume()
- }
- }, callback: { message in
- MainActor.assumeIsolated {
- guard let serial = message.serial else { return }
- guard let data = message.data as? String else { return }
-
- switch message.action {
- case .create:
- // New response started
- print("\n[Response started] \(serial)")
- responses[serial] = data
-
- case .append:
- // Append token to existing response
- let current = responses[serial] ?? ""
- responses[serial] = current + data
-
- // Display token as it arrives
- print(data, terminator: "")
-
- case .update:
- // Replace entire response content
- responses[serial] = data
- print("\n[Response updated with full content]")
-
- default:
- break
- }
- }
- })
-}
-
-print("Subscriber ready, waiting for tokens...")
-```
-{/* --- end example code --- */}
-
-```client_java
-import io.ably.lib.realtime.AblyRealtime;
-import io.ably.lib.realtime.Channel;
-import io.ably.lib.types.ClientOptions;
-import io.ably.lib.types.Message;
-import java.util.HashMap;
-import java.util.Map;
-
-public class Client {
- // Track responses by message serial
- private static final Map responses = new HashMap<>();
-
- public static void main(String[] args) throws Exception {
- // Initialize Ably Realtime client
- ClientOptions options = new ClientOptions("{{API_KEY}}");
- AblyRealtime realtime = new AblyRealtime(options);
-
- // Get the same channel used by the publisher
- Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
- // Subscribe to receive messages
- channel.subscribe(message -> {
- String serial = message.serial;
- if (serial == null) return;
-
- switch (message.action) {
- case MESSAGE_CREATE:
- // New response started
- System.out.println("\n[Response started] " + serial);
- responses.put(serial, message.data != null ? message.data.toString() : "");
- break;
-
- case MESSAGE_APPEND:
- // Append token to existing response
- String current = responses.getOrDefault(serial, "");
- String token = message.data != null ? message.data.toString() : "";
- responses.put(serial, current + token);
-
- // Display token as it arrives
- System.out.print(token);
- break;
-
- case MESSAGE_UPDATE:
- // Replace entire response content
- responses.put(serial, message.data != null ? message.data.toString() : "");
- System.out.println("\n[Response updated with full content]");
- break;
- }
- });
-
- System.out.println("Subscriber ready, waiting for tokens...");
- }
-}
-```
-
-
-Subscribers receive different message actions depending on when they join and how they're retrieving messages:
-
-- `message.create`: Indicates a new response has started (i.e. a new message was created). The message `data` contains the initial content (often empty or the first token). Store this as the beginning of a new response using `serial` as the identifier.
-
-- `message.append`: Contains a single token fragment to append. The message `data` contains only the new token, not the full concatenated response. Append this token to the existing response identified by `serial`.
-
-- `message.update`: Contains the whole response up to that point. The message `data` contains the full concatenated text so far. Replace the entire response content with this data for the message identified by `serial`. This action occurs when the channel needs to resynchronize the full message state, such as after a client [resumes](/docs/connect/states#resume) from a transient disconnection.
-
-
-Run the subscriber in a separate terminal:
-
-
-
-
-```shell
-cd ably-anthropic-client
-node client.mjs
-```
-
-
-
-
-Build and run your iOS or macOS app in Xcode.
-
-
-
-
-```shell
-mvn compile exec:java -Dexec.mainClass="Client"
-```
-
-
-
-
-With the subscriber running, run the publisher in another terminal. The tokens stream in realtime as the Anthropic model generates them.
-
-
-With the subscriber running, run the publisher in a terminal. The tokens stream in realtime as the Anthropic model generates them.
-
-
-## Step 5: Stream with multiple publishers and subscribers
-
-Ably's [channel-oriented sessions](/docs/ai-transport/sessions-identity#connection-oriented-vs-channel-oriented-sessions) enables multiple AI agents to publish responses and multiple users to receive them on a single channel simultaneously. Ably handles message delivery to all participants, eliminating the need to implement routing logic or manage state synchronization across connections.
-
-### Broadcasting to multiple subscribers
-
-Each subscriber receives the complete stream of tokens independently, enabling you to build collaborative experiences or multi-device applications.
-
-
-Run a subscriber in multiple separate terminals:
-
-
-
-
-```shell
-# Terminal 1
-cd ably-anthropic-client && node client.mjs
-
-# Terminal 2
-cd ably-anthropic-client && node client.mjs
-
-# Terminal 3
-cd ably-anthropic-client && node client.mjs
-```
-
-
-
-
-
-```shell
-# Terminal 1
-mvn compile exec:java -Dexec.mainClass="Client"
-
-# Terminal 2
-mvn compile exec:java -Dexec.mainClass="Client"
-
-# Terminal 3
-mvn compile exec:java -Dexec.mainClass="Client"
-```
-
-
-
-
-Run multiple instances of your iOS or macOS app, or run on multiple devices/simulators.
-
-
-All subscribers receive the same stream of tokens in realtime.
-
-### Publishing concurrent responses
-
-Multiple publishers can stream different responses concurrently on the same [channel](/docs/channels). Each response is a distinct message with its own unique `serial` identifier, so tokens from different responses are isolated to distinct messages and don't interfere with each other.
-
-To demonstrate this, run a publisher in multiple separate terminals:
-
-
-
-```shell
-# Terminal 1
-cd ably-anthropic-agent && node agent.mjs
-
-# Terminal 2
-cd ably-anthropic-agent && node agent.mjs
-
-# Terminal 3
-cd ably-anthropic-agent && node agent.mjs
-```
-
-
-
-
-
-```shell
-# Terminal 1
-cd ably-anthropic-agent && python agent.py
-
-# Terminal 2
-cd ably-anthropic-agent && python agent.py
-
-# Terminal 3
-cd ably-anthropic-agent && python agent.py
-```
-
-
-
-
-
-```shell
-# Terminal 1
-mvn compile exec:java -Dexec.mainClass="Agent"
-
-# Terminal 2
-mvn compile exec:java -Dexec.mainClass="Agent"
-
-# Terminal 3
-mvn compile exec:java -Dexec.mainClass="Agent"
-```
-
-
-
-All running subscribers receive tokens from all responses concurrently. Each subscriber correctly reconstructs each response separately using the `serial` to correlate tokens.
-
-## Step 6: Retrieve complete responses from history
-
-One key advantage of the message-per-response pattern is that each complete AI response is stored as a single message in channel history. This makes it efficient to retrieve conversation history without processing thousands of individual token messages.
-
-Use Ably's [rewind](/docs/channels/options/rewind) channel option to attach to the channel at some point in the recent past and automatically receive complete responses from history. Historical messages are delivered as `message.update` events containing the complete concatenated response, which then seamlessly transition to live `message.append` events for any ongoing responses.
-
-
-Update your `client.mjs` file in the `ably-anthropic-client` directory to use the `rewind` option when getting the channel:
-
-
-Update your subscriber code to use the `rewind` option when getting the channel:
-
-
-Update your `Client.java` file to use the `rewind` option when getting the channel:
-
-
-
-```client_javascript
-// Use rewind to receive recent historical messages
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}', {
- params: { rewind: '2m' } // Retrieve messages from the last 2 minutes
-});
-
-const responses = new Map();
-
-await channel.subscribe((message) => {
- switch (message.action) {
- case 'message.create':
- responses.set(message.serial, message.data);
- break;
-
- case 'message.append':
- const current = responses.get(message.serial) || '';
- responses.set(message.serial, current + message.data);
- process.stdout.write(message.data);
- break;
-
- case 'message.update':
- // Historical messages contain full concatenated response
- responses.set(message.serial, message.data);
- console.log('\n[Historical response]:', message.data);
- break;
- }
-});
-```
-
-{/* Swift example test harness
-ID: anthropic-message-per-response-2
-To verify: copy this comment into a Swift file, paste the example code into the function body, run `swift build`
-
-@MainActor
-func example_anthropic_message_per_response_2(realtime: ARTRealtime) async throws {
- // --- example code starts here ---
-*/}
-```client_swift
-// Use rewind to receive recent historical messages
-let channelOptions = ARTRealtimeChannelOptions()
-channelOptions.params = ["rewind": "2m"] // Retrieve messages from the last 2 minutes
-
-let channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}", options: channelOptions)
-
-var responses: [String: String] = [:]
-
-// Subscribe and wait for the channel to attach
-try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
- channel.subscribe(attachCallback: { error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume()
- }
- }, callback: { message in
- MainActor.assumeIsolated {
- guard let serial = message.serial else { return }
- guard let data = message.data as? String else { return }
-
- switch message.action {
- case .create:
- responses[serial] = data
-
- case .append:
- let current = responses[serial] ?? ""
- responses[serial] = current + data
- print(data, terminator: "")
-
- case .update:
- // Historical messages contain full concatenated response
- responses[serial] = data
- print("\n[Historical response]: \(responses[serial] ?? "")")
-
- default:
- break
- }
- }
- })
-}
-```
-{/* --- end example code --- */}
-
-```client_java
-import io.ably.lib.realtime.AblyRealtime;
-import io.ably.lib.realtime.Channel;
-import io.ably.lib.types.ClientOptions;
-import io.ably.lib.types.ChannelOptions;
-import java.util.HashMap;
-import java.util.Map;
-
-// Use rewind to receive recent historical messages
-ClientOptions clientOptions = new ClientOptions("{{API_KEY}}");
-AblyRealtime realtime = new AblyRealtime(clientOptions);
-
-ChannelOptions channelOptions = new ChannelOptions();
-Map params = new HashMap<>();
-params.put("rewind", "2m"); // Retrieve messages from the last 2 minutes
-channelOptions.params = params;
-
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}", channelOptions);
-
-Map responses = new HashMap<>();
-
-channel.subscribe(message -> {
- String serial = message.serial;
- if (serial == null) return;
-
- switch (message.action) {
- case MESSAGE_CREATE:
- responses.put(serial, message.data != null ? message.data.toString() : "");
- break;
-
- case MESSAGE_APPEND:
- String current = responses.getOrDefault(serial, "");
- String token = message.data != null ? message.data.toString() : "";
- responses.put(serial, current + token);
- System.out.print(token);
- break;
-
- case MESSAGE_UPDATE:
- // Historical messages contain full concatenated response
- responses.put(serial, message.data != null ? message.data.toString() : "");
- System.out.println("\n[Historical response]: " + responses.get(serial));
- break;
- }
-});
-```
-
-
-
-
-## Next steps
-
-- Learn more about the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response) used in this guide
-- Learn about [client hydration strategies](/docs/ai-transport/token-streaming/message-per-response#hydration) for handling late joiners and reconnections
-- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI enabled applications
-- Explore the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token) for explicit control over individual token messages
diff --git a/src/pages/docs/ai-transport/guides/anthropic/anthropic-message-per-token.mdx b/src/pages/docs/ai-transport/guides/anthropic/anthropic-message-per-token.mdx
deleted file mode 100644
index 12e139f103..0000000000
--- a/src/pages/docs/ai-transport/guides/anthropic/anthropic-message-per-token.mdx
+++ /dev/null
@@ -1,879 +0,0 @@
----
-title: "Guide: Stream Anthropic responses using the message-per-token pattern"
-meta_description: "Stream tokens from the Anthropic Messages API over Ably in realtime."
-meta_keywords: "AI, token streaming, Anthropic, Claude, Messages API, AI transport, Ably, realtime"
-redirect_from:
- - /docs/guides/ai-transport/anthropic-message-per-token
- - /docs/guides/ai-transport/anthropic/anthropic-message-per-token
----
-
-This guide shows you how to stream AI responses from Anthropic's [Messages API](https://docs.anthropic.com/en/api/messages) over Ably using the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token). Specifically, it implements the [explicit start/stop events approach](/docs/ai-transport/token-streaming/message-per-token#explicit-events), which publishes each response token as an individual message, along with explicit lifecycle events to signal when responses begin and end.
-
-Using Ably to distribute tokens from the Anthropic SDK enables you to broadcast AI responses to thousands of concurrent subscribers with reliable message delivery and ordering guarantees, ensuring that each client receives the complete response stream with all tokens delivered in order. This approach decouples your AI inference from client connections, enabling you to scale agents independently and handle reconnections gracefully.
-
-
-
-## Prerequisites
-
-
-Node.js 20 or higher is required.
-
-
-Python 3.8 or higher is required.
-
-
-Java 8 or higher is required.
-
-
-Xcode 15 or higher is required.
-
-
-You also need:
-- An Anthropic API key
-- An Ably API key
-
-Useful links:
-- [Anthropic API documentation](https://docs.anthropic.com/en/api)
-- [Token streaming overview](/docs/ai-transport/token-streaming)
-- [AI Transport overview](/docs/ai-transport)
-
-### Agent setup
-
-
-Create a new Node project for the agent code:
-
-
-```shell
-mkdir ably-anthropic-agent && cd ably-anthropic-agent
-npm init -y
-npm install @anthropic-ai/sdk ably
-```
-
-
-
-
-Create a new directory and install the required packages:
-
-
-```shell
-mkdir ably-anthropic-agent && cd ably-anthropic-agent
-pip install anthropic ably
-```
-
-
-
-
-Create a new project and add the required dependencies.
-
-For Maven, add to your `pom.xml`:
-
-
-```xml
-
-
- com.anthropic
- anthropic-java
- 2.15.0
-
-
- io.ably
- ably-java
- 1.6.1
-
-
-```
-
-
-For Gradle, add to your `build.gradle`:
-
-
-```text
-dependencies {
- implementation 'com.anthropic:anthropic-java:2.15.0'
- implementation 'io.ably:ably-java:1.6.1'
-}
-```
-
-
-
-Export your Anthropic API key to the environment:
-
-
-```shell
-export ANTHROPIC_API_KEY="your_api_key_here"
-```
-
-
-### Client setup
-
-
-Create a new Node project for the client code, or use the same project as the agent if both are JavaScript:
-
-
-```shell
-mkdir ably-anthropic-client && cd ably-anthropic-client
-npm init -y
-npm install ably
-```
-
-
-
-
-Add the Ably SDK to your iOS or macOS project using Swift Package Manager. In Xcode, go to File > Add Package Dependencies and add:
-
-
-```text
-https://github.com/ably/ably-cocoa
-```
-
-
-Or add it to your `Package.swift`:
-
-
-```client_swift
-dependencies: [
- .package(url: "https://github.com/ably/ably-cocoa", from: "1.2.0")
-]
-```
-
-
-
-
-Add the Ably Java SDK to your `pom.xml`:
-
-
-```xml
-
- io.ably
- ably-java
- 1.6.1
-
-```
-
-
-For Gradle, add to your `build.gradle`:
-
-
-```text
-implementation 'io.ably:ably-java:1.6.1'
-```
-
-
-
-
-
-## Step 1: Get a streamed response from Anthropic
-
-Initialize an Anthropic client and use the [Messages API](https://docs.anthropic.com/en/api/messages) to stream model output as a series of events.
-
-
-In your `ably-anthropic-agent` directory, create a new file called `agent.mjs``agent.py` with the following contents:
-
-
-In your agent project, create a new file called `Agent.java` with the following contents:
-
-
-
-```agent_javascript
-import Anthropic from '@anthropic-ai/sdk';
-
-// Initialize Anthropic client
-const anthropic = new Anthropic();
-
-// Process each streaming event
-function processEvent(event) {
- console.log(JSON.stringify(event));
- // This function is updated in the next sections
-}
-
-// Create streaming response from Anthropic
-async function streamAnthropicResponse(prompt) {
- const stream = await anthropic.messages.create({
- model: "claude-sonnet-4-5",
- max_tokens: 1024,
- messages: [{ role: "user", content: prompt }],
- stream: true,
- });
-
- // Iterate through streaming events
- for await (const event of stream) {
- processEvent(event);
- }
-}
-
-// Usage example
-streamAnthropicResponse("Tell me a short joke");
-```
-
-```agent_python
-import asyncio
-import anthropic
-
-# Initialize Anthropic client
-client = anthropic.AsyncAnthropic()
-
-# Process each streaming event
-async def process_event(event):
- print(event)
- # This function is updated in the next sections
-
-# Create streaming response from Anthropic
-async def stream_anthropic_response(prompt: str):
- async with client.messages.stream(
- model="claude-sonnet-4-5",
- max_tokens=1024,
- messages=[{"role": "user", "content": prompt}],
- ) as stream:
- async for event in stream:
- await process_event(event)
-
-# Usage example
-asyncio.run(stream_anthropic_response("Tell me a short joke"))
-```
-
-```agent_java
-import com.anthropic.client.AnthropicClient;
-import com.anthropic.client.okhttp.AnthropicOkHttpClient;
-import com.anthropic.core.http.StreamResponse;
-import com.anthropic.models.messages.*;
-
-public class Agent {
- // Initialize Anthropic client
- private static final AnthropicClient client = AnthropicOkHttpClient.fromEnv();
-
- // Process each streaming event
- private static void processEvent(RawMessageStreamEvent event) {
- System.out.println(event);
- // This method is updated in the next sections
- }
-
- // Create streaming response from Anthropic
- public static void streamAnthropicResponse(String prompt) {
- MessageCreateParams params = MessageCreateParams.builder()
- .model(Model.CLAUDE_SONNET_4_5)
- .maxTokens(1024)
- .addUserMessage(prompt)
- .build();
-
- try (StreamResponse stream =
- client.messages().createStreaming(params)) {
- stream.stream().forEach(Agent::processEvent);
- }
- }
-
- public static void main(String[] args) {
- streamAnthropicResponse("Tell me a short joke");
- }
-}
-```
-
-
-### Understand Anthropic streaming events
-
-Anthropic's Messages API [streams](https://docs.anthropic.com/en/api/messages-streaming) model output as a series of events when you set `stream: true`. Each streamed event includes a `type` property which describes the event type. A complete text response can be constructed from the following event types:
-
-- [`message_start`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Signals the start of a response. Contains a `message` object with an `id` to correlate subsequent events.
-
-- [`content_block_start`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Indicates the start of a new content block. For text responses, the `content_block` will have `type: "text"`; other types may be specified, such as `"thinking"` for internal reasoning tokens. The `index` indicates the position of this item in the message's `content` array.
-
-- [`content_block_delta`](https://platform.claude.com/docs/en/build-with-claude/streaming#content-block-delta-types): Contains a single text delta in the `delta.text` field. If `delta.type === "text_delta"` the delta contains model response text; other types may be specified, such as `"thinking_delta"` for internal reasoning tokens. Use the `index` to correlate deltas relating to a specific content block.
-
-- [`content_block_stop`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Signals completion of a content block. Contains the `index` that identifies the content block.
-
-- [`message_delta`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Contains additional message-level metadata that may be streamed incrementally. Includes a [`delta.stop_reason`](https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons) which indicates why the model successfully completed its response generation.
-
-- [`message_stop`](https://platform.claude.com/docs/en/build-with-claude/streaming#event-types): Signals the end of the response.
-
-The following example shows the event sequence received when streaming a response:
-
-
-```json
-// 1. Message starts
-{"type":"message_start","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_016hhjrqVK4rCZ2uEGdyWfmt","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}}
-
-// 2. Content block starts
-{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
-
-// 3. Text tokens stream in as delta events
-{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Why"}}
-{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" don't scientists trust atoms?\n\nBecause"}}
-{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" they make up everything!"}}
-
-// 4. Content block completes
-{"type":"content_block_stop","index":0}
-
-// 5. Message delta (usage stats)
-{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":17}}
-
-// 6. Message completes
-{"type":"message_stop"}
-```
-
-
-
-
-## Step 2: Publish streaming events to Ably
-
-Publish Anthropic streaming events to Ably to reliably and scalably distribute them to subscribers.
-
-This implementation follows the [explicit start/stop events pattern](/docs/ai-transport/token-streaming/message-per-token#explicit-events), which provides clear response boundaries.
-
-### Initialize the Ably client
-
-Add the Ably client initialization to your agent file:
-
-
-```agent_javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- echoMessages: false
-});
-
-// Create a channel for publishing streamed AI responses
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-```
-
-```agent_python
-from ably import AblyRealtime
-
-# Initialize Ably Realtime client
-realtime = AblyRealtime(key='{{API_KEY}}', transport_params={'echo': 'false'})
-
-# Create a channel for publishing streamed AI responses
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-```
-
-```agent_java
-import io.ably.lib.realtime.AblyRealtime;
-import io.ably.lib.realtime.Channel;
-import io.ably.lib.types.ClientOptions;
-
-// Initialize Ably Realtime client
-ClientOptions options = new ClientOptions("{{API_KEY}}");
-options.echoMessages = false;
-AblyRealtime realtime = new AblyRealtime(options);
-
-// Create a channel for publishing streamed AI responses
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-```
-
-
-The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish tokens at high message rates with low latency.
-
-
-
-### Map Anthropic streaming events to Ably messages
-
-Choose how to map [Anthropic streaming events](#understand-streaming-events) to Ably [messages](/docs/messages). You can choose any mapping strategy that suits your application's needs. This guide uses the following pattern as an example:
-
-- `start`: Signals the beginning of a response
-- `token`: Contains the incremental text content for each delta
-- `stop`: Signals the completion of a response
-
-
-
-Update your agent file to initialize the Ably client and update the `processEvent()` function to publish events to Ably:
-
-
-```agent_javascript
-// Track state across events
-let responseId = null;
-
-// Process each streaming event and publish to Ably
-function processEvent(event) {
- switch (event.type) {
- case 'message_start':
- // Capture message ID when response starts
- responseId = event.message.id;
-
- // Publish start event
- channel.publish({
- name: 'start',
- extras: {
- headers: { responseId }
- }
- });
- break;
-
- case 'content_block_delta':
- // Publish tokens from text deltas only
- if (event.delta.type === 'text_delta') {
- channel.publish({
- name: 'token',
- data: event.delta.text,
- extras: {
- headers: { responseId }
- }
- });
- }
- break;
-
- case 'message_stop':
- // Publish stop event when response completes
- channel.publish({
- name: 'stop',
- extras: {
- headers: { responseId }
- }
- });
- break;
- }
-}
-```
-
-```agent_python
-from ably.types.message import Message
-
-# Track state across events
-response_id = None
-
-# Process each streaming event and publish to Ably
-async def process_event(event):
- global response_id
-
- if event.type == 'message_start':
- # Capture message ID when response starts
- response_id = event.message.id
-
- # Publish start event
- await channel.publish(Message(
- name='start',
- extras={'headers': {'responseId': response_id}}
- ))
-
- elif event.type == 'content_block_delta':
- # Publish tokens from text deltas only
- if hasattr(event.delta, 'text'):
- await channel.publish(Message(
- name='token',
- data=event.delta.text,
- extras={'headers': {'responseId': response_id}}
- ))
-
- elif event.type == 'message_stop':
- # Publish stop event when response completes
- await channel.publish(Message(
- name='stop',
- extras={'headers': {'responseId': response_id}}
- ))
-```
-
-```agent_java
-import io.ably.lib.types.Message;
-import io.ably.lib.types.MessageExtras;
-import com.google.gson.JsonObject;
-
-// Track state across events
-private static String responseId = null;
-
-// Process each streaming event and publish to Ably
-private static void processEvent(RawMessageStreamEvent event) {
- if (event.isMessageStart()) {
- // Capture message ID when response starts
- responseId = event.asMessageStart().message().id();
-
- // Publish start event
- JsonObject headers = new JsonObject();
- headers.addProperty("responseId", responseId);
- JsonObject extras = new JsonObject();
- extras.add("headers", headers);
-
- channel.publish(new Message("start", null, new MessageExtras(extras)));
-
- } else if (event.isContentBlockDelta()) {
- // Publish tokens from text deltas only
- ContentBlockDeltaEvent delta = event.asContentBlockDelta();
- if (delta.delta().isText()) {
- String text = delta.delta().asText().text();
-
- JsonObject headers = new JsonObject();
- headers.addProperty("responseId", responseId);
- JsonObject extras = new JsonObject();
- extras.add("headers", headers);
-
- channel.publish(new Message("token", text, new MessageExtras(extras)));
- }
-
- } else if (event.isMessageStop()) {
- // Publish stop event when response completes
- JsonObject headers = new JsonObject();
- headers.addProperty("responseId", responseId);
- JsonObject extras = new JsonObject();
- extras.add("headers", headers);
-
- channel.publish(new Message("stop", null, new MessageExtras(extras)));
- }
-}
-```
-
-
-This implementation:
-
-- Publishes a `start` event when the response begins
-- Filters for `content_block_delta` events with `text_delta` type and publishes them as `token` events
-- Publishes a `stop` event when the response completes
-- All published events include the `responseId` in message [`extras`](/docs/messages#properties) to allow the client to correlate events relating to a particular response
-
-
-
-
-
-Run the publisher to see tokens streaming to Ably:
-
-
-
-```shell
-cd ably-anthropic-agent
-node agent.mjs
-```
-
-
-
-
-
-```shell
-cd ably-anthropic-agent
-python agent.py
-```
-
-
-
-
-
-```shell
-mvn compile exec:java -Dexec.mainClass="Agent"
-```
-
-
-
-## Step 3: Subscribe to streaming tokens
-
-Create a subscriber that receives the streaming events from Ably and reconstructs the response.
-
-
-In your `ably-anthropic-client`client project directory, create a new file called `client.mjs``Client.java` with the following contents:
-
-
-Add the following code to your iOS or macOS app:
-
-
-
-```client_javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({ key: '{{API_KEY}}' });
-
-// Get the same channel used by the publisher
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by ID
-const responses = new Map();
-
-// Handle response start
-await channel.subscribe('start', (message) => {
- const responseId = message.extras?.headers?.responseId;
- console.log('\n[Response started]', responseId);
- responses.set(responseId, '');
-});
-
-// Handle tokens
-await channel.subscribe('token', (message) => {
- const responseId = message.extras?.headers?.responseId;
- const token = message.data;
-
- // Append token to response
- const currentText = responses.get(responseId) || '';
- responses.set(responseId, currentText + token);
-
- // Display token as it arrives
- process.stdout.write(token);
-});
-
-// Handle response stop
-await channel.subscribe('stop', (message) => {
- const responseId = message.extras?.headers?.responseId;
- const finalText = responses.get(responseId);
- console.log('\n[Response completed]', responseId);
-});
-
-console.log('Subscriber ready, waiting for tokens...');
-```
-
-{/* Swift example test harness
-ID: anthropic-message-per-token-1
-To verify: copy this comment into a Swift file, paste the example code into the function body, run `swift build`
-
-@MainActor
-func example_anthropic_message_per_token_1() async throws {
- // --- example code starts here ---
-*/}
-```client_swift
-import Ably
-
-// Initialize Ably Realtime client
-let realtime = ARTRealtime(key: "{{API_KEY}}")
-
-// Get the same channel used by the publisher
-let channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}")
-
-// Track responses by ID
-var responses: [String: String] = [:]
-
-// Subscribe to all events and handle by message name
-try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
- channel.subscribe(attachCallback: { error in
- if let error {
- continuation.resume(throwing: error)
- } else {
- continuation.resume()
- }
- }) { message in
- MainActor.assumeIsolated {
- guard let extras = (try? message.extras?.toJSON()) as? [String: Any],
- let headers = extras["headers"] as? [String: Any],
- let responseID = headers["responseId"] as? String else { return }
-
- switch message.name {
- case "start":
- // Handle response start
- print("\n[Response started] \(responseID)")
- responses[responseID] = ""
-
- case "token":
- // Handle tokens
- guard let token = message.data as? String else { return }
-
- // Append token to response
- let currentText = responses[responseID] ?? ""
- responses[responseID] = currentText + token
-
- // Display token as it arrives
- print(token, terminator: "")
-
- case "stop":
- // Handle response stop
- let finalText = responses[responseID]
- print("\n[Response completed] \(responseID)")
-
- default:
- break
- }
- }
- }
-}
-
-print("Subscriber ready, waiting for tokens...")
-```
-{/* --- end example code --- */}
-
-```client_java
-import io.ably.lib.realtime.AblyRealtime;
-import io.ably.lib.realtime.Channel;
-import io.ably.lib.types.ClientOptions;
-import com.google.gson.JsonObject;
-import java.util.HashMap;
-import java.util.Map;
-
-public class Client {
- // Track responses by ID
- private static final Map responses = new HashMap<>();
-
- public static void main(String[] args) throws Exception {
- // Initialize Ably Realtime client
- ClientOptions options = new ClientOptions("{{API_KEY}}");
- AblyRealtime realtime = new AblyRealtime(options);
-
- // Get the same channel used by the publisher
- Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
- // Handle response start
- channel.subscribe("start", message -> {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers.get("responseId").getAsString();
- System.out.println("\n[Response started] " + responseId);
- responses.put(responseId, "");
- });
-
- // Handle tokens
- channel.subscribe("token", message -> {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers.get("responseId").getAsString();
- String token = message.data != null ? message.data.toString() : "";
-
- // Append token to response
- String currentText = responses.getOrDefault(responseId, "");
- responses.put(responseId, currentText + token);
-
- // Display token as it arrives
- System.out.print(token);
- });
-
- // Handle response stop
- channel.subscribe("stop", message -> {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers.get("responseId").getAsString();
- String finalText = responses.get(responseId);
- System.out.println("\n[Response completed] " + responseId);
- });
-
- System.out.println("Subscriber ready, waiting for tokens...");
- }
-}
-```
-
-
-
-Run the subscriber in a separate terminal:
-
-
-
-
-```shell
-cd ably-anthropic-client
-node client.mjs
-```
-
-
-
-
-Build and run your iOS or macOS app in Xcode.
-
-
-
-
-```shell
-mvn compile exec:java -Dexec.mainClass="Client"
-```
-
-
-
-
-With the subscriber running, run the publisher in another terminal. The tokens stream in realtime as the Anthropic model generates them.
-
-
-With the subscriber running, run the publisher in a terminal. The tokens stream in realtime as the Anthropic model generates them.
-
-
-## Step 4: Stream with multiple publishers and subscribers
-
-Ably's [channel-oriented sessions](/docs/ai-transport/sessions-identity#connection-oriented-vs-channel-oriented-sessions) enables multiple AI agents to publish responses and multiple users to receive them on a single channel simultaneously. Ably handles message delivery to all participants, eliminating the need to implement routing logic or manage state synchronization across connections.
-
-### Broadcasting to multiple subscribers
-
-Each subscriber receives the complete stream of tokens independently, enabling you to build collaborative experiences or multi-device applications.
-
-
-Run a subscriber in multiple separate terminals:
-
-
-
-
-```shell
-# Terminal 1
-cd ably-anthropic-client && node client.mjs
-
-# Terminal 2
-cd ably-anthropic-client && node client.mjs
-
-# Terminal 3
-cd ably-anthropic-client && node client.mjs
-```
-
-
-
-
-
-```shell
-# Terminal 1
-mvn compile exec:java -Dexec.mainClass="Client"
-
-# Terminal 2
-mvn compile exec:java -Dexec.mainClass="Client"
-
-# Terminal 3
-mvn compile exec:java -Dexec.mainClass="Client"
-```
-
-
-
-
-Run multiple instances of your iOS or macOS app, or run on multiple devices/simulators.
-
-
-All subscribers receive the same stream of tokens in realtime.
-
-### Publishing concurrent responses
-
-The implementation uses `responseId` in message [`extras`](/docs/messages#properties) to correlate tokens with their originating response. This enables multiple publishers to stream different responses concurrently on the same [channel](/docs/channels), with each subscriber correctly tracking all responses independently.
-
-To demonstrate this, run a publisher in multiple separate terminals:
-
-
-
-```shell
-# Terminal 1
-cd ably-anthropic-agent && node agent.mjs
-
-# Terminal 2
-cd ably-anthropic-agent && node agent.mjs
-
-# Terminal 3
-cd ably-anthropic-agent && node agent.mjs
-```
-
-
-
-
-
-```shell
-# Terminal 1
-cd ably-anthropic-agent && python agent.py
-
-# Terminal 2
-cd ably-anthropic-agent && python agent.py
-
-# Terminal 3
-cd ably-anthropic-agent && python agent.py
-```
-
-
-
-
-
-```shell
-# Terminal 1
-mvn compile exec:java -Dexec.mainClass="Agent"
-
-# Terminal 2
-mvn compile exec:java -Dexec.mainClass="Agent"
-
-# Terminal 3
-mvn compile exec:java -Dexec.mainClass="Agent"
-```
-
-
-
-All running subscribers receive tokens from all responses concurrently. Each subscriber correctly reconstructs each response separately using the `responseId` to correlate tokens.
-
-## Next steps
-
-- Learn more about the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token) used in this guide
-- Learn about [client hydration strategies](/docs/ai-transport/token-streaming/message-per-token#hydration) for handling late joiners and reconnections
-- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI enabled applications
-- Explore the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response) for storing complete AI responses as single messages in history
diff --git a/src/pages/docs/ai-transport/guides/langgraph/langgraph-human-in-the-loop.mdx b/src/pages/docs/ai-transport/guides/langgraph/langgraph-human-in-the-loop.mdx
deleted file mode 100644
index 58d1551d52..0000000000
--- a/src/pages/docs/ai-transport/guides/langgraph/langgraph-human-in-the-loop.mdx
+++ /dev/null
@@ -1,466 +0,0 @@
----
-title: "Guide: Human-in-the-loop approval with LangGraph"
-meta_description: "Implement human approval workflows for AI agent tool calls using LangGraph and Ably with role-based access control."
-meta_keywords: "AI, human in the loop, HITL, LangGraph, LangChain, tool calling, approval workflow, AI transport, Ably, realtime, RBAC"
----
-
-This guide shows you how to implement a human-in-the-loop (HITL) approval workflow for AI agent tool calls using LangGraph and Ably. The agent requests human approval before executing sensitive operations, with role-based access control to verify approvers have sufficient permissions.
-
-When the model calls a tool that requires human approval, the graph uses a custom tool node that handles the approval check before executing. Rather than using the standard `ToolNode` to execute tools automatically, this node publishes an `approval-request` message to an Ably channel, waits for an `approval-response` from a human approver, verifies the approver has the required role using [claims embedded in their JWT token](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims), and only then executes the action.
-
-
-
-## Prerequisites
-
-To follow this guide, you need:
-- Node.js 20 or higher
-- An Anthropic API key
-- An Ably API key
-
-Useful links:
-- [LangGraph documentation](https://docs.langchain.com/oss/javascript/langgraph/overview)
-- [LangGraph tool calling](https://js.langchain.com/docs/how_to/tool_calling)
-- [Ably JavaScript SDK getting started](/docs/getting-started/javascript)
-
-Create a new Node project, which will contain the agent, client, and server code:
-
-
-```shell
-mkdir ably-langgraph-hitl-example && cd ably-langgraph-hitl-example
-npm init -y
-```
-
-
-Install the required packages using NPM:
-
-
-```shell
-npm install @langchain/langgraph@^0.2 @langchain/anthropic@^0.3 @langchain/core@^0.3 ably@^2 express jsonwebtoken zod
-```
-
-
-
-
-Export your API keys to the environment:
-
-
-```shell
-export ANTHROPIC_API_KEY="your_anthropic_api_key_here"
-export ABLY_API_KEY="your_ably_api_key_here"
-```
-
-
-## Step 1: Initialize the agent
-
-Set up the agent that will use LangGraph and request human approval for sensitive operations. This example uses a `publish_blog_post` tool that requires authorization before execution.
-
-Initialize the Ably client and create a channel for communication between the agent and human approvers.
-
-Add the following to a new file called `agent.mjs`:
-
-
-```javascript
-import { ChatAnthropic } from "@langchain/anthropic";
-import { StateGraph, Annotation, START, END } from "@langchain/langgraph";
-import * as z from "zod";
-import Ably from "ably";
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: process.env.ABLY_API_KEY,
- echoMessages: false,
-});
-
-// Wait for connection to be established
-await realtime.connection.once("connected");
-
-// Create a channel for HITL communication
-const channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
-// Track pending approval requests
-const pendingApprovals = new Map();
-
-// Function that executes the approved action
-async function publishBlogPost(args) {
- const { title } = args;
- console.log(`Publishing blog post: ${title}`);
- // In production, this would call your CMS API
- return { published: true, title };
-}
-```
-
-
-
-
-Tools that modify data, access sensitive resources, or perform actions with business impact are good candidates for HITL approval workflows.
-
-## Step 2: Request human approval
-
-When the model returns a tool call, publish an approval request to the channel and wait for a human decision. The tool call ID is passed in the message headers to correlate requests with responses.
-
-Add the approval request function to `agent.mjs`:
-
-
-```javascript
-async function requestHumanApproval(toolCall) {
- const approvalPromise = new Promise((resolve, reject) => {
- pendingApprovals.set(toolCall.id, { toolCall, resolve, reject });
- });
-
- await channel.publish({
- name: "approval-request",
- data: {
- tool: toolCall.name,
- arguments: toolCall.args,
- },
- extras: {
- headers: {
- toolCallId: toolCall.id,
- },
- },
- });
-
- console.log(`Approval request sent for: ${toolCall.name}`);
- return approvalPromise;
-}
-```
-
-
-The `toolCall.id` provided by LangGraph correlates the approval request with the response, enabling the agent to handle multiple concurrent approval flows.
-
-## Step 3: Subscribe to approval responses
-
-Set up a subscription to receive approval decisions from human users. When a response arrives, verify the approver has sufficient permissions using role-based access control before resolving the pending promise.
-
-Add the subscription handler to `agent.mjs`:
-
-
-```javascript
-async function subscribeApprovalResponses() {
- // Define role hierarchy from lowest to highest privilege
- const roleHierarchy = ["editor", "publisher", "admin"];
-
- // Define minimum role required for each tool
- const approvalPolicies = {
- publish_blog_post: { minRole: "publisher" },
- };
-
- function canApprove(approverRole, requiredRole) {
- const approverLevel = roleHierarchy.indexOf(approverRole);
- const requiredLevel = roleHierarchy.indexOf(requiredRole);
- return approverLevel >= requiredLevel;
- }
-
- await channel.subscribe("approval-response", async (message) => {
- const { decision } = message.data;
- const toolCallId = message.extras?.headers?.toolCallId;
- const pending = pendingApprovals.get(toolCallId);
-
- if (!pending) {
- console.log(`No pending approval for tool call: ${toolCallId}`);
- return;
- }
-
- const policy = approvalPolicies[pending.toolCall.name];
- // Get the trusted role from the JWT user claim
- const approverRole = message.extras?.userClaim;
-
- // Verify the approver's role meets the minimum required
- if (!canApprove(approverRole, policy.minRole)) {
- console.log(`Insufficient role: ${approverRole} < ${policy.minRole}`);
- pending.reject(
- new Error(
- `Approver role '${approverRole}' insufficient for required '${policy.minRole}'`
- )
- );
- pendingApprovals.delete(toolCallId);
- return;
- }
-
- // Process the decision
- if (decision === "approved") {
- console.log(`Approved by ${approverRole}`);
- pending.resolve({ approved: true, approverRole });
- } else {
- console.log(`Rejected by ${approverRole}`);
- pending.reject(new Error(`Action rejected by ${approverRole}`));
- }
- pendingApprovals.delete(toolCallId);
- });
-}
-```
-
-
-The `message.extras.userClaim` contains the role embedded in the approver's JWT token, providing a trusted source for authorization decisions. See [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) for details on embedding claims in tokens. This ensures only users with sufficient privileges can approve sensitive operations.
-
-## Step 4: Process tool calls
-
-Create a function to process tool calls by requesting approval and executing the action if approved.
-
-Add the tool processing function to `agent.mjs`:
-
-
-```javascript
-async function processToolCall(toolCall) {
- if (toolCall.name === "publish_blog_post") {
- // requestHumanApproval returns a promise that resolves when the human
- // approves the tool call, or rejects if the human explicitly rejects
- // the tool call or the approver's role is insufficient.
- await requestHumanApproval(toolCall);
- return await publishBlogPost(toolCall.args);
- }
- throw new Error(`Unknown tool: ${toolCall.name}`);
-}
-```
-
-
-The function awaits approval before executing. If the approver rejects or has insufficient permissions, the promise rejects and the tool is not executed.
-
-## Step 5: Run the agent
-
-Create the LangGraph state graph that routes tool calls through the approval workflow. The graph uses a custom tool node instead of the standard `ToolNode` to intercept tool calls for approval.
-
-Add the agent runner to `agent.mjs`:
-
-
-```javascript
-// Initialize the model with tool definitions
-const model = new ChatAnthropic({
- model: "claude-sonnet-4-5-20250929",
-}).bindTools([
- {
- name: "publish_blog_post",
- description: "Publish a blog post to the website. Requires human approval.",
- schema: z.object({
- title: z.string().describe("Title of the blog post to publish"),
- }),
- },
-]);
-
-// Define state with message history
-const StateAnnotation = Annotation.Root({
- messages: Annotation({
- reducer: (x, y) => x.concat(y),
- default: () => [],
- }),
-});
-
-// Agent node that calls the model
-async function agent(state) {
- const response = await model.invoke(state.messages);
- return { messages: [response] };
-}
-
-// Custom tool node that handles approval before execution
-async function toolsWithApproval(state) {
- const lastMessage = state.messages[state.messages.length - 1];
- const toolCalls = lastMessage.tool_calls || [];
- const toolResults = [];
-
- for (const toolCall of toolCalls) {
- try {
- const result = await processToolCall(toolCall);
- toolResults.push({
- tool_call_id: toolCall.id,
- type: "tool",
- content: JSON.stringify(result),
- });
- } catch (error) {
- toolResults.push({
- tool_call_id: toolCall.id,
- type: "tool",
- content: `Error: ${error.message}`,
- });
- }
- }
-
- return { messages: toolResults };
-}
-
-// Determine next step based on tool calls
-function shouldContinue(state) {
- const lastMessage = state.messages[state.messages.length - 1];
- if (lastMessage.tool_calls && lastMessage.tool_calls.length > 0) {
- return "tools";
- }
- return END;
-}
-
-// Build and compile the graph
-const graph = new StateGraph(StateAnnotation)
- .addNode("agent", agent)
- .addNode("tools", toolsWithApproval)
- .addEdge(START, "agent")
- .addConditionalEdges("agent", shouldContinue, ["tools", END])
- .addEdge("tools", "agent");
-
-const app = graph.compile();
-
-async function runAgent(prompt) {
- await subscribeApprovalResponses();
-
- console.log(`User: ${prompt}`);
-
- const result = await app.invoke({
- messages: [{ role: "user", content: prompt }],
- });
-
- console.log("Agent completed. Final response:");
- const lastMessage = result.messages[result.messages.length - 1];
- console.log(lastMessage.content);
-
- realtime.close();
-}
-
-runAgent("Publish the blog post called 'Introducing our new API'");
-```
-
-
-## Step 6: Create the authentication server
-
-The authentication server issues JWT tokens with embedded role claims. The role claim is trusted by Ably and included in messages, enabling secure role-based authorization.
-
-Add the following to a new file called `server.mjs`:
-
-
-```javascript
-import express from "express";
-import jwt from "jsonwebtoken";
-
-const app = express();
-
-// Mock authentication - replace with your actual auth logic
-function authenticateUser(req, res, next) {
- // In production, verify the user's session/credentials
- req.user = { id: "user123", role: "publisher" };
- next();
-}
-
-// Return claims to embed in the JWT
-function getJWTClaims(user) {
- return {
- "ably.channel.*": user.role,
- };
-}
-
-app.get("/api/auth/token", authenticateUser, (req, res) => {
- const [keyName, keySecret] = process.env.ABLY_API_KEY.split(":");
-
- const token = jwt.sign(getJWTClaims(req.user), keySecret, {
- algorithm: "HS256",
- keyid: keyName,
- expiresIn: "1h",
- });
-
- res.type("application/jwt").send(token);
-});
-
-app.listen(3001, () => {
- console.log("Auth server running on http://localhost:3001");
-});
-```
-
-
-The `ably.channel.*` claim embeds the user's role in the JWT. When the user publishes messages, this claim is available as `message.extras.userClaim`, providing a trusted source for authorization.
-
-Run the server:
-
-
-```shell
-node server.mjs
-```
-
-
-## Step 7: Create the approval client
-
-The approval client receives approval requests and allows humans to approve or reject them. It authenticates via the server to obtain a JWT with the user's role.
-
-Add the following to a new file called `client.mjs`:
-
-
-```javascript
-import Ably from "ably";
-import readline from "readline";
-
-const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
-});
-
-const realtime = new Ably.Realtime({
- authCallback: async (tokenParams, callback) => {
- try {
- const response = await fetch("http://localhost:3001/api/auth/token");
- const token = await response.text();
- callback(null, token);
- } catch (error) {
- callback(error, null);
- }
- },
-});
-
-realtime.connection.on("connected", () => {
- console.log("Connected to Ably");
- console.log("Waiting for approval requests...\n");
-});
-
-const channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
-await channel.subscribe("approval-request", (message) => {
- const request = message.data;
-
- console.log("\n========================================");
- console.log("APPROVAL REQUEST");
- console.log("========================================");
- console.log(`Tool: ${request.tool}`);
- console.log(`Arguments: ${JSON.stringify(request.arguments, null, 2)}`);
- console.log("========================================");
-
- rl.question("Approve this action? (y/n): ", async (answer) => {
- const decision = answer.toLowerCase() === "y" ? "approved" : "rejected";
-
- await channel.publish({
- name: "approval-response",
- data: { decision },
- extras: {
- headers: {
- toolCallId: message.extras?.headers?.toolCallId,
- },
- },
- });
-
- console.log(`Decision sent: ${decision}\n`);
- });
-});
-```
-
-
-Run the client in a separate terminal:
-
-
-```shell
-node client.mjs
-```
-
-
-With the server, client, and agent running, the workflow proceeds as follows:
-
-1. The agent sends a prompt to the model that triggers a tool call
-2. The agent publishes an approval request to the channel
-3. The client displays the request and prompts the user
-4. The user approves or rejects the request
-5. The agent verifies the approver's role meets the minimum requirement
-6. If approved and authorized, the agent executes the tool
-
-## Next steps
-
-- Learn more about [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) patterns and verification strategies
-- Explore [identifying users and agents](/docs/ai-transport/sessions-identity/identifying-users-and-agents) for secure identity verification
-- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI-enabled applications
-- Learn about [tool calls](/docs/ai-transport/messaging/tool-calls) for agent-to-agent communication
diff --git a/src/pages/docs/ai-transport/guides/langgraph/langgraph-message-per-response.mdx b/src/pages/docs/ai-transport/guides/langgraph/langgraph-message-per-response.mdx
deleted file mode 100644
index bb3425db7b..0000000000
--- a/src/pages/docs/ai-transport/guides/langgraph/langgraph-message-per-response.mdx
+++ /dev/null
@@ -1,421 +0,0 @@
----
-title: "Guide: Stream LangGraph responses using the message-per-response pattern"
-meta_description: "Stream tokens from LangGraph over Ably in realtime using message appends."
-meta_keywords: "AI, token streaming, LangGraph, LangChain, Anthropic, AI transport, Ably, realtime, message appends"
-redirect_from:
- - /docs/guides/ai-transport/langgraph-message-per-response
- - /docs/guides/ai-transport/langgraph/langgraph-message-per-response
----
-
-This guide shows you how to stream AI responses from [LangGraph](https://docs.langchain.com/oss/javascript/langgraph/overview) over Ably using the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response). Specifically, it appends each response token to a single Ably message, creating a complete AI response that grows incrementally while delivering tokens in realtime.
-
-Using Ably to distribute tokens from LangGraph enables you to broadcast AI responses to thousands of concurrent subscribers with reliable message delivery and ordering guarantees. This approach stores each complete response as a single message in channel history, making it easy to retrieve conversation history without processing thousands of individual token messages.
-
-
-
-## Prerequisites
-
-To follow this guide, you need:
-- Node.js 20 or higher
-- An Anthropic API key
-- An Ably API key
-
-Useful links:
-- [LangGraph documentation](https://docs.langchain.com/oss/javascript/langgraph/overview)
-- [Ably JavaScript SDK getting started](/docs/getting-started/javascript)
-
-Create a new Node project, which will contain the publisher and subscriber code:
-
-
-```shell
-mkdir ably-langgraph-example-per-response && cd ably-langgraph-example-per-response
-npm init -y
-```
-
-
-Install the required packages using NPM:
-
-
-```shell
-npm install @langchain/langgraph@^0.2 @langchain/anthropic@^0.3 @langchain/core@^0.3 ably@^2
-```
-
-
-
-
-Export your Anthropic API key to the environment, which will be used later in the guide by the Anthropic SDK:
-
-
-```shell
-export ANTHROPIC_API_KEY="your_api_key_here"
-```
-
-
-## Step 1: Enable message appends
-
-Message append functionality requires "Message annotations, updates, deletes and appends" to be enabled in a [rule](/docs/channels#rules) associated with the channel.
-
-
-
-To enable the rule:
-
-1. Go to the [Ably dashboard](https://www.ably.com/dashboard) and select your app.
-2. Navigate to the "Configuration" > "Rules" section from the left-hand navigation bar.
-3. Choose "Add new rule".
-4. Enter a channel name or namespace pattern (e.g. `ai` for all channels starting with `ai:`).
-5. Select the "Message annotations, updates, deletes and appends" option from the list.
-6. Click "Create rule".
-
-The examples in this guide use the `ai:` namespace prefix, which assumes you have configured the rule for `ai:*`.
-
-
-
-## Step 2: Get a streamed response from LangGraph
-
-Initialize LangGraph with a simple graph that uses Claude to respond to prompts, and use [`stream`](https://docs.langchain.com/oss/javascript/langgraph/streaming) with `streamMode: "messages"` to stream model tokens.
-
-Create a new file `agent.mjs` with the following contents:
-
-
-```javascript
-import { ChatAnthropic } from "@langchain/anthropic";
-import { StateGraph, Annotation, START, END } from "@langchain/langgraph";
-
-// Initialize the model
-const model = new ChatAnthropic({ model: "claude-sonnet-4-5" });
-
-// Define state with message history
-const StateAnnotation = Annotation.Root({
- messages: Annotation({
- reducer: (x, y) => x.concat(y),
- default: () => [],
- }),
-});
-
-// Build and compile a simple graph
-const graph = new StateGraph(StateAnnotation)
- .addNode("agent", async (state) => {
- const response = await model.invoke(state.messages);
- return { messages: [response] };
- })
- .addEdge(START, "agent")
- .addEdge("agent", END);
-
-const app = graph.compile();
-
-// Stream response tokens
-async function streamLangGraphResponse(prompt) {
- const stream = await app.stream(
- { messages: [{ role: "user", content: prompt }] },
- { streamMode: "messages" }
- );
-
- for await (const [messageChunk, metadata] of stream) {
- console.log(messageChunk.content || "(empty)");
- }
-}
-
-// Usage example
-streamLangGraphResponse("Tell me a short joke");
-```
-
-
-### Understand LangGraph streaming
-
-LangGraph's [`stream`](https://docs.langchain.com/oss/javascript/langgraph/streaming) method with `streamMode: "messages"` streams LLM tokens from your graph. The stream returns tuples of `[messageChunk, metadata]` where:
-
-- `messageChunk`: Contains the token content in the `content` field. These represent incremental text chunks as the model generates them. The message chunk also includes an `id` field that uniquely identifies the response.
-
-- `metadata`: Contains metadata about the stream, including the `langgraph_node` where the LLM is invoked and any associated tags.
-
-The following example shows the message chunks received when streaming a response. Each event is a tuple of `[messageChunk, metadata]`:
-
-
-```json
-// 1. Stream initialization (empty content with model metadata)
-[{"lc":1,"type":"constructor","id":["langchain_core","messages","AIMessageChunk"],"kwargs":{"content":"","additional_kwargs":{"model":"claude-sonnet-4-5-20250929","id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5","type":"message","role":"assistant"},"tool_call_chunks":[],"usage_metadata":{"input_tokens":12,"output_tokens":1,"total_tokens":13},"id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5"}},{"langgraph_step":1,"langgraph_node":"agent","langgraph_triggers":["branch:to:agent"]}]
-
-// 2. Empty content chunk
-[{"lc":1,"type":"constructor","id":["langchain_core","messages","AIMessageChunk"],"kwargs":{"content":"","additional_kwargs":{},"tool_call_chunks":[],"id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5"}},{"langgraph_step":1,"langgraph_node":"agent"}]
-
-// 3. Text tokens stream in
-[{"lc":1,"type":"constructor","id":["langchain_core","messages","AIMessageChunk"],"kwargs":{"content":"Why","additional_kwargs":{},"tool_call_chunks":[],"id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5"}},{"langgraph_step":1,"langgraph_node":"agent"}]
-[{"lc":1,"type":"constructor","id":["langchain_core","messages","AIMessageChunk"],"kwargs":{"content":" don't scientists trust atoms?\n\nBecause","additional_kwargs":{},"tool_call_chunks":[],"id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5"}},{"langgraph_step":1,"langgraph_node":"agent"}]
-[{"lc":1,"type":"constructor","id":["langchain_core","messages","AIMessageChunk"],"kwargs":{"content":" they make up everything!","additional_kwargs":{},"tool_call_chunks":[],"id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5"}},{"langgraph_step":1,"langgraph_node":"agent"}]
-
-// 4. Stream completion (empty content with stop reason and final usage)
-[{"lc":1,"type":"constructor","id":["langchain_core","messages","AIMessageChunk"],"kwargs":{"content":"","additional_kwargs":{"stop_reason":"end_turn","stop_sequence":null},"usage_metadata":{"input_tokens":0,"output_tokens":17,"total_tokens":17},"id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5"}},{"langgraph_step":1,"langgraph_node":"agent"}]
-```
-
-
-
-
-## Step 3: Publish streaming tokens to Ably
-
-Publish LangGraph streaming tokens to Ably using message appends to reliably and scalably distribute them to subscribers.
-
-Each AI response is stored as a single Ably message that grows as tokens are appended.
-
-### Initialize the Ably client
-
-Add the Ably client initialization to your `agent.mjs` file:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- echoMessages: false
-});
-
-// Create a channel for publishing streamed AI responses
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-```
-
-
-The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish tokens at high message rates with low latency.
-
-
-
-### Publish initial message and append tokens
-
-When a new response begins, publish an initial message to create it. Ably assigns a [`serial`](/docs/messages#properties) identifier to the message. Use this `serial` to append each token to the message as it arrives from the LangGraph stream.
-
-Update your `agent.mjs` file to publish the initial message and append tokens:
-
-
-```javascript
-// Track state across chunks
-let msgSerial = null;
-
-// Stream response tokens
-async function streamLangGraphResponse(prompt) {
- const stream = await app.stream(
- { messages: [{ role: "user", content: prompt }] },
- { streamMode: "messages" }
- );
-
- for await (const [messageChunk, metadata] of stream) {
- const content = messageChunk?.content;
-
- // Publish initial empty message on first chunk
- if (!msgSerial && messageChunk?.id) {
- const result = await channel.publish({
- name: 'response',
- data: ''
- });
-
- // Capture the message serial for appending tokens
- msgSerial = result.serials[0];
- }
-
- // Append token content to the message
- if (content && msgSerial) {
- channel.appendMessage({
- serial: msgSerial,
- data: content
- });
- }
- }
-
- console.log('Stream completed!');
-}
-```
-
-
-This implementation:
-
-- Publishes an initial empty message when the first chunk arrives and captures the `serial`
-- Filters for chunks with non-empty `content`
-- Appends each token to the original message using the captured `serial`
-
-
-
-
-
-Run the publisher to see tokens streaming to Ably:
-
-
-```shell
-node agent.mjs
-```
-
-
-## Step 4: Subscribe to streaming tokens
-
-Create a subscriber that receives the streaming tokens from Ably and reconstructs the response in realtime.
-
-Create a new file `client.mjs` with the following contents:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({ key: '{{API_KEY}}' });
-
-// Get the same channel used by the publisher
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by message serial
-const responses = new Map();
-
-// Subscribe to receive messages
-await channel.subscribe((message) => {
- switch (message.action) {
- case 'message.create':
- // New response started
- console.log('\n[Response started]', message.serial);
- responses.set(message.serial, message.data);
- break;
-
- case 'message.append':
- // Append token to existing response
- const current = responses.get(message.serial) || '';
- responses.set(message.serial, current + message.data);
-
- // Display token as it arrives
- process.stdout.write(message.data);
- break;
-
- case 'message.update':
- // Replace entire response content
- responses.set(message.serial, message.data);
- console.log('\n[Response updated with full content]');
- break;
- }
-});
-
-console.log('Subscriber ready, waiting for tokens...');
-```
-
-
-Subscribers receive different message actions depending on when they join and how they're retrieving messages:
-
-- `message.create`: Indicates a new response has started (i.e. a new message was created). The message `data` contains the initial content (often empty or the first token). Store this as the beginning of a new response using `serial` as the identifier.
-
-- `message.append`: Contains a single token fragment to append. The message `data` contains only the new token, not the full concatenated response. Append this token to the existing response identified by `serial`.
-
-- `message.update`: Contains the whole response up to that point. The message `data` contains the full concatenated text so far. Replace the entire response content with this data for the message identified by `serial`. This action occurs when the channel needs to resynchronize the full message state, such as after a client [resumes](/docs/connect/states#resume) from a transient disconnection.
-
-Run the subscriber in a separate terminal:
-
-
-```shell
-node client.mjs
-```
-
-
-With the subscriber running, run the publisher in another terminal. The tokens stream in realtime as the AI model generates them.
-
-## Step 5: Stream with multiple publishers and subscribers
-
-Ably's [channel-oriented sessions](/docs/ai-transport/sessions-identity#connection-oriented-vs-channel-oriented-sessions) enables multiple AI agents to publish responses and multiple users to receive them on a single channel simultaneously. Ably handles message delivery to all participants, eliminating the need to implement routing logic or manage state synchronization across connections.
-
-### Broadcasting to multiple subscribers
-
-Each subscriber receives the complete stream of tokens independently, enabling you to build collaborative experiences or multi-device applications.
-
-Run a subscriber in multiple separate terminals:
-
-
-```shell
-# Terminal 1
-node client.mjs
-
-# Terminal 2
-node client.mjs
-
-# Terminal 3
-node client.mjs
-```
-
-
-All subscribers receive the same stream of tokens in realtime.
-
-### Publishing concurrent responses
-
-Multiple publishers can stream different responses concurrently on the same [channel](/docs/channels). Each response is stored as a separate message with its own `serial`, allowing subscribers to track all responses independently.
-
-To demonstrate this, run a publisher in multiple separate terminals:
-
-
-```shell
-# Terminal 1
-node agent.mjs
-
-# Terminal 2
-node agent.mjs
-
-# Terminal 3
-node agent.mjs
-```
-
-
-All running subscribers receive tokens from all responses concurrently. Each subscriber correctly reconstructs each response separately using the `serial` to correlate tokens.
-
-## Step 6: Retrieve complete responses from history
-
-One key advantage of the message-per-response pattern is that each complete AI response is stored as a single message in channel history. This makes it efficient to retrieve conversation history without processing thousands of individual token messages.
-
-Use Ably's [rewind](/docs/channels/options/rewind) channel option to attach to the channel at some point in the recent past and automatically receive complete responses from history. Historical messages are delivered as `message.update` events containing the complete concatenated response, which then seamlessly transition to live `message.append` events for any ongoing responses:
-
-
-```javascript
-// Use rewind to receive recent historical messages
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}', {
- params: { rewind: '2m' } // Retrieve messages from the last 2 minutes
-});
-
-const responses = new Map();
-
-await channel.subscribe((message) => {
- switch (message.action) {
- case 'message.create':
- responses.set(message.serial, message.data);
- break;
-
- case 'message.append':
- const current = responses.get(message.serial) || '';
- responses.set(message.serial, current + message.data);
- process.stdout.write(message.data);
- break;
-
- case 'message.update':
- // Historical messages contain full concatenated response
- responses.set(message.serial, message.data);
- console.log('\n[Historical response]:', message.data);
- break;
- }
-});
-```
-
-
-
-
-## Next steps
-
-- Learn more about the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response) used in this guide
-- Learn about [client hydration strategies](/docs/ai-transport/token-streaming/message-per-response#hydration) for handling late joiners and reconnections
-- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI enabled applications
-- Explore the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token) for streaming individual tokens as separate messages
diff --git a/src/pages/docs/ai-transport/guides/langgraph/langgraph-message-per-token.mdx b/src/pages/docs/ai-transport/guides/langgraph/langgraph-message-per-token.mdx
deleted file mode 100644
index 27796b8ffa..0000000000
--- a/src/pages/docs/ai-transport/guides/langgraph/langgraph-message-per-token.mdx
+++ /dev/null
@@ -1,366 +0,0 @@
----
-title: "Guide: Stream LangGraph responses using the message-per-token pattern"
-meta_description: "Stream tokens from LangGraph over Ably in realtime."
-meta_keywords: "AI, token streaming, LangGraph, LangChain, Anthropic, AI transport, Ably, realtime"
-redirect_from:
- - /docs/guides/ai-transport/langgraph-message-per-token
- - /docs/guides/ai-transport/langgraph/langgraph-message-per-token
----
-
-This guide shows you how to stream AI responses from [LangGraph](https://docs.langchain.com/oss/javascript/langgraph/overview) over Ably using the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token). Specifically, it implements the [explicit start/stop events approach](/docs/ai-transport/token-streaming/message-per-token#explicit-events), which publishes each response token as an individual message, along with explicit lifecycle events to signal when responses begin and end.
-
-Using Ably to distribute tokens from LangGraph enables you to broadcast AI responses to thousands of concurrent subscribers with reliable message delivery and ordering guarantees, ensuring that each client receives the complete response stream with all tokens delivered in order. This approach decouples your AI inference from client connections, enabling you to scale agents independently and handle reconnections gracefully.
-
-
-
-## Prerequisites
-
-To follow this guide, you need:
-- Node.js 20 or higher
-- An Anthropic API key
-- An Ably API key
-
-Useful links:
-- [LangGraph documentation](https://docs.langchain.com/oss/javascript/langgraph/overview)
-- [Ably JavaScript SDK getting started](/docs/getting-started/javascript)
-
-Create a new NPM package, which will contain the publisher and subscriber code:
-
-
-```shell
-mkdir ably-langgraph-example && cd ably-langgraph-example
-npm init -y
-```
-
-
-Install the required packages using NPM:
-
-
-```shell
-npm install @langchain/langgraph@^0.2 @langchain/anthropic@^0.3 @langchain/core@^0.3 ably@^2
-```
-
-
-
-
-Export your Anthropic API key to the environment, which will be used later in the guide by the Anthropic SDK:
-
-
-```shell
-export ANTHROPIC_API_KEY="your_api_key_here"
-```
-
-
-## Step 1: Get a streamed response from LangGraph
-
-Initialize LangGraph with a simple graph that uses Claude to respond to prompts, and use [`stream`](https://docs.langchain.com/oss/javascript/langgraph/streaming) with `streamMode: "messages"` to stream model tokens.
-
-Create a new file `agent.mjs` with the following contents:
-
-
-```javascript
-import { ChatAnthropic } from "@langchain/anthropic";
-import { StateGraph, Annotation, START, END } from "@langchain/langgraph";
-
-// Initialize the model
-const model = new ChatAnthropic({ model: "claude-sonnet-4-5" });
-
-// Define state with message history
-const StateAnnotation = Annotation.Root({
- messages: Annotation({
- reducer: (x, y) => x.concat(y),
- default: () => [],
- }),
-});
-
-// Build and compile a simple graph
-const graph = new StateGraph(StateAnnotation)
- .addNode("agent", async (state) => {
- const response = await model.invoke(state.messages);
- return { messages: [response] };
- })
- .addEdge(START, "agent")
- .addEdge("agent", END);
-
-const app = graph.compile();
-
-// Stream response tokens
-async function streamLangGraphResponse(prompt) {
- const stream = await app.stream(
- { messages: [{ role: "user", content: prompt }] },
- { streamMode: "messages" }
- );
-
- for await (const [messageChunk, metadata] of stream) {
- console.log(messageChunk.content || "(empty)");
- }
-}
-
-// Usage example
-streamLangGraphResponse("Tell me a short joke");
-```
-
-
-### Understand LangGraph streaming
-
-LangGraph's [`stream`](https://docs.langchain.com/oss/javascript/langgraph/streaming) method with `streamMode: "messages"` streams LLM tokens from your graph. The stream returns tuples of `[messageChunk, metadata]` where:
-
-- `messageChunk`: Contains the token content in the `content` field. These represent incremental text chunks as the model generates them.
-
-- `metadata`: Contains metadata about the stream, including the `langgraph_node` where the LLM is invoked and any associated tags.
-
-The following example shows the message chunks received when streaming a response. Each event is a tuple of `[messageChunk, metadata]`:
-
-
-```json
-// 1. Stream initialization (empty content with model metadata)
-[{"lc":1,"type":"constructor","id":["langchain_core","messages","AIMessageChunk"],"kwargs":{"content":"","additional_kwargs":{"model":"claude-sonnet-4-5-20250929","id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5","type":"message","role":"assistant"},"tool_call_chunks":[],"usage_metadata":{"input_tokens":12,"output_tokens":1,"total_tokens":13},"id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5"}},{"langgraph_step":1,"langgraph_node":"agent","langgraph_triggers":["branch:to:agent"]}]
-
-// 2. Empty content chunk
-[{"lc":1,"type":"constructor","id":["langchain_core","messages","AIMessageChunk"],"kwargs":{"content":"","additional_kwargs":{},"tool_call_chunks":[],"id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5"}},{"langgraph_step":1,"langgraph_node":"agent"}]
-
-// 3. Text tokens stream in
-[{"lc":1,"type":"constructor","id":["langchain_core","messages","AIMessageChunk"],"kwargs":{"content":"Why","additional_kwargs":{},"tool_call_chunks":[],"id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5"}},{"langgraph_step":1,"langgraph_node":"agent"}]
-[{"lc":1,"type":"constructor","id":["langchain_core","messages","AIMessageChunk"],"kwargs":{"content":" don't scientists trust atoms?\n\nBecause","additional_kwargs":{},"tool_call_chunks":[],"id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5"}},{"langgraph_step":1,"langgraph_node":"agent"}]
-[{"lc":1,"type":"constructor","id":["langchain_core","messages","AIMessageChunk"],"kwargs":{"content":" they make up everything!","additional_kwargs":{},"tool_call_chunks":[],"id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5"}},{"langgraph_step":1,"langgraph_node":"agent"}]
-
-// 4. Stream completion (empty content with stop reason and final usage)
-[{"lc":1,"type":"constructor","id":["langchain_core","messages","AIMessageChunk"],"kwargs":{"content":"","additional_kwargs":{"stop_reason":"end_turn","stop_sequence":null},"usage_metadata":{"input_tokens":0,"output_tokens":17,"total_tokens":17},"id":"msg_01SPbpi5P7CkNqgxPT2Ne9u5"}},{"langgraph_step":1,"langgraph_node":"agent"}]
-```
-
-
-
-
-## Step 2: Publish streaming events to Ably
-
-Publish LangGraph streaming events to Ably to reliably and scalably distribute them to subscribers.
-
-This implementation follows the [explicit start/stop events pattern](/docs/ai-transport/token-streaming/message-per-token#explicit-events), which provides clear response boundaries.
-
-### Initialize the Ably client
-
-Add the Ably client initialization to your `agent.mjs` file:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- echoMessages: false
-});
-
-// Create a channel for publishing streamed AI responses
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-```
-
-
-The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish tokens at high message rates with low latency.
-
-
-
-### Map LangGraph streaming events to Ably messages
-
-Choose how to map [LangGraph streaming events](#understand-streaming-events) to Ably [messages](/docs/messages). You can choose any mapping strategy that suits your application's needs. This guide uses the following pattern as an example:
-
-- `start`: Signals the beginning of a response
-- `token`: Contains the incremental text content for each delta
-- `stop`: Signals the completion of a response
-
-Update your `agent.mjs` file to initialize the Ably client and update the `streamLangGraphResponse()` function to publish streaming tokens to Ably:
-
-
-```javascript
-// Track response ID across events
-let responseId = null;
-
-// Create streaming response from LangGraph
-async function streamLangGraphResponse(prompt) {
- const input = {
- messages: [{ role: "user", content: prompt }],
- };
-
- // Stream tokens using messages mode
- const stream = await app.stream(input, { streamMode: "messages" });
-
- for await (const [messageChunk, metadata] of stream) {
- // Capture response ID from the first message chunk
- if (!responseId && messageChunk?.id) {
- responseId = messageChunk.id;
-
- // Publish start event with response ID
- channel.publish({
- name: 'start',
- extras: {
- headers: { responseId }
- }
- });
- }
-
- // Extract token content
- const content = messageChunk?.content;
- if (content) {
- channel.publish({
- name: 'token',
- data: content,
- extras: {
- headers: { responseId }
- }
- });
- }
- }
-
- // Publish stop event
- channel.publish({
- name: 'stop',
- extras: {
- headers: { responseId }
- }
- });
-}
-```
-
-
-This implementation:
-
-- Captures the `responseId` from the first message chunk's `id` field
-- Publishes a `start` event when the response ID is captured
-- Streams tokens from the graph using `streamMode: "messages"`
-- Extracts the `content` from each message chunk and publishes it as a `token` event
-- Publishes a `stop` event when streaming completes
-- All published events include the `responseId` in message [`extras`](/docs/messages#properties) to allow the client to correlate events relating to a particular response
-
-
-
-Run the publisher to see tokens streaming to Ably:
-
-
-```shell
-node agent.mjs
-```
-
-
-## Step 3: Subscribe to streaming tokens
-
-Create a subscriber that receives the streaming events from Ably and reconstructs the response.
-
-Create a new file `client.mjs` with the following contents:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({ key: '{{API_KEY}}' });
-
-// Get the same channel used by the publisher
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by ID
-const responses = new Map();
-
-// Handle response start
-await channel.subscribe('start', (message) => {
- const responseId = message.extras?.headers?.responseId;
- console.log('\n[Response started]', responseId);
- responses.set(responseId, '');
-});
-
-// Handle tokens
-await channel.subscribe('token', (message) => {
- const responseId = message.extras?.headers?.responseId;
- const token = message.data;
-
- // Append token to response
- const currentText = responses.get(responseId) || '';
- responses.set(responseId, currentText + token);
-
- // Display token as it arrives
- process.stdout.write(token);
-});
-
-// Handle response stop
-await channel.subscribe('stop', (message) => {
- const responseId = message.extras?.headers?.responseId;
- const finalText = responses.get(responseId);
-
- console.log('\n[Response completed]', responseId);
-});
-
-console.log('Subscriber ready, waiting for tokens...');
-```
-
-
-Run the subscriber in a separate terminal:
-
-
-```shell
-node client.mjs
-```
-
-
-With the subscriber running, run the publisher in another terminal. The tokens stream in realtime as the AI model generates them.
-
-## Step 4: Stream with multiple publishers and subscribers
-
-Ably's [channel-oriented sessions](/docs/ai-transport/sessions-identity#connection-oriented-vs-channel-oriented-sessions) enables multiple AI agents to publish responses and multiple users to receive them on a single channel simultaneously. Ably handles message delivery to all participants, eliminating the need to implement routing logic or manage state synchronization across connections.
-
-### Broadcasting to multiple subscribers
-
-Each subscriber receives the complete stream of tokens independently, enabling you to build collaborative experiences or multi-device applications.
-
-Run a subscriber in multiple separate terminals:
-
-
-```shell
-# Terminal 1
-node client.mjs
-
-# Terminal 2
-node client.mjs
-
-# Terminal 3
-node client.mjs
-```
-
-
-All subscribers receive the same stream of tokens in realtime.
-
-### Publishing concurrent responses
-
-The implementation uses `responseId` in message [`extras`](/docs/messages#properties) to correlate tokens with their originating response. This enables multiple publishers to stream different responses concurrently on the same [channel](/docs/channels), with each subscriber correctly tracking all responses independently.
-
-To demonstrate this, run a publisher in multiple separate terminals:
-
-
-```shell
-# Terminal 1
-node agent.mjs
-
-# Terminal 2
-node agent.mjs
-
-# Terminal 3
-node agent.mjs
-```
-
-
-All running subscribers receive tokens from all responses concurrently. Each subscriber correctly reconstructs each response separately using the `responseId` to correlate tokens.
-
-## Next steps
-
-- Learn more about the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token) used in this guide
-- Learn about [client hydration strategies](/docs/ai-transport/token-streaming/message-per-token#hydration) for handling late joiners and reconnections
-- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI enabled applications
-- Explore the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response) for storing complete AI responses as single messages in history
diff --git a/src/pages/docs/ai-transport/guides/openai/openai-citations.mdx b/src/pages/docs/ai-transport/guides/openai/openai-citations.mdx
deleted file mode 100644
index de7e02f625..0000000000
--- a/src/pages/docs/ai-transport/guides/openai/openai-citations.mdx
+++ /dev/null
@@ -1,545 +0,0 @@
----
-title: "Guide: Attach citations to OpenAI responses using message annotations"
-meta_description: "Attach source citations to AI responses from the OpenAI Responses API using Ably message annotations."
-meta_keywords: "AI, citations, OpenAI, Responses API, AI transport, Ably, realtime, message annotations, source attribution, web search"
-redirect_from:
- - /docs/guides/ai-transport/openai/openai-citations
----
-
-This guide shows you how to attach source citations to AI responses from OpenAI's [Responses API](https://platform.openai.com/docs/api-reference/responses) using Ably [message annotations](/docs/messages/annotations). When OpenAI provides citations from web search results, you can publish them as annotations on Ably messages, enabling clients to display source references alongside AI responses in realtime.
-
-Attaching citations to AI responses enables your users to see the original sources that were used in the generated response, explore topics in depth, and properly attribute the source content creators. Citations provide explicit traceability between the generated response and the information sources that were used when generating them.
-
-Ably [message annotations](/docs/messages/annotations) let you separate citation metadata from response content, display citation summaries updated in realtime, and retrieve detailed citation data on demand.
-
-
-
-## Prerequisites
-
-To follow this guide, you need:
-- Node.js 20 or higher
-- An OpenAI API key
-- An Ably API key
-
-Useful links:
-- [OpenAI Web Search documentation](https://platform.openai.com/docs/guides/tools-web-search)
-- [Ably JavaScript SDK getting started](/docs/getting-started/javascript)
-
-Create a new Node project, which will contain the publisher and subscriber code:
-
-
-```shell
-mkdir ably-openai-citations && cd ably-openai-citations
-npm init -y
-```
-
-
-Install the required packages using NPM:
-
-
-```shell
-npm install openai@^4 ably@^2
-```
-
-
-
-
-Export your OpenAI API key to the environment, which will be used later in the guide by the OpenAI SDK:
-
-
-```shell
-export OPENAI_API_KEY="your_api_key_here"
-```
-
-
-## Step 1: Enable message annotations
-
-Message annotations require "Message annotations, updates, deletes and appends" to be enabled in a [rule](/docs/channels#rules) associated with the channel.
-
-
-
-To enable the rule:
-
-1. Go to the [Ably dashboard](https://www.ably.com/dashboard) and select your app.
-2. Navigate to the "Configuration" > "Rules" section from the left-hand navigation bar.
-3. Choose "Add new rule".
-4. Enter a channel name or namespace pattern (e.g. `ai` for all channels starting with `ai:`).
-5. Select the "Message annotations, updates, deletes and appends" option from the list.
-6. Click "Create rule".
-
-The examples in this guide use the `ai:` namespace prefix, which assumes you have configured the rule for `ai:*`.
-
-
-
-## Step 2: Get a response with citations from OpenAI
-
-Initialize an OpenAI client and use the [Responses API](https://platform.openai.com/docs/api-reference/responses) with web search enabled. When web search is used, OpenAI includes `url_citation` annotations in the response.
-
-Create a new file `agent.mjs` with the following contents:
-
-
-```javascript
-import Ably from 'ably';
-import OpenAI from 'openai';
-
-// Initialize OpenAI client
-const openai = new OpenAI();
-
-// Create response with web search enabled
-async function getOpenAIResponseWithCitations(question) {
- const response = await openai.responses.create({
- model: "gpt-5",
- input: question,
- tools: [{ type: "web_search_preview" }]
- });
-
- console.log(JSON.stringify(response, null, 2));
-}
-
-// Usage example
-getOpenAIResponseWithCitations(
- "What are the latest discoveries from the James Webb Space Telescope in 2025?"
-);
-```
-
-
-
-
-### Understand OpenAI citation responses
-
-When web search is enabled, OpenAI's Responses API returns responses with `url_citation` annotations embedded in the output. The response includes both the web search call and the message with citations.
-
-The following example shows the response structure when citations are included:
-
-
-```json
-{
- "id": "resp_abc123",
- "status": "completed",
- "output": [
- {
- "type": "web_search_call",
- "id": "ws_456",
- "status": "completed"
- },
- {
- "type": "message",
- "id": "msg_789",
- "role": "assistant",
- "content": [
- {
- "type": "output_text",
- "text": "The James Webb Space Telescope launched on December 25, 2021 [1]. Its first full-color images were released on July 12, 2022 [2].",
- "annotations": [
- {
- "type": "url_citation",
- "start_index": 51,
- "end_index": 54,
- "url": "https://science.nasa.gov/mission/webb/",
- "title": "James Webb Space Telescope - NASA Science"
- },
- {
- "type": "url_citation",
- "start_index": 110,
- "end_index": 113,
- "url": "https://en.wikipedia.org/wiki/James_Webb_Space_Telescope",
- "title": "James Webb Space Telescope - Wikipedia"
- }
- ]
- }
- ]
- }
- ]
-}
-```
-
-
-Each `url_citation` annotation includes:
-
-- `type`: Always `"url_citation"` for web search citations.
-- `start_index`: The character position in the response text where the citation marker begins.
-- `end_index`: The character position where the citation marker ends.
-- `url`: The source URL being cited.
-- `title`: The title of the source page.
-
-
-
-## Step 3: Publish response and citations to Ably
-
-Publish the AI response as an Ably message, then publish each citation as a message annotation referencing the response message's `serial`.
-
-### Initialize the Ably client
-
-Add the Ably import and client initialization to your `agent.mjs` file:
-
-
-```javascript
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- echoMessages: false
-});
-
-// Create a channel for publishing AI responses
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-```
-
-
-The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish messages with low latency.
-
-
-
-### Publish response and citations
-
-Add a `processResponse` function to extract the text and citations from the OpenAI response, then publish them to Ably. Update `getOpenAIResponseWithCitations` to call it by replacing the `console.log` line with `await processResponse(response);`:
-
-
-```javascript
-// Process response and publish to Ably
-async function processResponse(response) {
- let fullText = '';
- const citations = [];
-
- // Extract text and citations from response
- for (const item of response.output) {
- if (item.type === 'message') {
- for (const content of item.content) {
- if (content.type === 'output_text') {
- fullText = content.text;
-
- if (content.annotations) {
- for (const annotation of content.annotations) {
- if (annotation.type === 'url_citation') {
- citations.push({
- url: annotation.url,
- title: annotation.title,
- startIndex: annotation.start_index,
- endIndex: annotation.end_index
- });
- }
- }
- }
- }
- }
- }
- }
-
- // Publish the AI response message
- const { serials: [msgSerial] } = await channel.publish('response', fullText);
- console.log('Published response with serial:', msgSerial);
-
- // Publish each citation as an annotation
- for (const citation of citations) {
- const sourceDomain = new URL(citation.url).hostname;
-
- await channel.annotations.publish(msgSerial, {
- type: 'citations:multiple.v1',
- name: sourceDomain,
- data: {
- url: citation.url,
- title: citation.title,
- startIndex: citation.startIndex,
- endIndex: citation.endIndex
- }
- });
- }
-
- console.log(`Published ${citations.length} citation(s)`);
-}
-```
-
-
-This implementation:
-
-- Extracts the response text from the `output_text` content block
-- Collects all `url_citation` annotations with their URLs, titles, and positions
-- Publishes the response as a single Ably message and captures its `serial`
-- Publishes each citation as an annotation using the [`multiple.v1`](/docs/messages/annotations#multiple) summarization method
-- Uses the source domain as the annotation `name` for grouping in summaries
-
-
-
-Run the publisher to see responses and citations published to Ably:
-
-
-```shell
-node agent.mjs
-```
-
-
-## Step 4: Subscribe to citation summaries
-
-Create a subscriber that receives AI responses and citation summaries in realtime.
-
-Create a new file `client.mjs` with the following contents:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({ key: '{{API_KEY}}' });
-
-// Get the same channel used by the publisher
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses
-const responses = new Map();
-
-// Subscribe to receive messages and summaries
-await channel.subscribe((message) => {
- switch (message.action) {
- case 'message.create':
- console.log('\n[New response]');
- console.log('Serial:', message.serial);
- console.log('Content:', message.data);
- responses.set(message.serial, { content: message.data, citations: {} });
- break;
-
- case 'message.summary':
- const citationsSummary = message.annotations?.summary['citations:multiple.v1'];
- if (citationsSummary) {
- console.log('\n[Citation summary updated]');
- for (const [source, data] of Object.entries(citationsSummary)) {
- console.log(` ${source}: ${data.total} citation(s)`);
- }
- }
- break;
- }
-});
-
-console.log('Subscriber ready, waiting for responses and citations...');
-```
-
-
-Run the subscriber in a separate terminal:
-
-
-```shell
-node client.mjs
-```
-
-
-With the subscriber running, run the publisher in another terminal. You'll see the response appear followed by citation summary updates showing counts grouped by source domain.
-
-## Step 5: Subscribe to individual citations
-
-To access the full citation data for rendering source links or inline markers, subscribe to individual annotation events.
-
-Create a new file `citation-client.mjs` with the following contents:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({ key: '{{API_KEY}}' });
-
-// Get the channel with annotation subscription enabled
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}', {
- modes: ['SUBSCRIBE', 'ANNOTATION_SUBSCRIBE']
-});
-
-// Track responses and their citations
-const responses = new Map();
-
-// Subscribe to messages
-await channel.subscribe((message) => {
- if (message.action === 'message.create') {
- console.log('\n[New response]');
- console.log('Serial:', message.serial);
- console.log('Content:', message.data);
- responses.set(message.serial, { content: message.data, citations: [] });
- }
-});
-
-// Subscribe to individual citation annotations
-await channel.annotations.subscribe((annotation) => {
- if (annotation.action === 'annotation.create' &&
- annotation.type === 'citations:multiple.v1') {
- const { url, title, startIndex, endIndex } = annotation.data;
-
- console.log('\n[Citation received]');
- console.log(` Title: ${title}`);
- console.log(` URL: ${url}`);
- console.log(` Position: ${startIndex}-${endIndex}`);
-
- // Store citation for the response
- const response = responses.get(annotation.messageSerial);
- if (response) {
- response.citations.push(annotation.data);
- }
- }
-});
-
-console.log('Subscriber ready, waiting for responses and citations...');
-```
-
-
-Run the citation subscriber:
-
-
-```shell
-node citation-client.mjs
-```
-
-
-This subscriber receives the full citation data as each annotation arrives, enabling you to:
-
-- Display clickable source links with titles and URLs
-- Link inline citation markers (like `[1]`) to their sources using the position indices
-- Build a references section with all cited sources
-
-## Step 6: Combine with streaming responses
-
-You can combine citations with the [message-per-response](/docs/ai-transport/token-streaming/message-per-response) streaming pattern. OpenAI's streaming responses include citation annotations in the final `response.output_text.done` event.
-
-
-```javascript
-import OpenAI from 'openai';
-import Ably from 'ably';
-
-const openai = new OpenAI();
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- echoMessages: false
-});
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track state for streaming
-let msgSerial = null;
-let messageItemId = null;
-
-// Process streaming events
-async function processStreamEvent(event) {
- switch (event.type) {
- case 'response.created':
- // Publish initial empty message
- const result = await channel.publish({ name: 'response', data: '' });
- msgSerial = result.serials[0];
- break;
-
- case 'response.output_item.added':
- if (event.item.type === 'message') {
- messageItemId = event.item.id;
- }
- break;
-
- case 'response.output_text.delta':
- // Append text token
- if (event.item_id === messageItemId && msgSerial) {
- channel.appendMessage({ serial: msgSerial, data: event.delta });
- }
- break;
-
- case 'response.output_text.done':
- // Process citations when text output is complete
- if (event.item_id === messageItemId && event.annotations) {
- for (const annotation of event.annotations) {
- if (annotation.type === 'url_citation') {
- const sourceDomain = new URL(annotation.url).hostname;
-
- await channel.annotations.publish(msgSerial, {
- type: 'citations:multiple.v1',
- name: sourceDomain,
- data: {
- url: annotation.url,
- title: annotation.title,
- startIndex: annotation.start_index,
- endIndex: annotation.end_index
- }
- });
- }
- }
- }
- break;
-
- case 'response.completed':
- console.log('Stream completed!');
- break;
- }
-}
-
-// Stream response with web search
-async function streamWithCitations(question) {
- const stream = await openai.responses.create({
- model: "gpt-5",
- input: question,
- tools: [{ type: "web_search_preview" }],
- stream: true
- });
-
- for await (const event of stream) {
- await processStreamEvent(event);
- }
-}
-
-// Example usage
-await streamWithCitations(
- "What are the latest discoveries from the James Webb Space Telescope in 2025?"
-);
-```
-
-
-
-
-## Step 7: Customize search behavior
-
-OpenAI's web search tool supports configuration options to customize search behavior:
-
-
-```javascript
-// Customize search context size
-const response = await openai.responses.create({
- model: "gpt-5",
- input: "What are the latest AI developments?",
- tools: [{
- type: "web_search_preview",
- search_context_size: "high" // Options: "low", "medium", "high"
- }]
-});
-
-// Filter to specific domains
-const response = await openai.responses.create({
- model: "gpt-5",
- input: "Find information about the James Webb Space Telescope",
- tools: [{
- type: "web_search_preview",
- user_location: {
- type: "approximate",
- country: "US"
- }
- }]
-});
-```
-
-
-The `search_context_size` option controls how much context from search results is provided to the model:
-- `low`: Faster responses with less context
-- `medium`: Balanced approach (default)
-- `high`: More comprehensive context, potentially more citations
-
-## Next steps
-
-- Learn more about [citations and message annotations](/docs/ai-transport/messaging/citations)
-- Explore [annotation summaries](/docs/messages/annotations#annotation-summaries) for displaying citation counts
-- Understand how to [retrieve annotations on demand](/docs/messages/annotations#rest-api) via the REST API
-- Combine with [message-per-response streaming](/docs/ai-transport/token-streaming/message-per-response) for live token delivery
diff --git a/src/pages/docs/ai-transport/guides/openai/openai-human-in-the-loop.mdx b/src/pages/docs/ai-transport/guides/openai/openai-human-in-the-loop.mdx
deleted file mode 100644
index 7e24d6c272..0000000000
--- a/src/pages/docs/ai-transport/guides/openai/openai-human-in-the-loop.mdx
+++ /dev/null
@@ -1,415 +0,0 @@
----
-title: "Guide: Human-in-the-loop approval with OpenAI"
-meta_description: "Implement human approval workflows for AI agent tool calls using OpenAI and Ably with role-based access control."
-meta_keywords: "AI, human in the loop, HITL, OpenAI, tool calls, approval workflow, AI transport, Ably, realtime, RBAC"
-redirect_from:
- - /docs/guides/ai-transport/openai/openai-human-in-the-loop
----
-
-This guide shows you how to implement a human-in-the-loop (HITL) approval workflow for AI agent tool calls using OpenAI and Ably. The agent requests human approval before executing sensitive operations, with role-based access control to verify approvers have sufficient permissions.
-
-When the model calls a tool that requires human approval, the tool implementation itself handles the approval check before executing. Rather than executing immediately, the tool publishes an `approval-request` message to an Ably channel, waits for an `approval-response` from a human approver, verifies the approver has the required role using [claims embedded in their JWT token](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims), and only then executes the action. The model calls the tool as normal, and the approval logic lives inside the tool's implementation.
-
-
-
-## Prerequisites
-
-To follow this guide, you need:
-- Node.js 20 or higher
-- An OpenAI API key
-- An Ably API key
-
-Useful links:
-- [OpenAI function calling guide](https://platform.openai.com/docs/guides/function-calling)
-- [Ably JavaScript SDK getting started](/docs/getting-started/javascript)
-
-Create a new Node project, which will contain the agent, client, and server code:
-
-
-```shell
-mkdir ably-openai-hitl-example && cd ably-openai-hitl-example
-npm init -y
-```
-
-
-Install the required packages using NPM:
-
-
-```shell
-npm install openai@^4 ably@^2 express jsonwebtoken
-```
-
-
-
-
-Export your API keys to the environment:
-
-
-```shell
-export OPENAI_API_KEY="your_openai_api_key_here"
-export ABLY_API_KEY="your_ably_api_key_here"
-```
-
-
-## Step 1: Initialize the agent
-
-Set up the agent that will call OpenAI and request human approval for sensitive operations. This example uses a `publish_blog_post` tool that requires authorization before execution.
-
-Initialize the OpenAI and Ably clients, and create a channel for communication between the agent and human approvers. Add the following to a new file called `agent.mjs`:
-
-
-```javascript
-import OpenAI from 'openai';
-import Ably from 'ably';
-
-const openai = new OpenAI();
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: process.env.ABLY_API_KEY,
- echoMessages: false
-});
-
-// Wait for connection to be established
-await realtime.connection.once('connected');
-
-// Create a channel for HITL communication
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track pending approval requests
-const pendingApprovals = new Map();
-
-// Function that executes the approved action
-async function publishBlogPost(args) {
- const { title } = JSON.parse(args);
- console.log(`Publishing blog post: ${title}`);
- // In production, this would call your CMS API
- return { published: true, title };
-}
-```
-
-
-
-
-Tools that modify data, access sensitive resources, or perform actions with business impact are good candidates for HITL approval workflows.
-
-## Step 2: Request human approval
-
-When the OpenAI model returns a tool call, publish an approval request to the channel and wait for a human decision. The tool call ID is passed in the message headers to correlate requests with responses.
-
-Add the approval request function to `agent.mjs`:
-
-
-```javascript
-async function requestHumanApproval(toolCall) {
- const approvalPromise = new Promise((resolve, reject) => {
- pendingApprovals.set(toolCall.call_id, { toolCall, resolve, reject });
- });
-
- await channel.publish({
- name: 'approval-request',
- data: {
- tool: toolCall.name,
- arguments: toolCall.arguments
- },
- extras: {
- headers: {
- toolCallId: toolCall.call_id
- }
- }
- });
-
- console.log(`Approval request sent for: ${toolCall.name}`);
- return approvalPromise;
-}
-```
-
-
-The `toolCall.call_id` provided by OpenAI correlates the approval request with the response, enabling the agent to handle multiple concurrent approval flows.
-
-## Step 3: Subscribe to approval responses
-
-Set up a subscription to receive approval decisions from human users. When a response arrives, verify the approver has sufficient permissions using role-based access control before resolving the pending promise.
-
-Add the subscription handler to `agent.mjs`:
-
-
-```javascript
-async function subscribeApprovalResponses() {
- // Define role hierarchy from lowest to highest privilege
- const roleHierarchy = ['editor', 'publisher', 'admin'];
-
- // Define minimum role required for each tool
- const approvalPolicies = {
- publish_blog_post: { minRole: 'publisher' }
- };
-
- function canApprove(approverRole, requiredRole) {
- const approverLevel = roleHierarchy.indexOf(approverRole);
- const requiredLevel = roleHierarchy.indexOf(requiredRole);
- return approverLevel >= requiredLevel;
- }
-
- await channel.subscribe('approval-response', async (message) => {
- const { decision } = message.data;
- const toolCallId = message.extras?.headers?.toolCallId;
- const pending = pendingApprovals.get(toolCallId);
-
- if (!pending) {
- console.log(`No pending approval for tool call: ${toolCallId}`);
- return;
- }
-
- const policy = approvalPolicies[pending.toolCall.name];
- // Get the trusted role from the JWT user claim
- const approverRole = message.extras?.userClaim;
-
- // Verify the approver's role meets the minimum required
- if (!canApprove(approverRole, policy.minRole)) {
- console.log(`Insufficient role: ${approverRole} < ${policy.minRole}`);
- pending.reject(new Error(
- `Approver role '${approverRole}' insufficient for required '${policy.minRole}'`
- ));
- pendingApprovals.delete(toolCallId);
- return;
- }
-
- // Process the decision
- if (decision === 'approved') {
- console.log(`Approved by ${approverRole}`);
- pending.resolve({ approved: true, approverRole });
- } else {
- console.log(`Rejected by ${approverRole}`);
- pending.reject(new Error(`Action rejected by ${approverRole}`));
- }
- pendingApprovals.delete(toolCallId);
- });
-}
-```
-
-
-The `message.extras.userClaim` contains the role embedded in the approver's JWT token, providing a trusted source for authorization decisions. See [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) for details on embedding claims in tokens. This ensures only users with sufficient privileges can approve sensitive operations.
-
-## Step 4: Process tool calls
-
-Create a function to process tool calls by requesting approval and executing the action if approved.
-
-Add the tool processing function to `agent.mjs`:
-
-
-```javascript
-async function processToolCall(toolCall) {
- if (toolCall.name === 'publish_blog_post') {
- // requestHumanApproval returns a promise that resolves when the human
- // approves the tool call, or rejects if the human explicitly rejects
- // the tool call or the approver's role is insufficient.
- await requestHumanApproval(toolCall);
- return await publishBlogPost(toolCall.arguments);
- }
- throw new Error(`Unknown tool: ${toolCall.name}`);
-}
-```
-
-
-The function awaits approval before executing. If the approver rejects or has insufficient permissions, the promise rejects and the tool is not executed.
-
-## Step 5: Run the agent
-
-Create the main agent loop that sends prompts to OpenAI and processes any tool calls that require approval.
-
-Add the agent runner to `agent.mjs`:
-
-
-```javascript
-async function runAgent(prompt) {
- await subscribeApprovalResponses();
-
- console.log(`User: ${prompt}`);
-
- const response = await openai.responses.create({
- model: 'gpt-4o',
- input: prompt,
- tools: [
- {
- type: 'function',
- name: 'publish_blog_post',
- description: 'Publish a blog post to the website. Requires human approval.',
- parameters: {
- type: 'object',
- properties: {
- title: {
- type: 'string',
- description: 'Title of the blog post to publish'
- }
- },
- required: ['title']
- }
- }
- ]
- });
-
- const toolCalls = response.output.filter(item => item.type === 'function_call');
-
- for (const toolCall of toolCalls) {
- console.log(`Tool call: ${toolCall.name}`);
- try {
- const result = await processToolCall(toolCall);
- console.log('Result:', result);
- } catch (err) {
- console.error('Tool call failed:', err.message);
- }
- }
-}
-
-runAgent("Publish the blog post called 'Introducing our new API'");
-```
-
-
-## Step 6: Create the authentication server
-
-The authentication server issues JWT tokens with embedded role claims. The role claim is trusted by Ably and included in messages, enabling secure role-based authorization.
-
-Add the following to a new file called `server.mjs`:
-
-
-```javascript
-import express from 'express';
-import jwt from 'jsonwebtoken';
-
-const app = express();
-
-// Mock authentication - replace with your actual auth logic
-function authenticateUser(req, res, next) {
- // In production, verify the user's session/credentials
- req.user = { id: 'user123', role: 'publisher' };
- next();
-}
-
-// Return claims to embed in the JWT
-function getJWTClaims(user) {
- return {
- 'ably.channel.*': user.role
- };
-}
-
-app.get('/api/auth/token', authenticateUser, (req, res) => {
- const [keyName, keySecret] = process.env.ABLY_API_KEY.split(':');
-
- const token = jwt.sign(getJWTClaims(req.user), keySecret, {
- algorithm: 'HS256',
- keyid: keyName,
- expiresIn: '1h'
- });
-
- res.type('application/jwt').send(token);
-});
-
-app.listen(3001, () => {
- console.log('Auth server running on http://localhost:3001');
-});
-```
-
-
-The `ably.channel.*` claim embeds the user's role in the JWT. When the user publishes messages, this claim is available as `message.extras.userClaim`, providing a trusted source for authorization.
-
-Run the server:
-
-
-```shell
-node server.mjs
-```
-
-
-## Step 7: Create the approval client
-
-The approval client receives approval requests and allows humans to approve or reject them. It authenticates via the server to obtain a JWT with the user's role.
-
-Add the following to a new file called `client.mjs`:
-
-
-```javascript
-import Ably from 'ably';
-import readline from 'readline';
-
-const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout
-});
-
-const realtime = new Ably.Realtime({
- authCallback: async (tokenParams, callback) => {
- try {
- const response = await fetch('http://localhost:3001/api/auth/token');
- const token = await response.text();
- callback(null, token);
- } catch (error) {
- callback(error, null);
- }
- }
-});
-
-realtime.connection.on('connected', () => {
- console.log('Connected to Ably');
- console.log('Waiting for approval requests...\n');
-});
-
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-await channel.subscribe('approval-request', (message) => {
- const request = message.data;
-
- console.log('\n========================================');
- console.log('APPROVAL REQUEST');
- console.log('========================================');
- console.log(`Tool: ${request.tool}`);
- console.log(`Arguments: ${request.arguments}`);
- console.log('========================================');
-
- rl.question('Approve this action? (y/n): ', async (answer) => {
- const decision = answer.toLowerCase() === 'y' ? 'approved' : 'rejected';
-
- await channel.publish({
- name: 'approval-response',
- data: { decision },
- extras: {
- headers: {
- toolCallId: message.extras?.headers?.toolCallId
- }
- }
- });
-
- console.log(`Decision sent: ${decision}\n`);
- });
-});
-```
-
-
-Run the client in a separate terminal:
-
-
-```shell
-node client.mjs
-```
-
-
-With the server, client, and agent running, the workflow proceeds as follows:
-
-1. The agent sends a prompt to OpenAI that triggers a tool call
-2. The agent publishes an approval request to the channel
-3. The client displays the request and prompts the user
-4. The user approves or rejects the request
-5. The agent verifies the approver's role meets the minimum requirement
-6. If approved and authorized, the agent executes the tool
-
-## Next steps
-
-- Learn more about [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) patterns and verification strategies
-- Explore [identifying users and agents](/docs/ai-transport/sessions-identity/identifying-users-and-agents) for secure identity verification
-- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI-enabled applications
-- Learn about [tool calls](/docs/ai-transport/messaging/tool-calls) for agent-to-agent communication
diff --git a/src/pages/docs/ai-transport/guides/openai/openai-message-per-response.mdx b/src/pages/docs/ai-transport/guides/openai/openai-message-per-response.mdx
deleted file mode 100644
index 1ac3ab5ee3..0000000000
--- a/src/pages/docs/ai-transport/guides/openai/openai-message-per-response.mdx
+++ /dev/null
@@ -1,447 +0,0 @@
----
-title: "Guide: Stream OpenAI responses using the message-per-response pattern"
-meta_description: "Stream tokens from the OpenAI Responses API over Ably in realtime using message appends."
-meta_keywords: "AI, token streaming, OpenAI, Responses API, AI transport, Ably, realtime, message appends"
-redirect_from:
- - /docs/guides/ai-transport/openai-message-per-response
- - /docs/guides/ai-transport/openai/openai-message-per-response
----
-
-This guide shows you how to stream AI responses from OpenAI's [Responses API](https://platform.openai.com/docs/api-reference/responses) over Ably using the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response). Specifically, it appends each response token to a single Ably message, creating a complete AI response that grows incrementally while delivering tokens in realtime.
-
-Using Ably to distribute tokens from the OpenAI SDK enables you to broadcast AI responses to thousands of concurrent subscribers with reliable message delivery and ordering guarantees. This approach stores each complete response as a single message in channel history, making it easy to retrieve conversation history without processing thousands of individual token messages.
-
-
-
-## Prerequisites
-
-To follow this guide, you need:
-- Node.js 20 or higher
-- An OpenAI API key
-- An Ably API key
-
-Useful links:
-- [OpenAI developer quickstart](https://platform.openai.com/docs/quickstart)
-- [Ably JavaScript SDK getting started](/docs/getting-started/javascript)
-
-Create a new Node project, which will contain the publisher and subscriber code:
-
-
-```shell
-mkdir ably-openai-example && cd ably-openai-example
-npm init -y
-```
-
-
-Install the required packages using NPM:
-
-
-```shell
-npm install openai@^4 ably@^2
-```
-
-
-
-
-Export your OpenAI API key to the environment, which will be used later in the guide by the OpenAI SDK:
-
-
-```shell
-export OPENAI_API_KEY="your_api_key_here"
-```
-
-
-## Step 1: Enable message appends
-
-Message append functionality requires "Message annotations, updates, deletes and appends" to be enabled in a [rule](/docs/channels#rules) associated with the channel.
-
-
-
-To enable the rule:
-
-1. Go to the [Ably dashboard](https://www.ably.com/dashboard) and select your app.
-2. Navigate to the "Configuration" > "Rules" section from the left-hand navigation bar.
-3. Choose "Add new rule".
-4. Enter a channel name or namespace pattern (e.g. `ai` for all channels starting with `ai:`).
-5. Select the "Message annotations, updates, deletes and appends" option from the list.
-6. Click "Create rule".
-
-The examples in this guide use the `ai:` namespace prefix, which assumes you have configured the rule for `ai:*`.
-
-
-
-## Step 2: Get a streamed response from OpenAI
-
-Initialize an OpenAI client and use the [Responses API](https://platform.openai.com/docs/api-reference/responses) to stream model output as a series of events.
-
-Create a new file `agent.mjs` with the following contents:
-
-
-```javascript
-import OpenAI from 'openai';
-
-// Initialize OpenAI client
-const openai = new OpenAI();
-
-// Process each streaming event
-async function processEvent(event) {
- console.log(JSON.stringify(event));
- // This function is updated in the next sections
-}
-
-// Create streaming response from OpenAI
-async function streamOpenAIResponse(prompt) {
- const stream = await openai.responses.create({
- model: "gpt-5",
- input: prompt,
- stream: true,
- });
-
- // Iterate through streaming events
- for await (const event of stream) {
- await processEvent(event);
- }
-}
-
-// Usage example
-streamOpenAIResponse("Tell me a short joke");
-```
-
-
-### Understand OpenAI streaming events
-
-OpenAI's Responses API [streams](https://platform.openai.com/docs/guides/streaming-responses) model output as a series of events when you set `stream: true`. Each streamed event includes a `type` property which describes the [event type](https://platform.openai.com/docs/api-reference/responses-streaming). A complete text response can be constructed from the following event types:
-
-- [`response.created`](https://platform.openai.com/docs/api-reference/responses-streaming/response/created): Signals the start of a response. Contains `response.id` to correlate subsequent events.
-
-- [`response.output_item.added`](https://platform.openai.com/docs/api-reference/responses-streaming/response/output_item/added): Indicates a new output item. If `item.type === "message"` the item contains model response text; other types may be specified, such as `"reasoning"` for internal reasoning tokens. The `output_index` indicates the position of this item in the response's [`output`](https://platform.openai.com/docs/api-reference/responses-streaming/response/completed#responses_streaming-response-completed-response-output) array.
-
-- [`response.content_part.added`](https://platform.openai.com/docs/api-reference/responses-streaming/response/content_part/added): Indicates a new content part within an output item. If `part.type === "output_text"` the part contains model response text; other types may be specified, such as `"reasoning_text"` for internal reasoning tokens. The `content_index` indicates the position of this item in the output items's [`content`](https://platform.openai.com/docs/api-reference/responses-streaming/response/completed#responses_streaming-response-completed-response-output-output_message-content) array.
-
-- [`response.output_text.delta`](https://platform.openai.com/docs/api-reference/responses-streaming/response/output_text/delta): Contains a single token in the `delta` field. Use the `item_id`, `output_index`, and `content_index` to correlate tokens relating to a specific content part.
-
-- [`response.content_part.done`](https://platform.openai.com/docs/api-reference/responses-streaming/response/content_part/done): Signals completion of a content part. Contains the complete `part` object with full text, along with `item_id`, `output_index`, and `content_index`.
-
-- [`response.output_item.done`](https://platform.openai.com/docs/api-reference/responses-streaming/response/output_item/done): Signals completion of an output item. Contains the complete `item` object and `output_index`.
-
-- [`response.completed`](https://platform.openai.com/docs/api-reference/responses-streaming/response/completed): Signals the end of the response. Contains the complete `response` object.
-
-The following example shows the event sequence received when streaming a response:
-
-
-```json
-// 1. Response starts
-{"type":"response.created","response":{"id":"resp_abc123","status":"in_progress"}}
-
-// 2. First output item (reasoning) is added
-{"type":"response.output_item.added","output_index":0,"item":{"id":"rs_456","type":"reasoning"}}
-{"type":"response.output_item.done","output_index":0,"item":{"id":"rs_456","type":"reasoning"}}
-
-// 3. Second output item (message) is added
-{"type":"response.output_item.added","output_index":1,"item":{"id":"msg_789","type":"message"}}
-{"type":"response.content_part.added","item_id":"msg_789","output_index":1,"content_index":0}
-
-// 4. Text tokens stream in as delta events
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":"Why"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" don"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":"'t"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" scientists"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" trust"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" atoms"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":"?"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" Because"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" they"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" make"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" up"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" everything"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":"."}
-
-// 5. Content part and output item complete
-{"type":"response.content_part.done","item_id":"msg_789","output_index":1,"content_index":0,"part":{"type":"output_text","text":"Why don't scientists trust atoms? Because they make up everything."}}
-{"type":"response.output_item.done","output_index":1,"item":{"id":"msg_789","type":"message","status":"completed","content":[{"type":"output_text","text":"Why don't scientists trust atoms? Because they make up everything."}]}}
-
-// 6. Response completes
-{"type":"response.completed","response":{"id":"resp_abc123","status":"completed","output":[{"id":"rs_456","type":"reasoning"},{"id":"msg_789","type":"message","status":"completed","content":[{"type":"output_text","text":"Why don't scientists trust atoms? Because they make up everything."}]}]}}
-```
-
-
-
-
-## Step 3: Publish streaming tokens to Ably
-
-Publish OpenAI streaming events to Ably using message appends to reliably and scalably distribute them to subscribers.
-
-Each AI response is stored as a single Ably message that grows as tokens are appended.
-
-### Initialize the Ably client
-
-Add the Ably client initialization to your `agent.mjs` file:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- echoMessages: false
-});
-
-// Create a channel for publishing streamed AI responses
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-```
-
-
-The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish tokens at high message rates with low latency.
-
-
-
-### Publish initial message and append tokens
-
-When a new response begins, publish an initial message to create it. Ably assigns a [`serial`](/docs/messages#properties) identifier to the message. Use this `serial` to append each token to the message as it arrives from the OpenAI model.
-
-
-
-Update your `agent.mjs` file to publish the initial message and append tokens:
-
-
-```javascript
-// Track state across events
-let msgSerial = null;
-let messageItemId = null;
-
-// Process each streaming event and publish to Ably
-async function processEvent(event) {
- switch (event.type) {
- case 'response.created':
- // Publish initial empty message when response starts
- const result = await channel.publish({
- name: 'response',
- data: ''
- });
-
- // Capture the message serial for appending tokens
- msgSerial = result.serials[0];
- break;
-
- case 'response.output_item.added':
- // Capture message item ID when a message output item is added
- if (event.item.type === 'message') {
- messageItemId = event.item.id;
- }
- break;
-
- case 'response.output_text.delta':
- // Append tokens from message output items only
- if (event.item_id === messageItemId && msgSerial) {
- channel.appendMessage({
- serial: msgSerial,
- data: event.delta
- });
- }
- break;
-
- case 'response.completed':
- console.log('Stream completed!');
- break;
- }
-}
-```
-
-
-This implementation:
-
-- Publishes an initial empty message when the response begins and captures the `serial`
-- Filters for `response.output_text.delta` events from `message` type output items
-- Appends each token to the original message
-
-
-
-
-
-Run the publisher to see tokens streaming to Ably:
-
-
-```shell
-node agent.mjs
-```
-
-
-## Step 4: Subscribe to streaming tokens
-
-Create a subscriber that receives the streaming tokens from Ably and reconstructs the response in realtime.
-
-Create a new file `client.mjs` with the following contents:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({ key: '{{API_KEY}}' });
-
-// Get the same channel used by the publisher
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by message serial
-const responses = new Map();
-
-// Subscribe to receive messages
-await channel.subscribe((message) => {
- switch (message.action) {
- case 'message.create':
- // New response started
- console.log('\n[Response started]', message.serial);
- responses.set(message.serial, message.data);
- break;
-
- case 'message.append':
- // Append token to existing response
- const current = responses.get(message.serial) || '';
- responses.set(message.serial, current + message.data);
-
- // Display token as it arrives
- process.stdout.write(message.data);
- break;
-
- case 'message.update':
- // Replace entire response content
- responses.set(message.serial, message.data);
- console.log('\n[Response updated with full content]');
- break;
- }
-});
-
-console.log('Subscriber ready, waiting for tokens...');
-```
-
-
-Subscribers receive different message actions depending on when they join and how they're retrieving messages:
-
-- `message.create`: Indicates a new response has started (i.e. a new message was created). The message `data` contains the initial content (often empty or the first token). Store this as the beginning of a new response using `serial` as the identifier.
-
-- `message.append`: Contains a single token fragment to append. The message `data` contains only the new token, not the full concatenated response. Append this token to the existing response identified by `serial`.
-
-- `message.update`: Contains the whole response up to that point. The message `data` contains the full concatenated text so far. Replace the entire response content with this data for the message identified by `serial`. This action occurs when the channel needs to resynchronize the full message state, such as after a client [resumes](/docs/connect/states#resume) from a transient disconnection.
-
-Run the subscriber in a separate terminal:
-
-
-```shell
-node client.mjs
-```
-
-
-With the subscriber running, run the publisher in another terminal. The tokens stream in realtime as the OpenAI model generates them.
-
-## Step 5: Stream with multiple publishers and subscribers
-
-Ably's [channel-oriented sessions](/docs/ai-transport/sessions-identity#connection-oriented-vs-channel-oriented-sessions) enables multiple AI agents to publish responses and multiple users to receive them on a single channel simultaneously. Ably handles message delivery to all participants, eliminating the need to implement routing logic or manage state synchronization across connections.
-
-### Broadcasting to multiple subscribers
-
-Each subscriber receives the complete stream of tokens independently, enabling you to build collaborative experiences or multi-device applications.
-
-Run a subscriber in multiple separate terminals:
-
-
-```shell
-# Terminal 1
-node client.mjs
-
-# Terminal 2
-node client.mjs
-
-# Terminal 3
-node client.mjs
-```
-
-
-All subscribers receive the same stream of tokens in realtime.
-
-### Publishing concurrent responses
-
-Multiple publishers can stream different responses concurrently on the same [channel](/docs/channels). Each response is a distinct message with its own unique `serial` identifier, so tokens from different responses are isolated to distinct messages and don't interfere with each other.
-
-To demonstrate this, run a publisher in multiple separate terminals:
-
-
-```shell
-# Terminal 1
-node agent.mjs
-
-# Terminal 2
-node agent.mjs
-
-# Terminal 3
-node agent.mjs
-```
-
-
-All running subscribers receive tokens from all responses concurrently. Each subscriber correctly reconstructs each response separately using the `serial` to correlate tokens.
-
-## Step 6: Retrieve complete responses from history
-
-One key advantage of the message-per-response pattern is that each complete AI response is stored as a single message in channel history. This makes it efficient to retrieve conversation history without processing thousands of individual token messages.
-
-Use Ably's [rewind](/docs/channels/options/rewind) channel option to attach to the channel at some point in the recent past and automatically receive complete responses from history. Historical messages are delivered as `message.update` events containing the complete concatenated response, which then seamlessly transition to live `message.append` events for any ongoing responses:
-
-
-```javascript
-// Use rewind to receive recent historical messages
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}', {
- params: { rewind: '2m' } // Retrieve messages from the last 2 minutes
-});
-
-const responses = new Map();
-
-await channel.subscribe((message) => {
- switch (message.action) {
- case 'message.create':
- responses.set(message.serial, message.data);
- break;
-
- case 'message.append':
- const current = responses.get(message.serial) || '';
- responses.set(message.serial, current + message.data);
- process.stdout.write(message.data);
- break;
-
- case 'message.update':
- // Historical messages contain full concatenated response
- responses.set(message.serial, message.data);
- console.log('\n[Historical response]:', message.data);
- break;
- }
-});
-```
-
-
-
-
-## Next steps
-
-- Learn more about the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response) used in this guide
-- Learn about [client hydration strategies](/docs/ai-transport/token-streaming/message-per-response#hydration) for handling late joiners and reconnections
-- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI enabled applications
-- Explore the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token) for explicit control over individual token messages
diff --git a/src/pages/docs/ai-transport/guides/openai/openai-message-per-token.mdx b/src/pages/docs/ai-transport/guides/openai/openai-message-per-token.mdx
deleted file mode 100644
index b561ad8f3a..0000000000
--- a/src/pages/docs/ai-transport/guides/openai/openai-message-per-token.mdx
+++ /dev/null
@@ -1,388 +0,0 @@
----
-title: "Guide: Stream OpenAI responses using the message-per-token pattern"
-meta_description: "Stream tokens from the OpenAI Responses API over Ably in realtime."
-meta_keywords: "AI, token streaming, OpenAI, Responses API, AI transport, Ably, realtime"
-redirect_from:
- - /docs/guides/ai-transport/openai-message-per-token
- - /docs/guides/ai-transport/openai/openai-message-per-token
----
-
-This guide shows you how to stream AI responses from OpenAI's [Responses API](https://platform.openai.com/docs/api-reference/responses) over Ably using the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token). Specifically, it implements the [explicit start/stop events approach](/docs/ai-transport/token-streaming/message-per-token#explicit-events), which publishes each response token as an individual message, along with explicit lifecycle events to signal when responses begin and end.
-
-Using Ably to distribute tokens from the OpenAI SDK enables you to broadcast AI responses to thousands of concurrent subscribers with reliable message delivery and ordering guarantees, ensuring that each client receives the complete response stream with all tokens delivered in order. This approach decouples your AI inference from client connections, enabling you to scale agents independently and handle reconnections gracefully.
-
-
-
-## Prerequisites
-
-To follow this guide, you need:
-- Node.js 20 or higher
-- An OpenAI API key
-- An Ably API key
-
-Useful links:
-- [OpenAI developer quickstart](https://platform.openai.com/docs/quickstart)
-- [Ably JavaScript SDK getting started](/docs/getting-started/javascript)
-
-Create a new Node project, which will contain the publisher and subscriber code:
-
-
-```shell
-mkdir ably-openai-example && cd ably-openai-example
-npm init -y
-```
-
-
-Install the required packages using NPM:
-
-
-```shell
-npm install openai@^4 ably@^2
-```
-
-
-
-
-Export your OpenAI API key to the environment, which will be used later in the guide by the OpenAI SDK:
-
-
-```shell
-export OPENAI_API_KEY="your_api_key_here"
-```
-
-
-## Step 1: Get a streamed response from OpenAI
-
-Initialize an OpenAI client and use the [Responses API](https://platform.openai.com/docs/api-reference/responses) to stream model output as a series of events.
-
-Create a new file `agent.mjs` with the following contents:
-
-
-```javascript
-import OpenAI from 'openai';
-
-// Initialize OpenAI client
-const openai = new OpenAI();
-
-// Process each streaming event
-function processEvent(event) {
- console.log(JSON.stringify(event));
- // This function is updated in the next sections
-}
-
-// Create streaming response from OpenAI
-async function streamOpenAIResponse(prompt) {
- const stream = await openai.responses.create({
- model: "gpt-5",
- input: prompt,
- stream: true,
- });
-
- // Iterate through streaming events
- for await (const event of stream) {
- processEvent(event);
- }
-}
-
-// Usage example
-streamOpenAIResponse("Tell me a short joke");
-```
-
-
-### Understand OpenAI streaming events
-
-OpenAI's Responses API [streams](https://platform.openai.com/docs/guides/streaming-responses) model output as a series of events when you set `stream: true`. Each streamed event includes a `type` property which describes the [event type](https://platform.openai.com/docs/api-reference/responses-streaming). A complete text response can be constructed from the following event types:
-
-- [`response.created`](https://platform.openai.com/docs/api-reference/responses-streaming/response/created): Signals the start of a response. Contains `response.id` to correlate subsequent events.
-
-- [`response.output_item.added`](https://platform.openai.com/docs/api-reference/responses-streaming/response/output_item/added): Indicates a new output item. If `item.type === "message"` the item contains model response text; other types may be specified, such as `"reasoning"` for internal reasoning tokens. The `output_index` indicates the position of this item in the response's [`output`](https://platform.openai.com/docs/api-reference/responses-streaming/response/completed#responses_streaming-response-completed-response-output) array.
-
-- [`response.content_part.added`](https://platform.openai.com/docs/api-reference/responses-streaming/response/content_part/added): Indicates a new content part within an output item. If `part.type === "output_text"` the part contains model response text; other types may be specified, such as `"reasoning_text"` for internal reasoning tokens. The `content_index` indicates the position of this item in the output items's [`content`](https://platform.openai.com/docs/api-reference/responses-streaming/response/completed#responses_streaming-response-completed-response-output-output_message-content) array.
-
-- [`response.output_text.delta`](https://platform.openai.com/docs/api-reference/responses-streaming/response/output_text/delta): Contains a single token in the `delta` field. Use the `item_id`, `output_index`, and `content_index` to correlate tokens relating to a specific content part.
-
-- [`response.content_part.done`](https://platform.openai.com/docs/api-reference/responses-streaming/response/content_part/done): Signals completion of a content part. Contains the complete `part` object with full text, along with `item_id`, `output_index`, and `content_index`.
-
-- [`response.output_item.done`](https://platform.openai.com/docs/api-reference/responses-streaming/response/output_item/done): Signals completion of an output item. Contains the complete `item` object and `output_index`.
-
-- [`response.completed`](https://platform.openai.com/docs/api-reference/responses-streaming/response/completed): Signals the end of the response. Contains the complete `response` object.
-
-The following example shows the event sequence received when streaming a response:
-
-
-```json
-// 1. Response starts
-{"type":"response.created","response":{"id":"resp_abc123","status":"in_progress"}}
-
-// 2. First output item (reasoning) is added
-{"type":"response.output_item.added","output_index":0,"item":{"id":"rs_456","type":"reasoning"}}
-{"type":"response.output_item.done","output_index":0,"item":{"id":"rs_456","type":"reasoning"}}
-
-// 3. Second output item (message) is added
-{"type":"response.output_item.added","output_index":1,"item":{"id":"msg_789","type":"message"}}
-{"type":"response.content_part.added","item_id":"msg_789","output_index":1,"content_index":0}
-
-// 4. Text tokens stream in as delta events
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":"Why"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" don"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":"'t"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" scientists"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" trust"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" atoms"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":"?"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" Because"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" they"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" make"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" up"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":" everything"}
-{"type":"response.output_text.delta","item_id":"msg_789","output_index":1,"content_index":0,"delta":"."}
-
-// 5. Content part and output item complete
-{"type":"response.content_part.done","item_id":"msg_789","output_index":1,"content_index":0,"part":{"type":"output_text","text":"Why don't scientists trust atoms? Because they make up everything."}}
-{"type":"response.output_item.done","output_index":1,"item":{"id":"msg_789","type":"message","status":"completed","content":[{"type":"output_text","text":"Why don't scientists trust atoms? Because they make up everything."}]}}
-
-// 6. Response completes
-{"type":"response.completed","response":{"id":"resp_abc123","status":"completed","output":[{"id":"rs_456","type":"reasoning"},{"id":"msg_789","type":"message","status":"completed","content":[{"type":"output_text","text":"Why don't scientists trust atoms? Because they make up everything."}]}]}}
-```
-
-
-
-
-## Step 2: Publish streaming events to Ably
-
-Publish OpenAI streaming events to Ably to reliably and scalably distribute them to subscribers.
-
-This implementation follows the [explicit start/stop events pattern](/docs/ai-transport/token-streaming/message-per-token#explicit-events), which provides clear response boundaries.
-
-### Initialize the Ably client
-
-Add the Ably client initialization to your `agent.mjs` file:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- echoMessages: false
-});
-
-// Create a channel for publishing streamed AI responses
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-```
-
-
-The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish tokens at high message rates with low latency.
-
-
-
-### Map OpenAI streaming events to Ably messages
-
-Choose how to map [OpenAI streaming events](#understand-streaming-events) to Ably [messages](/docs/messages). You can choose any mapping strategy that suits your application's needs. This guide uses the following pattern as an example:
-
-- `start`: Signals the beginning of a response
-- `token`: Contains the incremental text content for each delta
-- `stop`: Signals the completion of a response
-
-
-
-Update your `agent.mjs` file to initialize the Ably client and update the `processEvent()` function to publish events to Ably:
-
-
-```javascript
-// Track state across events
-let responseId = null;
-let messageItemId = null;
-
-// Process each streaming event and publish to Ably
-function processEvent(event) {
- switch (event.type) {
- case 'response.created':
- // Capture response ID when response starts
- responseId = event.response.id;
-
- // Publish start event
- channel.publish({
- name: 'start',
- extras: {
- headers: { responseId }
- }
- });
- break;
-
- case 'response.output_item.added':
- // Capture message item ID when a message output item is added
- if (event.item.type === 'message') {
- messageItemId = event.item.id;
- }
- break;
-
- case 'response.output_text.delta':
- // Publish tokens from message output items only
- if (event.item_id === messageItemId) {
- channel.publish({
- name: 'token',
- data: event.delta,
- extras: {
- headers: { responseId }
- }
- });
- }
- break;
-
- case 'response.completed':
- // Publish stop event when response completes
- channel.publish({
- name: 'stop',
- extras: {
- headers: { responseId }
- }
- });
- break;
- }
-}
-```
-
-
-This implementation:
-
-- Publishes a `start` event when the response begins
-- Filters for `response.output_text.delta` events from `message` type output items and publishes them as `token` events
-- Publishes a `stop` event when the response completes
-- All published events include the `responseId` in message [`extras`](/docs/messages#properties) to allow the client to correlate events relating to a particular response
-
-
-
-Run the publisher to see tokens streaming to Ably:
-
-
-```shell
-node agent.mjs
-```
-
-
-## Step 3: Subscribe to streaming tokens
-
-Create a subscriber that receives the streaming events from Ably and reconstructs the response.
-
-Create a new file `client.mjs` with the following contents:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({ key: '{{API_KEY}}' });
-
-// Get the same channel used by the publisher
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by ID
-const responses = new Map();
-
-// Handle response start
-await channel.subscribe('start', (message) => {
- const responseId = message.extras?.headers?.responseId;
- console.log('\n[Response started]', responseId);
- responses.set(responseId, '');
-});
-
-// Handle tokens
-await channel.subscribe('token', (message) => {
- const responseId = message.extras?.headers?.responseId;
- const token = message.data;
-
- // Append token to response
- const currentText = responses.get(responseId) || '';
- responses.set(responseId, currentText + token);
-
- // Display token as it arrives
- process.stdout.write(token);
-});
-
-// Handle response stop
-await channel.subscribe('stop', (message) => {
- const responseId = message.extras?.headers?.responseId;
- const finalText = responses.get(responseId);
- console.log('\n[Response completed]', responseId);
-});
-
-console.log('Subscriber ready, waiting for tokens...');
-```
-
-
-Run the subscriber in a separate terminal:
-
-
-```shell
-node client.mjs
-```
-
-
-With the subscriber running, run the publisher in another terminal. The tokens stream in realtime as the OpenAI model generates them.
-
-## Step 4: Stream with multiple publishers and subscribers
-
-Ably's [channel-oriented sessions](/docs/ai-transport/sessions-identity#connection-oriented-vs-channel-oriented-sessions) enables multiple AI agents to publish responses and multiple users to receive them on a single channel simultaneously. Ably handles message delivery to all participants, eliminating the need to implement routing logic or manage state synchronization across connections.
-
-### Broadcasting to multiple subscribers
-
-Each subscriber receives the complete stream of tokens independently, enabling you to build collaborative experiences or multi-device applications.
-
-Run a subscriber in multiple separate terminals:
-
-
-```shell
-# Terminal 1
-node client.mjs
-
-# Terminal 2
-node client.mjs
-
-# Terminal 3
-node client.mjs
-```
-
-
-All subscribers receive the same stream of tokens in realtime.
-
-### Publishing concurrent responses
-
-The implementation uses `responseId` in message [`extras`](/docs/messages#properties) to correlate tokens with their originating response. This enables multiple publishers to stream different responses concurrently on the same [channel](/docs/channels), with each subscriber correctly tracking all responses independently.
-
-To demonstrate this, run a publisher in multiple separate terminals:
-
-
-```shell
-# Terminal 1
-node agent.mjs
-
-# Terminal 2
-node agent.mjs
-
-# Terminal 3
-node agent.mjs
-```
-
-
-All running subscribers receive tokens from all responses concurrently. Each subscriber correctly reconstructs each response separately using the `responseId` to correlate tokens.
-
-## Next steps
-
-- Learn more about the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token) used in this guide
-- Learn about [client hydration strategies](/docs/ai-transport/token-streaming/message-per-token#hydration) for handling late joiners and reconnections
-- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI enabled applications
-- Explore the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response) for storing complete AI responses as single messages in history
diff --git a/src/pages/docs/ai-transport/guides/vercel-ai-sdk/vercel-human-in-the-loop.mdx b/src/pages/docs/ai-transport/guides/vercel-ai-sdk/vercel-human-in-the-loop.mdx
deleted file mode 100644
index 4ad30b2e18..0000000000
--- a/src/pages/docs/ai-transport/guides/vercel-ai-sdk/vercel-human-in-the-loop.mdx
+++ /dev/null
@@ -1,414 +0,0 @@
----
-title: "Guide: Human-in-the-loop approval with Vercel AI SDK"
-meta_description: "Implement human approval workflows for AI agent tool calls using the Vercel AI SDK and Ably with role-based access control."
-meta_keywords: "AI, human in the loop, HITL, Vercel AI SDK, tool calling, approval workflow, AI transport, Ably, realtime, RBAC"
----
-
-This guide shows you how to implement a human-in-the-loop (HITL) approval workflow for AI agent tool calls using the Vercel AI SDK and Ably. The agent requests human approval before executing sensitive operations, with role-based access control to verify approvers have sufficient permissions.
-
-When the model calls a tool that requires human approval, the agent intercepts the tool call, publishes an `approval-request` message to an Ably channel, waits for an `approval-response` from a human approver, verifies the approver has the required role using [claims embedded in their JWT token](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims), and only then executes the action.
-
-
-
-## Prerequisites
-
-To follow this guide, you need:
-- Node.js 20 or higher
-- An API key for your preferred model provider (such as OpenAI or Anthropic)
-- An Ably API key
-
-Useful links:
-- [Vercel AI SDK documentation](https://ai-sdk.dev/docs)
-- [Vercel AI SDK tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling)
-- [Ably JavaScript SDK getting started](/docs/getting-started/javascript)
-
-Create a new Node project, which will contain the agent, client, and server code:
-
-
-```shell
-mkdir ably-vercel-hitl-example && cd ably-vercel-hitl-example
-npm init -y
-```
-
-
-Install the required packages using NPM:
-
-
-```shell
-npm install ai@^6 @ai-sdk/openai ably@^2 express jsonwebtoken zod@^4
-```
-
-
-
-
-Export your API keys to the environment:
-
-
-```shell
-export OPENAI_API_KEY="your_openai_api_key_here"
-export ABLY_API_KEY="your_ably_api_key_here"
-```
-
-
-## Step 1: Initialize the agent
-
-Set up the agent that will use the Vercel AI SDK and request human approval for sensitive operations. This example uses a `publishBlogPost` tool that requires authorization before execution.
-
-Initialize the Ably client and create a channel for communication between the agent and human approvers.
-
-Add the following to a new file called `agent.mjs`:
-
-
-```javascript
-import { generateText, tool } from "ai";
-import { openai } from "@ai-sdk/openai";
-import { z } from "zod";
-import Ably from "ably";
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: process.env.ABLY_API_KEY,
- echoMessages: false,
-});
-
-// Wait for connection to be established
-await realtime.connection.once("connected");
-
-// Create a channel for HITL communication
-const channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
-// Track pending approval requests
-const pendingApprovals = new Map();
-
-// Function that executes the approved action
-async function publishBlogPost(args) {
- const { title } = args;
- console.log(`Publishing blog post: ${title}`);
- // In production, this would call your CMS API
- return { published: true, title };
-}
-```
-
-
-
-
-Tools that modify data, access sensitive resources, or perform actions with business impact are good candidates for HITL approval workflows.
-
-## Step 2: Request human approval
-
-When the model returns a tool call, publish an approval request to the channel and wait for a human decision. The tool call ID is passed in the message headers to correlate requests with responses.
-
-Add the approval request function to `agent.mjs`:
-
-
-```javascript
-async function requestHumanApproval(toolCall) {
- const approvalPromise = new Promise((resolve, reject) => {
- pendingApprovals.set(toolCall.toolCallId, { toolCall, resolve, reject });
- });
-
- await channel.publish({
- name: "approval-request",
- data: {
- tool: toolCall.toolName,
- arguments: toolCall.input,
- },
- extras: {
- headers: {
- toolCallId: toolCall.toolCallId,
- },
- },
- });
-
- console.log(`Approval request sent for: ${toolCall.toolName}`);
- return approvalPromise;
-}
-```
-
-
-The `toolCall.toolCallId` provided by the Vercel AI SDK correlates the approval request with the response, enabling the agent to handle multiple concurrent approval flows.
-
-## Step 3: Subscribe to approval responses
-
-Set up a subscription to receive approval decisions from human users. When a response arrives, verify the approver has sufficient permissions using role-based access control before resolving the pending promise.
-
-Add the subscription handler to `agent.mjs`:
-
-
-```javascript
-async function subscribeApprovalResponses() {
- // Define role hierarchy from lowest to highest privilege
- const roleHierarchy = ["editor", "publisher", "admin"];
-
- // Define minimum role required for each tool
- const approvalPolicies = {
- publishBlogPost: { minRole: "publisher" },
- };
-
- function canApprove(approverRole, requiredRole) {
- const approverLevel = roleHierarchy.indexOf(approverRole);
- const requiredLevel = roleHierarchy.indexOf(requiredRole);
- return approverLevel >= requiredLevel;
- }
-
- await channel.subscribe("approval-response", async (message) => {
- const { decision } = message.data;
- const toolCallId = message.extras?.headers?.toolCallId;
- const pending = pendingApprovals.get(toolCallId);
-
- if (!pending) {
- console.log(`No pending approval for tool call: ${toolCallId}`);
- return;
- }
-
- const policy = approvalPolicies[pending.toolCall.toolName];
- // Get the trusted role from the JWT user claim
- const approverRole = message.extras?.userClaim;
-
- // Verify the approver's role meets the minimum required
- if (!canApprove(approverRole, policy.minRole)) {
- console.log(`Insufficient role: ${approverRole} < ${policy.minRole}`);
- pending.reject(
- new Error(
- `Approver role '${approverRole}' insufficient for required '${policy.minRole}'`
- )
- );
- pendingApprovals.delete(toolCallId);
- return;
- }
-
- // Process the decision
- if (decision === "approved") {
- console.log(`Approved by ${approverRole}`);
- pending.resolve({ approved: true, approverRole });
- } else {
- console.log(`Rejected by ${approverRole}`);
- pending.reject(new Error(`Action rejected by ${approverRole}`));
- }
- pendingApprovals.delete(toolCallId);
- });
-}
-```
-
-
-The `message.extras.userClaim` contains the role embedded in the approver's JWT token, providing a trusted source for authorization decisions. See [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) for details on embedding claims in tokens. This ensures only users with sufficient privileges can approve sensitive operations.
-
-## Step 4: Process tool calls
-
-Create a function to process tool calls by requesting approval and executing the action if approved.
-
-Add the tool processing function to `agent.mjs`:
-
-
-```javascript
-async function processToolCall(toolCall) {
- if (toolCall.toolName === "publishBlogPost") {
- // requestHumanApproval returns a promise that resolves when the human
- // approves the tool call, or rejects if the human explicitly rejects
- // the tool call or the approver's role is insufficient.
- await requestHumanApproval(toolCall);
- return await publishBlogPost(toolCall.input);
- }
- throw new Error(`Unknown tool: ${toolCall.toolName}`);
-}
-```
-
-
-The function awaits approval before executing. If the approver rejects or has insufficient permissions, the promise rejects and the tool is not executed.
-
-## Step 5: Run the agent
-
-Create the main agent function that sends a prompt to the model and processes any tool calls that require approval. The `publishBlogPost` tool includes a passthrough `execute` function since execution is handled manually via `processToolCall` after human approval.
-
-Add the agent runner to `agent.mjs`:
-
-
-```javascript
-async function runAgent(prompt) {
- await subscribeApprovalResponses();
-
- console.log(`User: ${prompt}`);
-
- const response = await generateText({
- model: openai.chat("gpt-4o"),
- messages: [{ role: "user", content: prompt }],
- tools: {
- publishBlogPost: tool({
- description: "Publish a blog post to the website. Requires human approval.",
- inputSchema: z.object({
- title: z.string().describe("Title of the blog post to publish"),
- }),
- execute: async (args) => {
- return args;
- },
- }),
- },
- });
-
- const toolCalls = response.toolCalls;
-
- for (const toolCall of toolCalls) {
- console.log(`Tool call: ${toolCall.toolName}`);
- try {
- const result = await processToolCall(toolCall);
- console.log("Result:", result);
- } catch (err) {
- console.error("Tool call failed:", err.message);
- }
- }
-
- realtime.close();
-}
-
-runAgent("Publish the blog post called 'Introducing our new API'");
-```
-
-
-## Step 6: Create the authentication server
-
-The authentication server issues JWT tokens with embedded role claims. The role claim is trusted by Ably and included in messages, enabling secure role-based authorization.
-
-Add the following to a new file called `server.mjs`:
-
-
-```javascript
-import express from "express";
-import jwt from "jsonwebtoken";
-
-const app = express();
-
-// Mock authentication - replace with your actual auth logic
-function authenticateUser(req, res, next) {
- // In production, verify the user's session/credentials
- req.user = { id: "user123", role: "publisher" };
- next();
-}
-
-// Return claims to embed in the JWT
-function getJWTClaims(user) {
- return {
- "ably.channel.*": user.role,
- };
-}
-
-app.get("/api/auth/token", authenticateUser, (req, res) => {
- const [keyName, keySecret] = process.env.ABLY_API_KEY.split(":");
-
- const token = jwt.sign(getJWTClaims(req.user), keySecret, {
- algorithm: "HS256",
- keyid: keyName,
- expiresIn: "1h",
- });
-
- res.type("application/jwt").send(token);
-});
-
-app.listen(3001, () => {
- console.log("Auth server running on http://localhost:3001");
-});
-```
-
-
-The `ably.channel.*` claim embeds the user's role in the JWT. When the user publishes messages, this claim is available as `message.extras.userClaim`, providing a trusted source for authorization.
-
-Run the server:
-
-
-```shell
-node server.mjs
-```
-
-
-## Step 7: Create the approval client
-
-The approval client receives approval requests and allows humans to approve or reject them. It authenticates via the server to obtain a JWT with the user's role.
-
-Add the following to a new file called `client.mjs`:
-
-
-```javascript
-import Ably from "ably";
-import readline from "readline";
-
-const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout,
-});
-
-const realtime = new Ably.Realtime({
- authCallback: async (tokenParams, callback) => {
- try {
- const response = await fetch("http://localhost:3001/api/auth/token");
- const token = await response.text();
- callback(null, token);
- } catch (error) {
- callback(error, null);
- }
- },
-});
-
-realtime.connection.on("connected", () => {
- console.log("Connected to Ably");
- console.log("Waiting for approval requests...\n");
-});
-
-const channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
-await channel.subscribe("approval-request", (message) => {
- const request = message.data;
-
- console.log("\n========================================");
- console.log("APPROVAL REQUEST");
- console.log("========================================");
- console.log(`Tool: ${request.tool}`);
- console.log(`Arguments: ${JSON.stringify(request.arguments, null, 2)}`);
- console.log("========================================");
-
- rl.question("Approve this action? (y/n): ", async (answer) => {
- const decision = answer.toLowerCase() === "y" ? "approved" : "rejected";
-
- await channel.publish({
- name: "approval-response",
- data: { decision },
- extras: {
- headers: {
- toolCallId: message.extras?.headers?.toolCallId,
- },
- },
- });
-
- console.log(`Decision sent: ${decision}\n`);
- });
-});
-```
-
-
-Run the client in a separate terminal:
-
-
-```shell
-node client.mjs
-```
-
-
-With the server, client, and agent running, the workflow proceeds as follows:
-
-1. The agent sends a prompt to the model that triggers a tool call
-2. The agent publishes an approval request to the channel
-3. The client displays the request and prompts the user
-4. The user approves or rejects the request
-5. The agent verifies the approver's role meets the minimum requirement
-6. If approved and authorized, the agent executes the tool
-
-## Next steps
-
-- Learn more about [human-in-the-loop](/docs/ai-transport/messaging/human-in-the-loop) patterns and verification strategies
-- Explore [identifying users and agents](/docs/ai-transport/sessions-identity/identifying-users-and-agents) for secure identity verification
-- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI-enabled applications
-- Learn about [tool calls](/docs/ai-transport/messaging/tool-calls) for agent-to-agent communication
diff --git a/src/pages/docs/ai-transport/guides/vercel-ai-sdk/vercel-message-per-response.mdx b/src/pages/docs/ai-transport/guides/vercel-ai-sdk/vercel-message-per-response.mdx
deleted file mode 100644
index cc4d804443..0000000000
--- a/src/pages/docs/ai-transport/guides/vercel-ai-sdk/vercel-message-per-response.mdx
+++ /dev/null
@@ -1,417 +0,0 @@
----
-title: "Guide: Stream Vercel AI SDK responses using the message-per-response pattern"
-meta_description: "Stream tokens from the Vercel AI SDK over Ably in realtime using message appends."
-meta_keywords: "AI, token streaming, Vercel, AI SDK, AI transport, Ably, realtime, message appends"
-redirect_from:
- - /docs/guides/ai-transport/vercel-message-per-response
- - /docs/guides/ai-transport/vercel-ai-sdk/vercel-message-per-response
----
-
-This guide shows you how to stream AI responses from the [Vercel AI SDK](https://ai-sdk.dev/docs/ai-sdk-core/generating-text) over Ably using the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response). Specifically, it appends each response token to a single Ably message, creating a complete AI response that grows incrementally while delivering tokens in realtime.
-
-Using Ably to distribute tokens from the Vercel AI SDK enables you to broadcast AI responses to thousands of concurrent subscribers with reliable message delivery and ordering guarantees. This approach stores each complete response as a single message in channel history, making it easy to retrieve conversation history without processing thousands of individual token messages.
-
-
-
-## Prerequisites
-
-To follow this guide, you need:
-- Node.js 20 or higher
-- A Vercel AI Gateway API key
-- An Ably API key
-
-Useful links:
-- [Vercel AI Gateway documentation](https://vercel.com/docs/ai-gateway)
-- [Vercel AI SDK documentation](https://ai-sdk.dev/docs)
-- [Ably JavaScript SDK getting started](/docs/getting-started/javascript)
-
-Create a new Node project, which will contain the publisher and subscriber code:
-
-
-```shell
-mkdir ably-vercel-message-per-response && cd ably-vercel-message-per-response
-npm init -y
-```
-
-
-Install the required packages using NPM:
-
-
-```shell
-npm install ai@^6 ably@^2
-```
-
-
-
-
-Export your Vercel AI Gateway API key to the environment, which will be used later in the guide by the Vercel AI SDK:
-
-
-```shell
-export AI_GATEWAY_API_KEY="your_api_key_here"
-```
-
-
-## Step 1: Enable message appends
-
-Message append functionality requires "Message annotations, updates, deletes and appends" to be enabled in a [rule](/docs/channels#rules) associated with the channel.
-
-
-
-To enable the rule:
-
-1. Go to the [Ably dashboard](https://www.ably.com/dashboard) and select your app.
-2. Navigate to the "Configuration" > "Rules" section from the left-hand navigation bar.
-3. Choose "Add new rule".
-4. Enter a channel name or namespace pattern (e.g. `ai` for all channels starting with `ai:`).
-5. Select the "Message annotations, updates, deletes and appends" option from the list.
-6. Click "Create rule".
-
-The examples in this guide use the `ai:` namespace prefix, which assumes you have configured the rule for `ai:*`.
-
-
-
-## Step 2: Get a streamed response from Vercel AI SDK
-
-Initialize the Vercel AI SDK and use [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) to stream model output as a series of events.
-
-Create a new file `agent.mjs` with the following contents:
-
-
-```javascript
-import { streamText } from 'ai';
-
-// Process each streaming event
-async function processEvent(event) {
- console.log(JSON.stringify(event));
- // This function is updated in the next sections
-}
-
-// Create streaming response from Vercel AI SDK
-async function streamVercelResponse(prompt) {
- const result = streamText({
- model: 'openai/gpt-4o',
- prompt: prompt,
- });
-
- // Iterate through streaming events using fullStream
- for await (const event of result.fullStream) {
- await processEvent(event);
- }
-}
-
-// Usage example
-streamVercelResponse("Tell me a short joke");
-```
-
-
-### Understand Vercel AI SDK streaming events
-
-The Vercel AI SDK's [`streamText`](https://ai-sdk.dev/docs/ai-sdk-core/generating-text#streamtext) function provides a [`fullStream`](https://ai-sdk.dev/docs/ai-sdk-core/generating-text#fullstream-property) property that returns all stream events. Each event includes a `type` property which describes the event type. A complete text response can be constructed from the following event types:
-
-- [`text-start`](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#text-start-part): Signals the start of a text response. Contains an `id` to correlate subsequent events.
-
-- [`text-delta`](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#text-delta-part): Contains a single text token in the `text` field. These events represent incremental text chunks as the model generates them.
-
-- [`text-end`](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#text-end-part): Signals the completion of a text response.
-
-The following example shows the event sequence received when streaming a response:
-
-
-```json
-// 1. Stream initialization
-{"type":"start"}
-{"type":"start-step","request":{...}}
-{"type":"text-start","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","providerMetadata":{...}}
-
-// 2. Text tokens stream in as delta events
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":"Why"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" don't"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" skeleton"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":"s"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" fight"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" each"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" other"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":"?\n\n"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":"They"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" don't"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" have"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" the"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" guts"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":"!"}
-
-// 3. Stream completion
-{"type":"text-end","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","providerMetadata":{...}}
-{"type":"finish-step","finishReason":"stop","usage":{"inputTokens":12,"outputTokens":15,"totalTokens":27,"reasoningTokens":0,"cachedInputTokens":0},"providerMetadata":{...}}
-{"type":"finish","finishReason":"stop","totalUsage":{"inputTokens":12,"outputTokens":15,"totalTokens":27,"reasoningTokens":0,"cachedInputTokens":0}}
-```
-
-
-
-
-## Step 3: Publish streaming tokens to Ably
-
-Publish Vercel AI SDK streaming events to Ably using message appends to reliably and scalably distribute them to subscribers.
-
-Each AI response is stored as a single Ably message that grows as tokens are appended.
-
-### Initialize the Ably client
-
-Add the Ably client initialization to your `agent.mjs` file:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- echoMessages: false
-});
-
-// Create a channel for publishing streamed AI responses
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-```
-
-
-The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish tokens at high message rates with low latency.
-
-
-
-### Publish initial message and append tokens
-
-When a new response begins, publish an initial message to create it. Ably assigns a [`serial`](/docs/messages#properties) identifier to the message. Use this `serial` to append each token to the message as it arrives from the AI model.
-
-Update your `agent.mjs` file to publish the initial message and append tokens:
-
-
-```javascript
-// Track state across events
-let msgSerial = null;
-
-// Process each streaming event and publish to Ably
-async function processEvent(event) {
- switch (event.type) {
- case 'text-start':
- // Publish initial empty message when response starts
- const result = await channel.publish({
- name: 'response',
- data: ''
- });
-
- // Capture the message serial for appending tokens
- msgSerial = result.serials[0];
- break;
-
- case 'text-delta':
- // Append each text token to the message
- if (msgSerial) {
- channel.appendMessage({
- serial: msgSerial,
- data: event.text
- });
- }
- break;
-
- case 'text-end':
- console.log('Stream completed!');
- break;
- }
-}
-```
-
-
-This implementation:
-
-- Publishes an initial empty message when the response begins and captures the `serial`
-- Filters for `text-delta` events and appends each token to the original message
-- Logs completion when the `text-end` event is received
-
-
-
-
-
-Run the publisher to see tokens streaming to Ably:
-
-
-```shell
-node agent.mjs
-```
-
-
-## Step 4: Subscribe to streaming tokens
-
-Create a subscriber that receives the streaming tokens from Ably and reconstructs the response in realtime.
-
-Create a new file `client.mjs` with the following contents:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({ key: '{{API_KEY}}' });
-
-// Get the same channel used by the publisher
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by message serial
-const responses = new Map();
-
-// Subscribe to receive messages
-await channel.subscribe((message) => {
- switch (message.action) {
- case 'message.create':
- // New response started
- console.log('\n[Response started]', message.serial);
- responses.set(message.serial, message.data);
- break;
-
- case 'message.append':
- // Append token to existing response
- const current = responses.get(message.serial) || '';
- responses.set(message.serial, current + message.data);
-
- // Display token as it arrives
- process.stdout.write(message.data);
- break;
-
- case 'message.update':
- // Replace entire response content
- responses.set(message.serial, message.data);
- console.log('\n[Response updated with full content]');
- break;
- }
-});
-
-console.log('Subscriber ready, waiting for tokens...');
-```
-
-
-Subscribers receive different message actions depending on when they join and how they're retrieving messages:
-
-- `message.create`: Indicates a new response has started (i.e. a new message was created). The message `data` contains the initial content (often empty or the first token). Store this as the beginning of a new response using `serial` as the identifier.
-
-- `message.append`: Contains a single token fragment to append. The message `data` contains only the new token, not the full concatenated response. Append this token to the existing response identified by `serial`.
-
-- `message.update`: Contains the whole response up to that point. The message `data` contains the full concatenated text so far. Replace the entire response content with this data for the message identified by `serial`. This action occurs when the channel needs to resynchronize the full message state, such as after a client [resumes](/docs/connect/states#resume) from a transient disconnection.
-
-Run the subscriber in a separate terminal:
-
-
-```shell
-node client.mjs
-```
-
-
-With the subscriber running, run the publisher in another terminal. The tokens stream in realtime as the AI model generates them.
-
-## Step 5: Stream with multiple publishers and subscribers
-
-Ably's [channel-oriented sessions](/docs/ai-transport/sessions-identity#connection-oriented-vs-channel-oriented-sessions) enables multiple AI agents to publish responses and multiple users to receive them on a single channel simultaneously. Ably handles message delivery to all participants, eliminating the need to implement routing logic or manage state synchronization across connections.
-
-### Broadcasting to multiple subscribers
-
-Each subscriber receives the complete stream of tokens independently, enabling you to build collaborative experiences or multi-device applications.
-
-Run a subscriber in multiple separate terminals:
-
-
-```shell
-# Terminal 1
-node client.mjs
-
-# Terminal 2
-node client.mjs
-
-# Terminal 3
-node client.mjs
-```
-
-
-All subscribers receive the same stream of tokens in realtime.
-
-### Publishing concurrent responses
-
-Multiple publishers can stream different responses concurrently on the same [channel](/docs/channels). Each response is a distinct message with its own unique `serial` identifier, so tokens from different responses are isolated to distinct messages and don't interfere with each other.
-
-To demonstrate this, run a publisher in multiple separate terminals:
-
-
-```shell
-# Terminal 1
-node agent.mjs
-
-# Terminal 2
-node agent.mjs
-
-# Terminal 3
-node agent.mjs
-```
-
-
-All running subscribers receive tokens from all responses concurrently. Each subscriber correctly reconstructs each response separately using the `serial` to correlate tokens.
-
-## Step 6: Retrieve complete responses from history
-
-One key advantage of the message-per-response pattern is that each complete AI response is stored as a single message in channel history. This makes it efficient to retrieve conversation history without processing thousands of individual token messages.
-
-Use Ably's [rewind](/docs/channels/options/rewind) channel option to attach to the channel at some point in the recent past and automatically receive complete responses from history. Historical messages are delivered as `message.update` events containing the complete concatenated response, which then seamlessly transition to live `message.append` events for any ongoing responses:
-
-
-```javascript
-// Use rewind to receive recent historical messages
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}', {
- params: { rewind: '2m' } // Retrieve messages from the last 2 minutes
-});
-
-const responses = new Map();
-
-await channel.subscribe((message) => {
- switch (message.action) {
- case 'message.create':
- responses.set(message.serial, message.data);
- break;
-
- case 'message.append':
- const current = responses.get(message.serial) || '';
- responses.set(message.serial, current + message.data);
- process.stdout.write(message.data);
- break;
-
- case 'message.update':
- // Historical messages contain full concatenated response
- responses.set(message.serial, message.data);
- console.log('\n[Historical response]:', message.data);
- break;
- }
-});
-```
-
-
-
-
-## Next steps
-
-- Learn more about the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response) used in this guide
-- Learn about [client hydration strategies](/docs/ai-transport/token-streaming/message-per-response#hydration) for handling late joiners and reconnections
-- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI enabled applications
-- Explore the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token) for explicit control over individual token messages
diff --git a/src/pages/docs/ai-transport/guides/vercel-ai-sdk/vercel-message-per-token.mdx b/src/pages/docs/ai-transport/guides/vercel-ai-sdk/vercel-message-per-token.mdx
deleted file mode 100644
index ddebb3ac7b..0000000000
--- a/src/pages/docs/ai-transport/guides/vercel-ai-sdk/vercel-message-per-token.mdx
+++ /dev/null
@@ -1,358 +0,0 @@
----
-title: "Guide: Stream Vercel AI SDK responses using the message-per-token pattern"
-meta_description: "Stream tokens from the Vercel AI SDK over Ably in realtime."
-meta_keywords: "AI, token streaming, Vercel, AI SDK, AI transport, Ably, realtime"
-redirect_from:
- - /docs/guides/ai-transport/vercel-message-per-token
- - /docs/guides/ai-transport/vercel-ai-sdk/vercel-message-per-token
----
-
-This guide shows you how to stream AI responses from the [Vercel AI SDK](https://ai-sdk.dev/docs/ai-sdk-core/generating-text) over Ably using the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token). Specifically, it implements the [explicit start/stop events approach](/docs/ai-transport/token-streaming/message-per-token#explicit-events), which publishes each response token as an individual message, along with explicit lifecycle events to signal when responses begin and end.
-
-Using Ably to distribute tokens from the Vercel AI SDK enables you to broadcast AI responses to thousands of concurrent subscribers with reliable message delivery and ordering guarantees, ensuring that each client receives the complete response stream with all tokens delivered in order. This approach decouples your AI inference from client connections, enabling you to scale agents independently and handle reconnections gracefully.
-
-
-
-## Prerequisites
-
-To follow this guide, you need:
-- Node.js 20 or higher
-- A Vercel AI Gateway API key
-- An Ably API key
-
-Useful links:
-- [Vercel AI Gateway documentation](https://vercel.com/docs/ai-gateway)
-- [Vercel AI SDK documentation](https://ai-sdk.dev/docs)
-- [Ably JavaScript SDK getting started](/docs/getting-started/javascript)
-
-Create a new Node project, which will contain the publisher and subscriber code:
-
-
-```shell
-mkdir ably-vercel-message-per-token && cd ably-vercel-message-per-token
-npm init -y
-```
-
-
-Install the required packages using NPM:
-
-
-```shell
-npm install ai@^6 ably@^2
-```
-
-
-
-
-Export your Vercel AI Gateway API key to the environment, which will be used later in the guide by the Vercel AI SDK:
-
-
-```shell
-export AI_GATEWAY_API_KEY="your_api_key_here"
-```
-
-
-## Step 1: Get a streamed response from Vercel AI SDK
-
-Initialize the Vercel AI SDK and use [`streamText`](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text) to stream model output as a series of events.
-
-Create a new file `agent.mjs` with the following contents:
-
-
-```javascript
-import { streamText } from 'ai';
-
-// Process each streaming event
-function processEvent(event) {
- console.log(JSON.stringify(event));
- // This function is updated in the next sections
-}
-
-// Create streaming response from Vercel AI SDK
-async function streamVercelResponse(prompt) {
- const result = streamText({
- model: 'openai/gpt-4o',
- prompt: prompt,
- });
-
- // Iterate through streaming events using fullStream
- for await (const event of result.fullStream) {
- processEvent(event);
- }
-}
-
-// Usage example
-streamVercelResponse("Tell me a short joke");
-```
-
-
-### Understand Vercel AI SDK streaming events
-
-The Vercel AI SDK's [`streamText`](https://ai-sdk.dev/docs/ai-sdk-core/generating-text#streamtext) function provides a [`fullStream`](https://ai-sdk.dev/docs/ai-sdk-core/generating-text#fullstream-property) property that returns all stream events. Each event includes a `type` property which describes the event type. A complete text response can be constructed from the following event types:
-
-- [`text-start`](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#text-start-part): Signals the start of a text response. Contains an `id` to correlate subsequent events.
-
-- [`text-delta`](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#text-delta-part): Contains a single text token in the `text` field. These events represent incremental text chunks as the model generates them.
-
-- [`text-end`](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol#text-end-part): Signals the completion of a text response.
-
-The following example shows the event sequence received when streaming a response:
-
-
-```json
-// 1. Stream initialization
-{"type":"start"}
-{"type":"start-step","request":{...}}
-{"type":"text-start","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","providerMetadata":{...}}
-
-// 2. Text tokens stream in as delta events
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":"Why"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" don't"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" skeleton"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":"s"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" fight"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" each"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" other"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":"?\n\n"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":"They"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" don't"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" have"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" the"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":" guts"}
-{"type":"text-delta","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","text":"!"}
-
-// 3. Stream completion
-{"type":"text-end","id":"msg_0cc4da489ab9d4d101696f97d7c9548196a04f71d10a3a4c99","providerMetadata":{...}}
-{"type":"finish-step","finishReason":"stop","usage":{"inputTokens":12,"outputTokens":15,"totalTokens":27,"reasoningTokens":0,"cachedInputTokens":0},"providerMetadata":{...}}
-{"type":"finish","finishReason":"stop","totalUsage":{"inputTokens":12,"outputTokens":15,"totalTokens":27,"reasoningTokens":0,"cachedInputTokens":0}}
-```
-
-
-
-
-## Step 2: Publish streaming events to Ably
-
-Publish Vercel AI SDK streaming events to Ably to reliably and scalably distribute them to subscribers.
-
-This implementation follows the [explicit start/stop events pattern](/docs/ai-transport/token-streaming/message-per-token#explicit-events), which provides clear response boundaries.
-
-### Initialize the Ably client
-
-Add the Ably client initialization to your `agent.mjs` file:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- echoMessages: false
-});
-
-// Create a channel for publishing streamed AI responses
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-```
-
-
-The Ably Realtime client maintains a persistent connection to the Ably service, which allows you to publish tokens at high message rates with low latency.
-
-
-
-### Map Vercel AI SDK streaming events to Ably messages
-
-Choose how to map [Vercel AI SDK streaming events](#understand-streaming-events) to Ably [messages](/docs/messages). You can choose any mapping strategy that suits your application's needs. This guide uses the following pattern as an example:
-
-- `start`: Signals the beginning of a response
-- `token`: Contains the incremental text content for each delta
-- `stop`: Signals the completion of a response
-
-Update your `agent.mjs` file to initialize the Ably client and update the `processEvent()` function to publish events to Ably:
-
-
-```javascript
-// Track response ID across events
-let responseId = null;
-
-// Process each streaming event and publish to Ably
-function processEvent(event) {
- switch (event.type) {
- case 'text-start':
- // Capture response ID from text-start event
- responseId = event.id;
-
- // Publish start event with response ID
- channel.publish({
- name: 'start',
- extras: {
- headers: { responseId }
- }
- });
- break;
-
- case 'text-delta':
- // Publish each text delta as a token
- channel.publish({
- name: 'token',
- data: event.text,
- extras: {
- headers: { responseId }
- }
- });
- break;
-
- case 'text-end':
- // Publish stop event when stream completes
- channel.publish({
- name: 'stop',
- extras: {
- headers: { responseId }
- }
- });
- break;
- }
-}
-```
-
-
-This implementation:
-
-- Captures the `responseId` from the `text-start` event
-- Publishes a `start` event at the beginning of the response
-- Filters for `text-delta` events and publishes them as `token` events
-- Publishes a `stop` event when the response completes using the `text-end` event
-- All published events include the `responseId` in message [`extras`](/docs/messages#properties) to allow the client to correlate events relating to a particular response
-
-
-
-Run the publisher to see tokens streaming to Ably:
-
-
-```shell
-node agent.mjs
-```
-
-
-## Step 3: Subscribe to streaming tokens
-
-Create a subscriber that receives the streaming events from Ably and reconstructs the response.
-
-Create a new file `client.mjs` with the following contents:
-
-
-```javascript
-import Ably from 'ably';
-
-// Initialize Ably Realtime client
-const realtime = new Ably.Realtime({ key: '{{API_KEY}}' });
-
-// Get the same channel used by the publisher
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by ID
-const responses = new Map();
-
-// Handle response start
-await channel.subscribe('start', (message) => {
- const responseId = message.extras?.headers?.responseId;
- console.log('\n[Response started]', responseId);
- responses.set(responseId, '');
-});
-
-// Handle tokens
-await channel.subscribe('token', (message) => {
- const responseId = message.extras?.headers?.responseId;
- const token = message.data;
-
- // Append token to response
- const currentText = responses.get(responseId) || '';
- responses.set(responseId, currentText + token);
-
- // Display token as it arrives
- process.stdout.write(token);
-});
-
-// Handle response stop
-await channel.subscribe('stop', (message) => {
- const responseId = message.extras?.headers?.responseId;
- const finalText = responses.get(responseId);
-
- console.log('\n[Response completed]', responseId);
-});
-
-console.log('Subscriber ready, waiting for tokens...');
-```
-
-
-Run the subscriber in a separate terminal:
-
-
-```shell
-node client.mjs
-```
-
-
-With the subscriber running, run the publisher in another terminal. The tokens stream in realtime as the AI model generates them.
-
-## Step 4: Stream with multiple publishers and subscribers
-
-Ably's [channel-oriented sessions](/docs/ai-transport/sessions-identity#connection-oriented-vs-channel-oriented-sessions) enables multiple AI agents to publish responses and multiple users to receive them on a single channel simultaneously. Ably handles message delivery to all participants, eliminating the need to implement routing logic or manage state synchronization across connections.
-
-### Broadcasting to multiple subscribers
-
-Each subscriber receives the complete stream of tokens independently, enabling you to build collaborative experiences or multi-device applications.
-
-Run a subscriber in multiple separate terminals:
-
-
-```shell
-# Terminal 1
-node client.mjs
-
-# Terminal 2
-node client.mjs
-
-# Terminal 3
-node client.mjs
-```
-
-
-All subscribers receive the same stream of tokens in realtime.
-
-### Publishing concurrent responses
-
-The implementation uses `responseId` in message [`extras`](/docs/messages#properties) to correlate tokens with their originating response. This enables multiple publishers to stream different responses concurrently on the same [channel](/docs/channels), with each subscriber correctly tracking all responses independently.
-
-To demonstrate this, run a publisher in multiple separate terminals:
-
-
-```shell
-# Terminal 1
-node agent.mjs
-
-# Terminal 2
-node agent.mjs
-
-# Terminal 3
-node agent.mjs
-```
-
-
-All running subscribers receive tokens from all responses concurrently. Each subscriber correctly reconstructs each response separately using the `responseId` to correlate tokens.
-
-## Next steps
-
-- Learn more about the [message-per-token pattern](/docs/ai-transport/token-streaming/message-per-token) used in this guide
-- Learn about [client hydration strategies](/docs/ai-transport/token-streaming/message-per-token#hydration) for handling late joiners and reconnections
-- Understand [sessions and identity](/docs/ai-transport/sessions-identity) in AI enabled applications
-- Explore the [message-per-response pattern](/docs/ai-transport/token-streaming/message-per-response) for storing complete AI responses as single messages in history
diff --git a/src/pages/docs/ai-transport/how-it-works/index.mdx b/src/pages/docs/ai-transport/how-it-works/index.mdx
new file mode 100644
index 0000000000..3268c13039
--- /dev/null
+++ b/src/pages/docs/ai-transport/how-it-works/index.mdx
@@ -0,0 +1,8 @@
+---
+title: "How it works"
+meta_description: "Understand the architecture behind AI Transport, including sessions, transport layers, and the turn-based communication model."
+redirect_from:
+ - /docs/ai-transport/sessions-identity
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/how-it-works/sessions.mdx b/src/pages/docs/ai-transport/how-it-works/sessions.mdx
new file mode 100644
index 0000000000..396dae40ac
--- /dev/null
+++ b/src/pages/docs/ai-transport/how-it-works/sessions.mdx
@@ -0,0 +1,6 @@
+---
+title: "Sessions"
+meta_description: "Learn how AI Transport sessions work, including lifecycle management, state persistence, and identity binding."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/how-it-works/transport.mdx b/src/pages/docs/ai-transport/how-it-works/transport.mdx
new file mode 100644
index 0000000000..d2dcefbd7e
--- /dev/null
+++ b/src/pages/docs/ai-transport/how-it-works/transport.mdx
@@ -0,0 +1,6 @@
+---
+title: "Transport"
+meta_description: "Understand the transport layer in AI Transport, including how messages flow between clients and AI backends over Ably channels."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/how-it-works/turns.mdx b/src/pages/docs/ai-transport/how-it-works/turns.mdx
new file mode 100644
index 0000000000..97fe3486ab
--- /dev/null
+++ b/src/pages/docs/ai-transport/how-it-works/turns.mdx
@@ -0,0 +1,8 @@
+---
+title: "Turns"
+meta_description: "Understand the turn-based communication model in AI Transport, including how user input triggers agent responses and how turns are sequenced."
+redirect_from:
+ - /docs/ai-transport/messaging/accepting-user-input
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/index.mdx b/src/pages/docs/ai-transport/index.mdx
index dfcde0ab47..ec9ab51ab9 100644
--- a/src/pages/docs/ai-transport/index.mdx
+++ b/src/pages/docs/ai-transport/index.mdx
@@ -1,226 +1,7 @@
---
-title: About AI Transport
+title: "About AI Transport"
intro: "Ably AI Transport is a drop-in infrastructure layer that upgrades your AI streams into bi-directional, stateful experiences. It enables you to build multi-device, steerable AI applications that are agent agnostic, incredibly resilient and highly scalable."
-meta_description: "Learn more about Ably's AI Transport and the features that enable you to quickly build functionality into new and existing applications."
+meta_description: "AI Transport provides realtime infrastructure for AI agents, enabling token streaming, tool calls, and bidirectional communication between clients and AI backends."
---
-AI Transport enables you to add a realtime delivery layer to your application, providing the infrastructure required to deliver modern, stateful AI experiences to users. It works seamlessly with any AI model or framework, such as OpenAI, Anthropic, Vercel or LangChain.
-
-AI Transport runs on Ably's [fault-tolerant](/docs/platform/architecture/fault-tolerance) and highly-available platform. The platform supports streaming data between all internet-connected devices at [low latencies](/docs/platform/architecture/latency) across the globe. Its elastic global infrastructure delivers enterprise-scale messaging that [effortlessly scales](/docs/platform/architecture/platform-scalability) to meet demand.
-
-Drop AI Transport into your applications to transform them into modern, bi-directional AI experiences that keep users engaged. AI Transport provides the building blocks to deliver reliable, resumable token streams with robust session management and state hydration to always keep your users and agents in sync.
-
-
-
-## Get started
-
-Start learning the basics of AI Transport right away with a getting started guide using your agent and framework of choice:
-
-### OpenAI
-
-Use the following guides to get started with OpenAI:
-
-
-{[
- {
- title: 'Message-per-response',
- description: 'Stream OpenAI responses using message appends',
- image: 'icon-tech-javascript',
- link: '/docs/ai-transport/guides/openai/openai-message-per-response',
- },
- {
- title: 'Message-per-token',
- description: 'Stream OpenAI responses using individual token messages',
- image: 'icon-tech-javascript',
- link: '/docs/ai-transport/guides/openai/openai-message-per-token',
- },
- {
- title: 'Human-in-the-loop',
- description: 'Implement human-in-the-loop approval workflows with OpenAI',
- image: 'icon-tech-javascript',
- link: '/docs/ai-transport/guides/openai/openai-human-in-the-loop',
- },
-]}
-
-
-### Anthropic
-
-Use the following guides to get started with Anthropic:
-
-
-{[
- {
- title: 'Message-per-response',
- description: 'Stream Anthropic responses using message appends',
- image: 'icon-tech-javascript',
- link: '/docs/ai-transport/guides/anthropic/anthropic-message-per-response',
- },
- {
- title: 'Message-per-token',
- description: 'Stream Anthropic responses using individual token messages',
- image: 'icon-tech-javascript',
- link: '/docs/ai-transport/guides/anthropic/anthropic-message-per-token',
- },
- {
- title: 'Human-in-the-loop',
- description: 'Implement human-in-the-loop approval workflows with Anthropic',
- image: 'icon-tech-javascript',
- link: '/docs/ai-transport/guides/anthropic/anthropic-human-in-the-loop',
- },
-]}
-
-
-### Vercel AI SDK
-
-Use the following guides to get started with the Vercel AI SDK:
-
-
-{[
- {
- title: 'Message-per-response',
- description: 'Stream Vercel AI SDK responses using message appends',
- image: 'icon-tech-javascript',
- link: '/docs/ai-transport/guides/vercel-ai-sdk/vercel-message-per-response',
- },
- {
- title: 'Message-per-token',
- description: 'Stream Vercel AI SDK responses using individual token messages',
- image: 'icon-tech-javascript',
- link: '/docs/ai-transport/guides/vercel-ai-sdk/vercel-message-per-token',
- },
- {
- title: 'Human-in-the-loop',
- description: 'Implement HITL workflows with tool approval over Ably',
- image: 'icon-tech-javascript',
- link: '/docs/ai-transport/guides/vercel-ai-sdk/vercel-human-in-the-loop',
- },
-]}
-
-
-### LangGraph
-
-Use the following guides to get started with LangGraph:
-
-
-{[
- {
- title: 'Message-per-response',
- description: 'Stream LangGraph responses using message appends',
- image: 'icon-tech-javascript',
- link: '/docs/ai-transport/guides/langgraph/langgraph-message-per-response',
- },
- {
- title: 'Message-per-token',
- description: 'Stream LangGraph responses using individual token messages',
- image: 'icon-tech-javascript',
- link: '/docs/ai-transport/guides/langgraph/langgraph-message-per-token',
- },
- {
- title: 'Human-in-the-loop',
- description: 'Implement HITL workflows with tool approval over Ably',
- image: 'icon-tech-javascript',
- link: '/docs/ai-transport/guides/langgraph/langgraph-human-in-the-loop',
- },
-]}
-
-
-
-## Features
-
-AI Transport provides a range of features built on Ably's highly-scalable realtime platform to enable you to deliver reliable, stateful AI experiences that provide the first-class UX your users expect from modern applications.
-
-### Token streaming
-
-Token streaming is the core of how LLMs deliver their responses to users. Tokens are progressively streamed to users from your LLM so that users don't need to wait for a complete response before seeing any output.
-
-Using AI Transport, your token streams are reliable and persistent. They survive modern environments where users change browser tabs, refresh the page or switch devices, and common interruptions such as temporary network loss. Your users can always reconnect and continue where they left off without having to start over.
-
-[Read more about token streaming](/docs/ai-transport/token-streaming).
-
-### Bi-directional communication
-
-AI Transport supports rich, bi-directional communication patterns between users and agents.
-
-Build sophisticated AI experiences with features like accepting user input for interactive conversations, streaming chain-of-thought reasoning for transparency, attaching citations to responses for verifiability, implementing human-in-the-loop workflows for sensitive operations, and exposing tool calls for generative UI and visibility.
-
-These messaging features work seamlessly with [token streaming](/docs/ai-transport/token-streaming) to create complete, interactive AI experiences.
-
-[Read more about messaging features](/docs/ai-transport/messaging/accepting-user-input).
-
-### Durable sessions
-
-AI Transport enables durable sessions that persist beyond the lifetime of individual connections, allowing users and agents to connect and disconnect independently.
-
-Communication shouldn't be tied to the connection state of either party. If a user goes offline or their connection drops, they should be able to continue their session without losing context. AI Transport provides robust session management by enabling users and agents to connect independently of one another.
-
-Your users can start a conversation on their mobile and seamlessly continue it on their desktop. Similarly, multiple users can participate in the same conversation with a single agent and they will all remain in sync, in realtime.
-
-[Read more about sessions and identity](/docs/ai-transport/sessions-identity).
-
-### Automatic catch-up
-
-AI Transport enables clients to hydrate conversation and session state from the [channel](/docs/channels), including [message history](/docs/storage-history/history) and in-progress responses.
-
-Whether a user is briefly disconnected when they drive through a tunnel, or they're rejoining a conversation the following day of work, AI Transport allows clients to resynchronise the full conversation state, including both historical messages and in-progress responses. Your users are always up to date with the full conversation, in order, anywhere.
-
-[Read more about client hydration](/docs/ai-transport/token-streaming/message-per-response#hydration).
-
-### Background processing
-
-AI Transport allows agents to process jobs in the background while users go offline, with full awareness of their online status through realtime presence tracking.
-
-Users can work asynchronously by prompting an agent to perform a task without having to monitor its progress. They can go offline and receive a push notification when the agent has completed the task, or reconnect at any time to seamlessly resume and see all progress made while they were away using [state hydration](#catch-up).
-
-It also puts you in control of how you manage your application when there aren't any users online. For example, you can choose whether to pause a conversation when a user exits their browser tab, or allow the agent to complete its response for the user to view when they return.
-
-[Read more about status-aware cost controls](/docs/ai-transport/sessions-identity/online-status).
-
-### Enterprise controls
-
-Ably's platform provides [integrations](/docs/platform/integrations) and functionality to ensure that your applications always exceed the requirements of enterprise environments. Whether that's [message auditing](/docs/platform/integrations/streaming), [client identification](/docs/auth/identified-clients) or [fine-grained authorization](/docs/auth/capabilities).
-
-## Examples
-
-Take a look at some example code running in-browser of the sorts of features you can build with AI Transport underpinning your applications:
-
-
-{[
- {
- title: 'Message per response streaming',
- description: 'Stream individual tokens from AI models into a single message.',
- image: 'icon-tech-javascript',
- link: '/examples/ai-transport-message-per-response?lang=javascript',
- },
- {
- title: 'Message per response streaming',
- description: 'Stream individual tokens from AI models into a single message.',
- image: 'icon-tech-react',
- link: '/examples/ai-transport-message-per-response?lang=react',
- },
- {
- title: 'Message per token streaming',
- description: 'Stream individual tokens from AI models as separate messages.',
- image: 'icon-tech-javascript',
- link: '/examples/ai-transport-message-per-token?lang=javascript',
- },
- {
- title: 'Message per token streaming',
- description: 'Stream individual tokens from AI models as separate messages.',
- image: 'icon-tech-react',
- link: '/examples/ai-transport-message-per-token?lang=react',
- },
-]}
-
-
-## Pricing
-
-AI Transport uses Ably's [usage based billing model](/docs/platform/pricing) at your package rates. Your consumption costs will depend on the number of messages inbound (published to Ably) and outbound (delivered to subscribers), and how long channels or connections are active. [Contact Ably](https://ably.com/contact) to discuss options for Enterprise pricing and volume discounts.
-
-The cost of streaming token responses over Ably depends on:
-
-- the number of tokens in the LLM responses that you are streaming. For example, a simple support chatbot response might be around 300 tokens, a coding session can be 2,000-3,000 tokens and a deep reasoning response could be over 50,000 tokens.
-- the rate at which your agent publishes tokens to Ably and the number of messages it uses to do so. Some LLMs output every token as a single event, while others batch multiple tokens together. Similarly, your agent may publish tokens as they are received from the LLM or perform its own processing and batching first.
-- the number of subscribers receiving the response.
-- the [token streaming pattern](/docs/ai-transport/token-streaming#token-streaming-patterns) you choose.
-
-For example, suppose an AI support chatbot sends a response of 300 tokens, each as a discrete update, using the [message-per-response](/docs/ai-transport/token-streaming/message-per-response) pattern, and with a single client subscribed to the channel. With AI Transport's [append rollup](/docs/ai-transport/token-streaming/token-rate-limits#per-response),those 300 input tokens will be conflated to 100 discrete inbound messages, resulting in 100 outbound messages and 100 persisted messages. See the [AI support chatbot pricing example](/docs/platform/pricing/examples/ai-chatbot) for a full breakdown of the costs in this scenario.
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/internals/codec-architecture.mdx b/src/pages/docs/ai-transport/internals/codec-architecture.mdx
new file mode 100644
index 0000000000..2ec1f0a145
--- /dev/null
+++ b/src/pages/docs/ai-transport/internals/codec-architecture.mdx
@@ -0,0 +1,6 @@
+---
+title: "Codec architecture"
+meta_description: "Understand the codec architecture in AI Transport, including how messages are encoded, decoded, and transformed across the transport layer."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/internals/event-mapping.mdx b/src/pages/docs/ai-transport/internals/event-mapping.mdx
new file mode 100644
index 0000000000..59acbf2546
--- /dev/null
+++ b/src/pages/docs/ai-transport/internals/event-mapping.mdx
@@ -0,0 +1,6 @@
+---
+title: "Event mapping"
+meta_description: "Understand how AI Transport maps AI provider events to Ably channel messages, including event types and payload structures."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/internals/transport-patterns.mdx b/src/pages/docs/ai-transport/internals/transport-patterns.mdx
new file mode 100644
index 0000000000..8828f4befa
--- /dev/null
+++ b/src/pages/docs/ai-transport/internals/transport-patterns.mdx
@@ -0,0 +1,6 @@
+---
+title: "Transport patterns"
+meta_description: "Explore the transport patterns used by AI Transport, including request-response, streaming, and pub/sub communication models."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/internals/wire-protocol.mdx b/src/pages/docs/ai-transport/internals/wire-protocol.mdx
new file mode 100644
index 0000000000..be38b5d397
--- /dev/null
+++ b/src/pages/docs/ai-transport/internals/wire-protocol.mdx
@@ -0,0 +1,6 @@
+---
+title: "Wire protocol"
+meta_description: "Technical reference for the AI Transport wire protocol, including message format, framing, and serialization."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/messaging/accepting-user-input.mdx b/src/pages/docs/ai-transport/messaging/accepting-user-input.mdx
deleted file mode 100644
index 9713595eb8..0000000000
--- a/src/pages/docs/ai-transport/messaging/accepting-user-input.mdx
+++ /dev/null
@@ -1,595 +0,0 @@
----
-title: "User input"
-meta_description: "Enable users to send prompts to AI agents over Ably with verified identity and message correlation."
-meta_keywords: "user input, AI prompts, message correlation, identified clients, clientId, agent messaging"
----
-
-User input enables users to send prompts and requests to AI agents over Ably channels. The agent subscribes to a channel to receive user messages, processes them, and sends responses back. This pattern uses [Ably Pub/Sub](/docs/basics) for realtime, bi-directional communication between users and agents.
-
-User input works alongside [token streaming](/docs/ai-transport/token-streaming) patterns to create complete conversational AI experiences. While token streaming handles agent-to-user output, user input handles user-to-agent prompts.
-
-## How it works
-
-User input follows a channel-based pattern where both users and agents connect to a shared channel:
-
-1. The agent subscribes to the channel to listen for user messages.
-2. The user publishes a message containing their prompt.
-3. The agent receives the message, processes it, and generates a response.
-4. The agent publishes the response back to the channel, correlating it to the original input.
-
-This decoupled approach means agents don't need to manage persistent connections to individual users. Instead, they subscribe to channels and respond to messages as they arrive.
-
-
-
-## Identify the user
-
-Agents need to verify that incoming messages are from legitimate users. Use [identified clients](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-identity) or [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) to establish a verified identity or role for the user.
-
-
-
-### Verify by user identity
-
-Use the `clientId` to identify the user who sent a message. This enables personalized responses, per-user rate limiting, or looking up user-specific preferences from your database.
-
-When a user [authenticates with Ably](/docs/ai-transport/sessions-identity/identifying-users-and-agents#authenticating), embed their identity in the JWT:
-
-
-```javascript
-const claims = {
- 'x-ably-clientId': 'user-123'
-};
-```
-```python
-claims = {
- 'x-ably-clientId': 'user-123'
-}
-```
-```java
-Map claims = new HashMap<>();
-claims.put("x-ably-clientId", "user-123");
-```
-
-
-The `clientId` is automatically attached to every message the user publishes, so agents can trust this identity.
-
-
-```javascript
-await channel.subscribe('user-input', (message) => {
- const userId = message.clientId;
- // promptId is a user-generated UUID for correlating responses
- const { promptId, text } = message.data;
-
- console.log(`Received prompt from user ${userId}`);
- processAndRespond(channel, text, promptId, userId);
-});
-```
-```python
-def on_user_input(message):
- user_id = message.client_id
- # promptId is a user-generated UUID for correlating responses
- prompt_id = message.data['promptId']
- text = message.data['text']
-
- print(f'Received prompt from user {user_id}')
- process_and_respond(channel, text, prompt_id, user_id)
-
-await channel.subscribe('user-input', on_user_input)
-```
-```java
-channel.subscribe("user-input", message -> {
- String userId = message.clientId;
- // promptId is a user-generated UUID for correlating responses
- JsonObject data = (JsonObject) message.data;
- String promptId = data.get("promptId").getAsString();
- String text = data.get("text").getAsString();
-
- System.out.println("Received prompt from user " + userId);
- processAndRespond(channel, text, promptId, userId);
-});
-```
-
-
-### Verify by role
-
-Use [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) to verify that a message comes from a user rather than another agent sharing the channel. This is useful when the agent needs to distinguish message sources without needing the specific user identity.
-
-When a user [authenticates with Ably](/docs/ai-transport/sessions-identity/identifying-users-and-agents#authenticating), embed their role in the JWT:
-
-
-```javascript
-const claims = {
- 'ably.channel.*': 'user'
-};
-```
-```python
-claims = {
- 'ably.channel.*': 'user'
-}
-```
-```java
-Map claims = new HashMap<>();
-claims.put("ably.channel.*", "user");
-```
-
-
-The user claim is automatically attached to every message the user publishes, so agents can trust this role information.
-
-
-```javascript
-await channel.subscribe('user-input', (message) => {
- const role = message.extras?.userClaim;
- // promptId is a user-generated UUID for correlating responses
- const { promptId, text } = message.data;
-
- if (role !== 'user') {
- console.log('Ignoring message from non-user');
- return;
- }
-
- processAndRespond(channel, text, promptId);
-});
-```
-```python
-def on_user_input(message):
- role = message.extras.get('userClaim')
- # promptId is a user-generated UUID for correlating responses
- prompt_id = message.data['promptId']
- text = message.data['text']
-
- if role != 'user':
- print('Ignoring message from non-user')
- return
-
- process_and_respond(channel, text, prompt_id)
-
-await channel.subscribe('user-input', on_user_input)
-```
-```java
-channel.subscribe("user-input", message -> {
- String role = message.extras.get("userClaim").getAsString();
- // promptId is a user-generated UUID for correlating responses
- JsonObject data = (JsonObject) message.data;
- String promptId = data.get("promptId").getAsString();
- String text = data.get("text").getAsString();
-
- if (!role.equals("user")) {
- System.out.println("Ignoring message from non-user");
- return;
- }
-
- processAndRespond(channel, text, promptId);
-});
-```
-
-
-## Publish user input
-
-Users publish messages to the channel to send prompts to the agent. Generate a unique `promptId` for each message to correlate agent responses back to the original prompt.
-
-
-```javascript
-const channel = ably.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-const promptId = crypto.randomUUID();
-await channel.publish('user-input', {
- promptId: promptId,
- text: 'What is the weather like today?'
-});
-```
-```python
-import uuid
-
-channel = ably.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-prompt_id = str(uuid.uuid4())
-message = Message(
- name='user-input',
- data={
- 'promptId': prompt_id,
- 'text': 'What is the weather like today?'
- }
-)
-await channel.publish(message)
-```
-```java
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-String promptId = UUID.randomUUID().toString();
-JsonObject data = new JsonObject();
-data.addProperty("promptId", promptId);
-data.addProperty("text", "What is the weather like today?");
-channel.publish("user-input", data);
-```
-
-
-
-
-## Subscribe to user input
-
-The agent subscribes to a channel to receive messages from users. When a user publishes a message to the channel, the agent receives it through the subscription callback.
-
-The following example demonstrates an agent subscribing to receive user input:
-
-
-```javascript
-const Ably = require('ably');
-
-const ably = new Ably.Realtime({ key: '{{API_KEY}}' });
-const channel = ably.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-await channel.subscribe('user-input', (message) => {
- const { promptId, text } = message.data;
- const userId = message.clientId;
-
- console.log(`Received prompt from ${userId}: ${text}`);
-
- // Process the prompt and generate a response
- processAndRespond(channel, text, promptId);
-});
-```
-```python
-from ably import AblyRealtime
-
-ably = AblyRealtime(key='{{API_KEY}}')
-channel = ably.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-def on_user_input(message):
- prompt_id = message.data['promptId']
- text = message.data['text']
- user_id = message.client_id
-
- print(f'Received prompt from {user_id}: {text}')
-
- # Process the prompt and generate a response
- process_and_respond(channel, text, prompt_id)
-
-await channel.subscribe('user-input', on_user_input)
-```
-```java
-import io.ably.lib.realtime.AblyRealtime;
-import io.ably.lib.realtime.Channel;
-
-AblyRealtime ably = new AblyRealtime("{{API_KEY}}");
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-channel.subscribe("user-input", message -> {
- JsonObject data = (JsonObject) message.data;
- String promptId = data.get("promptId").getAsString();
- String text = data.get("text").getAsString();
- String userId = message.clientId;
-
- System.out.println("Received prompt from " + userId + ": " + text);
-
- // Process the prompt and generate a response
- processAndRespond(channel, text, promptId);
-});
-```
-
-
-
-
-## Publish agent responses
-
-When the agent sends a response, it includes the `promptId` from the original input so users know which prompt the response relates to. This is especially important when users send multiple prompts in quick succession or when responses are streamed.
-
-Use the `extras.headers` field to include the `promptId` in agent responses:
-
-
-```javascript
-async function processAndRespond(channel, prompt, promptId) {
- // Generate the response (e.g., call your AI model)
- const response = await generateAIResponse(prompt);
-
- // Publish the response with the promptId for correlation
- await channel.publish({
- name: 'agent-response',
- data: response,
- extras: {
- headers: {
- promptId: promptId
- }
- }
- });
-}
-```
-```python
-async def process_and_respond(channel, prompt, prompt_id):
- # Generate the response (e.g., call your AI model)
- response = await generate_ai_response(prompt)
-
- message = Message(
- name='agent-response',
- data=response,
- extras={
- 'headers': {
- 'promptId': prompt_id
- }
- }
- )
-
- # Publish the response with the promptId for correlation
- await channel.publish(message)
-```
-```java
-void processAndRespond(Channel channel, String prompt, String promptId) {
- // Generate the response (e.g., call your AI model)
- String response = generateAIResponse(prompt);
-
- // Publish the response with the promptId for correlation
- JsonObject extras = new JsonObject();
- JsonObject headers = new JsonObject();
- headers.addProperty("promptId", promptId);
- extras.add("headers", headers);
-
- Message message = new Message("agent-response", response, new MessageExtras(extras));
-
- channel.publish(message);
-}
-```
-
-
-The user's client can then match responses to their original prompts:
-
-
-```javascript
-const pendingPrompts = new Map();
-
-// Send a prompt and track it
-async function sendPrompt(text) {
- const promptId = crypto.randomUUID();
- pendingPrompts.set(promptId, { text });
- await channel.publish('user-input', { promptId, text });
- return promptId;
-}
-
-// Handle responses
-await channel.subscribe('agent-response', (message) => {
- const promptId = message.extras?.headers?.promptId;
-
- if (promptId && pendingPrompts.has(promptId)) {
- const originalPrompt = pendingPrompts.get(promptId);
- console.log(`Response for "${originalPrompt.text}": ${message.data}`);
- pendingPrompts.delete(promptId);
- }
-});
-```
-```python
-import uuid
-
-pending_prompts = {}
-
-# Send a prompt and track it
-async def send_prompt(text):
- prompt_id = str(uuid.uuid4())
- pending_prompts[prompt_id] = {'text': text}
- message = Message(name='user-input', data={'promptId': prompt_id, 'text': text})
- await channel.publish(message)
- return prompt_id
-
-# Handle responses
-def on_agent_response(message):
- prompt_id = message.extras.get('headers', {}).get('promptId')
-
- if prompt_id and prompt_id in pending_prompts:
- original_prompt = pending_prompts[prompt_id]
- print(f'Response for "{original_prompt["text"]}": {message.data}')
- del pending_prompts[prompt_id]
-
-await channel.subscribe('agent-response', on_agent_response)
-```
-```java
-Map> pendingPrompts = new ConcurrentHashMap<>();
-
-// Send a prompt and track it
-String sendPrompt(String text) {
- String promptId = UUID.randomUUID().toString();
- Map promptData = new HashMap<>();
- promptData.put("text", text);
- pendingPrompts.put(promptId, promptData);
-
- JsonObject data = new JsonObject();
- data.addProperty("promptId", promptId);
- data.addProperty("text", text);
- channel.publish("user-input", data);
-
- return promptId;
-}
-
-// Handle responses
-channel.subscribe("agent-response", message -> {
- JsonObject headers = message.extras.asJsonObject().get("headers");
- String promptId = headers != null ? headers.get("promptId").getAsString() : null;
-
- if (promptId != null && pendingPrompts.containsKey(promptId)) {
- Map originalPrompt = pendingPrompts.get(promptId);
- System.out.println("Response for \"" + originalPrompt.get("text") + "\": " + message.data);
- pendingPrompts.remove(promptId);
- }
-});
-```
-
-
-
-
-## Stream responses
-
-For longer AI responses, you'll typically want to stream tokens back to the user rather than waiting for the complete response. The `promptId` correlation allows users to associate streamed tokens with their original prompt.
-
-When streaming tokens using [message-per-response](/docs/ai-transport/token-streaming/message-per-response) or [message-per-token](/docs/ai-transport/token-streaming/message-per-token) patterns, include the `promptId` in the message extras:
-
-
-```javascript
-async function streamResponse(channel, prompt, promptId) {
- // Create initial message for message-per-response pattern
- const message = await channel.publish({
- name: 'agent-response',
- data: '',
- extras: {
- headers: {
- promptId: promptId
- }
- }
- });
-
- // Stream tokens by appending to the message
- for await (const token of generateTokens(prompt)) {
- await channel.appendMessage({
- serial: message.serial,
- data: token,
- extras: {
- headers: {
- promptId: promptId
- }
- }
- });
- }
-}
-```
-```python
-async def stream_response(channel, prompt, prompt_id):
- # Create initial message for message-per-response pattern
- message = Message(
- name='agent-response',
- data='',
- extras={
- 'headers': {
- 'promptId': prompt_id
- }
- }
- )
- result = await channel.publish(message)
- message_serial = result.serials[0]
-
- # Stream tokens by appending to the message
- async for token in generate_tokens(prompt):
- await channel.append_message(Message(
- serial=message_serial,
- data=token,
- extras={
- 'headers': {
- 'promptId': prompt_id
- }
- }
- ))
-```
-```java
-void streamResponse(Channel channel, String prompt, String promptId) throws Exception {
- // Create initial message for message-per-response pattern
- JsonObject extras = new JsonObject();
- JsonObject headers = new JsonObject();
- headers.addProperty("promptId", promptId);
- extras.add("headers", headers);
-
- CompletableFuture publishFuture = new CompletableFuture<>();
- channel.publish("agent-response", "", extras, new Callback() {
- @Override
- public void onSuccess(PublishResult result) {
- publishFuture.complete(result);
- }
-
- @Override
- public void onError(ErrorInfo reason) {
- publishFuture.completeExceptionally(AblyException.fromErrorInfo(reason));
- }
- });
- String messageSerial = publishFuture.get().serials[0];
-
- // Stream tokens by appending to the message
- for (String token : generateTokens(prompt)) {
- JsonObject appendExtras = new JsonObject();
- JsonObject appendHeaders = new JsonObject();
- appendHeaders.addProperty("promptId", promptId);
- appendExtras.add("headers", appendHeaders);
-
- channel.appendMessage(messageSerial, token, appendExtras);
- }
-}
-```
-
-
-
-
-## Handle multiple concurrent prompts
-
-Users may send multiple prompts before receiving responses, especially during long-running AI operations. The correlation pattern ensures responses are matched to the correct prompts:
-
-
-```javascript
-// Agent handling multiple concurrent prompts
-const activeRequests = new Map();
-
-await channel.subscribe('user-input', async (message) => {
- const { promptId, text } = message.data;
- const userId = message.clientId;
-
- // Track active request
- activeRequests.set(promptId, {
- userId,
- text,
- });
-
- try {
- await streamResponse(channel, text, promptId);
- } finally {
- activeRequests.delete(promptId);
- }
-});
-```
-```python
-# Agent handling multiple concurrent prompts
-active_requests = {}
-
-async def on_user_input(message):
- prompt_id = message.data['promptId']
- text = message.data['text']
- user_id = message.client_id
-
- # Track active request
- active_requests[prompt_id] = {
- 'userId': user_id,
- 'text': text,
- }
-
- try:
- await stream_response(channel, text, prompt_id)
- finally:
- del active_requests[prompt_id]
-
-await channel.subscribe('user-input', on_user_input)
-```
-```java
-// Agent handling multiple concurrent prompts
-Map> activeRequests = new ConcurrentHashMap<>();
-
-channel.subscribe("user-input", message -> {
- JsonObject data = (JsonObject) message.data;
- String promptId = data.get("promptId").getAsString();
- String text = data.get("text").getAsString();
- String userId = message.clientId;
-
- // Track active request
- Map requestData = new HashMap<>();
- requestData.put("userId", userId);
- requestData.put("text", text);
- activeRequests.put(promptId, requestData);
-
- try {
- streamResponse(channel, text, promptId);
- } finally {
- activeRequests.remove(promptId);
- }
-});
-```
-
diff --git a/src/pages/docs/ai-transport/messaging/chain-of-thought.mdx b/src/pages/docs/ai-transport/messaging/chain-of-thought.mdx
deleted file mode 100644
index c218673536..0000000000
--- a/src/pages/docs/ai-transport/messaging/chain-of-thought.mdx
+++ /dev/null
@@ -1,547 +0,0 @@
----
-title: "Chain of thought"
-meta_description: "Stream chain-of-thought reasoning from thinking models in AI applications"
-meta_keywords: "chain of thought, thinking models, extended thinking, reasoning transparency, reasoning streams, thought process, AI transparency, separate channels, model reasoning, tool calls, reasoning tokens"
----
-
-Modern AI applications stream chain-of-thought reasoning from thinking models alongside their output. Rather than immediately generating output, thinking models work through problems step-by-step, evaluating different approaches and refining their reasoning before and during output generation. Exposing this reasoning provides transparency into how the model arrived at its output, enabling richer user experiences and deeper insights into model behavior.
-
-## What is chain-of-thought?
-
-Chain-of-thought is the model's internal reasoning process as it works through a problem. Modern thinking models output this reasoning as a stream of messages that show how they evaluate options, consider trade-offs, and plan their approach while generating output.
-
-A single response from the model may consist of multiple output messages interleaved with reasoning messages, which are all associated with the same model response.
-
-As an application developer, you decide what reasoning to surface and to whom. You may choose to expose all reasoning, filter or summarize it (for example, via a separate model call), or keep internal thinking entirely private.
-
-Surfacing chain-of-thought reasoning provides:
-
-- Trust and transparency: Users can see how the AI reached its conclusions, building confidence in the output
-- Better user experience: Displaying reasoning in realtime provides feedback that the model is making progress during longer operations
-- Enhanced steerability: Users can intervene and redirect the model based on its reasoning so far, guiding it toward better outcomes
-
-## Streaming patterns
-
-As an application developer, you decide how to surface chain-of-thought reasoning to end users. Ably's pub/sub model is flexible and can accommodate any messaging pattern you choose. Below are a few common patterns used in modern AI applications, each showing both agent-side publishing and client-side subscription. Choose the approach that fits your use case, or create your own variation.
-
-### Inline pattern
-
-In the inline pattern, agents publish reasoning messages to the same channel as model output messages.
-
-By publishing all content to a single channel, the inline pattern:
-
-- Simplifies channel management by consolidating all conversation content in one place
-- Maintains relative order of reasoning and model output messages as the model generates them
-- Supports retrieving reasoning and response messages together from history
-
-#### Publish
-
-Publish both reasoning and model output messages to a single channel.
-
-In the example below, the `responseId` is included in the message [extras](/docs/messages#properties) to allow subscribers to correlate all messages belonging to the same response. The message [`name`](/docs/messages#properties) allows the client distinguish between the different message types:
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Example: stream returns events like:
-// { type: 'reasoning', text: "Let's break down the problem: 27 * 12", responseId: 'resp_abc123' }
-// { type: 'reasoning', text: 'First, I can split this into 27 * 10 + 27 * 2', responseId: 'resp_abc123' }
-// { type: 'reasoning', text: 'That gives us 270 + 54 = 324', responseId: 'resp_abc123' }
-// { type: 'message', text: '27 * 12 = 324', responseId: 'resp_abc123' }
-
-for await (const event of stream) {
- if (event.type === 'reasoning') {
- // Publish reasoning messages
- await channel.publish({
- name: 'reasoning',
- data: event.text,
- extras: {
- headers: {
- responseId: event.responseId
- }
- }
- });
- } else if (event.type === 'message') {
- // Publish model output messages
- await channel.publish({
- name: 'message',
- data: event.text,
- extras: {
- headers: {
- responseId: event.responseId
- }
- }
- });
- }
-}
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-# Example: stream returns events like:
-# { 'type': 'reasoning', 'text': "Let's break down the problem: 27 * 12", 'responseId': 'resp_abc123' }
-# { 'type': 'reasoning', 'text': 'First, I can split this into 27 * 10 + 27 * 2', 'responseId': 'resp_abc123' }
-# { 'type': 'reasoning', 'text': 'That gives us 270 + 54 = 324', 'responseId': 'resp_abc123' }
-# { 'type': 'message', 'text': '27 * 12 = 324', 'responseId': 'resp_abc123' }
-
-async for event in stream:
- if event['type'] == 'reasoning':
- # Publish reasoning messages
- await channel.publish(Message(
- name='reasoning',
- data=event['text'],
- extras={
- 'headers': {
- 'responseId': event['responseId']
- }
- }
- ))
- elif event['type'] == 'message':
- # Publish model output messages
- await channel.publish(Message(
- name='message',
- data=event['text'],
- extras={
- 'headers': {
- 'responseId': event['responseId']
- }
- }
- ))
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Example: stream returns events like:
-// { type: 'reasoning', text: "Let's break down the problem: 27 * 12", responseId: 'resp_abc123' }
-// { type: 'reasoning', text: 'First, I can split this into 27 * 10 + 27 * 2', responseId: 'resp_abc123' }
-// { type: 'reasoning', text: 'That gives us 270 + 54 = 324', responseId: 'resp_abc123' }
-// { type: 'message', text: '27 * 12 = 324', responseId: 'resp_abc123' }
-
-for (Event event : stream) {
- JsonObject extras = new JsonObject();
- JsonObject headers = new JsonObject();
- headers.addProperty("responseId", event.getResponseId());
- extras.add("headers", headers);
-
- if (event.getType().equals("reasoning")) {
- // Publish reasoning messages
- channel.publish(new Message("reasoning", event.getText(), new MessageExtras(extras)));
- } else if (event.getType().equals("message")) {
- // Publish model output messages
- channel.publish(new Message("message", event.getText(), new MessageExtras(extras)));
- }
-}
-```
-
-
-
-
-
-
-#### Subscribe
-
-Subscribe to both reasoning and model output messages on the same channel.
-
-In the example below, the `responseId` from the message [`extras`](/docs/api/realtime-sdk/messages#extras) is used to group reasoning and model output messages belonging to the same response. The message [`name`](/docs/messages#properties) allows the client distinguish between the different message types:
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by ID, each containing reasoning messages and final response
-const responses = new Map();
-
-// Subscribe to all events on the channel
-await channel.subscribe((message) => {
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) {
- console.warn('Message missing responseId');
- return;
- }
-
- // Initialize response object if needed
- if (!responses.has(responseId)) {
- responses.set(responseId, {
- reasoning: [],
- message: ''
- });
- }
-
- const response = responses.get(responseId);
-
- // Handle each message type
- switch (message.name) {
- case 'message':
- response.message = message.data;
- break;
- case 'reasoning':
- response.reasoning.push(message.data);
- break;
- }
-
- // Display the reasoning and response for this turn
- console.log(`Response ${responseId}:`, response);
-});
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-# Track responses by ID, each containing reasoning messages and final response
-responses = {}
-
-# Subscribe to all events on the channel
-def on_message(message):
- response_id = message.extras.get('headers', {}).get('responseId')
-
- if not response_id:
- print('Message missing responseId')
- return
-
- # Initialize response object if needed
- if response_id not in responses:
- responses[response_id] = {
- 'reasoning': [],
- 'message': ''
- }
-
- response = responses[response_id]
-
- # Handle each message type
- if message.name == 'message':
- response['message'] = message.data
- elif message.name == 'reasoning':
- response['reasoning'].append(message.data)
-
- # Display the reasoning and response for this turn
- print(f'Response {response_id}:', response)
-
-await channel.subscribe(on_message)
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Track responses by ID, each containing reasoning messages and final response
-Map responses = new ConcurrentHashMap<>();
-
-// Subscribe to all events on the channel
-channel.subscribe(message -> {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers != null ? headers.get("responseId").getAsString() : null;
-
- if (responseId == null) {
- System.err.println("Message missing responseId");
- return;
- }
-
- // Initialize response object if needed
- responses.putIfAbsent(responseId, new Response());
- Response response = responses.get(responseId);
-
- // Handle each message type
- switch (message.name) {
- case "message":
- response.setMessage((String) message.data);
- break;
- case "reasoning":
- response.getReasoning().add((String) message.data);
- break;
- }
-
- // Display the reasoning and response for this turn
- System.out.println("Response " + responseId + ": " + response);
-});
-```
-
-
-
-
-### Threading pattern
-
-In the threading pattern, agents publish reasoning messages to a separate channel from model output messages. The reasoning channel name is communicated to clients, allowing them to discover where to obtain reasoning content on demand.
-
-By separating reasoning into its own channel, the threading pattern:
-
-- Keeps the main channel clean and focused on final responses without reasoning output cluttering the conversation history
-- Reduces bandwidth usage by delivering reasoning messages only when users choose to view them
-- Works well for long reasoning threads, where not all the detail needs to be immediately surfaced to the user, but is helpful to see on demand
-
-#### Publish
-
-Publish model output messages to the main conversation channel and reasoning messages to a dedicated reasoning channel. The reasoning channel name includes the response ID, creating a unique reasoning channel per response.
-
-In the example below, the agent sends a `start` control message on the main channel at the beginning of each response, which includes the response ID in the message [`extras`](/docs/api/realtime-sdk/messages#extras). Clients can derive the reasoning channel name from the response ID, allowing them to discover and subscribe to the stream of reasoning messages on demand:
-
-
-
-
-```javascript
-const conversationChannel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Example: stream returns events like:
-// { type: 'start', responseId: 'resp_abc123' }
-// { type: 'reasoning', text: "Let's break down the problem: 27 * 12", responseId: 'resp_abc123' }
-// { type: 'reasoning', text: 'First, I can split this into 27 * 10 + 27 * 2', responseId: 'resp_abc123' }
-// { type: 'reasoning', text: 'That gives us 270 + 54 = 324', responseId: 'resp_abc123' }
-// { type: 'message', text: '27 * 12 = 324', responseId: 'resp_abc123' }
-
-for await (const event of stream) {
- if (event.type === 'start') {
- // Publish response start control message
- await conversationChannel.publish({
- name: 'start',
- extras: {
- headers: {
- responseId: event.responseId
- }
- }
- });
- } else if (event.type === 'reasoning') {
- // Publish reasoning to separate reasoning channel
- const reasoningChannel = realtime.channels.get(`{{RANDOM_CHANNEL_NAME}}:${event.responseId}`);
-
- await reasoningChannel.publish({
- name: 'reasoning',
- data: event.text
- });
- } else if (event.type === 'message') {
- // Publish model output to main channel
- await conversationChannel.publish({
- name: 'message',
- data: event.text,
- extras: {
- headers: {
- responseId: event.responseId
- }
- }
- });
- }
-}
-```
-```python
-conversation_channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-# Example: stream returns events like:
-# { 'type': 'start', 'responseId': 'resp_abc123' }
-# { 'type': 'reasoning', 'text': "Let's break down the problem: 27 * 12", 'responseId': 'resp_abc123' }
-# { 'type': 'reasoning', 'text': 'First, I can split this into 27 * 10 + 27 * 2', 'responseId': 'resp_abc123' }
-# { 'type': 'reasoning', 'text': 'That gives us 270 + 54 = 324', 'responseId': 'resp_abc123' }
-# { 'type': 'message', 'text': '27 * 12 = 324', 'responseId': 'resp_abc123' }
-
-async for event in stream:
- if event['type'] == 'start':
- # Publish response start control message
- await conversation_channel.publish(Message(
- name='start',
- extras={
- 'headers': {
- 'responseId': event['responseId']
- }
- }
- ))
- elif event['type'] == 'reasoning':
- # Publish reasoning to separate reasoning channel
- reasoning_channel = realtime.channels.get(f"{{{{RANDOM_CHANNEL_NAME}}}}:{event['responseId']}")
-
- await reasoning_channel.publish(
- name='reasoning',
- data=event['text']
- )
- elif event['type'] == 'message':
- # Publish model output to main channel
- await conversation_channel.publish(Message(
- name='message',
- data=event['text'],
- extras={
- 'headers': {
- 'responseId': event['responseId']
- }
- }
- ))
-```
-```java
-Channel conversationChannel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Example: stream returns events like:
-// { type: 'start', responseId: 'resp_abc123' }
-// { type: 'reasoning', text: "Let's break down the problem: 27 * 12", responseId: 'resp_abc123' }
-// { type: 'reasoning', text: 'First, I can split this into 27 * 10 + 27 * 2', responseId: 'resp_abc123' }
-// { type: 'reasoning', text: 'That gives us 270 + 54 = 324', responseId: 'resp_abc123' }
-// { type: 'message', text: '27 * 12 = 324', responseId: 'resp_abc123' }
-
-for (Event event : stream) {
- if (event.getType().equals("start")) {
- // Publish response start control message
- JsonObject extras = new JsonObject();
- JsonObject headers = new JsonObject();
- headers.addProperty("responseId", event.getResponseId());
- extras.add("headers", headers);
-
- conversationChannel.publish("start", null, extras);
- } else if (event.getType().equals("reasoning")) {
- // Publish reasoning to separate reasoning channel
- Channel reasoningChannel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}:" + event.getResponseId());
-
- reasoningChannel.publish("reasoning", event.getText());
- } else if (event.getType().equals("message")) {
- // Publish model output to main channel
- JsonObject extras = new JsonObject();
- JsonObject headers = new JsonObject();
- headers.addProperty("responseId", event.getResponseId());
- extras.add("headers", headers);
-
- conversationChannel.publish("message", event.getText(), extras);
- }
-}
-```
-
-
-
-
-
-
-#### Subscribe
-
-Subscribe to the main conversation channel to receive control messages and model output. Subscribe to the reasoning channel on demand, for example in response to a click event.
-
-In the example below, `responseId` from the message [`extras`](/docs/api/realtime-sdk/messages#extras) is used to derive the reasoning channel name, allowing clients to subscribe to the reasoning channel on demand to retrieve the reasoning associated with a response:
-
-
-```javascript
-const conversationChannel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by ID
-const responses = new Map();
-
-// Subscribe to all messages on the main channel
-await conversationChannel.subscribe((message) => {
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) {
- console.warn('Message missing responseId');
- return;
- }
-
- // Handle response start control message
- if (message.name === 'start') {
- responses.set(responseId, '');
- }
-
- // Handle model output message
- if (message.name === 'message') {
- responses.set(responseId, message.data);
- }
-});
-
-// Subscribe to reasoning on demand (e.g., when user clicks to view reasoning)
-async function onClickViewReasoning(responseId) {
- // Derive reasoning channel name from responseId and
- // use rewind to retrieve historical reasoning
- const reasoningChannel = realtime.channels.get(`{{RANDOM_CHANNEL_NAME}}:${responseId}`, {
- params: { rewind: '2m' }
- });
-
- // Subscribe to reasoning messages
- await reasoningChannel.subscribe((message) => {
- console.log(`[Reasoning]: ${message.data}`);
- });
-}
-```
-```python
-conversation_channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-# Track responses by ID
-responses = {}
-
-# Subscribe to all messages on the main channel
-def on_message(message):
- response_id = message.extras.get('headers', {}).get('responseId')
-
- if not response_id:
- print('Message missing responseId')
- return
-
- # Handle response start control message
- if message.name == 'start':
- responses[response_id] = ''
-
- # Handle model output message
- if message.name == 'message':
- responses[response_id] = message.data
-
-await conversation_channel.subscribe(on_message)
-
-# Subscribe to reasoning on demand (e.g., when user clicks to view reasoning)
-async def on_click_view_reasoning(response_id):
- # Derive reasoning channel name from responseId and
- # use rewind to retrieve historical reasoning
- reasoning_channel = realtime.channels.get(
- f'{{{{RANDOM_CHANNEL_NAME}}}}:{response_id}',
- params={'rewind': '2m'}
- )
-
- # Subscribe to reasoning messages
- def on_reasoning(message):
- print(f'[Reasoning]: {message.data}')
-
- await reasoning_channel.subscribe(on_reasoning)
-```
-```java
-Channel conversationChannel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Track responses by ID
-Map responses = new ConcurrentHashMap<>();
-
-// Subscribe to all messages on the main channel
-conversationChannel.subscribe(message -> {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers != null ? headers.get("responseId").getAsString() : null;
-
- if (responseId == null) {
- System.err.println("Message missing responseId");
- return;
- }
-
- // Handle response start control message
- if (message.name.equals("start")) {
- responses.put(responseId, "");
- }
-
- // Handle model output message
- if (message.name.equals("message")) {
- responses.put(responseId, (String) message.data);
- }
-});
-
-// Subscribe to reasoning on demand (e.g., when user clicks to view reasoning)
-void onClickViewReasoning(String responseId) {
- // Derive reasoning channel name from responseId and
- // use rewind to retrieve historical reasoning
- ChannelOptions options = new ChannelOptions();
- options.params = Map.of("rewind", "2m");
- Channel reasoningChannel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}:" + responseId, options);
-
- // Subscribe to reasoning messages
- reasoningChannel.subscribe(message -> {
- System.out.println("[Reasoning]: " + message.data);
- });
-}
-```
-
-
-
diff --git a/src/pages/docs/ai-transport/messaging/citations.mdx b/src/pages/docs/ai-transport/messaging/citations.mdx
deleted file mode 100644
index 28674bdd34..0000000000
--- a/src/pages/docs/ai-transport/messaging/citations.mdx
+++ /dev/null
@@ -1,397 +0,0 @@
----
-title: "Citations"
-meta_description: "Attach source citations to AI responses using message annotations"
-meta_keywords: "citations, references, source attribution, message annotations, AI transparency, source tracking, annotation summaries"
----
-
-AI agents often draw information from external sources such as documents, web pages, or databases. Citations to those sources enable users to verify information, explore sources in detail, and understand where responses came from. Ably's [message annotations](/docs/messages/annotations) provide a model-agnostic, structured way to attach source citations to AI responses without modifying the response content. It enables clients to append information to existing messages on a channel.
-
-This pattern works when publishing complete responses as messages on a channel or when streaming responses using the [message-per-response](/docs/ai-transport/token-streaming/message-per-response) pattern.
-
-## Why citations matter
-
-Including citations on AI responses provides:
-
-- Transparency: Users can verify claims and understand the basis for AI responses. This builds trust and allows users to fact-check information independently.
-- Source exploration: Citations enable users to dive deeper into topics by accessing original sources. This is particularly valuable for research, learning, and decision-making workflows.
-- Attribution: Proper attribution respects content creators and helps users understand which sources informed the AI's response.
-- Audit trails: For enterprise applications, citations provide explicit traceability between LLM responses and the information sources that were consulted when generating them.
-
-## How it works
-
-Use [message annotations](/docs/messages/annotations) to attach source metadata to AI response messages without modifying the response content:
-
-1. The agent publishes an AI response as a single message, or builds it incrementally using [message appends](/docs/ai-transport/token-streaming/message-per-response).
-2. The agent publishes one or more annotations to attach citations to the response message, each referencing the response message [`serial`](/docs/messages#properties).
-3. Ably automatically aggregates annotations and generates summaries showing total counts and groupings (for example, by source domain name).
-4. Clients receive citation summaries automatically and can optionally subscribe to individual annotation events for detailed citation data as part of the realtime stream. Alternatively, clients can obtain annotations for a given message via the REST API.
-
-## Enable message annotations
-
-Message append functionality requires "Message annotations, updates, deletes and appends" to be enabled in a [rule](/docs/channels#rules) associated with the channel.
-
-
-
-To enable the rule:
-
-1. Go to the [Ably dashboard](https://www.ably.com/dashboard) and select your app.
-2. Navigate to the "Configuration" > "Rules" section from the left-hand navigation bar.
-3. Choose "Add new rule".
-4. Enter a channel name or namespace pattern (for example, `ai` for all channels starting with `ai:`).
-5. Select the "Message annotations, updates, deletes and appends" option from the list.
-6. Click "Create rule".
-
-The examples in this guide use the `ai:` namespace prefix, which assumes you have configured the rule for `ai`.
-
-
-
-## Citation data model
-
-Citations are implemented using [message annotations](/docs/messages/annotations). Each citation includes an annotation `type` that determines how citations are aggregated into summaries, and a `data` payload containing the citation details.
-
-### Annotation type
-
-[Annotation types](/docs/messages/annotations#annotation-types) determine how annotations are processed and aggregated into summaries. The type is a string of the format `namespace:summarization_method`:
-
-- `namespace` is a string that logically groups related annotations. For example, use `citations` for AI response citations.
-- `summarization_method` specifies how annotations are aggregated to produce summaries.
-
-Use the [`multiple.v1`](/docs/messages/annotations#multiple) summarization method for AI response citations. This is well suited for citations because:
-
-- AI responses often reference the same source multiple times, and `multiple.v1` counts each citation separately.
-- Citations can be grouped by source using the `name` field (for example, by domain name), so clients can display "3 citations from wikipedia.org, 2 from nasa.gov".
-
-The examples below use the annotation type `citations:multiple.v1`.
-
-### Annotation data
-
-The annotation `data` field can contain any structured data relevant to your citation use case. For example, a citation for a web search result might include:
-
-
-```json
-{
- "url": "https://example.com/article",
- "title": "Example Article Title",
- "startOffset": 120,
- "endOffset": 180,
- "snippet": "Short excerpt from source"
-}
-```
-
-
-In this example:
-
-- `url` is the source URL.
-- `title` is the title of the web page.
-- `startOffset` is the character position in the response where this citation begins.
-- `endOffset` is the character position in the response where the citation ends.
-- `snippet` is a short excerpt from the source content for preview displays.
-
-Including character offsets in annotation data allow UIs to attach inline citation markers to specific portions of the response text.
-
-
-
-## Publish citations
-
-Agents create citations by publishing [message annotations](/docs/messages/annotations) that reference the [`serial`](/docs/messages#properties) of the response message:
-
-
-```javascript
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Publish the AI response message
-const response = 'The James Webb Space Telescope launched in December 2021 and its first images were released in July 2022.';
-const { serials: [msgSerial] } = await channel.publish('response', response);
-
-// Add citations by annotating the response message
-await channel.annotations.publish(msgSerial, {
- type: 'citations:multiple.v1',
- name: 'science.nasa.gov',
- data: {
- url: 'https://science.nasa.gov/mission/webb/',
- title: 'James Webb Space Telescope - NASA Science',
- startOffset: 43,
- endOffset: 56,
- snippet: 'Webb launched on Dec. 25th 2021'
- }
-});
-await channel.annotations.publish(msgSerial, {
- type: 'citations:multiple.v1',
- name: 'en.wikipedia.org',
- data: {
- url: 'https://en.wikipedia.org/wiki/James_Webb_Space_Telescope',
- title: 'James Webb Space Telescope - Wikipedia',
- startOffset: 95,
- endOffset: 104,
- snippet: 'The telescope\'s first image was released to the public on 11 July 2022.'
- }
-});
-```
-```java
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
-// Publish the AI response message
-String response = "The James Webb Space Telescope launched in December 2021 and its first images were released in July 2022.";
-CompletableFuture publishFuture = new CompletableFuture<>()
-channel.publish("response", response, new Callback() {
- @Override
- public void onSuccess(PublishResult result) {
- publishFuture.complete(result);
- }
-
- @Override
- public void onError(ErrorInfo reason) {
- publishFuture.completeExceptionally(AblyException.fromErrorInfo(reason));
- }
-});
-String msgSerial = publishFuture.get().serials[0];
-
-// Add citations by annotating the response message
-JsonObject citation1Data = new JsonObject();
-citation1Data.addProperty("url", "https://science.nasa.gov/mission/webb/");
-citation1Data.addProperty("title", "James Webb Space Telescope - NASA Science");
-citation1Data.addProperty("startOffset", 43);
-citation1Data.addProperty("endOffset", 56);
-citation1Data.addProperty("snippet", "Webb launched on Dec. 25th 2021");
-
-Annotation citation1 = new Annotation();
-citation1.name = "science.nasa.gov";
-citation1.type = "citations:multiple.v1";
-citation1.data = citation1Data;
-channel.annotations.publish(msgSerial, citation1);
-
-JsonObject citation2Data = new JsonObject();
-citation2Data.addProperty("url", "https://en.wikipedia.org/wiki/James_Webb_Space_Telescope");
-citation2Data.addProperty("title", "James Webb Space Telescope - Wikipedia");
-citation2Data.addProperty("startOffset", 95);
-citation2Data.addProperty("endOffset", 104);
-citation2Data.addProperty("snippet", "The telescope's first image was released to the public on 11 July 2022.");
-
-Annotation citation2 = new Annotation();
-citation2.name = "en.wikipedia.org";
-citation2.type = "citations:multiple.v1";
-citation2.data = citation2Data;
-channel.annotations.publish(msgSerial, citation2);
-```
-```python
-channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}')
-
-# Publish the AI response message
-response = 'The James Webb Space Telescope launched in December 2021 and its first images were released in July 2022.'
-publish_result = await channel.publish('response', response)
-msg_serial = publish_result.serials[0]
-
-# Add citations by annotating the response message
-citation1 = Annotation(
- type='citations:multiple.v1',
- name='science.nasa.gov',
- data={
- 'url': 'https://science.nasa.gov/mission/webb/',
- 'title': 'James Webb Space Telescope - NASA Science',
- 'startOffset': 43,
- 'endOffset': 56,
- 'snippet': 'Webb launched on Dec. 25th 2021'
- }
-)
-await channel.annotations.publish(msg_serial, citation1)
-
-citation2 = Annotation(
- type='citations:multiple.v1',
- name='en.wikipedia.org',
- data={
- 'url': 'https://en.wikipedia.org/wiki/James_Webb_Space_Telescope',
- 'title': 'James Webb Space Telescope - Wikipedia',
- 'startOffset': 95,
- 'endOffset': 104,
- 'snippet': "The telescope's first image was released to the public on 11 July 2022."
- }
-)
-await channel.annotations.publish(msg_serial, citation2)
-```
-
-
-
-
-
-
-
-
-## Subscribe to summaries
-
-
-Clients can display a summary of the citations attached to a response by using [annotation summaries](/docs/messages/annotations#annotation-summaries). Clients receive realtime updates to annotation summaries automatically when subscribing to a channel, which are [delivered as messages](/docs/messages/annotations#subscribe) with an `action` of `message.summary`. When using [`multiple.v1`](/docs/messages/annotations#multiple) summarization, counts are grouped by the annotation `name`.
-
-
-
-In the example below, the `name` is set to the domain name of the citation source, so summaries show counts per domain:
-
-
-```javascript
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-await channel.subscribe((message) => {
- if (message.action === 'message.summary') {
- const citations = message.annotations.summary['citations:multiple.v1'];
- if (citations) {
- console.log('Citation summary:', citations);
- }
- }
-});
-```
-```java
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
-channel.subscribe(message -> {
- if (message.action == MessageAction.MESSAGE_SUMMARY) {
- JsonObject citations = message.annotations.summary.get("citations:multiple.v1");
- if (citations != null) {
- System.out.println("Citation summary: " + citations);
- }
- }
-});
-```
-```python
-channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}')
-
-def message_handler(message):
- if message.action == MessageAction.MESSAGE_SUMMARY:
- citations = message.annotations.summary.get('citations:multiple.v1')
- if citations:
- print('Citation summary:', citations)
-
-await channel.subscribe(message_handler)
-```
-
-
-The `multiple.v1` summary groups counts by the annotation `name`, with totals and per-client breakdowns for each group:
-
-
-```json
-{
- "citations:multiple.v1": {
- "science.nasa.gov": {
- "total": 1,
- "clientIds": {
- "research-agent": 1
- },
- "totalUnidentified": 0,
- "totalClientIds": 1,
- "clipped": false
- },
- "en.wikipedia.org": {
- "total": 1,
- "clientIds": {
- "research-agent": 1
- },
- "totalUnidentified": 0,
- "totalClientIds": 1,
- "clipped": false
- }
- }
-}
-```
-
-
-When agents publish citations with a [`clientId`](/docs/auth/identified-clients), summaries include a per-client count showing how many citations each agent contributed. Citations published by [unidentified](/docs/auth/identified-clients#unidentified) clients are counted in the `totalUnidentified` field.
-
-
-
-## Subscribe to individual citations
-
-To access the full citation data, subscribe to [individual annotation events](/docs/messages/annotations#individual-annotations):
-
-
-```javascript
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}', {
- modes: ['ANNOTATION_SUBSCRIBE']
-});
-
-await channel.annotations.subscribe((annotation) => {
- if (annotation.action === 'annotation.create' &&
- annotation.type === 'citations:multiple.v1') {
- const { url, title } = annotation.data;
- console.log(`Citation: ${title} (${url})`);
- // Output: Citation: James Webb Space Telescope - Wikipedia (https://en.wikipedia.org/wiki/James_Webb_Space_Telescope)
- }
-});
-```
-```java
-ChannelOptions options = new ChannelOptions();
-options.modes = new ChannelMode[]{ChannelMode.ANNOTATION_SUBSCRIBE};
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}", options);
-
-channel.annotations.subscribe(annotation -> {
- if (annotation.action == AnnotationAction.ANNOTATION_CREATE &&
- annotation.type.equals("citations:multiple.v1")) {
- JsonObject data = annotation.data;
- String url = data.get("url").getAsString();
- String title = data.get("title").getAsString();
- System.out.println("Citation: " + title + " (" + url + ")");
- // Output: Citation: James Webb Space Telescope - Wikipedia (https://en.wikipedia.org/wiki/James_Webb_Space_Telescope)
- }
-});
-```
-```python
-channel_options = ChannelOptions(modes=[ChannelMode.ANNOTATION_SUBSCRIBE])
-channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}', channel_options)
-
-def annotation_handler(annotation):
- if (annotation.action == AnnotationAction.ANNOTATION_CREATE and
- annotation.type == 'citations:multiple.v1'):
- url = annotation.data['url']
- title = annotation.data['title']
- print(f"Citation: {title} ({url})")
- # Output: Citation: James Webb Space Telescope - Wikipedia (https://en.wikipedia.org/wiki/James_Webb_Space_Telescope)
-
-await channel.annotations.subscribe(annotation_handler)
-```
-
-
-Each annotation event includes the `messageSerial` of the response message it is attached to, the `name` used for grouping in summaries, and the full citation `data` payload. This data can be used to render clickable source links or attach inline citation markers to specific portions of the response text:
-
-
-```json
-{
- "action": "annotation.create",
- "clientId": "research-agent",
- "type": "citations:multiple.v1",
- "messageSerial": "01767638186693-000@108SP4XcgBxfMO07491612:000",
- "name": "en.wikipedia.org",
- "data": {
- "url": "https://en.wikipedia.org/wiki/James_Webb_Space_Telescope",
- "title": "James Webb Space Telescope - Wikipedia",
- "startOffset": 95,
- "endOffset": 104,
- "snippet": "The telescope's first image was released to the public on 11 July 2022."
- }
-}
-```
-
-
-
-
-## Retrieve citations on demand
-
-Annotations can also be retrieved via the [REST API](/docs/api/rest-api#annotations-list) without maintaining a realtime subscription.
-
-
diff --git a/src/pages/docs/ai-transport/messaging/completion-and-cancellation.mdx b/src/pages/docs/ai-transport/messaging/completion-and-cancellation.mdx
deleted file mode 100644
index 9216ce4548..0000000000
--- a/src/pages/docs/ai-transport/messaging/completion-and-cancellation.mdx
+++ /dev/null
@@ -1,387 +0,0 @@
----
-title: "Completion and cancellation"
-meta_description: "Signal when AI responses are complete and support user-initiated cancellation of in-progress responses."
-meta_keywords: "completion signalling, cancellation, abort, streaming lifecycle, metadata, AI transport, realtime"
----
-
-AI responses streamed using the [message-per-response](/docs/ai-transport/token-streaming/message-per-response) or [message-per-token](/docs/ai-transport/token-streaming/message-per-token) pattern do not require explicit completion signals to function. Subscribers receive tokens as they arrive and can render them progressively. However, some applications benefit from explicitly signalling when a response is complete, or allowing users to cancel an in-progress response.
-
-## Benefits of completion and cancellation signals
-
-Explicit completion and cancellation signals are useful when your application needs to:
-
-- Detect whether a response is still in progress after reconnection, so clients can distinguish between a completed response and one that is still streaming
-- Finalize UI state when a response ends, such as removing typing indicators or enabling input controls
-- Allow users to abort a response mid-stream, stopping generation and saving compute resources
-- Coordinate multiple content parts within a single response, where downstream logic depends on knowing when each part is finished
-
-## Signal completion
-
-Use [operation metadata](/docs/messages/updates-deletes#append-operation-metadata) to signal that a content part or response is complete. Operation metadata is a set of key-value pairs carried on each append or update operation. Subscribers can inspect this metadata to determine the current phase of a message.
-
-### Content-part completion
-
-When streaming content using the [message-per-response](/docs/ai-transport/token-streaming/message-per-response) pattern, signal that a content part is complete by appending an empty string with a metadata marker. The empty append does not change the message's data, but the metadata signals to subscribers that no more content follows for this message.
-
-This keeps the entire content lifecycle (create, stream, done) within a single Ably message:
-
-
-```javascript
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Publish initial message
-const { serials: [msgSerial] } = await channel.publish({ name: 'response', data: '' });
-
-// Stream tokens
-for await (const event of stream) {
- if (event.type === 'token') {
- channel.appendMessage({
- serial: msgSerial,
- data: event.text
- }, {
- metadata: { phase: 'streaming' }
- });
- }
-}
-
-// Signal content-part completion with an empty append
-channel.appendMessage({
- serial: msgSerial,
- data: ''
-}, {
- metadata: { phase: 'done' }
-});
-```
-```python
-channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}')
-
-# Publish initial message
-message = Message(name='response', data='')
-result = await channel.publish(message)
-msg_serial = result.serials[0]
-
-# Stream tokens
-async for event in stream:
- if event['type'] == 'token':
- asyncio.create_task(channel.append_message(
- serial=msg_serial,
- data=event['text'],
- metadata={'phase': 'streaming'}
- ))
-
-# Signal content-part completion with an empty append
-asyncio.create_task(channel.append_message(
- serial=msg_serial,
- data='',
- metadata={'phase': 'done'}
-))
-```
-```java
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
-// Publish initial message
-CompletableFuture publishFuture = new CompletableFuture<>();
-channel.publish("response", "", new Callback() {
- @Override
- public void onSuccess(PublishResult result) {
- publishFuture.complete(result);
- }
-
- @Override
- public void onError(ErrorInfo reason) {
- publishFuture.completeExceptionally(AblyException.fromErrorInfo(reason));
- }
-});
-String msgSerial = publishFuture.get().serials[0];
-
-// Stream tokens
-for (Event event : stream) {
- if (event.getType().equals("token")) {
- MessageMetadata metadata = new MessageMetadata();
- metadata.put("phase", "streaming");
- channel.appendMessage(msgSerial, event.getText(), metadata);
- }
-}
-
-// Signal content-part completion with an empty append
-MessageMetadata metadata = new MessageMetadata();
-metadata.put("phase", "done");
-channel.appendMessage(msgSerial, "", metadata);
-```
-
-
-
-
-### Response-level completion
-
-A single AI response may span multiple content parts, each represented as a separate Ably message with its own stream of appends. To signal that the entire response is complete, publish a discrete message after all content parts are finished. Subscribers can use this as a cue to finalize the response in the UI.
-
-
-```javascript
-// After all content parts are done, signal response-level completion
-await channel.publish({
- name: 'response-end',
- data: '',
- extras: {
- headers: {
- responseId: 'resp_abc123'
- }
- }
-});
-```
-```python
-# After all content parts are done, signal response-level completion
-await channel.publish(Message(
- name='response-end',
- data='',
- extras={
- 'headers': {
- 'responseId': 'resp_abc123'
- }
- }
-))
-```
-```java
-// After all content parts are done, signal response-level completion
-JsonObject extras = new JsonObject();
-JsonObject headers = new JsonObject();
-headers.addProperty("responseId", "resp_abc123");
-extras.add("headers", headers);
-
-channel.publish(new Message("response-end", "", new MessageExtras(extras)));
-```
-
-
-
-
-### Detect completion from history
-
-When [hydrating client state](/docs/ai-transport/token-streaming/message-per-response#hydration) from history, inspect `version.metadata` on each message to determine whether a content part was fully completed or is still in progress. If the most recent operation's metadata carries your completion marker, the content part is done. If it carries a streaming marker or no marker, the stream may still be active.
-
-
-```javascript
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-await channel.subscribe((message) => {
- // ...handle message actions as normal...
-});
-
-let page = await channel.history({ untilAttach: true });
-
-while (page) {
- for (const message of page.items) {
- const phase = message.version?.metadata?.phase;
-
- if (phase === 'done') {
- // Content part is complete, render as final
- } else {
- // Content part may still be streaming, listen for live appends
- }
- }
- page = page.hasNext() ? await page.next() : null;
-}
-```
-```python
-channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}')
-
-await channel.subscribe(on_message)
-
-page = await channel.history(until_attach=True)
-
-while page:
- for message in page.items:
- phase = getattr(message.version, 'metadata', {}).get('phase')
-
- if phase == 'done':
- # Content part is complete, render as final
- pass
- else:
- # Content part may still be streaming, listen for live appends
- pass
-
- page = await page.next() if page.has_next() else None
-```
-```java
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
-channel.subscribe(message -> { /* handle message actions as normal */ });
-
-PaginatedResult page = channel.history(new Param("untilAttach", "true"));
-
-while (page != null) {
- for (Message message : page.items()) {
- String phase = message.version != null && message.version.metadata != null
- ? message.version.metadata.get("phase")
- : null;
-
- if ("done".equals(phase)) {
- // Content part is complete, render as final
- } else {
- // Content part may still be streaming, listen for live appends
- }
- }
- page = page.hasNext() ? page.next() : null;
-}
-```
-
-
-
-
-## Cancel a response
-
-Cancellation allows users to stop an in-progress response. The subscriber publishes a cancellation message on the channel, and the publisher stops generating and flushes any pending operations.
-
-### How it works
-
-1. The subscriber publishes a cancellation message on the channel with a response ID identifying the response to cancel.
-2. The publisher receives the cancellation message, stops generating, and flushes any pending append operations.
-3. The publisher optionally publishes a confirmation message to signal clean shutdown to other subscribers.
-
-### Publish a cancellation request
-
-The subscriber sends a cancellation message with a `responseId` in the message [extras](/docs/messages#properties) to identify which response to cancel:
-
-
-```javascript
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Send cancellation request for a specific response
-await channel.publish({
- name: 'cancel',
- data: '',
- extras: {
- headers: {
- responseId: 'resp_abc123'
- }
- }
-});
-```
-```python
-channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}')
-
-# Send cancellation request for a specific response
-await channel.publish(Message(
- name='cancel',
- data='',
- extras={
- 'headers': {
- 'responseId': 'resp_abc123'
- }
- }
-))
-```
-```java
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
-// Send cancellation request for a specific response
-JsonObject extras = new JsonObject();
-JsonObject headers = new JsonObject();
-headers.addProperty("responseId", "resp_abc123");
-extras.add("headers", headers);
-
-channel.publish(new Message("cancel", "", new MessageExtras(extras)));
-```
-
-
-### Handle cancellation
-
-The publisher subscribes for cancellation messages and stops generation when one arrives. After stopping, flush any pending append operations before optionally publishing a confirmation message:
-
-
-```javascript
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track pending appends for flushing
-const pendingAppends = [];
-
-// Listen for cancellation requests
-await channel.subscribe('cancel', async (message) => {
- const responseId = message.extras?.headers?.responseId;
-
- // Stop generation for the matching response
- stopGeneration(responseId);
-
- // Flush any pending appends before confirming
- await Promise.all(pendingAppends);
-
- // Optionally confirm cancellation to all subscribers
- await channel.publish({
- name: 'cancelled',
- data: '',
- extras: {
- headers: {
- responseId
- }
- }
- });
-});
-```
-```python
-channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}')
-
-# Track pending appends for flushing
-pending_appends = []
-
-# Listen for cancellation requests
-async def on_cancel(message):
- response_id = message.extras.get('headers', {}).get('responseId')
-
- # Stop generation for the matching response
- stop_generation(response_id)
-
- # Flush any pending appends before confirming
- await asyncio.gather(*pending_appends)
-
- # Optionally confirm cancellation to all subscribers
- await channel.publish(Message(
- name='cancelled',
- data='',
- extras={
- 'headers': {
- 'responseId': response_id
- }
- }
- ))
-
-await channel.subscribe('cancel', on_cancel)
-```
-```java
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
-// Listen for cancellation requests
-channel.subscribe("cancel", message -> {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers != null ? headers.get("responseId").getAsString() : null;
-
- // Stop generation for the matching response
- stopGeneration(responseId);
-
- // Flush any pending appends before confirming
- flushPendingAppends();
-
- // Optionally confirm cancellation to all subscribers
- JsonObject confirmExtras = new JsonObject();
- JsonObject confirmHeaders = new JsonObject();
- confirmHeaders.addProperty("responseId", responseId);
- confirmExtras.add("headers", confirmHeaders);
-
- channel.publish(new Message("cancelled", "", new MessageExtras(confirmExtras)));
-});
-```
-
-
-
-
-
diff --git a/src/pages/docs/ai-transport/messaging/human-in-the-loop.mdx b/src/pages/docs/ai-transport/messaging/human-in-the-loop.mdx
deleted file mode 100644
index 8428724be9..0000000000
--- a/src/pages/docs/ai-transport/messaging/human-in-the-loop.mdx
+++ /dev/null
@@ -1,453 +0,0 @@
----
-title: "Human in the loop"
-meta_description: "Implement human-in-the-loop workflows for AI agents using Ably capabilities and claims to ensure authorized users approve sensitive tool calls."
-meta_keywords: "human in the loop, HITL, AI agent authorization, tool call approval, JWT claims, capabilities, admin approval, agentic workflows, AI safety, human oversight"
----
-
-Human-in-the-loop (HITL) enables human oversight of AI agent actions. When an agent needs to perform sensitive operations, such as modifying data, performing sensitive actions, or accessing restricted resources, the action is paused and routed to an authorized human for approval before execution.
-
-This pattern ensures humans remain in control of high-stakes AI operations, providing safety, compliance, and trust in agentic workflows.
-
-## Why human-in-the-loop matters
-
-AI agents are increasingly capable of taking autonomous actions, but certain operations require human judgement:
-
-- Safety: Prevent unintended consequences from AI decisions.
-- Compliance: Meet regulatory requirements for human oversight in sensitive domains.
-- Trust: Build user confidence by keeping humans in control of critical actions.
-- Accountability: Create clear audit trails of who approved what actions.
-- Clarification: Allow the agent to request more information or guidance from users before proceeding.
-
-HITL puts a human approval step in front of agent actions that carry risk or uncertainty.
-
-## How it works
-
-Human-in-the-loop authorization follows a request-approval pattern over Ably channels:
-
-1. The AI agent determines a tool call requires human approval.
-2. The agent publishes an authorization request to the channel.
-3. An authorized user receives and reviews the request.
-4. The human approves or rejects the request.
-5. The agent receives the decision, verifies the responder's identity or role and proceeds accordingly.
-
-## Request human approval
-
-When an agent identifies an action requiring human oversight, it publishes a request to the channel. The request should include sufficient context for the approver to make an informed decision. The `toolCallId` in the message [extras](/docs/messages#properties) enables correlation between requests and responses when handling multiple concurrent approval flows.
-
-The agent stores each pending request in some local state before publishing. When an approval response arrives, the agent uses the `toolCallId` to retrieve the original tool call details, verify the approver's permissions for that specific action, execute the tool if approved, and resolve the pending approval.
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-const pendingApprovals = new Map();
-
-async function requestHumanApproval(toolCall) {
- pendingApprovals.set(toolCall.id, { toolCall });
-
- await channel.publish({
- name: 'approval-request',
- data: {
- tool: toolCall.name,
- arguments: toolCall.arguments
- },
- extras: {
- headers: {
- toolCallId: toolCall.id
- }
- }
- });
-}
-```
-```python
-import uuid
-
-channel = ably.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-async def request_human_approval(tool_call):
- request_id = str(uuid.uuid4())
-
- await channel.publish('approval-request', {
- 'requestId': request_id,
- 'action': tool_call['name'],
- 'parameters': tool_call['parameters']
- })
-
- return request_id
-```
-```java
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-String requestHumanApproval(ToolCall toolCall) {
- String requestId = UUID.randomUUID().toString();
-
- JsonObject data = new JsonObject();
- data.addProperty("requestId", requestId);
- data.addProperty("action", toolCall.getName());
- data.add("parameters", toolCall.getParameters());
-
- channel.publish("approval-request", data);
-
- return requestId;
-}
-```
-
-
-
-
-## Review and decide
-
-Authorized humans subscribe to approval requests on the conversation channel and publish their decisions. The `toolCallId` correlates the response with the original request.
-
-Use [identified clients](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-identity) or [user claims](/docs/ai-transport/sessions-identity/identifying-users-and-agents#user-claims) to establish a verified identity or role for the approver. For example, when a user [authenticates with Ably](/docs/ai-transport/sessions-identity/identifying-users-and-agents#authenticating), embed their identity and role in the JWT:
-
-
-```javascript
-const claims = {
- 'x-ably-clientId': 'user-123',
- 'ably.channel.*': 'user'
-};
-```
-```python
-claims = {
- 'x-ably-clientId': 'user-123',
- 'ably.channel.*': 'user'
-}
-```
-```java
-Map claims = new HashMap<>();
-claims.put("x-ably-clientId", "user-123");
-claims.put("ably.channel.*", "user");
-```
-
-
-The `clientId` and user claims are automatically attached to every message the user publishes and cannot be forged, so agents can trust this identity and role information.
-
-
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-await channel.subscribe('approval-request', (message) => {
- const request = message.data;
- const toolCallId = message.extras?.headers?.toolCallId;
- // Display request for human review
- displayApprovalUI(request, toolCallId);
-});
-
-async function approve(toolCallId) {
- await channel.publish({
- name: 'approval-response',
- data: {
- decision: 'approved'
- },
- extras: {
- headers: {
- toolCallId: toolCallId
- }
- }
- });
-}
-
-async function reject(toolCallId) {
- await channel.publish({
- name: 'approval-response',
- data: {
- decision: 'rejected'
- },
- extras: {
- headers: {
- toolCallId: toolCallId
- }
- }
- });
-}
-```
-```python
-channel = ably.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-def on_approval_request(message):
- request = message.data
- # Display request for human review
- display_approval_ui(request)
-
-await channel.subscribe('approval-request', on_approval_request)
-
-async def approve(request_id):
- await channel.publish('approval-response', {
- 'requestId': request_id,
- 'decision': 'approved'
- })
-
-async def reject(request_id):
- await channel.publish('approval-response', {
- 'requestId': request_id,
- 'decision': 'rejected'
- })
-```
-```java
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-channel.subscribe("approval-request", message -> {
- JsonObject request = (JsonObject) message.data;
- // Display request for human review
- displayApprovalUI(request);
-});
-
-void approve(String requestId) {
- JsonObject data = new JsonObject();
- data.addProperty("requestId", requestId);
- data.addProperty("decision", "approved");
- channel.publish("approval-response", data);
-}
-
-void reject(String requestId) {
- JsonObject data = new JsonObject();
- data.addProperty("requestId", requestId);
- data.addProperty("decision", "rejected");
- channel.publish("approval-response", data);
-}
-```
-
-
-
-
-## Process the decision
-
-The agent listens for human decisions and acts accordingly. When a response arrives, the agent retrieves the pending request using the `toolCallId`, verifies that the user is permitted to approve that specific action, and either executes the action or handles the rejection.
-
-
-
-### Verify by user identity
-
-Use the `clientId` to identify the approver and look up their permissions in your database or access control system. This approach is useful when permissions are managed externally or change frequently.
-
-
-
-
-```javascript
-const pendingApprovals = new Map();
-
-await channel.subscribe('approval-response', async (message) => {
- const response = message.data;
- const toolCallId = message.extras?.headers?.toolCallId;
- const pending = pendingApprovals.get(toolCallId);
-
- if (!pending) return;
-
- // The clientId is the trusted approver identity
- const approverId = message.clientId;
-
- // Look up user-specific permissions from your database
- const userPermissions = await getUserPermissions(approverId);
-
- if (!userPermissions.canApprove(pending.toolCall.name)) {
- console.log(`User ${approverId} not authorized to approve ${pending.toolCall.name}`);
- return;
- }
-
- if (response.decision === 'approved') {
- const result = await executeToolCall(pending.toolCall);
- console.log(`Action approved by ${approverId}`);
- } else {
- console.log(`Action rejected by ${approverId}`);
- }
-
- pendingApprovals.delete(toolCallId);
-});
-```
-```python
-pending_approvals = {}
-
-async def on_approval_response(message):
- response = message.data
- pending = pending_approvals.get(response['requestId'])
-
- if not pending:
- return
-
- # The clientId is verified by Ably - this is the trusted approver identity
- approver_id = message.client_id
-
- # Look up user-specific permissions from your database
- user_permissions = await get_user_permissions(approver_id)
-
- if not user_permissions.can_approve(pending['toolCall']['name']):
- print(f"User {approver_id} not authorized to approve {pending['toolCall']['name']}")
- return
-
- if response['decision'] == 'approved':
- result = await execute_tool_call(pending['toolCall'])
- print(f"Action approved by {approver_id}")
- else:
- print(f"Action rejected by {approver_id}")
-
- del pending_approvals[response['requestId']]
-
-await channel.subscribe('approval-response', on_approval_response)
-```
-```java
-Map pendingApprovals = new ConcurrentHashMap<>();
-
-channel.subscribe("approval-response", message -> {
- JsonObject response = (JsonObject) message.data;
- PendingApproval pending = pendingApprovals.get(response.get("requestId").getAsString());
-
- if (pending == null) return;
-
- // The clientId is verified by Ably - this is the trusted approver identity
- String approverId = message.clientId;
-
- // Look up user-specific permissions from your database
- UserPermissions userPermissions = getUserPermissions(approverId);
-
- if (!userPermissions.canApprove(pending.getToolCall().getName())) {
- System.out.println("User " + approverId + " not authorized to approve " + pending.getToolCall().getName());
- return;
- }
-
- if (response.get("decision").getAsString().equals("approved")) {
- Object result = executeToolCall(pending.getToolCall());
- System.out.println("Action approved by " + approverId);
- } else {
- System.out.println("Action rejected by " + approverId);
- }
-
- pendingApprovals.remove(response.get("requestId").getAsString());
-});
-```
-
-
-### Verify by role
-
-Use [user claims](/docs/auth/capabilities#custom-restrictions-on-channels-) to embed roles directly in the JWT for role-based access control (RBAC). This approach is useful when permissions are role-based rather than user-specific, allowing you to make authorization decisions based on the user's role without looking up individual user permissions.
-
-
-
-Different actions may require different authorization levels. For example, an editor might be able to create drafts for review, but only a publisher or admin can approve publishing a blog post. Define approval policies that map tool names to minimum required roles, and when an approval arrives, compare the approver's role against the required role for that action type:
-
-
-```javascript
-const roleHierarchy = ['editor', 'publisher', 'admin'];
-
-const approvalPolicies = {
- publish_blog_post: 'publisher'
-};
-
-function canApprove(approverRole, requiredRole) {
- const approverLevel = roleHierarchy.indexOf(approverRole);
- const requiredLevel = roleHierarchy.indexOf(requiredRole);
-
- return approverLevel >= requiredLevel;
-}
-
-// When processing approval response
-await channel.subscribe('approval-response', async (message) => {
- const response = message.data;
- const toolCallId = message.extras?.headers?.toolCallId;
- const pending = pendingApprovals.get(toolCallId);
-
- if (!pending) return;
-
- const policy = approvalPolicies[pending.toolCall.name];
-
- // Get the trusted role from the JWT claim
- const approverRole = message.extras?.userClaim;
-
- // Verify the approver's role meets the minimum required role for this action
- if (!canApprove(approverRole, policy)) {
- console.log(`Approver role '${approverRole}' insufficient: minimum required role is '${policy}'`);
- return;
- }
-
- if (response.decision === 'approved') {
- const result = await executeToolCall(pending.toolCall);
- console.log(`Action approved by role ${approverRole}`);
- } else {
- console.log(`Action rejected by role ${approverRole}`);
- }
-
- pendingApprovals.delete(toolCallId);
-});
-```
-```python
-role_hierarchy = ['user', 'manager', 'admin']
-
-def can_approve(approver_role, required_role):
- approver_level = role_hierarchy.index(approver_role)
- required_level = role_hierarchy.index(required_role)
-
- return approver_level >= required_level
-
-# When processing approval response
-async def on_approval_response(message):
- response = message.data
- pending = pending_approvals.get(response['requestId'])
- policy = approval_policies[pending['toolCall']['name']]
-
- # Get the trusted role from the JWT claim
- approver_role = message.extras.get('userClaim')
-
- # Verify the approver's role meets the minimum required role for this action
- if not can_approve(approver_role, policy['minRole']):
- print(f"Approver role '{approver_role}' insufficient for required '{policy['minRole']}'")
- return
-
- if response['decision'] == 'approved':
- result = await execute_tool_call(pending['toolCall'])
- print(f"Action approved by role {approver_role}")
- else:
- print(f"Action rejected by role {approver_role}")
-
- del pending_approvals[response['requestId']]
-
-await channel.subscribe('approval-response', on_approval_response)
-```
-```java
-String[] roleHierarchy = {"user", "manager", "admin"};
-
-boolean canApprove(String approverRole, String requiredRole) {
- int approverLevel = Arrays.asList(roleHierarchy).indexOf(approverRole);
- int requiredLevel = Arrays.asList(roleHierarchy).indexOf(requiredRole);
-
- return approverLevel >= requiredLevel;
-}
-
-// When processing approval response
-channel.subscribe("approval-response", message -> {
- JsonObject response = (JsonObject) message.data;
- PendingApproval pending = pendingApprovals.get(response.get("requestId").getAsString());
- ApprovalPolicy policy = approvalPolicies.get(pending.getToolCall().getName());
-
- // Get the trusted role from the JWT claim
- String approverRole = message.extras.get("userClaim").getAsString();
-
- // Verify the approver's role meets the minimum required role for this action
- if (!canApprove(approverRole, policy.getMinRole())) {
- System.out.println("Approver role '" + approverRole + "' insufficient for required '" + policy.getMinRole() + "'");
- return;
- }
-
- if (response.get("decision").getAsString().equals("approved")) {
- Object result = executeToolCall(pending.getToolCall());
- System.out.println("Action approved by role " + approverRole);
- } else {
- System.out.println("Action rejected by role " + approverRole);
- }
-
- pendingApprovals.remove(response.get("requestId").getAsString());
-});
-```
-
diff --git a/src/pages/docs/ai-transport/messaging/tool-calls.mdx b/src/pages/docs/ai-transport/messaging/tool-calls.mdx
deleted file mode 100644
index 4004b459b1..0000000000
--- a/src/pages/docs/ai-transport/messaging/tool-calls.mdx
+++ /dev/null
@@ -1,1093 +0,0 @@
----
-title: "Tool calls"
-meta_description: "Stream tool call execution visibility to users, enabling transparent AI interactions and generative UI experiences."
-meta_keywords: "tool calls, function calling, generative UI, AI transparency, tool execution, streaming JSON, realtime feedback"
----
-
-Modern AI models can invoke tools (also called functions) to perform specific tasks like retrieving data, performing calculations, or triggering actions. Streaming tool call information to users provides visibility into what the AI is doing, creates opportunities for rich generative UI experiences, and builds trust through transparency.
-
-## What are tool calls?
-
-Tool calls occur when an AI model decides to invoke a specific function or tool to accomplish a task. Rather than only returning text, the model can request to execute tools you've defined, such as fetching weather data, searching a database, or performing calculations.
-
-A tool call consists of:
-
-- Tool name: The identifier of the tool being invoked
-- Tool input: Parameters passed to the tool, often structured as JSON
-- Tool output: The result returned after execution
-
-As an application developer, you decide how to surface tool calls to users. You may choose to display all tool calls, selectively surface specific tools or inputs/outputs, or keep tool calls entirely private.
-
-Surfacing tool calls supports:
-
-- Trust and transparency: Users see what actions the AI is taking, building confidence in the agent
-- Human-in-the-loop workflows: Expose tool calls [resolved by humans](/docs/ai-transport/messaging/human-in-the-loop) where users can review and approve tool execution before it happens
-- Generative UI: Build dynamic, contextual UI components based on the structured tool data
-
-## Publish tool calls
-
-Publish tool call and model output messages to the channel.
-
-In the example below, the `responseId` is included in the message [extras](/docs/messages#properties) to allow subscribers to correlate all messages belonging to the same response. The message [`name`](/docs/messages#properties) allows the client to distinguish between the different message types:
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Example: stream returns events like:
-// { type: 'tool_call', name: 'get_weather', args: '{"location":"San Francisco"}', toolCallId: 'tool_123', responseId: 'resp_abc123' }
-// { type: 'tool_result', name: 'get_weather', result: '{"temp":72,"conditions":"sunny"}', toolCallId: 'tool_123', responseId: 'resp_abc123' }
-// { type: 'message', text: 'The weather in San Francisco is 72°F and sunny.', responseId: 'resp_abc123' }
-
-for await (const event of stream) {
- if (event.type === 'tool_call') {
- // Publish tool call arguments
- await channel.publish({
- name: 'tool_call',
- data: {
- name: event.name,
- args: event.args
- },
- extras: {
- headers: {
- responseId: event.responseId,
- toolCallId: event.toolCallId
- }
- }
- });
- } else if (event.type === 'tool_result') {
- // Publish tool call results
- await channel.publish({
- name: 'tool_result',
- data: {
- name: event.name,
- result: event.result
- },
- extras: {
- headers: {
- responseId: event.responseId,
- toolCallId: event.toolCallId
- }
- }
- });
- } else if (event.type === 'message') {
- // Publish model output messages
- await channel.publish({
- name: 'message',
- data: event.text,
- extras: {
- headers: {
- responseId: event.responseId
- }
- }
- });
- }
-}
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-# Example: stream returns events like:
-# { 'type': 'tool_call', 'name': 'get_weather', 'args': '{"location":"San Francisco"}', 'toolCallId': 'tool_123', 'responseId': 'resp_abc123' }
-# { 'type': 'tool_result', 'name': 'get_weather', 'result': '{"temp":72,"conditions":"sunny"}', 'toolCallId': 'tool_123', 'responseId': 'resp_abc123' }
-# { 'type': 'message', 'text': 'The weather in San Francisco is 72°F and sunny.', 'responseId': 'resp_abc123' }
-
-async for event in stream:
- if event['type'] == 'tool_call':
- # Publish tool call arguments
- message = Message(
- name='tool_call',
- data={
- 'name': event['name'],
- 'args': event['args']
- },
- extras={
- 'headers': {
- 'responseId': event['responseId'],
- 'toolCallId': event['toolCallId']
- }
- }
- )
- await channel.publish(message)
- elif event['type'] == 'tool_result':
- # Publish tool call results
- message = Message(
- name='tool_result',
- data={
- 'name': event['name'],
- 'result': event['result']
- },
- extras={
- 'headers': {
- 'responseId': event['responseId'],
- 'toolCallId': event['toolCallId']
- }
- }
- )
- await channel.publish(message)
- elif event['type'] == 'message':
- # Publish model output messages
- message = Message(
- name='message',
- data=event['text'],
- extras={
- 'headers': {
- 'responseId': event['responseId']
- }
- }
- )
- await channel.publish(message)
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Example: stream returns events like:
-// { type: 'tool_call', name: 'get_weather', args: '{"location":"San Francisco"}', toolCallId: 'tool_123', responseId: 'resp_abc123' }
-// { type: 'tool_result', name: 'get_weather', result: '{"temp":72,"conditions":"sunny"}', toolCallId: 'tool_123', responseId: 'resp_abc123' }
-// { type: 'message', text: 'The weather in San Francisco is 72°F and sunny.', responseId: 'resp_abc123' }
-
-for (Event event : stream) {
- JsonObject extras = new JsonObject();
- JsonObject headers = new JsonObject();
-
- if (event.getType().equals("tool_call")) {
- // Publish tool call arguments
- JsonObject data = new JsonObject();
- data.addProperty("name", event.getName());
- data.addProperty("args", event.getArgs());
-
- headers.addProperty("responseId", event.getResponseId());
- headers.addProperty("toolCallId", event.getToolCallId());
- extras.add("headers", headers);
-
- channel.publish(new Message("tool_call", data, new MessageExtras(extras)));
- } else if (event.getType().equals("tool_result")) {
- // Publish tool call results
- JsonObject data = new JsonObject();
- data.addProperty("name", event.getName());
- data.addProperty("result", event.getResult());
-
- headers.addProperty("responseId", event.getResponseId());
- headers.addProperty("toolCallId", event.getToolCallId());
- extras.add("headers", headers);
-
- channel.publish(new Message("tool_result", data, new MessageExtras(extras)));
- } else if (event.getType().equals("message")) {
- // Publish model output messages
- headers.addProperty("responseId", event.getResponseId());
- extras.add("headers", headers);
-
- channel.publish(new Message("message", event.getText(), new MessageExtras(extras)));
- }
-}
-```
-
-
-
-
-
-
-
-
-## Subscribe to tool calls
-
-Subscribe to tool call and model output messages on the channel.
-
-In the example below, the `responseId` from the message [`extras`](/docs/api/realtime-sdk/messages#extras) is used to group tool calls and model output messages belonging to the same response. The message [`name`](/docs/messages#properties) allows the client to distinguish between the different message types:
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by ID, each containing tool calls and final response
-const responses = new Map();
-
-// Subscribe to all events on the channel
-await channel.subscribe((message) => {
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) {
- console.warn('Message missing responseId');
- return;
- }
-
- // Initialize response object if needed
- if (!responses.has(responseId)) {
- responses.set(responseId, {
- toolCalls: new Map(),
- message: ''
- });
- }
-
- const response = responses.get(responseId);
-
- // Handle each message type
- switch (message.name) {
- case 'message':
- response.message = message.data;
- break;
- case 'tool_call':
- const toolCallId = message.extras?.headers?.toolCallId;
- response.toolCalls.set(toolCallId, {
- name: message.data.name,
- args: message.data.args
- });
- break;
- case 'tool_result':
- const resultToolCallId = message.extras?.headers?.toolCallId;
- const toolCall = response.toolCalls.get(resultToolCallId);
- if (toolCall) {
- toolCall.result = message.data.result;
- }
- break;
- }
-
- // Display the tool calls and response for this turn
- console.log(`Response ${responseId}:`, response);
-});
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-# Track responses by ID, each containing tool calls and final response
-responses = {}
-
-# Subscribe to all events on the channel
-def on_message(message):
- response_id = message.extras.get('headers', {}).get('responseId')
-
- if not response_id:
- print('Message missing responseId')
- return
-
- # Initialize response object if needed
- if response_id not in responses:
- responses[response_id] = {
- 'toolCalls': {},
- 'message': ''
- }
-
- response = responses[response_id]
-
- # Handle each message type
- if message.name == 'message':
- response['message'] = message.data
- elif message.name == 'tool_call':
- tool_call_id = message.extras.get('headers', {}).get('toolCallId')
- response['toolCalls'][tool_call_id] = {
- 'name': message.data['name'],
- 'args': message.data['args']
- }
- elif message.name == 'tool_result':
- result_tool_call_id = message.extras.get('headers', {}).get('toolCallId')
- tool_call = response['toolCalls'].get(result_tool_call_id)
- if tool_call:
- tool_call['result'] = message.data['result']
-
- # Display the tool calls and response for this turn
- print(f'Response {response_id}:', response)
-
-await channel.subscribe(on_message)
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Track responses by ID, each containing tool calls and final response
-Map responses = new ConcurrentHashMap<>();
-
-// Subscribe to all events on the channel
-channel.subscribe(message -> {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers != null ? headers.get("responseId").getAsString() : null;
-
- if (responseId == null) {
- System.err.println("Message missing responseId");
- return;
- }
-
- // Initialize response object if needed
- responses.putIfAbsent(responseId, new Response());
- Response response = responses.get(responseId);
-
- // Handle each message type
- switch (message.name) {
- case "message":
- response.setMessage((String) message.data);
- break;
- case "tool_call":
- String toolCallId = headers.get("toolCallId").getAsString();
- JsonObject data = (JsonObject) message.data;
- ToolCall toolCall = new ToolCall();
- toolCall.setName(data.get("name").getAsString());
- toolCall.setArgs(data.get("args").getAsString());
- response.getToolCalls().put(toolCallId, toolCall);
- break;
- case "tool_result":
- String resultToolCallId = headers.get("toolCallId").getAsString();
- ToolCall existingToolCall = response.getToolCalls().get(resultToolCallId);
- if (existingToolCall != null) {
- JsonObject resultData = (JsonObject) message.data;
- existingToolCall.setResult(resultData.get("result").getAsString());
- }
- break;
- }
-
- // Display the tool calls and response for this turn
- System.out.println("Response " + responseId + ": " + response);
-});
-```
-
-
-
-
-## Generative UI
-
-Tool calls provide structured data that can form the basis of generative UI - dynamically creating UI components based on the tool being invoked, its parameters, and the results returned. Rather than just displaying raw tool call information, you can render rich, contextual components that provide a better user experience.
-
-For example, when a weather tool is invoked, instead of showing raw JSON like `{ location: 'San Francisco', temp: 72, conditions: 'sunny' }`, you can render a weather card component with icons, formatted temperature, and visual indicators:
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-await channel.subscribe((message) => {
- // Render component when tool is invoked
- if (message.name === 'tool_call' && message.data.name === 'get_weather') {
- const args = JSON.parse(message.data.args);
- renderWeatherCard({ location: args.location, loading: true });
- }
-
- // Update component with results
- if (message.name === 'tool_result' && message.data.name === 'get_weather') {
- const result = JSON.parse(message.data.result);
- renderWeatherCard(result);
- }
-});
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-def on_message(message):
- # Render component when tool is invoked
- if message.name == 'tool_call' and message.data['name'] == 'get_weather':
- args = json.loads(message.data['args'])
- render_weather_card({'location': args['location'], 'loading': True})
-
- # Update component with results
- if message.name == 'tool_result' and message.data['name'] == 'get_weather':
- result = json.loads(message.data['result'])
- render_weather_card(result)
-
-await channel.subscribe(on_message)
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-channel.subscribe(message -> {
- // Render component when tool is invoked
- if (message.name.equals("tool_call")) {
- JsonObject data = (JsonObject) message.data;
- if (data.get("name").getAsString().equals("get_weather")) {
- JsonObject args = JsonParser.parseString(data.get("args").getAsString()).getAsJsonObject();
- renderWeatherCard(args.get("location").getAsString(), true);
- }
- }
-
- // Update component with results
- if (message.name.equals("tool_result")) {
- JsonObject data = (JsonObject) message.data;
- if (data.get("name").getAsString().equals("get_weather")) {
- JsonObject result = JsonParser.parseString(data.get("result").getAsString()).getAsJsonObject();
- renderWeatherCard(result);
- }
- }
-});
-```
-
-
-
-
-## Client-side tools
-
-Some tools need to be executed directly on the client device rather than on the server, allowing agents to dynamically access information available on the end user's device as needed. These include tools that access device capabilities such as GPS location, camera, SMS, local files, or other native functionality.
-
-Client-side tool calls follow a request-response pattern over Ably channels:
-
-1. The agent publishes a tool call request to the channel.
-2. The client receives and executes the tool using device APIs.
-3. The client publishes the result back to the channel.
-4. The agent receives the result and continues processing.
-
-
-
-The client subscribes to tool call requests, executes the tool using device APIs, and publishes the result back to the channel. The `toolCallId` enables correlation between tool call requests and results:
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-await channel.subscribe('tool_call', async (message) => {
- const { name, args } = message.data;
- const { responseId, toolCallId } = message.extras?.headers || {};
-
- if (name === 'get_location') {
- const result = await getGeolocationPosition();
- await channel.publish({
- name: 'tool_result',
- data: {
- name: name,
- result: {
- lat: result.coords.latitude,
- lng: result.coords.longitude
- }
- },
- extras: {
- headers: {
- responseId: responseId,
- toolCallId: toolCallId
- }
- }
- });
- }
-});
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-async def on_tool_call(message):
- name = message.data['name']
- args = message.data.get('args')
- headers = message.extras.get('headers', {})
- response_id = headers.get('responseId')
- tool_call_id = headers.get('toolCallId')
-
- if name == 'get_location':
- result = await get_geolocation_position()
- message = Message(
- name='tool_result',
- data={
- 'name': name,
- 'result': {
- 'lat': result['coords']['latitude'],
- 'lng': result['coords']['longitude']
- }
- },
- extras={
- 'headers': {
- 'responseId': response_id,
- 'toolCallId': tool_call_id
- }
- }
- )
- await channel.publish(message)
-
-await channel.subscribe('tool_call', on_tool_call)
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-channel.subscribe("tool_call", message -> {
- JsonObject data = (JsonObject) message.data;
- String name = data.get("name").getAsString();
- JsonObject headers = message.extras.asJsonObject().get("headers");
- String responseId = headers.get("responseId").getAsString();
- String toolCallId = headers.get("toolCallId").getAsString();
-
- if (name.equals("get_location")) {
- GeolocationPosition result = getGeolocationPosition();
-
- JsonObject resultData = new JsonObject();
- resultData.addProperty("name", name);
- JsonObject resultValue = new JsonObject();
- resultValue.addProperty("lat", result.getCoords().getLatitude());
- resultValue.addProperty("lng", result.getCoords().getLongitude());
- resultData.add("result", resultValue);
-
- JsonObject resultExtras = new JsonObject();
- JsonObject resultHeaders = new JsonObject();
- resultHeaders.addProperty("responseId", responseId);
- resultHeaders.addProperty("toolCallId", toolCallId);
- resultExtras.add("headers", resultHeaders);
-
- channel.publish(new Message("tool_result", resultData, new MessageExtras(resultExtras)));
- }
-});
-```
-
-
-
-
-
-
-The agent subscribes to tool results to continue processing. The `toolCallId` correlates the result back to the original request:
-
-
-```javascript
-const pendingToolCalls = new Map();
-
-await channel.subscribe('tool_result', (message) => {
- const { toolCallId, result } = message.data;
- const pending = pendingToolCalls.get(toolCallId);
-
- if (!pending) return;
-
- // Pass result back to the AI model to continue the conversation
- processResult(pending.responseId, toolCallId, result);
-
- pendingToolCalls.delete(toolCallId);
-});
-```
-```python
-pending_tool_calls = {}
-
-def on_tool_result(message):
- tool_call_id = message.data.get('toolCallId')
- result = message.data.get('result')
- pending = pending_tool_calls.get(tool_call_id)
-
- if not pending:
- return
-
- # Pass result back to the AI model to continue the conversation
- process_result(pending['responseId'], tool_call_id, result)
-
- del pending_tool_calls[tool_call_id]
-
-await channel.subscribe('tool_result', on_tool_result)
-```
-```java
-Map pendingToolCalls = new ConcurrentHashMap<>();
-
-channel.subscribe("tool_result", message -> {
- JsonObject data = (JsonObject) message.data;
- String toolCallId = data.get("toolCallId").getAsString();
- JsonObject result = data.get("result").getAsJsonObject();
- PendingToolCall pending = pendingToolCalls.get(toolCallId);
-
- if (pending == null) {
- return;
- }
-
- // Pass result back to the AI model to continue the conversation
- processResult(pending.getResponseId(), toolCallId, result);
-
- pendingToolCalls.remove(toolCallId);
-});
-```
-
-
-## Progress updates
-
-Some tool calls take significant time to complete, such as processing large files, performing complex calculations, or executing multi-step operations. For long-running tools, streaming progress updates to users provides visibility into execution status and improves the user experience by showing that work is actively happening.
-
-You can deliver progress updates using two approaches:
-
-- Messages: Best for discrete status updates and milestone events
-- LiveObjects: Best for continuous numeric progress and shared state synchronization
-
-### Progress updates via messages
-
-Publish progress messages to the channel as the tool executes, using the `toolCallId` to correlate progress updates with the specific tool call:
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Publish initial tool call
-await channel.publish({
- name: 'tool_call',
- data: {
- name: 'process_document',
- args: { documentId: 'doc_123', pages: 100 }
- },
- extras: {
- headers: {
- responseId: 'resp_abc123',
- toolCallId: 'tool_456'
- }
- }
-});
-
-// Publish progress updates as tool executes
-await channel.publish({
- name: 'tool_progress',
- data: {
- name: 'process_document',
- status: 'Processing page 25 of 100',
- percentComplete: 25
- },
- extras: {
- headers: {
- responseId: 'resp_abc123',
- toolCallId: 'tool_456'
- }
- }
-});
-
-// Continue publishing progress as work progresses
-await channel.publish({
- name: 'tool_progress',
- data: {
- name: 'process_document',
- status: 'Processing page 75 of 100',
- percentComplete: 75
- },
- extras: {
- headers: {
- responseId: 'resp_abc123',
- toolCallId: 'tool_456'
- }
- }
-});
-
-// Publish final result
-await channel.publish({
- name: 'tool_result',
- data: {
- name: 'process_document',
- result: { processedPages: 100, summary: 'Document processed successfully' }
- },
- extras: {
- headers: {
- responseId: 'resp_abc123',
- toolCallId: 'tool_456'
- }
- }
-});
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-# Publish initial tool call
-await channel.publish(Message(
- name='tool_call',
- data={
- 'name': 'process_document',
- 'args': {'documentId': 'doc_123', 'pages': 100}
- },
- extras={
- 'headers': {
- 'responseId': 'resp_abc123',
- 'toolCallId': 'tool_456'
- }
- }
-))
-
-# Publish progress updates as tool executes
-await channel.publish(Message(
- name='tool_progress',
- data={
- 'name': 'process_document',
- 'status': 'Processing page 25 of 100',
- 'percentComplete': 25
- },
- extras={
- 'headers': {
- 'responseId': 'resp_abc123',
- 'toolCallId': 'tool_456'
- }
- }
-))
-
-# Continue publishing progress as work progresses
-await channel.publish(Message(
- name='tool_progress',
- data={
- 'name': 'process_document',
- 'status': 'Processing page 75 of 100',
- 'percentComplete': 75
- },
- extras={
- 'headers': {
- 'responseId': 'resp_abc123',
- 'toolCallId': 'tool_456'
- }
- }
-))
-
-# Publish final result
-await channel.publish(Message(
- name='tool_result',
- data={
- 'name': 'process_document',
- 'result': {'processedPages': 100, 'summary': 'Document processed successfully'}
- },
- extras={
- 'headers': {
- 'responseId': 'resp_abc123',
- 'toolCallId': 'tool_456'
- }
- }
-))
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Helper method to create message extras with headers
-MessageExtras createExtras(String responseId, String toolCallId) {
- JsonObject extrasJson = new JsonObject();
- JsonObject headers = new JsonObject();
- headers.addProperty("responseId", responseId);
- headers.addProperty("toolCallId", toolCallId);
- extrasJson.add("headers", headers);
- return new MessageExtras(extrasJson);
-}
-
-// Publish initial tool call
-JsonObject toolCallData = new JsonObject();
-toolCallData.addProperty("name", "process_document");
-JsonObject args = new JsonObject();
-args.addProperty("documentId", "doc_123");
-args.addProperty("pages", 100);
-toolCallData.add("args", args);
-
-Message toolCall = new Message(
- "tool_call",
- toolCallData.toString(),
- createExtras("resp_abc123", "tool_456")
-);
-channel.publish(toolCall);
-
-// Publish progress updates as tool executes
-JsonObject progress1 = new JsonObject();
-progress1.addProperty("name", "process_document");
-progress1.addProperty("status", "Processing page 25 of 100");
-progress1.addProperty("percentComplete", 25);
-
-Message progressMsg1 = new Message(
- "tool_progress",
- progress1.toString(),
- createExtras("resp_abc123", "tool_456")
-);
-channel.publish(progressMsg1);
-
-// Continue publishing progress as work progresses
-JsonObject progress2 = new JsonObject();
-progress2.addProperty("name", "process_document");
-progress2.addProperty("status", "Processing page 75 of 100");
-progress2.addProperty("percentComplete", 75);
-
-Message progressMsg2 = new Message(
- "tool_progress",
- progress2.toString(),
- createExtras("resp_abc123", "tool_456")
-);
-channel.publish(progressMsg2);
-
-// Publish final result
-JsonObject resultData = new JsonObject();
-resultData.addProperty("name", "process_document");
-JsonObject result = new JsonObject();
-result.addProperty("processedPages", 100);
-result.addProperty("summary", "Document processed successfully");
-resultData.add("result", result);
-
-Message resultMsg = new Message(
- "tool_result",
- resultData.toString(),
- createExtras("resp_abc123", "tool_456")
-);
-channel.publish(resultMsg);
-```
-
-
-Subscribe to progress updates on the client by listening for the `tool_progress` message type:
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Track tool execution progress
-const toolProgress = new Map();
-
-await channel.subscribe((message) => {
- const { responseId, toolCallId } = message.extras?.headers || {};
-
- switch (message.name) {
- case 'tool_call':
- toolProgress.set(toolCallId, {
- name: message.data.name,
- status: 'Starting...',
- percentComplete: 0
- });
- renderProgressBar(toolCallId, 0);
- break;
-
- case 'tool_progress':
- const progress = toolProgress.get(toolCallId);
- if (progress) {
- progress.status = message.data.status;
- progress.percentComplete = message.data.percentComplete;
- renderProgressBar(toolCallId, message.data.percentComplete);
- }
- break;
-
- case 'tool_result':
- toolProgress.delete(toolCallId);
- renderCompleted(toolCallId, message.data.result);
- break;
- }
-});
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-# Track tool execution progress
-tool_progress = {}
-
-async def handle_message(message):
- headers = message.extras.get('headers', {}) if message.extras else {}
- response_id = headers.get('responseId')
- tool_call_id = headers.get('toolCallId')
-
- if message.name == 'tool_call':
- tool_progress[tool_call_id] = {
- 'name': message.data.get('name'),
- 'status': 'Starting...',
- 'percentComplete': 0
- }
- render_progress_bar(tool_call_id, 0)
-
- elif message.name == 'tool_progress':
- progress = tool_progress.get(tool_call_id)
- if progress:
- progress['status'] = message.data.get('status')
- progress['percentComplete'] = message.data.get('percentComplete')
- render_progress_bar(tool_call_id, message.data.get('percentComplete'))
-
- elif message.name == 'tool_result':
- if tool_call_id in tool_progress:
- del tool_progress[tool_call_id]
- render_completed(tool_call_id, message.data.get('result'))
-
-# Subscribe to all messages on the channel
-await channel.subscribe(handle_message)
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Track tool execution progress
-Map toolProgress = new HashMap<>();
-
-// Subscribe to all messages on the channel
-channel.subscribe(message -> {
- JsonObject headers = message.extras != null
- ? message.extras.asJsonObject().getAsJsonObject("headers")
- : null;
-
- String responseId = headers != null && headers.has("responseId")
- ? headers.get("responseId").getAsString()
- : null;
- String toolCallId = headers != null && headers.has("toolCallId")
- ? headers.get("toolCallId").getAsString()
- : null;
-
- switch (message.name) {
- case "tool_call":
- JsonObject newProgress = new JsonObject();
- newProgress.addProperty("name", ((JsonObject) message.data).get("name").getAsString());
- newProgress.addProperty("status", "Starting...");
- newProgress.addProperty("percentComplete", 0);
- toolProgress.put(toolCallId, newProgress);
- renderProgressBar(toolCallId, 0);
- break;
-
- case "tool_progress":
- JsonObject progress = toolProgress.get(toolCallId);
- if (progress != null) {
- JsonObject progressData = (JsonObject) message.data;
- progress.addProperty("status", progressData.get("status").getAsString());
- progress.addProperty("percentComplete", progressData.get("percentComplete").getAsInt());
- renderProgressBar(toolCallId, progressData.get("percentComplete").getAsInt());
- }
- break;
-
- case "tool_result":
- toolProgress.remove(toolCallId);
- renderCompleted(toolCallId, ((JsonObject) message.data).get("result"));
- break;
- }
-});
-```
-
-
-Message-based progress is useful for:
-
-- Step-by-step status descriptions
-- Milestone notifications
-- Workflow stages with distinct phases
-- Audit trails requiring discrete event records
-
-### Progress updates via LiveObjects
-
-Use [LiveObjects](/docs/liveobjects) for state-based progress tracking. LiveObjects provides a shared data layer where progress state is automatically synchronized across all subscribed clients, making it ideal for continuous progress tracking.
-
-Use [LiveCounter](/docs/liveobjects/counter) for numeric progress values like completion percentages or item counts. Use [LiveMap](/docs/liveobjects/map) to track complex progress state with multiple fields.
-
-First, import and initialize the LiveObjects plugin:
-
-
-```javascript
-import * as Ably from 'ably';
-import { LiveObjects, LiveMap, LiveCounter } from 'ably/liveobjects';
-
-// Initialize client with LiveObjects plugin
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- plugins: { LiveObjects }
-});
-
-// Get channel with LiveObjects capabilities
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}', {
- modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH']
-});
-
-// Get the channel's LiveObjects root
-const root = await channel.object.get();
-```
-
-
-Create a LiveMap to track tool progress:
-
-
-```javascript
-// Create a LiveMap to track tool progress
-await root.set('tool_456_progress', LiveMap.create({
- status: 'starting',
- itemsProcessed: LiveCounter.create(0),
- totalItems: 100,
- currentItem: ''
-}));
-
-// Update progress as tool executes
-const progress = root.get('tool_456_progress');
-
-await progress.set('status', 'processing');
-await progress.set('currentItem', 'item_25');
-await progress.get('itemsProcessed').increment(25);
-
-// Continue updating as work progresses
-await progress.set('currentItem', 'item_75');
-await progress.get('itemsProcessed').increment(50);
-
-// Final increment to reach 100%
-await progress.set('currentItem', 'item_100');
-await progress.get('itemsProcessed').increment(25);
-
-// Mark complete
-await progress.set('status', 'completed');
-```
-
-
-Subscribe to LiveObjects updates on the client to render realtime progress:
-
-
-```javascript
-import * as Ably from 'ably';
-import { LiveObjects } from 'ably/liveobjects';
-
-// Initialize client with LiveObjects plugin
-const realtime = new Ably.Realtime({
- key: '{{API_KEY}}',
- plugins: { LiveObjects }
-});
-
-// Get channel with LiveObjects capabilities
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}', {
- modes: ['OBJECT_SUBSCRIBE', 'OBJECT_PUBLISH']
-});
-
-// Get the channel's LiveObjects root
-const root = await channel.object.get();
-
-// Subscribe to progress updates
-const progress = root.get('tool_456_progress');
-progress.subscribe(() => {
- const status = progress.get('status').value();
- const itemsProcessed = progress.get('itemsProcessed').value();
- const totalItems = progress.get('totalItems').value();
- const percentComplete = Math.round((itemsProcessed / totalItems) * 100);
-
- renderProgressBar('tool_456', percentComplete, status);
-});
-```
-
-
-
-
-LiveObjects-based progress is useful for:
-
-- Continuous progress bars with frequent updates
-- Distributed tool execution across multiple workers
-- Complex progress state with multiple fields
-- Scenarios where multiple agents or processes contribute to the same progress counter
-
-### Choosing the right approach
-
-Choose messages when:
-
-- Progress updates are infrequent (every few seconds or at specific milestones)
-- You need a complete audit trail of all progress events
-- Progress information is descriptive text rather than numeric
-- Each update represents a distinct event or stage transition
-
-Choose LiveObjects when:
-
-- Progress updates are frequent (multiple times per second)
-- You're tracking numeric progress like percentages or counts
-- Multiple processes or workers contribute to the same progress counter
-- You want to minimize message overhead for high-frequency updates
-
-You can combine both approaches for comprehensive progress tracking. Use LiveObjects for high-frequency numeric progress and messages for important milestone notifications:
-
-
-```javascript
-// Update numeric progress continuously via LiveObjects
-await progress.get('itemsProcessed').increment(1);
-
-// Publish milestone messages at key points
-if (itemsProcessed === totalItems / 2) {
- await channel.publish({
- name: 'tool_progress',
- data: {
- name: 'process_document',
- status: 'Halfway complete - 50 of 100 items processed'
- },
- extras: {
- headers: {
- responseId: 'resp_abc123',
- toolCallId: 'tool_456'
- }
- }
- });
-}
-```
-
-
-## Human-in-the-loop workflows
-
-Tool calls resolved by humans are one approach to implementing human-in-the-loop workflows. When an agent encounters a tool call that needs human resolution, it publishes the tool call to the channel and waits for the human to publish the result back over the channel.
-
-For example, a tool that modifies data, performs financial transactions, or accesses sensitive resources might require explicit user approval before execution. The tool call information is surfaced to the user, who can then approve or reject the action.
-
-
diff --git a/src/pages/docs/ai-transport/sessions-identity/identifying-users-and-agents.mdx b/src/pages/docs/ai-transport/sessions-identity/identifying-users-and-agents.mdx
deleted file mode 100644
index 5a43b61ddd..0000000000
--- a/src/pages/docs/ai-transport/sessions-identity/identifying-users-and-agents.mdx
+++ /dev/null
@@ -1,907 +0,0 @@
----
-title: "Identifying users and agents"
-meta_description: "Establish trusted identity and roles in decoupled AI sessions"
-meta_keywords: "user authentication, agent identity, JWT authentication, token authentication, verified identity, capabilities, authorization, user claims, RBAC, role-based access control, API key authentication, message attribution"
----
-
-Secure AI applications require agents to trust who sent each message and understand what that sender is authorized to do. Ably's identity system uses token-based authentication to provide cryptographically-verified identities with custom attributes that you can access throughout your applications.
-
-## Why identity matters
-
-In decoupled architectures, identity serves several critical purposes:
-
-- Prevent spoofing: Without verified identity, malicious users could impersonate others by claiming to be someone else. Ably supports cryptographically binding each client's identity to their credentials, making spoofing impossible.
-- Message attribution: Agents need to know whether messages come from users or other agents. This is essential for conversation flows in which agent responses should be securely distinguished from user prompts.
-- Personalized behavior: Different users may have different privileges or attributes. A premium user might get access to more capable models, while a free user gets basic functionality. Ably allows your trusted authentication server to embed this information in the client's credentials, allowing this information to be securely passed to agents.
-- Authorization decisions: Some operations should only be performed for specific users. For example, human-in-the-loop (HITL) tool calls that access sensitive data might require admin privileges. Ably allows agents to verify the privilege level and role of the user resolving the tool call.
-
-## Authenticating users
-
-Use [token authentication](/docs/auth/token) to authenticate users securely. Your authentication server generates a token that is signed with the secret part of your Ably API key. Clients use this token to connect to Ably, and the token signature ensures it cannot be tampered with.
-
-The following examples use [JWT authentication](/docs/auth/token#jwt) for its simplicity and standard tooling support. For other approaches, see [token authentication](/docs/auth/token).
-
-Create a server endpoint that generates signed JWTs after verifying user authentication:
-
-
-```javascript
-// Server code
-import express from "express";
-import jwt from "jsonwebtoken";
-
-const app = express();
-
-// Mock authentication middleware.
-// This should be replaced with your actual authentication logic.
-function authenticateUser(req, res, next) {
- // Assign a mock user ID for demonstration
- req.session = { userId: "user123" };
- next();
-}
-
-// Return the claims payload to embed in the signed JWT.
-function getJWTClaims(userId) {
- // Returns an empty payload, so the token
- // inherits the capabilities of the signing key.
- return {};
-}
-
-// Define an auth endpoint used by the client to obtain a signed JWT
-// which it can use to authenticate with the Ably service.
-app.get("/api/auth/token", authenticateUser, (req, res) => {
- const [keyName, keySecret] = "{{API_KEY}}".split(":");
-
- // Sign a JWT using the secret part of the Ably API key.
- const token = jwt.sign(getJWTClaims(req.session.userId), keySecret, {
- algorithm: "HS256",
- keyid: keyName,
- expiresIn: "1h",
- });
-
- res.type("application/jwt").send(token);
-});
-
-app.listen(3001);
-```
-```python
-# Server code
-from flask import Flask, request, session
-import jwt
-import time
-
-app = Flask(__name__)
-
-# Mock authentication middleware.
-# This should be replaced with your actual authentication logic.
-def authenticate_user():
- # Assign a mock user ID for demonstration
- session['user_id'] = 'user123'
-
-# Return the claims payload to embed in the signed JWT.
-def get_jwt_claims(user_id):
- # Returns an empty payload, so the token
- # inherits the capabilities of the signing key.
- return {}
-
-# Define an auth endpoint used by the client to obtain a signed JWT
-# which it can use to authenticate with the Ably service.
-@app.route('/api/auth/token')
-def auth_token():
- authenticate_user()
-
- key_name, key_secret = "{{API_KEY}}".split(":")
-
- # Sign a JWT using the secret part of the Ably API key.
- token = jwt.encode(
- get_jwt_claims(session['user_id']),
- key_secret,
- algorithm="HS256",
- headers={"kid": key_name}
- )
-
- # Set expiration time (1 hour from now)
- payload = get_jwt_claims(session['user_id'])
- payload['exp'] = int(time.time()) + 3600
-
- token = jwt.encode(
- payload,
- key_secret,
- algorithm="HS256",
- headers={"kid": key_name}
- )
-
- return token, 200, {'Content-Type': 'application/jwt'}
-
-if __name__ == '__main__':
- app.run(port=3001)
-```
-```java
-// Server code
-import org.jose4j.jws.*;
-import org.jose4j.jwt.JwtClaims;
-import org.jose4j.keys.HmacKey;
-import spark.Spark;
-
-import java.util.HashMap;
-import java.util.Map;
-
-public class AuthServer {
- public static void main(String[] args) {
- Spark.port(3001);
-
- // Define an auth endpoint used by the client to obtain a signed JWT
- // which it can use to authenticate with the Ably service.
- Spark.get("/api/auth/token", (req, res) -> {
- // Mock authentication - assign a mock user ID for demonstration
- String userId = authenticateUser(req);
-
- String[] keyParts = "{{API_KEY}}".split(":");
- String keyName = keyParts[0];
- String keySecret = keyParts[1];
-
- // Get the claims payload to embed in the signed JWT
- JwtClaims claims = getJWTClaims(userId);
- jwtClaims.setExpirationTimeMinutesInTheFuture(60); // 1 hour
-
- JsonWebSignature jws = new JsonWebSignature();
- jws.setPayload(jwtClaims.toJson());
- jws.setKey(new HmacKey(keySecret.getBytes()));
- jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.HMAC_SHA256);
- jws.setHeader("kid", keyName);
-
- String token = jws.getCompactSerialization();
-
- res.type("application/jwt");
- return token;
- });
- }
-
- // Mock authentication middleware.
- // This should be replaced with your actual authentication logic.
- private static String authenticateUser(spark.Request req) {
- // Assign a mock user ID for demonstration
- return "user123";
- }
-
- // Return the claims payload to embed in the signed JWT.
- private static JwtClaims getJWTClaims(String userId) {
- // Returns an empty payload, so the token
- // inherits the capabilities of the signing key.
- return new JwtClaims();
- }
-}
-```
-
-
-
-
-The JWT is signed with the secret part of your Ably API key using [HMAC-SHA-256](https://datatracker.ietf.org/doc/html/rfc4868). This example does not embed any claims in the JWT payload, so by default the token inherits the capabilities of the Ably API key used to sign the token.
-
-Configure your client to obtain a signed JWT from your server endpoint using an [`authCallback`](/docs/auth/token#auth-callback). The client obtains a signed JWT from the callback and uses it to authenticate requests to Ably. The client automatically makes a request for a new token before it expires.
-
-
-
-
-```javascript
-// Client code
-import * as Ably from "ably";
-
-const ably = new Ably.Realtime({
- authCallback: async (tokenParams, callback) => {
- try {
- const response = await fetch("/api/auth/token");
- const token = await response.text();
- callback(null, token);
- } catch (error) {
- callback(error, null);
- }
- }
-});
-
-ably.connection.on("connected", () => {
- console.log("Connected to Ably");
-});
-```
-```python
-# Client code
-from ably import AblyRealtime
-import requests
-
-def auth_callback(token_params):
- response = requests.get("/api/auth/token")
- return response.text
-
-ably = AblyRealtime(auth_callback=auth_callback)
-
-def on_connected(state_change):
- print("Connected to Ably")
-
-ably.connection.on('connected', on_connected)
-```
-```java
-// Client code
-import io.ably.lib.realtime.AblyRealtime;
-import io.ably.lib.types.ClientOptions;
-
-ClientOptions options = new ClientOptions();
-options.authCallback = (tokenParams) -> {
- // Make HTTP request to your auth endpoint
- String response = makeHttpRequest("/api/auth/token");
- return response;
-};
-
-AblyRealtime ably = new AblyRealtime(options);
-
-ably.connection.on(ConnectionState.connected, state -> {
- System.out.println("Connected to Ably");
-});
-```
-
-
-## Authenticating agents
-
-Agents typically run on servers in trusted environments where API keys can be securely stored. Use [API key authentication](/docs/auth#basic-authentication) to authenticate agents directly with Ably.
-
-
-```javascript
-// Agent code
-import * as Ably from "ably";
-
-const ably = new Ably.Realtime({
- key: "{{API_KEY}}"
-});
-
-ably.connection.on("connected", () => {
- console.log("Connected to Ably");
-});
-```
-```python
-# Agent code
-from ably import AblyRealtime
-
-ably = AblyRealtime(key="{{API_KEY}}")
-
-def on_connected(state_change):
- print("Connected to Ably")
-
-ably.connection.on('connected', on_connected)
-```
-```java
-// Agent code
-import io.ably.lib.realtime.AblyRealtime;
-
-AblyRealtime ably = new AblyRealtime("{{API_KEY}}");
-
-ably.connection.on(ConnectionState.connected, state -> {
- System.out.println("Connected to Ably");
-});
-```
-
-
-
-
-
-
-## Specifying capabilities
-
-Use [capabilities](/docs/auth/capabilities) to specify which operations clients can perform on which channels. This applies to both users and agents, allowing you to enforce fine-grained permissions.
-
-### User capabilities
-
-Add the [`x-ably-capability`](/docs/api/realtime-sdk/authentication#ably-jwt) claim to your JWT to specify the allowed capabilities of a client. This allows you to enforce fine-grained permissions, such as restricting some users to only subscribe to messages while allowing others to publish.
-
-Update your `getJWTClaims` function to specify the allowed capabilities for the authenticated user:
-
-
-```javascript
-// Server code
-
-// Return the claims payload to embed in the signed JWT.
-// Includes the `x-ably-capabilities` claim, which controls
-// which operations the user can perform on which channels.
-function getJWTClaims(userId) {
- const orgId = "acme"; // Mock organization ID for demonstration
- const capabilities = {
- // The user can publish and subscribe to channels within the organization,
- // that is, any channel matching `org:acme:*`.
- [`org:${orgId}:*`]: ["publish", "subscribe"],
- // The user can only subscribe to the `announcements` channel.
- announcements: ["subscribe"],
- };
- return {
- "x-ably-capability": JSON.stringify(capabilities),
- };
-}
-```
-```python
-# Server code
-import json
-
-# Return the claims payload to embed in the signed JWT.
-# Includes the `x-ably-capabilities` claim, which controls
-# which operations the user can perform on which channels.
-def get_jwt_claims(user_id):
- org_id = "acme" # Mock organization ID for demonstration
- capabilities = {
- # The user can publish and subscribe to channels within the organization,
- # that is, any channel matching `org:acme:*`.
- f"org:{org_id}:*": ["publish", "subscribe"],
- # The user can only subscribe to the `announcements` channel.
- "announcements": ["subscribe"],
- }
- return {
- "x-ably-capability": json.dumps(capabilities),
- }
-```
-```java
-// Server code
-import com.google.gson.Gson;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-// Return the claims payload to embed in the signed JWT.
-// Includes the `x-ably-capabilities` claim, which controls
-// which operations the user can perform on which channels.
-private static Map getJWTClaims(String userId) {
- String orgId = "acme"; // Mock organization ID for demonstration
- Map> capabilities = new HashMap<>();
- // The user can publish and subscribe to channels within the organization,
- // that is, any channel matching `org:acme:*`.
- capabilities.put("org:" + orgId + ":*", List.of("publish", "subscribe"));
- // The user can only subscribe to the `announcements` channel.
- capabilities.put("announcements", List.of("subscribe"));
-
- Map claims = new HashMap<>();
- claims.put("x-ably-capability", new Gson().toJson(capabilities));
- return claims;
-}
-```
-
-
-When a client authenticates with this token, Ably enforces these capabilities server-side. Any attempt to perform unauthorized operations will be rejected. For example, a client with the capabilities above can publish to channels prefixed with `org:acme:`, but an attempt to publish to a channel prefixed with `org:foobar:` will fail with error code [`40160`](/docs/platform/errors/codes#40160):
-
-
-```javascript
-// Client code
-const acmeChannel = ably.channels.get("org:acme:{{RANDOM_CHANNEL_NAME}}");
-await acmeChannel.publish("prompt", "What is the weather like today?"); // succeeds
-
-const foobarChannel = ably.channels.get("org:foobar:{{RANDOM_CHANNEL_NAME}}");
-await foobarChannel.publish("prompt", "What is the weather like today?"); // fails
-
-const announcementsChannel = ably.channels.get("announcements");
-await announcementsChannel.publish("prompt", "What is the weather like today?"); // fails
-await announcementsChannel.subscribe((msg) => console.log(msg)); // succeeds
-```
-```python
-# Client code
-acme_channel = ably.channels.get("org:acme:{{RANDOM_CHANNEL_NAME}}")
-await acme_channel.publish("prompt", "What is the weather like today?") # succeeds
-
-foobar_channel = ably.channels.get("org:foobar:{{RANDOM_CHANNEL_NAME}}")
-await foobar_channel.publish("prompt", "What is the weather like today?") # fails
-
-announcements_channel = ably.channels.get("announcements")
-await announcements_channel.publish("prompt", "What is the weather like today?") # fails
-await announcements_channel.subscribe(lambda msg: print(msg)) # succeeds
-```
-```java
-// Client code
-Channel acmeChannel = ably.channels.get("org:acme:{{RANDOM_CHANNEL_NAME}}");
-acmeChannel.publish("prompt", "What is the weather like today?"); // succeeds
-
-Channel foobarChannel = ably.channels.get("org:foobar:{{RANDOM_CHANNEL_NAME}}");
-foobarChannel.publish("prompt", "What is the weather like today?"); // fails
-
-Channel announcementsChannel = ably.channels.get("announcements");
-announcementsChannel.publish("prompt", "What is the weather like today?"); // fails
-announcementsChannel.subscribe(msg -> System.out.println(msg)); // succeeds
-```
-
-
-
-
-### Agent capabilities
-
-When using API key authentication, provision API keys through the [Ably dashboard](https://ably.com/dashboard) or [Control API](/docs/platform/account/control-api) with only the capabilities required by the agent.
-
-The following example uses the Control API to create an API key with specific capabilities for a weather agent:
-
-
-
-
-```shell
-curl --location --request POST 'https://control.ably.net/v1/apps/{{APP_ID}}/keys' \
---header 'Content-Type: application/json' \
---header 'Authorization: Bearer ${ACCESS_TOKEN}' \
---data-raw '{
- "name": "weather-agent-key",
- "capability": {
- "org:acme:weather:*": ["publish", "subscribe"]
- }
-}'
-```
-
-
-This creates an API key that can only publish and subscribe on channels matching `org:acme:weather:*`. The agent can then use this key to authenticate:
-
-
-```javascript
-// Agent code
-const weatherChannel = ably.channels.get("org:acme:weather:{{RANDOM_CHANNEL_NAME}}");
-await weatherChannel.subscribe((msg) => console.log(msg)); // succeeds
-await weatherChannel.publish("update", "It's raining in London"); // succeeds
-
-const otherChannel = ably.channels.get("org:acme:other:{{RANDOM_CHANNEL_NAME}}");
-await otherChannel.subscribe((msg) => console.log(msg)); // fails
-await otherChannel.publish("update", "It's raining in London"); // fails
-```
-```python
-# Agent code
-weather_channel = ably.channels.get("org:acme:weather:{{RANDOM_CHANNEL_NAME}}")
-await weather_channel.subscribe(lambda msg: print(msg)) # succeeds
-await weather_channel.publish("update", "It's raining in London") # succeeds
-
-other_channel = ably.channels.get("org:acme:other:{{RANDOM_CHANNEL_NAME}}")
-await other_channel.subscribe(lambda msg: print(msg)) # fails
-await other_channel.publish("update", "It's raining in London") # fails
-```
-```java
-// Agent code
-Channel weatherChannel = ably.channels.get("org:acme:weather:{{RANDOM_CHANNEL_NAME}}");
-weatherChannel.subscribe(msg -> System.out.println(msg)); // succeeds
-weatherChannel.publish("update", "It's raining in London"); // succeeds
-
-Channel otherChannel = ably.channels.get("org:acme:other:{{RANDOM_CHANNEL_NAME}}");
-otherChannel.subscribe(msg -> System.out.println(msg)); // fails
-otherChannel.publish("update", "It's raining in London"); // fails
-```
-
-
-
-
-## Establishing verified identity
-
-Use the [`clientId`](/docs/messages#properties) to identify the user or agent that published a message. The method for setting `clientId` depends on your authentication approach:
-
-- When using [basic authentication](/docs/auth/identified-clients#basic), specify the `clientId` directly in the client options when instantiating the client instance.
-- When using [token authentication](/docs/auth/identified-clients#token), specify an explicit `clientId` when issuing the token.
-
-### User identity
-
-Users typically authenticate using [token authentication](/docs/auth/identified-clients#token). Add the [`x-ably-clientId`](/docs/api/realtime-sdk/authentication#ably-jwt) claim to your JWT to establish a verified identity for each user client. This identity appears as the [`clientId`](/docs/messages#properties) in all messages the user publishes, and subscribers can trust this identity because only your server can issue JWTs with specific `clientId` values.
-
-As with all clients, the method for setting `clientId` depends on your [authentication approach](#identity).
-
-Update your `getJWTClaims` function to specify a `clientId` for the user:
-
-
-```javascript
-// Server code
-
-// Return the claims payload to embed in the signed JWT.
-function getJWTClaims(userId) {
- // Returns a payload with the `x-ably-clientId` claim, which ensures
- // that the user's ID appears as the `clientId` on all messages
- // published by the client using this token.
- return { "x-ably-clientId": userId };
-}
-```
-```python
-# Server code
-
-# Return the claims payload to embed in the signed JWT.
-def get_jwt_claims(user_id):
- # Returns a payload with the `x-ably-clientId` claim, which ensures
- # that the user's ID appears as the `clientId` on all messages
- # published by the client using this token.
- return {"x-ably-clientId": user_id}
-```
-```java
-// Server code
-import java.util.HashMap;
-import java.util.Map;
-
-// Return the claims payload to embed in the signed JWT.
-private static Map getJWTClaims(String userId) {
- // Returns a payload with the `x-ably-clientId` claim, which ensures
- // that the user's ID appears as the `clientId` on all messages
- // published by the client using this token.
- Map claims = new HashMap<>();
- claims.put("x-ably-clientId", userId);
- return claims;
-}
-```
-
-
-When a client authenticates using this token, Ably's servers automatically attach the `clientId` specified in the token to every message the user publishes:
-
-
-```javascript
-// Client code
-const channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Publish a message - the clientId is automatically attached
-await channel.publish("prompt", "What is the weather like today?");
-```
-```python
-# Client code
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-
-# Publish a message - the clientId is automatically attached
-message = Message(name="prompt", data="What is the weather like today?")
-await channel.publish(message)
-```
-```java
-// Client code
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Publish a message - the clientId is automatically attached
-channel.publish("prompt", "What is the weather like today?");
-```
-
-
-Agents can then access this verified identity to identify the sender:
-
-
-```javascript
-// Agent code
-const channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Subscribe to messages from clients
-await channel.subscribe("prompt", (message) => {
- // Access the verified clientId from the message
- const userId = message.clientId;
- const prompt = message.data;
-
- console.log(`Received message from user: ${userId}`);
- console.log(`Prompt:`, prompt);
-});
-```
-```python
-# Agent code
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-
-# Subscribe to messages from clients
-def on_prompt(message):
- # Access the verified clientId from the message
- user_id = message.client_id
- prompt = message.data
-
- print(f"Received message from user: {user_id}")
- print(f"Prompt: {prompt}")
-
-await channel.subscribe("prompt", on_prompt)
-```
-```java
-// Agent code
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Subscribe to messages from clients
-channel.subscribe("prompt", message -> {
- // Access the verified clientId from the message
- String userId = message.clientId;
- String prompt = (String) message.data;
-
- System.out.println("Received message from user: " + userId);
- System.out.println("Prompt: " + prompt);
-});
-```
-
-
-The `clientId` in the message can be trusted, so agents can use this identity to make decisions about what actions the user can take. For example, agents can check user permissions before executing tool calls, route messages to appropriate AI models based on subscription tiers, or maintain per-user conversation history and context.
-
-### Agent identity
-
-Agent code typically runs in a trusted environment, so you can use [basic authentication](/docs/auth/identified-clients#basic) and directly specify the `clientId` when instantiating the agent client. This identity appears as the [`clientId`](/docs/messages#properties) in all messages the agent publishes, allowing subscribers to identify the agent which published a message.
-
-
-```javascript
-// Agent code
-import * as Ably from "ably";
-
-const ably = new Ably.Realtime({
- key: "{{API_KEY}}",
- // Specify an identity for this agent
- clientId: "weather-agent"
-});
-```
-```python
-# Agent code
-from ably import AblyRealtime
-
-ably = AblyRealtime(
- key="{{API_KEY}}",
- # Specify an identity for this agent
- client_id="weather-agent"
-)
-```
-```java
-// Agent code
-import io.ably.lib.realtime.AblyRealtime;
-import io.ably.lib.types.ClientOptions;
-
-ClientOptions options = new ClientOptions();
-options.key = "{{API_KEY}}";
-// Specify an identity for this agent
-options.clientId = "weather-agent";
-
-AblyRealtime ably = new AblyRealtime(options);
-```
-
-
-When subscribers receive messages, they can use the `clientId` to determine which agent published the message:
-
-
-```javascript
-// Client code
-const channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-await channel.subscribe((message) => {
- if (message.clientId === "weather-agent") {
- console.log("Weather agent response:", message.data);
- }
-});
-```
-```python
-# Client code
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-
-def on_message(message):
- if message.client_id == "weather-agent":
- print("Weather agent response:", message.data)
-
-await channel.subscribe(on_message)
-```
-```java
-// Client code
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-channel.subscribe(message -> {
- if ("weather-agent".equals(message.clientId)) {
- System.out.println("Weather agent response: " + message.data);
- }
-});
-```
-
-
-
-
-## Adding roles and attributes
-
-Embed custom roles and attributes in messages to enable role-based access control (RBAC) and convey additional context about users and agents. This enables agents to make authorization decisions without additional database lookups.
-
-### User claims
-
-Use [authenticated claims for users](/docs/auth/capabilities#custom-restrictions-on-channels-) to embed custom claims in JWTs that represent user roles or attributes.
-
-Add claims with names matching the `ably.channel.*` pattern to your JWT to specify user claims for specific channels. Claims can be scoped to individual channels or to [namespaces](/docs/channels#namespaces) of channels. The most specific user claim matching the channel is automatically included under `extras.userClaim` in all messages the client publishes.
-
-Update your `getJWTClaims` function to specify some user claims:
-
-
-```javascript
-// Server code
-
-// Return the claims payload to embed in the signed JWT.
-function getJWTClaims(userId) {
- // Returns a payload with `ably.channel.*` claims, which ensures that
- // the most specific claim appears as the `message.extras.userClaim`
- // on all messages published by the client using this token.
- return {
- // The user is an editor on all acme channels.
- "ably.channel.org:acme:*": "editor",
- // The user is a guest on all other channels.
- "ably.channel.*": "guest",
- };
-}
-```
-```python
-# Server code
-
-# Return the claims payload to embed in the signed JWT.
-def get_jwt_claims(user_id):
- # Returns a payload with `ably.channel.*` claims, which ensures that
- # the most specific claim appears as the `message.extras.userClaim`
- # on all messages published by the client using this token.
- return {
- # The user is an editor on all acme channels.
- "ably.channel.org:acme:*": "editor",
- # The user is a guest on all other channels.
- "ably.channel.*": "guest",
- }
-```
-```java
-// Server code
-import java.util.HashMap;
-import java.util.Map;
-
-// Return the claims payload to embed in the signed JWT.
-private static Map getJWTClaims(String userId) {
- // Returns a payload with `ably.channel.*` claims, which ensures that
- // the most specific claim appears as the `message.extras.userClaim`
- // on all messages published by the client using this token.
- Map claims = new HashMap<>();
- // The user is an editor on all acme channels.
- claims.put("ably.channel.org:acme:*", "editor");
- // The user is a guest on all other channels.
- claims.put("ably.channel.*", "guest");
- return claims;
-}
-```
-
-
-When a client authenticates with a JWT containing `ably.channel.*` claims, Ably automatically includes the most specific matching claim value in the `message.extras.userClaim` field on messages published by the client:
-
-
-```javascript
-// Agent code
-const channel = ably.channels.get("org:acme:{{RANDOM_CHANNEL_NAME}}");
-
-// Subscribe to user prompts
-await channel.subscribe("prompt", async (message) => {
- // Access the user's role from the user claim in message extras
- const role = message.extras?.userClaim;
-
- console.log(`Message from user with role: ${role}`);
-});
-```
-```python
-# Agent code
-channel = ably.channels.get("org:acme:{{RANDOM_CHANNEL_NAME}}")
-
-# Subscribe to user prompts
-async def on_prompt(message):
- # Access the user's role from the user claim in message extras
- role = message.extras.get('userClaim')
-
- print(f"Message from user with role: {role}")
-
-await channel.subscribe("prompt", on_prompt)
-```
-```java
-// Agent code
-Channel channel = ably.channels.get("org:acme:{{RANDOM_CHANNEL_NAME}}");
-
-// Subscribe to user prompts
-channel.subscribe("prompt", message -> {
- // Access the user's role from the user claim in message extras
- String role = message.extras.get("userClaim").getAsString();
-
- System.out.println("Message from user with role: " + role);
-});
-```
-
-
-The `message.extras.userClaim` in the message can be trusted, so agents can rely on this information to make decisions about what actions the user can take. For example, an agent could allow users with an "editor" role to execute tool calls that modify documents, while restricting users with a "guest" role to read-only operations.
-
-### Agent metadata
-
-Use [`message.extras.headers`](/docs/api/realtime-sdk/types#extras) to include custom metadata in agent messages, such as agent roles or attributes.
-
-Agents can directly specify metadata in `message.extras.headers`. Since agents run as trusted code in server environments, this metadata can be trusted by subscribers. This is useful for communicating agent characteristics, such as which model the agent uses, the agent's role in a multi-agent system, or version information.
-
-
-
-
-```javascript
-// Agent code
-import * as Ably from "ably";
-
-const ably = new Ably.Realtime({
- key: "{{API_KEY}}",
- clientId: "weather-agent"
-});
-
-const channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-await channel.publish({
- name: "update",
- data: "It's raining in London",
- extras: {
- headers: {
- model: "gpt-4"
- }
- }
-});
-```
-```python
-# Agent code
-from ably import AblyRealtime
-
-ably = AblyRealtime(
- key="{{API_KEY}}",
- client_id="weather-agent"
-)
-
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-
-message = Message(
- name="update",
- data="It's raining in London",
- extras={
- "headers": {
- "model": "gpt-4"
- }
- }
-)
-await channel.publish(message)
-```
-```java
-// Agent code
-import io.ably.lib.realtime.AblyRealtime;
-import io.ably.lib.types.ClientOptions;
-
-ClientOptions options = new ClientOptions();
-options.key = "{{API_KEY}}";
-options.clientId = "weather-agent";
-
-AblyRealtime ably = new AblyRealtime(options);
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-JsonObject extras = new JsonObject();
-JsonObject headers = new JsonObject();
-headers.addProperty("model", "gpt-4");
-extras.add("headers", headers);
-
-channel.publish(new Message("update", "It's raining in London", new MessageExtras(extras)));
-```
-
-
-Clients and other agents can access this metadata when messages are received:
-
-
-```javascript
-// Client code
-const channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-await channel.subscribe((message) => {
- if (message.clientId === "weather-agent") {
- const model = message.extras?.headers?.model;
- console.log(`Response from weather agent using ${model}:`, message.data);
- }
-});
-```
-```python
-# Client code
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-
-def on_message(message):
- if message.client_id == "weather-agent":
- model = message.extras.get('headers', {}).get('model')
- print(f"Response from weather agent using {model}: {message.data}")
-
-await channel.subscribe(on_message)
-```
-```java
-// Client code
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-channel.subscribe(message -> {
- if ("weather-agent".equals(message.clientId)) {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String model = headers != null ? headers.get("model").getAsString() : null;
- System.out.println("Response from weather agent using " + model + ": " + message.data);
- }
-});
-```
-
diff --git a/src/pages/docs/ai-transport/sessions-identity/index.mdx b/src/pages/docs/ai-transport/sessions-identity/index.mdx
deleted file mode 100644
index 16de4bee05..0000000000
--- a/src/pages/docs/ai-transport/sessions-identity/index.mdx
+++ /dev/null
@@ -1,63 +0,0 @@
----
-title: "Sessions & identity overview"
-meta_description: "Manage session lifecycle and identity in decoupled AI architectures"
-meta_keywords: "AI sessions, session management, channel-oriented sessions, connection-oriented sessions, session persistence, session lifecycle, identity management, decoupled architecture, session resumption, multi-device, multi-user"
----
-
-Ably AI Transport provides robust session management and identity capabilities designed for modern AI applications. Sessions persist beyond individual connections, enabling agents and clients to connect independently through shared channels. Built-in token-based authentication provides verified user identity and fine-grained authorization for channel operations.
-
-## What is a session?
-
-A session is an interaction between a user (or multiple users) and an AI agent where messages and data are exchanged, building up shared context over time. In AI Transport, sessions are designed to persist beyond the boundaries of individual connections, enabling modern AI experiences where users expect to:
-
-- Resume conversations across devices: Start a conversation on mobile and seamlessly continue on desktop with full context preserved
-- Return to long-running work: Close the browser while agents continue processing in the background, delivering results when you return
-- Recover from interruptions: Experience connection drops, browser refreshes, or network instability without losing conversation progress
-- Collaborate in shared sessions: Multiple users can participate in the same conversation simultaneously and remain in sync
-
-These capabilities represent a fundamental shift from traditional request/response AI experiences to continuous, resumable interactions that are accessible across all user devices and locations. Sessions have a lifecycle: they begin when a user starts interacting with an agent, remain active while the interaction continues, and can persist even when users disconnect - enabling truly asynchronous AI workflows.
-
-Managing this lifecycle in AI Transport's decoupled architecture involves detecting when users are present, deciding when to stop or continue agent work, and handling scenarios where users disconnect and return.
-
-## Connection-oriented vs channel-oriented sessions
-
-In traditional connection-oriented architectures, sessions are bound to the lifecycle of a WebSocket or SSE connection:
-
-1. Client opens connection to agent server to establish a session
-2. Agent streams response over the connection
-3. When the connection closes, the session ends
-
-This tight coupling means network interruptions terminate sessions, agents cannot continue work after disconnections, and supporting multiple devices or users introduces significant complexity.
-
-AI Transport uses a channel-oriented model where sessions persist independently of individual connections. Clients and agents communicate through [Channels](/docs/channels):
-
-1. Client sends a single request to agent server to establish a session
-2. Server responds with a unique ID for the session, which is used to identify the channel
-3. All further communication happens over the channel
-
-In this model, sessions are associated with the channel, enabling seamless reconnection, background agent work, and multi-device access without additional complexity.
-
-
-
-
-The channel-oriented model provides key benefits for modern AI applications: sessions maintain continuity in the face of disconnections, users can refresh or navigate back to the ongoing session, multiple users or devices can participate in the same session, and agents can continue long-running or asynchronous workloads even when clients disconnect.
-
-The following table compares how each architecture addresses the engineering challenges of delivering these capabilities:
-
-| Challenge | Connection-oriented sessions | Channel-oriented sessions |
-|-----------|------------------------------|---------------------------|
-| Routing | Agents must track which instance holds each session. Reconnecting clients need routing logic to find the correct agent instance across your infrastructure. | Agents and clients only need the channel name. Ably handles message delivery to all subscribers without agents tracking sessions or implementing routing logic. |
-| Message resume | Agents must buffer sent messages and implement replay logic. When clients reconnect, agents must determine what was missed and retransmit without duplicates or gaps, distinctly for each connection. | When clients reconnect, they automatically receive messages published while disconnected. The channel maintains history without agents implementing buffering or replay logic, eliminating the need for server-side session state. |
-| Abandonment detection | Agents must implement logic to distinguish between brief network interruptions and users who have actually left, so they can decide whether to continue work or clean up resources. | Built-in presence tracking signals when users enter and leave channels, providing clear lifecycle events to agents without custom detection logic. |
-| Multi-user and multi-device | Agents must manage multiple concurrent connections from the same user across devices, or from multiple users in collaborative sessions. This requires tracking connections, synchronizing state, and ensuring all participants receive consistent updates. | Multiple users and devices can connect to the same channel. The channel handles message delivery to all participants, simplifying agent logic for multi-user and multi-device scenarios. |
-
-## Identity in channel-oriented sessions
-
-In connection-oriented architectures, the agent server handles authentication directly when establishing the connection. When the connection is opened, the server verifies credentials and associates the authenticated user identity with that specific connection.
-
-In channel-oriented sessions, agents don't manage connections or handle authentication directly. Instead, your server authenticates users and issues tokens that control their access to channels. Ably enforces these authorization rules and provides verified identity information to agents, giving you powerful capabilities for managing who can participate in sessions and what they can do:
-
-- Verified identity: Agents automatically receive the authenticated identity of message senders, with cryptographic guarantees that identities cannot be spoofed
-- Fine-grained authorization: Control precisely what operations each user can perform on specific channels through fine-grained capabilities
-- Rich user attributes: Pass authenticated user data to agents for personalized behavior without building custom token systems
-- Role-based participation: Distinguish between different types of participants, such as users and agents, to customize behaviour based on their role
diff --git a/src/pages/docs/ai-transport/sessions-identity/online-status.mdx b/src/pages/docs/ai-transport/sessions-identity/online-status.mdx
deleted file mode 100644
index 72cd20186a..0000000000
--- a/src/pages/docs/ai-transport/sessions-identity/online-status.mdx
+++ /dev/null
@@ -1,567 +0,0 @@
----
-title: "Online status"
-meta_description: "Use Ably Presence to show which users and agents are currently connected to an AI session"
-meta_keywords: "presence, online status, multi-device, multi-user, session abandonment, async workflows"
----
-
-Modern AI applications require agents to know when users are online, when they've fully disconnected, and how to handle users connected across multiple devices. Ably's [Presence](/docs/presence-occupancy/presence) feature provides realtime online status with automatic lifecycle management, allowing agents to decide when to continue processing, when to wait for user input, and when to clean up resources. Presence detects which users and agents are currently connected to a session, distinguishes between a single device disconnecting and a user going completely offline, and enables responsive online/offline indicators.
-
-## Why online status matters
-
-In channel-oriented sessions, online status serves several critical purposes:
-
-- Session abandonment detection: Agents need to know when users have fully disconnected to decide whether to continue processing, pause work, or clean up resources. Presence provides reliable signals when all of a user's devices have left the session.
-- Multi-device coordination: A single user can connect from multiple devices simultaneously. Presence tracks each connection separately while maintaining stable identity across devices, allowing you to distinguish between "one device left" and "user completely offline".
-- Agent availability signaling: Clients need to know when agents are online and ready to process requests. Agents can enter presence to advertise availability and leave when they complete work or shut down.
-- Collaborative session awareness: In sessions with multiple users, participants can see who else is currently present. This enables realtime collaboration features and helps users understand the current session context.
-
-## Going online
-
-Use the [`enter()`](/docs/presence-occupancy/presence#enter) method to signal that a user or agent is online. When a client enters presence, they are added to the presence set and identified by their `clientId`. You can optionally include data when entering presence to communicate additional context.
-
-
-
-You have flexibility in when to enter presence. For example, an agent might choose to appear as online only while processing a specific task, or remain present for the duration of the entire session. Users typically enter presence when they connect to a session and remain present until they disconnect.
-
-
-
-For example, a user client can enter presence when joining a session:
-
-
-```javascript
-// Client code
-const channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Enter presence with metadata about the user's device
-await channel.presence.enter({
- device: "mobile",
- platform: "ios"
-});
-```
-```python
-# Client code
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-
-# Enter presence with metadata about the user's device
-await channel.presence.enter({
- "device": "mobile",
- "platform": "ios"
-})
-```
-```java
-// Client code
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Enter presence with metadata about the user's device
-JsonObject data = new JsonObject();
-data.addProperty("device", "mobile");
-data.addProperty("platform", "ios");
-channel.presence.enter(data);
-```
-
-
-Similarly, an agent can enter presence to signal that it's online:
-
-
-```javascript
-// Agent code
-const channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Enter presence with metadata about the agent
-await channel.presence.enter({
- model: "gpt-4"
-});
-```
-```python
-# Agent code
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-
-# Enter presence with metadata about the agent
-await channel.presence.enter({
- "model": "gpt-4"
-})
-```
-```java
-// Agent code
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Enter presence with metadata about the agent
-JsonObject data = new JsonObject();
-data.addProperty("model", "gpt-4");
-channel.presence.enter(data);
-```
-
-
-### Going online from multiple devices
-
-A single user can be present on a channel from multiple devices simultaneously. Ably tracks each connection separately using a unique [`connectionId`](/docs/connect#connection-ids), while maintaining the same [`clientId`](/docs/auth/identified-clients#assign) across all connections.
-
-When a user connects from multiple devices, each device enters presence independently. All connections share the same `clientId` but have different `connectionId` values.
-
-For example, when the user connects from their desktop browser:
-
-
-```javascript
-// Client code (device 1: desktop browser)
-const channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-await channel.presence.enter({ device: "desktop" });
-```
-```python
-# Client code (device 1: desktop browser)
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-await channel.presence.enter({"device": "desktop"})
-```
-```java
-// Client code (device 1: desktop browser)
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-JsonObject data = new JsonObject();
-data.addProperty("device", "desktop");
-channel.presence.enter(data);
-```
-
-
-And then connects from their mobile app while still connected on desktop:
-
-
-```javascript
-// Client code (device 2: mobile app)
-const channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-await channel.presence.enter({ device: "mobile" });
-```
-```python
-# Client code (device 2: mobile app)
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-await channel.presence.enter({"device": "mobile"})
-```
-```java
-// Client code (device 2: mobile app)
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-JsonObject data = new JsonObject();
-data.addProperty("device", "mobile");
-channel.presence.enter(data);
-```
-
-
-Both devices are now members of the presence set with the same `clientId` but different `connectionId` values. When you query the presence set, you'll see two separate entries:
-
-
-```javascript
-// Query presence to see both devices
-const members = await channel.presence.get();
-for (const { clientId, connectionId, data } of members) {
- console.log(clientId, connectionId, data);
-}
-// Example output:
-// user-123 hd67s4!abcdef-0 { device: "desktop" }
-// user-123 hd67s4!ghijkl-1 { device: "mobile" }
-```
-```python
-# Query presence to see both devices
-members = await channel.presence.get()
-for member in members:
- print(member.client_id, member.connection_id, member.data)
-# Example output:
-# user-123 hd67s4!abcdef-0 { 'device': 'desktop' }
-# user-123 hd67s4!ghijkl-1 { 'device': 'mobile' }
-```
-```java
-// Query presence to see both devices
-PresenceMessage[] members = channel.presence.get();
-for (PresenceMessage member : members) {
- System.out.println(member.clientId + " " + member.connectionId + " " + member.data);
-}
-// Example output:
-// user-123 hd67s4!abcdef-0 { device: "desktop" }
-// user-123 hd67s4!ghijkl-1 { device: "mobile" }
-```
-
-
-When either device leaves or disconnects, the other device remains in the presence set.
-
-## Going offline
-
-Clients can go offline in two ways: explicitly by calling the leave method, or automatically when Ably detects a disconnection.
-
-### Explicitly going offline
-
-Use the [`leave()`](/docs/presence-occupancy/presence#leave) method when a user or agent wants to mark themselves as offline. This immediately notifies presence subscribers on the channel and removes the entry from the presence set, even if they remain connected to Ably.
-
-
-
-For example, a user client can explicitly leave presence:
-
-
-```javascript
-// Client code
-const channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Leave presence when the user marks themselves offline
-await channel.presence.leave();
-```
-```python
-# Client code
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-
-# Leave presence when the user marks themselves offline
-await channel.presence.leave()
-```
-```java
-// Client code
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Leave presence when the user marks themselves offline
-channel.presence.leave();
-```
-
-
-Similarly, an agent can leave presence when it completes its work or shuts down:
-
-
-```javascript
-// Agent code
-const channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Leave presence when the agent shuts down
-await channel.presence.leave();
-```
-```python
-# Agent code
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-
-# Leave presence when the agent shuts down
-await channel.presence.leave()
-```
-```java
-// Agent code
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Leave presence when the agent shuts down
-channel.presence.leave();
-```
-
-
-Optionally include data when leaving presence to communicate the reason for going offline. This data is delivered to presence subscribers listening to `leave` events and is also available in [presence history](/docs/presence-occupancy/presence#history):
-
-
-```javascript
-// Leave with a reason
-await channel.presence.leave({
- reason: "session-completed",
- timestamp: Date.now()
-});
-```
-```python
-# Leave with a reason
-import time
-await channel.presence.leave({
- "reason": "session-completed",
- "timestamp": int(time.time() * 1000)
-})
-```
-```java
-// Leave with a reason
-JsonObject data = new JsonObject();
-data.addProperty("reason", "session-completed");
-data.addProperty("timestamp", System.currentTimeMillis());
-channel.presence.leave(data);
-```
-
-
-Subscribers receive the `leave` data in the presence message:
-
-
-```javascript
-// Subscribe to leave events to see why members left
-await channel.presence.subscribe("leave", (presenceMessage) => {
- console.log(`${presenceMessage.clientId} left`);
- if (presenceMessage.data) {
- console.log(`Reason: ${presenceMessage.data.reason}`);
- }
-});
-```
-```python
-# Subscribe to leave events to see why members left
-def on_leave(presence_message):
- print(f"{presence_message.client_id} left")
- if presence_message.data:
- print(f"Reason: {presence_message.data['reason']}")
-
-await channel.presence.subscribe("leave", on_leave)
-```
-```java
-// Subscribe to leave events to see why members left
-channel.presence.subscribe("leave", presenceMessage -> {
- System.out.println(presenceMessage.clientId + " left");
- if (presenceMessage.data != null) {
- JsonObject data = (JsonObject) presenceMessage.data;
- System.out.println("Reason: " + data.get("reason").getAsString());
- }
-});
-```
-
-
-### Going offline after disconnection
-
-When a client loses connection unexpectedly, Ably detects the lost connection and automatically leaves the client from the presence set.
-
-By default, clients remain present for 15 seconds after an abrupt disconnection. This prevents excessive enter/leave events during brief network interruptions. If the client reconnects within this window, they remain in the presence set without triggering leave and reenter events.
-
-Use the `transportParams` [client option](/docs/api/realtime-sdk#client-options) to configure disconnection detection and presence lifecycle behaviour. After an abrupt disconnection, the `heartbeatInterval` transport parameter controls how quickly Ably detects the dead connection, while the `remainPresentFor` option controls how long the member is kept in presence before Ably emits the leave event.
-
-
-
-For example, if implementing resumable agents using techniques such as durable execution, configure a longer `remainPresentFor` period to allow time for the new agent instance to come online and resume processing before the previous instance appears as offline. This provides a seamless handoff:
-
-
-```javascript
-// Agent code
-const ably = new Ably.Realtime({
- key: "{{API_KEY}}",
- clientId: "weather-agent",
- // Allow 30 seconds for agent resume and reconnection
- transportParams: {
- remainPresentFor: 30000
- }
-});
-```
-```python
-# Agent code
-ably = AblyRealtime(
- key="{{API_KEY}}",
- client_id="weather-agent",
- # Allow 30 seconds for agent resume and reconnection
- transport_params={
- "remainPresentFor": 30000
- }
-)
-```
-```java
-// Agent code
-ClientOptions options = new ClientOptions();
-options.key = "{{API_KEY}}";
-options.clientId = "weather-agent";
-// Allow 30 seconds for agent resume and reconnection
-options.transportParams = Map.of("remainPresentFor", "30000");
-
-AblyRealtime ably = new AblyRealtime(options);
-```
-
-
-## Viewing who is online
-
-Participants in a session can query the current presence set or subscribe to presence events to see who else is online and react to changes in realtime. Users might want to see which agents are processing work, while agents might want to detect when specific users are offline to pause or cancel work.
-
-
-
-### Retrieving current presence members
-
-Use [`presence.get()`](/docs/api/realtime-sdk/presence#get) to retrieve the current list of users and agents in the session. Each presence member is uniquely identified by the combination of their `clientId` and `connectionId`. This is useful for showing who is currently available or checking if a specific participant is online before taking action.
-
-
-```javascript
-// Get all currently present members
-const members = await channel.presence.get();
-
-// Display each member - the same user will appear once per distinct connection
-members.forEach((member) => {
- console.log(`${member.clientId} (connection: ${member.connectionId})`);
-});
-```
-```python
-# Get all currently present members
-members = await channel.presence.get()
-
-# Display each member - the same user will appear once per distinct connection
-for member in members:
- print(f"{member.client_id} (connection: {member.connection_id})")
-```
-```java
-// Get all currently present members
-PresenceMessage[] members = channel.presence.get();
-
-// Display each member - the same user will appear once per distinct connection
-for (PresenceMessage member : members) {
- System.out.println(member.clientId + " (connection: " + member.connectionId + ")");
-}
-```
-
-
-### Subscribing to presence changes
-
-Use [`presence.subscribe()`](/docs/api/realtime-sdk/presence#subscribe) to receive realtime notifications when users or agents enter or leave the session. This enables building responsive UIs that show online users, or implementing agent logic that reacts to user connectivity changes.
-
-
-```javascript
-// Client code
-const channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Subscribe to changes to the presence set
-await channel.presence.subscribe(async (presenceMessage) => {
- // Get the current synced presence set after any change
- const members = await channel.presence.get();
-
- // Display each member - the same user will appear once per distinct connection
- members.forEach((member) => {
- console.log(`${member.clientId} (connection: ${member.connectionId})`);
- });
-});
-```
-```python
-# Client code
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-
-# Subscribe to changes to the presence set
-async def on_presence_change(presence_message):
- # Get the current synced presence set after any change
- members = await channel.presence.get()
-
- # Display each member - the same user will appear once per distinct connection
- for member in members:
- print(f"{member.client_id} (connection: {member.connection_id})")
-
-await channel.presence.subscribe(on_presence_change)
-```
-```java
-// Client code
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Subscribe to changes to the presence set
-channel.presence.subscribe(presenceMessage -> {
- // Get the current synced presence set after any change
- try {
- PresenceMessage[] members = channel.presence.get();
-
- // Display each member - the same user will appear once per distinct connection
- for (PresenceMessage member : members) {
- System.out.println(member.clientId + " (connection: " + member.connectionId + ")");
- }
- } catch (AblyException e) {
- e.printStackTrace();
- }
-});
-```
-
-
-You can also subscribe to specific presence events:
-
-
-```javascript
-// Subscribe only to enter events
-await channel.presence.subscribe("enter", (presenceMessage) => {
- console.log(`${presenceMessage.clientId} joined on connection ${presenceMessage.connectionId}`);
-});
-
-// Subscribe only to leave events
-await channel.presence.subscribe("leave", (presenceMessage) => {
- console.log(`${presenceMessage.clientId} left on connection ${presenceMessage.connectionId}`);
-});
-```
-```python
-# Subscribe only to enter events
-def on_enter(presence_message):
- print(f"{presence_message.client_id} joined on connection {presence_message.connection_id}")
-
-await channel.presence.subscribe("enter", on_enter)
-
-# Subscribe only to leave events
-def on_leave(presence_message):
- print(f"{presence_message.client_id} left on connection {presence_message.connection_id}")
-
-await channel.presence.subscribe("leave", on_leave)
-```
-```java
-// Subscribe only to enter events
-channel.presence.subscribe("enter", presenceMessage -> {
- System.out.println(presenceMessage.clientId + " joined on connection " + presenceMessage.connectionId);
-});
-
-// Subscribe only to leave events
-channel.presence.subscribe("leave", presenceMessage -> {
- System.out.println(presenceMessage.clientId + " left on connection " + presenceMessage.connectionId);
-});
-```
-
-
-### Detecting when a user is offline on all devices
-
-Agents can monitor presence changes to detect when a specific user has gone completely offline across all devices. This is useful for deciding whether to pause expensive operations, cancel ongoing work, deprioritize tasks, or schedule work for later.
-
-
-```javascript
-// Agent code
-const channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-await channel.presence.subscribe(async (presenceMessage) => {
- // Get the current synced presence set
- const members = await channel.presence.get();
-
- // Check if all clients are offline
- if (members.length === 0) {
- console.log(`All clients are offline`);
- }
-
- // Check if a specific client is offline
- if (!members.map(m => m.clientId).includes(targetUserId)) {
- console.log(`${targetUserId} is now offline on all devices`);
- }
-});
-```
-```python
-# Agent code
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-
-async def on_presence_change(presence_message):
- # Get the current synced presence set
- members = await channel.presence.get()
-
- # Check if all clients are offline
- if len(members) == 0:
- print("All clients are offline")
-
- # Check if a specific client is offline
- if target_user_id not in [m.client_id for m in members]:
- print(f"{target_user_id} is now offline on all devices")
-
-await channel.presence.subscribe(on_presence_change)
-```
-```java
-// Agent code
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-channel.presence.subscribe(presenceMessage -> {
- try {
- // Get the current synced presence set
- PresenceMessage[] members = channel.presence.get();
-
- // Check if all clients are offline
- if (members.length == 0) {
- System.out.println("All clients are offline");
- }
-
- // Check if a specific client is offline
- boolean found = false;
- for (PresenceMessage member : members) {
- if (member.clientId.equals(targetUserId)) {
- found = true;
- break;
- }
- }
- if (!found) {
- System.out.println(targetUserId + " is now offline on all devices");
- }
- } catch (AblyException e) {
- e.printStackTrace();
- }
-});
-```
-
diff --git a/src/pages/docs/ai-transport/sessions-identity/push-notifications.mdx b/src/pages/docs/ai-transport/sessions-identity/push-notifications.mdx
deleted file mode 100644
index 4da9d08626..0000000000
--- a/src/pages/docs/ai-transport/sessions-identity/push-notifications.mdx
+++ /dev/null
@@ -1,481 +0,0 @@
----
-title: "Push notifications"
-meta_description: "Notify users via push notifications when an AI agent completes work while they are offline"
-meta_keywords: "push notifications, offline notifications, background processing, async AI, agent completion, deep linking, mobile notifications"
----
-
-When agents perform long-running tasks, users may go offline before the work completes. Rather than requiring users to keep their app open, agents can send push notifications to bring them back when results are ready.
-
-Push notifications complement the [online status](/docs/ai-transport/sessions-identity/online-status) capabilities of AI Transport. Use presence to detect when a user goes offline, then use push notifications to reach them on their devices when there's something to come back to.
-
-## When to use push notifications
-
-Push notifications are suited to scenarios where there is a delay between a user's request and the agent's completion:
-
-- Long-running agent tasks such as data analysis, report generation, or multi-step research that may take minutes to complete.
-- Async workflows where users submit a request and move on, such as "generate a summary of this quarter's sales data and notify me when it's done".
-- Background processing where agents continue work after users close their app, such as processing uploads, running batch operations, or monitoring for specific conditions.
-
-If a user is still online when the agent completes work, deliver results directly through the channel. Push notifications are for reaching users who have already disconnected.
-
-## Set up push notifications
-
-Before agents can send push notifications, the user's devices must be registered with Ably's push notification service.
-
-
-
-
-
-## Opt-in patterns
-
-Users should have control over when they receive push notifications. There are two common opt-in patterns.
-
-### Per-session opt-in
-
-Users can request a notification for a specific task. This is useful for tasks that are typically fast but occasionally take longer, where a blanket notification preference would be noisy.
-
-The user sends a message to the agent indicating they want to be notified when the current task completes:
-
-
-```javascript
-// Client code
-const channel = ably.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Request notification when the current task finishes
-await channel.publish('request', {
- prompt: 'Analyze last quarter\'s sales data and generate a report',
- notifyOnComplete: true
-});
-```
-```python
-# Client code
-channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}")
-
-# Request notification when the current task finishes
-await channel.publish("request", {
- "prompt": "Analyze last quarter's sales data and generate a report",
- "notifyOnComplete": True
-})
-```
-```java
-// Client code
-Channel channel = ably.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Request notification when the current task finishes
-JsonObject data = new JsonObject();
-data.addProperty("prompt", "Analyze last quarter's sales data and generate a report");
-data.addProperty("notifyOnComplete", true);
-channel.publish("request", data);
-```
-
-
-The agent reads this flag and stores the preference for the duration of the task:
-
-
-```javascript
-// Agent code
-let notifyUserOnComplete = false;
-
-await channel.subscribe('request', (message) => {
- notifyUserOnComplete = message.data.notifyOnComplete === true;
- // Begin processing the task...
-});
-```
-```python
-# Agent code
-notify_user_on_complete = False
-
-async def on_request(message):
- global notify_user_on_complete
- notify_user_on_complete = message.data.get("notifyOnComplete", False)
- # Begin processing the task...
-
-await channel.subscribe("request", on_request)
-```
-```java
-// Agent code
-AtomicBoolean notifyUserOnComplete = new AtomicBoolean(false);
-
-channel.subscribe("request", message -> {
- JsonObject data = (JsonObject) message.data;
- notifyUserOnComplete.set(data.has("notifyOnComplete") && data.get("notifyOnComplete").getAsBoolean());
- // Begin processing the task...
-});
-```
-
-
-### App-wide opt-in
-
-Users can enable notifications for all agent tasks through their app settings. The agent checks this preference before sending any notification. Store the preference in your application's user settings and pass it to the agent as configuration:
-
-
-```javascript
-// Agent code
-async function shouldNotifyUser(userId) {
- // Check the user's notification preference from your application's settings
- const userSettings = await getUserSettings(userId);
- return userSettings.pushNotificationsEnabled === true;
-}
-```
-```python
-# Agent code
-async def should_notify_user(user_id):
- # Check the user's notification preference from your application's settings
- user_settings = await get_user_settings(user_id)
- return user_settings.get("push_notifications_enabled", False)
-```
-```java
-// Agent code
-boolean shouldNotifyUser(String userId) {
- // Check the user's notification preference from your application's settings
- UserSettings userSettings = getUserSettings(userId);
- return userSettings.isPushNotificationsEnabled();
-}
-```
-
-
-## Sending notifications conditionally
-
-Sending a push notification every time an agent completes work would be disruptive if the user is already looking at the results. Check the user's online status before deciding whether to send a push notification.
-
-### Check if the user is online
-
-Use [presence](/docs/ai-transport/sessions-identity/online-status#detecting-offline) to determine if the user is still connected. If they are online, publish results to the channel as normal. If they are offline, send a push notification:
-
-
-```javascript
-// Agent code
-async function onTaskComplete(channel, userId, result) {
- // Always publish results to the channel for history and connected clients
- await channel.publish('result', result);
-
- // If user is offline, also send a push notification
- const members = await channel.presence.get();
- const userIsOnline = members.some(m => m.clientId === userId);
-
- if (!userIsOnline) {
- await sendPushNotification(userId, channel.name, result);
- }
-}
-```
-```python
-# Agent code
-async def on_task_complete(channel, user_id, result):
- # Always publish results to the channel for history and connected clients
- await channel.publish("result", result)
-
- # If user is offline, also send a push notification
- members = await channel.presence.get()
- user_is_online = any(m.client_id == user_id for m in members)
-
- if not user_is_online:
- await send_push_notification(user_id, channel.name, result)
-```
-```java
-// Agent code
-void onTaskComplete(Channel channel, String userId, JsonObject result) throws AblyException {
- // Always publish results to the channel for history and connected clients
- channel.publish("result", result);
-
- // If user is offline, also send a push notification
- PresenceMessage[] members = channel.presence.get();
- boolean userIsOnline = Arrays.stream(members)
- .anyMatch(m -> m.clientId.equals(userId));
-
- if (!userIsOnline) {
- sendPushNotification(userId, channel.name, result);
- }
-}
-```
-
-
-
-
-### Multi-device awareness
-
-A user may be connected on their desktop but not on their mobile phone. When the user has entered presence with [device metadata](/docs/ai-transport/sessions-identity/online-status#multiple-devices), the agent can use this information to make smarter notification decisions.
-
-For example, skip push notifications entirely if the user has any active device, or only send to mobile when the user is not on desktop:
-
-
-```javascript
-// Agent code
-async function shouldSendPush(channel, userId) {
- const members = await channel.presence.get();
- const userDevices = members.filter(m => m.clientId === userId);
-
- if (userDevices.length === 0) {
- // User is completely offline, send push notification
- return true;
- }
-
- // User has at least one active device, no push needed
- // Results will be delivered via the channel
- return false;
-}
-```
-```python
-# Agent code
-async def should_send_push(channel, user_id):
- members = await channel.presence.get()
- user_devices = [m for m in members if m.client_id == user_id]
-
- if len(user_devices) == 0:
- # User is completely offline, send push notification
- return True
-
- # User has at least one active device, no push needed
- # Results will be delivered via the channel
- return False
-```
-```java
-// Agent code
-boolean shouldSendPush(Channel channel, String userId) throws AblyException {
- PresenceMessage[] members = channel.presence.get();
- long userDeviceCount = Arrays.stream(members)
- .filter(m -> m.clientId.equals(userId))
- .count();
-
- if (userDeviceCount == 0) {
- // User is completely offline, send push notification
- return true;
- }
-
- // User has at least one active device, no push needed
- // Results will be delivered via the channel
- return false;
-}
-```
-
-
-## Send a push notification
-
-Use the [Push Admin API](/docs/push/publish#direct-publishing) to send a notification directly to a user by their `clientId`. The Realtime client exposes `push.admin.publish()`, so no separate REST client is needed. This delivers the notification to all devices registered to that user:
-
-
-```javascript
-// Agent code
-var recipient = {
- clientId: 'user-123'
-};
-
-var data = {
- notification: {
- title: 'Task complete',
- body: 'Your sales report is ready to view'
- },
- data: {
- channelName: 'session-abc-123',
- type: 'task-complete'
- }
-};
-
-ably.push.admin.publish(recipient, data);
-```
-```python
-# Agent code
-recipient = {
- "clientId": "user-123"
-}
-
-data = {
- "notification": {
- "title": "Task complete",
- "body": "Your sales report is ready to view"
- },
- "data": {
- "channelName": "session-abc-123",
- "type": "task-complete"
- }
-}
-
-ably.push.admin.publish(recipient, data)
-```
-```java
-// Agent code
-JsonObject payload = JsonUtils.object()
- .add("notification", JsonUtils.object()
- .add("title", "Task complete")
- .add("body", "Your sales report is ready to view")
- )
- .add("data", JsonUtils.object()
- .add("channelName", "session-abc-123")
- .add("type", "task-complete")
- )
- .toJson();
-
-ably.push.admin.publish(new Param[]{new Param("clientId", "user-123")}, payload);
-```
-
-
-
-
-## Notification payloads
-
-Structure your notification payloads to give users enough context to decide whether to act immediately and to navigate directly to the relevant session when they do.
-
-### Deep linking
-
-Include the channel name or session ID in the notification's `data` field so your app can navigate directly to the conversation when the user taps the notification:
-
-
-```javascript
-// Agent code
-var recipient = { clientId: userId };
-
-var data = {
- notification: {
- title: 'Research complete',
- body: 'Your market analysis is ready'
- },
- data: {
- channelName: channel.name,
- sessionId: 'session-abc-123',
- action: 'view-results'
- }
-};
-
-ably.push.admin.publish(recipient, data);
-```
-```python
-# Agent code
-recipient = {"clientId": user_id}
-
-data = {
- "notification": {
- "title": "Research complete",
- "body": "Your market analysis is ready"
- },
- "data": {
- "channelName": channel.name,
- "sessionId": "session-abc-123",
- "action": "view-results"
- }
-}
-
-ably.push.admin.publish(recipient, data)
-```
-```java
-// Agent code
-JsonObject payload = JsonUtils.object()
- .add("notification", JsonUtils.object()
- .add("title", "Research complete")
- .add("body", "Your market analysis is ready")
- )
- .add("data", JsonUtils.object()
- .add("channelName", channel.name)
- .add("sessionId", "session-abc-123")
- .add("action", "view-results")
- )
- .toJson();
-
-ably.push.admin.publish(new Param[]{new Param("clientId", userId)}, payload);
-```
-
-
-On the client side, read the `data` fields when handling the notification to navigate to the correct session:
-
-
-```javascript
-// Client code - handling a push notification tap
-function onNotificationTap(notification) {
- const { channelName, sessionId } = notification.data;
- // Navigate to the session and reattach to the channel
- navigateToSession(sessionId, channelName);
-}
-```
-```python
-# Client code - handling a push notification tap
-def on_notification_tap(notification):
- channel_name = notification.data["channelName"]
- session_id = notification.data["sessionId"]
- # Navigate to the session and reattach to the channel
- navigate_to_session(session_id, channel_name)
-```
-```java
-// Client code - handling a push notification tap
-void onNotificationTap(Intent intent) {
- String channelName = intent.getStringExtra("channelName");
- String sessionId = intent.getStringExtra("sessionId");
- // Navigate to the session and reattach to the channel
- navigateToSession(sessionId, channelName);
-}
-```
-
-
-### Completion summary
-
-Include a meaningful summary of what completed in the notification body so users can triage without opening the app:
-
-
-```javascript
-// Agent code
-const summary = generateSummary(result);
-
-var recipient = { clientId: userId };
-
-var data = {
- notification: {
- title: 'Report ready',
- body: summary // For example: 'Q4 sales report: revenue up 12%, 3 action items identified'
- },
- data: {
- channelName: channel.name,
- sessionId: sessionId,
- action: 'view-results'
- }
-};
-
-ably.push.admin.publish(recipient, data);
-```
-```python
-# Agent code
-summary = generate_summary(result)
-
-recipient = {"clientId": user_id}
-
-data = {
- "notification": {
- "title": "Report ready",
- "body": summary # For example: "Q4 sales report: revenue up 12%, 3 action items identified"
- },
- "data": {
- "channelName": channel.name,
- "sessionId": session_id,
- "action": "view-results"
- }
-}
-
-ably.push.admin.publish(recipient, data)
-```
-```java
-// Agent code
-String summary = generateSummary(result);
-
-JsonObject payload = JsonUtils.object()
- .add("notification", JsonUtils.object()
- .add("title", "Report ready")
- .add("body", summary) // For example: "Q4 sales report: revenue up 12%, 3 action items identified"
- )
- .add("data", JsonUtils.object()
- .add("channelName", channel.name)
- .add("sessionId", sessionId)
- .add("action", "view-results")
- )
- .toJson();
-
-ably.push.admin.publish(new Param[]{new Param("clientId", userId)}, payload);
-```
-
diff --git a/src/pages/docs/ai-transport/sessions-identity/resuming-sessions.mdx b/src/pages/docs/ai-transport/sessions-identity/resuming-sessions.mdx
deleted file mode 100644
index 88d63c12fb..0000000000
--- a/src/pages/docs/ai-transport/sessions-identity/resuming-sessions.mdx
+++ /dev/null
@@ -1,253 +0,0 @@
----
-title: Resuming sessions
-description: How clients and agents reconnect to ongoing AI Transport sessions after network interruptions or service restarts
-meta_keywords: "session resumption, reconnection, hydration, presence sync, conversation history, channel history, untilAttach, durable execution, agent restart, message recovery, failover"
----
-
-AI Transport uses a channel-oriented model where sessions persist independently of individual connections. Both users and agents can disconnect and rejoin without ending the session. When users or agents rejoin, they need to resume the session from where they left off.
-
-An agent or user might resume an existing session when:
-
-- A user goes offline or navigates away before returning, expecting to see the latest conversation state
-- An agent goes offline and comes back online when the user returns
-- An agent resumes after a failover or service restart
-
-## Hydrating presence
-
-When you attach to a channel, Ably automatically syncs the complete current presence set to your client. You can then query the presence set or subscribe to presence events without any additional hydration steps. This works the same way for both users and agents.
-
-For details on obtaining the synced presence set, see [Viewing who is online](/docs/ai-transport/sessions-identity/online-status#viewing-presence).
-
-## User resumes a session
-
-Users resume by reattaching to the same session channel and hydrating the conversation transcript, in-progress model output, or other session state.
-
-### Hydrating conversation history
-
-The hydration strategy you choose depends on your application model and your chosen approach to token streaming. Clients typically hydrate conversation state using one of these patterns:
-
-- Hydrate entirely from the channel: Use [rewind](/docs/channels/options/rewind) or [history](/docs/storage-history/history) to obtain previous messages on the channel.
-- Hydrate in-progress responses from the channel: Load completed messages from your database and catch up on any in-progress responses from the channel.
-
-For detailed examples of hydrating the token stream, see the token streaming documentation:
-- [Message-per-response hydration](/docs/ai-transport/token-streaming/message-per-response#hydration)
-- [Message-per-token hydration](/docs/ai-transport/token-streaming/message-per-token#hydration)
-
-## Agent resumes a session
-
-When an agent restarts, it needs to resume from where it left off. This involves two distinct concerns:
-
-1. Recovering the agent's execution state: The current step in the workflow, local variables, function call results, pending operations, and any other state needed to continue execution. This state is internal to the agent and typically not visible to users.
-
-2. Catching up on session activity: Any user messages, events, or other activity that occurred while the agent was offline.
-
-These are separate problems requiring different solutions. Agent execution state is handled by your application and you choose how to persist and restore the internal state your agent needs to resume.
-
-
-
-Ably provides access to channel message history, enabling agents to retrieve any messages sent while they were offline. When your agent comes back online, it reattaches to the same channel and catches up on messages it missed. This channel-oriented model provides several key benefits:
-
-- Guaranteed message delivery: Clients can continue publishing messages even while the agent faults and relocates since the channel exists independently of the agent
-- Reliable catch-up: The agent can retrieve any messages published during the interim when it comes back online
-- Ordered delivery: Messages are delivered in the order they were published, ensuring agents process events in the correct sequence
-- Channel-based addressing: The agent only needs the channel name to reconnect, no need to track individual client connections or manage connection state
-
-### Catching up on messages using history
-
-When an agent resumes, it needs to retrieve messages published while it was offline. Use [channel history](/docs/storage-history/history) with the [`untilAttach` option](/docs/storage-history/history#continuous-history) to catch up on historical messages while preserving continuity with live message delivery.
-
-
-
-#### Persisted session state
-
-Your agent should persist the following state to enable resumption:
-
-- Channel name: The channel the agent was processing
-- Last processed timestamp: The timestamp of the last message successfully processed by the agent
-
-This state allows the agent to reconnect to the correct channel and retrieve only the messages it missed.
-
-#### Catching up with continuity
-
-The recommended pattern uses `untilAttach` to paginate backwards through history while maintaining continuity with live message delivery. This ensures no messages are lost between history retrieval and subscription.
-
-
-
-
-```javascript
-// Agent code
-import * as Ably from 'ably';
-
-const ably = new Ably.Realtime({
- key: process.env.ABLY_API_KEY,
- clientId: 'agent:assistant'
-});
-
-// Load persisted session state
-const channelName = await loadChannelName();
-const lastProcessedTimestamp = await loadLastProcessedTimestamp();
-
-// Use a channel in a namespace with persistence enabled
-// to access more than 2 minutes of message history
-const channel = ably.channels.get(channelName);
-
-// Subscribe to live messages (implicitly attaches the channel)
-await channel.subscribe('prompt', (message) => {
- // Process the live message
- processMessage(message);
-
- // Persist the timestamp after successful processing
- saveLastProcessedTimestamp(message.timestamp);
-});
-
-// Fetch history from the attachment point back to the last checkpoint
-let page = await channel.history({
- untilAttach: true,
- start: lastProcessedTimestamp,
-});
-
-// Paginate through all missed messages, storing in oldest-to-newest order
-const missedMessages = [];
-while (page) {
- missedMessages.unshift(...page.items.reverse());
-
- // Move to next page if available
- page = page.hasNext() ? await page.next() : null;
-}
-
-// Process messages oldest-to-newest
-for (const message of missedMessages) {
- await processMessage(message);
-
- // Persist the timestamp after successful processing
- await saveLastProcessedTimestamp(message.timestamp);
-}
-```
-```python
-# Agent code
-import os
-from ably import AblyRealtime
-
-ably = AblyRealtime(
- key=os.environ.get("ABLY_API_KEY"),
- client_id="agent:assistant"
-)
-
-# Load persisted session state
-channel_name = await load_channel_name()
-last_processed_timestamp = await load_last_processed_timestamp()
-
-# Use a channel in a namespace with persistence enabled
-# to access more than 2 minutes of message history
-channel = ably.channels.get(channel_name)
-
-# Subscribe to live messages (implicitly attaches the channel)
-def on_prompt(message):
- # Process the live message
- process_message(message)
-
- # Persist the timestamp after successful processing
- save_last_processed_timestamp(message.timestamp)
-
-await channel.subscribe("prompt", on_prompt)
-
-# Fetch history from the attachment point back to the last checkpoint
-page = await channel.history(
- until_attach=True,
- start=last_processed_timestamp,
-)
-
-# Paginate through all missed messages, storing in oldest-to-newest order
-missed_messages = []
-while page:
- missed_messages = list(reversed(page.items)) + missed_messages
-
- # Move to next page if available
- page = await page.next() if page.has_next() else None
-
-# Process messages oldest-to-newest
-for message in missed_messages:
- await process_message(message)
-
- # Persist the timestamp after successful processing
- await save_last_processed_timestamp(message.timestamp)
-```
-```java
-// Agent code
-import io.ably.lib.realtime.AblyRealtime;
-import io.ably.lib.realtime.Channel;
-import io.ably.lib.types.ClientOptions;
-import io.ably.lib.types.Message;
-import io.ably.lib.types.PaginatedResult;
-import io.ably.lib.types.Param;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-ClientOptions options = new ClientOptions();
-options.key = System.getenv("ABLY_API_KEY");
-options.clientId = "agent:assistant";
-
-AblyRealtime ably = new AblyRealtime(options);
-
-// Load persisted session state
-String channelName = loadChannelName();
-long lastProcessedTimestamp = loadLastProcessedTimestamp();
-
-// Use a channel in a namespace with persistence enabled
-// to access more than 2 minutes of message history
-Channel channel = ably.channels.get(channelName);
-
-// Subscribe to live messages (implicitly attaches the channel)
-channel.subscribe("prompt", message -> {
- // Process the live message
- processMessage(message);
-
- // Persist the timestamp after successful processing
- saveLastProcessedTimestamp(message.timestamp);
-});
-
-// Fetch history from the attachment point back to the last checkpoint
-Param[] params = new Param[] {
- new Param("untilAttach", "true"),
- new Param("start", String.valueOf(lastProcessedTimestamp))
-};
-PaginatedResult page = channel.history(params);
-
-// Paginate through all missed messages, storing in oldest-to-newest order
-List missedMessages = new ArrayList<>();
-while (page != null) {
- List items = Arrays.asList(page.items());
- Collections.reverse(items);
- missedMessages.addAll(0, items);
-
- // Move to next page if available
- page = page.hasNext() ? page.next() : null;
-}
-
-// Process messages oldest-to-newest
-for (Message message : missedMessages) {
- processMessage(message);
-
- // Persist the timestamp after successful processing
- saveLastProcessedTimestamp(message.timestamp);
-}
-```
-
-
-
-
-This pattern provides guaranteed continuity between historical and live message processing by ensuring that:
-
-1. The subscription starts receiving live messages immediately when you subscribe
-2. History retrieval stops exactly at the point the channel attached
-3. No messages are lost between the end of history and the start of live delivery
diff --git a/src/pages/docs/ai-transport/token-streaming/index.mdx b/src/pages/docs/ai-transport/token-streaming/index.mdx
deleted file mode 100644
index 07450b91d6..0000000000
--- a/src/pages/docs/ai-transport/token-streaming/index.mdx
+++ /dev/null
@@ -1,93 +0,0 @@
----
-title: Token streaming
-meta_description: "Learn about token streaming with Ably AI Transport, including common patterns and the features provided by the Ably solution."
----
-
-Ably AI Transport provides a drop-in infrastructure layer that transforms brittle HTTP token streams into resilient, multi-device AI experiences.
-
-## What is token streaming?
-
-Token streaming delivers LLM responses progressively as each token is generated, rather than waiting for the complete response. Users see text appear incrementally, similar to watching someone type in realtime, which creates responsive, engaging AI experiences.
-
-This is the foundation of modern conversational AI, from chatbots to code assistants. User expectations for AI experiences are continually rising and they now expect to:
-
-- Recover from interruptions: Experience connection drops, browser refreshes, or network instability without losing conversation progress or having to restart the task
-- Resume conversations across devices: Start a conversation on mobile and seamlessly continue on desktop with full context preserved
-- Return to long-running work: Close the browser while agents continue processing in the background, receiving results when they return
-- Collaborate in shared sessions: Multiple users can participate in the same conversation simultaneously and remain in sync
-
-## Why HTTP token streaming falls short
-
-Standard HTTP token streaming creates a direct pipeline between your agent and the user's browser. This works well under ideal conditions, but if a client loses network connectivity, switches tabs, or experiences a browser crash then all tokens transmitted during the interruption are lost. Users must restart their request and wait for the model to regenerate the entire response.
-
-These failures frustrate users and waste compute resources. Every dropped stream means paying for tokens that never reached the user.
-
-Ably AI Transport solves this by decoupling token delivery from connection state. Tokens stream to a [Pub/Sub channel](/docs/channels) that persists independently of both client and agent connections.
-
-1. Client sends a single request to agent server to establish a session
-2. Server responds with a unique ID for the session, which is used to identify the channel
-3. All further communication happens over the channel
-
-
-
-
-Dropping in AI Transport to handle the token stream completely changes the user's experience of device switching and failures. You do not need to add complex failure-handling code to your application or deploy additional infrastructure.
-
-| Scenario | HTTP streaming result | Ably AI Transport result |
-|----------|----------------------|--------------------------|
-| Network interruption | Tokens lost, request must restart | Client reconnects automatically and receives missed tokens |
-| User switches tabs | Stream may be throttled or dropped | Stream continues, tokens buffered for delivery |
-| User reloads page | All progress lost, task must be restarted | New session hydrates with complete history, including in-progress response |
-| User switches device | No continuity | New device receives full conversation state, including in-progress response |
-| Mobile network handoff | Connection drops, tokens lost | Seamless recovery within milliseconds, no missed tokens |
-| Multi-user session | Agent must stream to a connection per-user | Agent publishes to single channel, all subscribed users receive the response |
-
-The Ably platform guarantees that messages from a given realtime publisher are [delivered in order](/docs/platform/architecture/message-ordering#ordering-guarantees) and [exactly once](/docs/platform/architecture/idempotency). Your client application does not need to handle duplicate or out-of-order tokens.
-
-## Token streaming patterns
-
-Ably AI Transport is built on the Pub/Sub messaging platform, giving you flexibility to structure messages and channels for your specific use case. AI Transport supports two token streaming patterns using a [Realtime](/docs/api/realtime-sdk) client, each optimized for different requirements.
-
-The Realtime client maintains a persistent connection to the Ably service, enabling high message rates with the lowest possible latencies while preserving delivery guarantees. For more information, see [Realtime and REST](/docs/basics#realtime-and-rest).
-
-### Message-per-response
-
-[Message-per-response](/docs/ai-transport/token-streaming/message-per-response) streams tokens as they arrive while maintaining a clean, compacted message history. Each LLM response becomes a single message on an Ably channel that grows as tokens are appended. This results in efficient storage and straightforward retrieval of complete responses.
-
-This pattern is the recommended approach for most applications. It excels when:
-
-- Clients joining mid-stream need to catch up efficiently without receiving thousands of individual token messages
-- Applications maintain long conversation histories that must load efficiently on new or reconnecting devices
-
-Example use cases:
-
-- Chat experiences: Replay full conversation history when users change devices or when new participants join, allowing both users and agents to maintain context.
-- Long-running and asynchronous tasks: Users reconnect to check progress throughout a task's lifetime without needing to receive the tokens that make up the response individually.
-- Backend-stored responses: Complete responses persist in your database for loading history, while Ably handles realtime delivery of in-progress output.
-
-### Message-per-token
-
-[Message-per-token](/docs/ai-transport/token-streaming/message-per-token) publishes every generated token as an independent Ably message. Each token appears as a separate message in channel history.
-
-This pattern is useful when:
-
-- Clients only need the most recent portion of a response
-- You treat channel history as a short sliding window rather than a full conversation log
-- You need to preserve the specific token fragmentation generated by the model
-
-Example use cases:
-
-- Live transcription, captioning, or translation: Viewers joining a live stream need only enough tokens for the current subtitle frame, not the entire transcript.
-- Code assistance in an editor: Streamed tokens become part of the file on disk as users accept them, so past tokens do not need to be replayed.
-- Autocomplete: Each user edit triggers a fresh response stream, with only the latest suggestion being relevant.
-
-## Message events
-
-Different models and frameworks use different events to signal streaming state, for example start events, stop events, tool calls, and content deltas. When you publish a message to an Ably [channel](/docs/channels), you can set the [message name](/docs/messages#properties) to the event type your client expects, or encode the information in message [`extras`](/docs/messages#properties) or within the payload itself. This allows your frontend to handle each event type appropriately without parsing message content.
-
-## Next steps
-
-- Implement token streaming with [message-per-response](/docs/ai-transport/token-streaming/message-per-response) (recommended for most applications)
-- Implement token streaming with [message-per-token](/docs/ai-transport/token-streaming/message-per-token) for sliding-window use cases
-- Explore the [guides](/docs/ai-transport/guides/openai/openai-message-per-response) for integration with specific models and frameworks
-- Learn about [sessions and identity](/docs/ai-transport/sessions-identity) in AI Transport applications
diff --git a/src/pages/docs/ai-transport/token-streaming/message-per-response.mdx b/src/pages/docs/ai-transport/token-streaming/message-per-response.mdx
deleted file mode 100644
index baa2bf3dcd..0000000000
--- a/src/pages/docs/ai-transport/token-streaming/message-per-response.mdx
+++ /dev/null
@@ -1,1315 +0,0 @@
----
-title: Message per response
-meta_description: "Stream individual tokens from AI models into a single message over Ably."
----
-
-Token streaming with message-per-response is a pattern where every token generated by your model for a given response is appended to a single Ably message. Each complete AI response then appears as one message in the channel history while delivering live tokens in realtime. This uses [Ably Pub/Sub](/docs/basics) for realtime communication between agents and clients.
-
-This pattern is useful for chat-style applications where you want each complete AI response stored as a single message in history, making it easy to retrieve and display multi-response conversation history. Each agent response becomes a single message that grows as tokens are appended, allowing clients joining mid-stream to catch up efficiently without processing thousands of individual tokens.
-
-The message-per-response pattern includes [automatic rate limit protection](/docs/ai-transport/token-streaming/token-rate-limits#per-response) through rollups, making it the recommended approach for most token streaming use cases.
-
-## How it works
-
-1. **Initial message**: When an agent response begins, publish an initial message with `message.create` action to the Ably channel that is either empty or contains the first token as content.
-2. **Token streaming**: Append subsequent tokens to the original message by publishing those tokens with the `message.append` action.
-3. **Live delivery**: Clients subscribed to the channel receive each appended token in realtime, allowing them to progressively render the response.
-4. **Compacted history**: The channel history contains only one message per agent response, which includes all appended tokens concatenated as contiguous text.
-
-You do not need to mark the message or token stream as completed; the final message content automatically includes the full response constructed from all appended tokens.
-
-
-
-## Enable appends
-
-Message append functionality requires "Message annotations, updates, deletes and appends" to be enabled in a [rule](/docs/channels#rules) associated with the channel.
-
-
-
-To enable the rule:
-
-1. Go to the [Ably dashboard](https://www.ably.com/dashboard) and select your app.
-2. Navigate to the "Configuration" > "Rules" section from the left-hand navigation bar.
-3. Choose "Add new rule".
-4. Enter a channel name or namespace pattern (e.g. `ai` for all channels starting with `ai:`).
-5. Select the "Message annotations, updates, deletes and appends" option from the list.
-6. Click "Create rule".
-
-The examples on this page use the `ai:` namespace prefix, which assumes you have configured the rule for `ai`.
-
-Your token or API key needs the following [capabilities](/docs/auth/capabilities) on the channel:
-
-| Capability | Purpose |
-| --- | --- |
-| `subscribe` | Receive messages |
-| `history` | Retrieve historical messages for [client hydration](#hydration) |
-| `publish` | Create new messages |
-| `message-update-own` | Append to your own messages |
-
-
-
-## Publishing tokens
-
-Publish tokens from a [Realtime](/docs/api/realtime-sdk) client, which maintains a persistent connection to the Ably service. This allows you to publish at very high message rates with the lowest possible latencies, while preserving guarantees around message delivery order. For more information, see [Realtime and REST](/docs/basics#realtime-and-rest).
-
-[Channels](/docs/channels) separate message traffic into different topics. For token streaming, each conversation or session typically has its own channel.
-
-Use the [`get()`](/docs/api/realtime-sdk/channels#get) method to create or retrieve a channel instance:
-
-
-```javascript
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-```
-```python
-channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}')
-```
-```java
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-```
-
-
-To start streaming an AI response, publish the initial message. The message is identified by a server-assigned identifier called a [`serial`](/docs/messages#properties). Use the `serial` to append each subsequent token to the message as it arrives from the AI model:
-
-
-```javascript highlight="2,8"
-// Publish initial message and capture the serial for appending tokens
-const { serials: [msgSerial] } = await channel.publish({ name: 'response', data: '' });
-
-// Example: stream returns events like { type: 'token', text: 'Hello' }
-for await (const event of stream) {
- // Append each token as it arrives
- if (event.type === 'token') {
- channel.appendMessage({ serial: msgSerial, data: event.text });
- }
-}
-```
-```python
-# Publish initial message and capture the serial for appending tokens
-message = Message(name='response', data='')
-result = await channel.publish(message)
-msg_serial = result.serials[0]
-
-# Example: stream returns events like { 'type': 'token', 'text': 'Hello' }
-async for event in stream:
- # Append each token as it arrives
- if event['type'] == 'token':
- asyncio.create_task(channel.append_message(serial=msg_serial, data=event['text']))
-```
-```java
-// Publish initial message and capture the serial for appending tokens
-CompletableFuture publishFuture = new CompletableFuture<>();
-channel.publish("response", "", new Callback() {
- @Override
- public void onSuccess(PublishResult result) {
- publishFuture.complete(result);
- }
-
- @Override
- public void onError(ErrorInfo reason) {
- publishFuture.completeExceptionally(AblyException.fromErrorInfo(reason));
- }
-});
-String msgSerial = publishFuture.get().serials[0];
-
-// Example: stream returns events like { type: 'token', text: 'Hello' }
-for (Event event : stream) {
- // Append each token as it arrives
- if (event.getType().equals("token")) {
- channel.appendMessage(msgSerial, event.getText());
- }
-}
-```
-
-
-When publishing tokens, don't await the `channel.appendMessage()` call. Ably rolls up acknowledgments and debounces them for efficiency, which means awaiting each append would unnecessarily slow down your token stream. Messages are still published in the order that `appendMessage()` is called, so delivery order is not affected.
-
-
-
-
-```javascript
-// ✅ Do this - append without await for maximum throughput
-for await (const event of stream) {
- if (event.type === 'token') {
- channel.appendMessage({ serial: msgSerial, data: event.text });
- }
-}
-
-// ❌ Don't do this - awaiting each append reduces throughput
-for await (const event of stream) {
- if (event.type === 'token') {
- await channel.appendMessage({ serial: msgSerial, data: event.text });
- }
-}
-```
-```python
-# ✅ Do this - use create_task for fire-and-forget throughput
-async for event in stream:
- if event['type'] == 'token':
- asyncio.create_task(channel.append_message(serial=msg_serial, data=event['text']))
-
-# ❌ Don't do this - awaiting each append reduces throughput
-async for event in stream:
- if event['type'] == 'token':
- await channel.append_message(serial=msg_serial, data=event['text'])
-```
-```java
-// ✅ Do this - append without blocking for maximum throughput
-for (Event event : stream) {
- if (event.getType().equals("token")) {
- channel.appendMessage(msgSerial, event.getText());
- }
-}
-
-// ❌ Don't do this - blocking on each append reduces throughput
-for (Event event : stream) {
- if (event.getType().equals("token")) {
- CompletableFuture appendFuture = new CompletableFuture<>();
- channel.appendMessage(msgSerial, event.getText(), new Callback() {
- @Override
- public void onSuccess(UpdateDeleteResult result) {
- appendFuture.complete(result);
- }
-
- @Override
- public void onError(ErrorInfo reason) {
- appendFuture.completeExceptionally(AblyException.fromErrorInfo(reason));
- }
- });
- appendFuture.get(); // blocking call
- }
-}
-```
-
-
-### Handling append failures
-
-The examples above append successive tokens to a response message by pipelining the append operations — that is, the agent publishes an append operation without waiting for prior operations to complete. This is necessary in order to avoid the append rate being capped by the round-trip time from the agent to the Ably endpoint. However, this means that the agent does not await the outcome of each append operation, and that can result in the agent continuing to submit append operations after an earlier operation has failed. For example, if a rate limit is exceeded, a single append may be rejected while the following tokens continue to be accepted.
-
-The agent needs to obtain the outcome of each append operation, and take corrective action in the event that any operation failed for some reason. A simple but effective way to do this is to ensure that, if streaming of a response fails for any reason, the message is updated with the final complete response text once it is available. This means that although the streaming experience is disrupted in the case of failure, there is no consistency problem with the final result once the response completes.
-
-To detect append failures, keep a reference to each append operation and check for rejections after the stream completes:
-
-
-```javascript
-// Publish initial message and capture the serial for appending tokens
-const { serials: [msgSerial] } = await channel.publish({ name: 'response', data: '' });
-
-// Track all append operations
-const appendPromises = [];
-
-// Track the full response for recovery
-let fullResponse = '';
-
-for await (const event of stream) {
- if (event.type === 'token') {
- fullResponse += event.text;
- appendPromises.push(channel.appendMessage({ serial: msgSerial, data: event.text }));
- }
-}
-
-// Check for any failures after the stream completes
-const results = await Promise.allSettled(appendPromises);
-const failed = results.some(result => result.status === 'rejected');
-
-if (failed) {
- // Replace the message with the full response
- await channel.updateMessage({ serial: msgSerial, data: fullResponse });
-}
-```
-
-```python
-# Publish initial message and capture the serial for appending tokens
-message = Message(name='response', data='')
-result = await channel.publish(message)
-msg_serial = result.serials[0]
-
-# Track all append tasks
-append_tasks = []
-
-# Track the full response for recovery
-full_response = ''
-
-async for event in stream:
- if event['type'] == 'token':
- full_response += event['text']
- append_tasks.append(
- asyncio.create_task(channel.append_message(serial=msg_serial, data=event['text']))
- )
-
-# Check for any failures after the stream completes
-results = await asyncio.gather(*append_tasks, return_exceptions=True)
-failed = any(isinstance(r, Exception) for r in results)
-
-if failed:
- # Replace the message with the full response
- await channel.update_message(serial=msg_serial, data=full_response)
-```
-
-```java
-// Publish initial message and capture the serial for appending tokens
-CompletableFuture publishFuture = new CompletableFuture<>();
-channel.publish("response", "", new Callback() {
- @Override
- public void onSuccess(PublishResult result) {
- publishFuture.complete(result);
- }
-
- @Override
- public void onError(ErrorInfo reason) {
- publishFuture.completeExceptionally(AblyException.fromErrorInfo(reason));
- }
-});
-String msgSerial = publishFuture.get().serials[0];
-
-// Track all append operations
-List> appendFutures = new ArrayList<>();
-
-// Track the full response for recovery
-StringBuilder fullResponse = new StringBuilder();
-
-for (Event event : stream) {
- if (event.getType().equals("token")) {
- fullResponse.append(event.getText());
- CompletableFuture future = new CompletableFuture<>();
- channel.appendMessage(msgSerial, event.getText(), new Callback() {
- @Override
- public void onSuccess(UpdateDeleteResult result) {
- future.complete(null);
- }
-
- @Override
- public void onError(ErrorInfo reason) {
- future.completeExceptionally(AblyException.fromErrorInfo(reason));
- }
- });
- appendFutures.add(future);
- }
-}
-
-// Check for any failures after the stream completes
-boolean failed = false;
-for (CompletableFuture future : appendFutures) {
- try {
- future.get();
- } catch (Exception e) {
- failed = true;
- }
-}
-
-if (failed) {
- // Replace the message with the full response
- channel.updateMessage(msgSerial, fullResponse.toString());
-}
-```
-
-
-If any append fails, use `updateMessage()` to replace the message content with the complete response. This ensures subscribers receive the full response regardless of any gaps caused by failed appends. The `message.update` action replaces the entire message content, so subscribers will have the complete response after processing the update.
-
-
-
-
-
-This pattern allows publishing append operations for multiple concurrent model responses on the same channel. As long as you append to the correct message serial, tokens from different responses will not interfere with each other, and the final concatenated message for each response will contain only the tokens from that response.
-
-
-
-### Configuring rollup behaviour
-
-By default, AI Transport automatically rolls up tokens into messages at a rate of 25 messages per second (using a 40ms rollup window). This protects you from hitting connection rate limits while maintaining a smooth user experience. You can tune this behaviour when establishing your connection to balance between message costs and delivery speed:
-
-
-```javascript
-const realtime = new Ably.Realtime({
- key: 'your-api-key',
- transportParams: { appendRollupWindow: 100 } // 10 messages/s
-});
-```
-```python
-realtime = AblyRealtime(
- key='your-api-key',
- transport_params={'appendRollupWindow': 100} # 10 messages/s
-)
-```
-```java
-ClientOptions options = new ClientOptions();
-options.key = "your-api-key";
-options.transportParams = Map.of("appendRollupWindow", "100"); // 10 messages/s
-AblyRealtime realtime = new AblyRealtime(options);
-```
-
-
-The `appendRollupWindow` parameter controls how many tokens are combined into each published message for a given model output rate. This creates a trade-off between delivery smoothness and the number of concurrent model responses you can stream on a single connection:
-
-- With a shorter rollup window, tokens are published more frequently, creating a smoother, more fluid experience for users as they see the response appear in more fine-grained chunks. However, this consumes more of your [connection's message rate capacity](/docs/platform/pricing/limits#connection), limiting how many simultaneous model responses you can stream.
-- With a longer rollup window, multiple tokens are batched together into fewer messages, allowing you to run more concurrent response streams on the same connection, but users will notice tokens arriving in larger chunks.
-
-The default 40ms window strikes a balance, delivering tokens at 25 messages per second - smooth enough for a great user experience while allowing you to run two simultaneous response streams on a single connection. If you need to support more concurrent streams, increase the rollup window (up to 500ms), accepting that tokens will arrive in more noticeable batches. Alternatively, instantiate a separate Ably client which uses its own connection, giving you access to additional message rate capacity.
-
-
-
-## Subscribing to token streams
-
-
-
-
-
-Subscribers receive different message actions depending on when they join and how they're retrieving messages. Each message has an `action` field that indicates how to process it, and a `serial` field that identifies which message the action relates to:
-
-- `message.create`: Indicates a new response has started (i.e. a new message was created). The message `data` contains the initial content (often empty or the first token). Store this as the beginning of a new response using `serial` as the identifier.
-- `message.append`: Contains a single token fragment to append. The message `data` contains only the new token, not the full concatenated response. Append this token to the existing response identified by `serial`.
-- `message.update`: Contains the whole response up to that point. The message `data` contains the full concatenated text so far. Replace the entire response content with this data for the message identified by `serial`. This action occurs when the channel needs to resynchronize the full message state, such as after a client [resumes](/docs/connect/states#resume) from a transient disconnection.
-
-
-
-
-```javascript
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by message serial
-const responses = new Map();
-
-// Subscribe to live messages (implicitly attaches the channel)
-await channel.subscribe((message) => {
- switch (message.action) {
- case 'message.create':
- // New response started
- responses.set(message.serial, message.data);
- break;
- case 'message.append':
- // Append token to existing response
- const current = responses.get(message.serial) || '';
- responses.set(message.serial, current + message.data);
- break;
- case 'message.update':
- // Replace entire response content
- responses.set(message.serial, message.data);
- break;
- }
-});
-```
-```python
-channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}')
-
-# Track responses by message serial
-responses = {}
-
-# Subscribe to live messages (implicitly attaches the channel)
-def on_message(message):
- action = message.action
-
- if action == MessageAction.MESSAGE_CREATE:
- # New response started
- responses[message.serial] = message.data
- elif action == MessageAction.MESSAGE_APPEND:
- # Append token to existing response
- current = responses.get(message.serial, '')
- responses[message.serial] = current + message.data
- elif action == MessageAction.MESSAGE_UPDATE:
- # Replace entire response content
- responses[message.serial] = message.data
-
-await channel.subscribe(on_message)
-```
-```java
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
-// Track responses by message serial
-Map responses = new ConcurrentHashMap<>();
-
-// Subscribe to live messages (implicitly attaches the channel)
-channel.subscribe(message -> {
- switch (message.action) {
- case MessageAction.MESSAGE_CREATE:
- // New response started
- responses.put(message.serial, (String) message.data);
- break;
- case MessageAction.MESSAGE_APPEND:
- // Append token to existing response
- String current = responses.getOrDefault(message.serial, "");
- responses.put(message.serial, current + (String) message.data);
- break;
- case MessageAction.MESSAGE_UPDATE:
- // Replace entire response content
- responses.put(message.serial, (String) message.data);
- break;
- }
-});
-```
-```react
-const [responses, setResponses] = useState(new Map());
-
-// Subscribe to live messages
-useChannel('ai:{{RANDOM_CHANNEL_NAME}}', (message) => {
- setResponses((prev) => {
- const next = new Map(prev);
- switch (message.action) {
- case 'message.create':
- // New response started
- next.set(message.serial, message.data);
- break;
- case 'message.append':
- // Append token to existing response
- next.set(message.serial, (next.get(message.serial) || '') + message.data);
- break;
- case 'message.update':
- // Replace entire response content
- next.set(message.serial, message.data);
- break;
- }
- return next;
- });
-});
-```
-
-
-## Client hydration
-
-When clients connect or reconnect, such as after a page refresh, they often need to catch up on complete responses and individual tokens that were published while they were offline or before they joined.
-
-The message per response pattern enables efficient client state hydration without needing to process every individual token and supports seamlessly transitioning from historical responses to live tokens.
-
-
-
-### Using rewind for recent history
-
-The simplest approach is to use Ably's [rewind](/docs/channels/options/rewind) channel option to attach to the channel at some point in the recent past, and automatically receive all messages since that point. Historical messages are delivered as `message.update` events containing the complete concatenated response, which then seamlessly transition to live `message.append` events for any ongoing responses:
-
-
-```javascript
-// Use rewind to receive recent historical messages
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}', {
- params: { rewind: '2m' } // or rewind: '10' for message count
-});
-
-// Track responses by message serial
-const responses = new Map();
-
-// Subscribe to receive both recent historical and live messages,
-// which are delivered in order to the subscription
-await channel.subscribe((message) => {
- switch (message.action) {
- case 'message.create':
- // New response started
- responses.set(message.serial, message.data);
- break;
- case 'message.append':
- // Append token to existing response
- const current = responses.get(message.serial) || '';
- responses.set(message.serial, current + message.data);
- break;
- case 'message.update':
- // Replace entire response content
- responses.set(message.serial, message.data);
- break;
- }
-});
-```
-```python
-# Use rewind to receive recent historical messages
-channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}', params={'rewind': '2m'}) # or rewind: '10' for message count
-
-# Track responses by message serial
-responses = {}
-
-# Subscribe to receive both recent historical and live messages,
-# which are delivered in order to the subscription
-def on_message(message):
- action = message.action
-
- if action == MessageAction.MESSAGE_CREATE:
- # New response started
- responses[message.serial] = message.data
- elif action == MessageAction.MESSAGE_APPEND:
- # Append token to existing response
- current = responses.get(message.serial, '')
- responses[message.serial] = current + message.data
- elif action == MessageAction.MESSAGE_UPDATE:
- # Replace entire response content
- responses[message.serial] = message.data
-
-await channel.subscribe(on_message)
-```
-```java
-// Use rewind to receive recent historical messages
-ChannelOptions options = new ChannelOptions();
-options.params = Map.of("rewind", "2m"); // or rewind: '10' for message count
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}", options);
-
-// Track responses by message serial
-Map responses = new ConcurrentHashMap<>();
-
-// Subscribe to receive both recent historical and live messages,
-// which are delivered in order to the subscription
-channel.subscribe(message -> {
- switch (message.action) {
- case MessageAction.MESSAGE_CREATE:
- // New response started
- responses.put(message.serial, (String) message.data);
- break;
- case MessageAction.MESSAGE_APPEND:
- // Append token to existing response
- String current = responses.getOrDefault(message.serial, "");
- responses.put(message.serial, current + (String) message.data);
- break;
- case MessageAction.MESSAGE_UPDATE:
- // Replace entire response content
- responses.put(message.serial, (String) message.data);
- break;
- }
-});
-```
-```react
-// Ensure the outer ChannelProvider has options={{ params: { rewind: '2m' } }}
-
-const [responses, setResponses] = useState(new Map());
-
-// Receive both recent historical (via rewind) and live messages
-useChannel('ai:{{RANDOM_CHANNEL_NAME}}', (message) => {
- setResponses((prev) => {
- const next = new Map(prev);
- switch (message.action) {
- case 'message.create':
- next.set(message.serial, message.data);
- break;
- case 'message.append':
- const current = next.get(message.serial) || '';
- next.set(message.serial, current + message.data);
- break;
- case 'message.update':
- next.set(message.serial, message.data);
- break;
- }
- return next;
- });
-});
-```
-
-
-Rewind supports two formats:
-
-- **Time-based**: Use a time interval like `'30s'` or `'2m'` to retrieve messages from that time period
-- **Count-based**: Use a number like `10` or `50` to retrieve the most recent N messages (maximum 100)
-
-
-
-### Using history for older messages
-
-Use [channel history](/docs/storage-history/history) with the [`untilAttach` option](/docs/storage-history/history#continuous-history) to paginate back through history to obtain historical responses, while preserving continuity with the delivery of live tokens:
-
-
-```javascript
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by message serial
-const responses = new Map();
-
-// Subscribe to live messages (implicitly attaches the channel)
-await channel.subscribe((message) => {
- switch (message.action) {
- case 'message.create':
- // New response started
- responses.set(message.serial, message.data);
- break;
- case 'message.append':
- // Append token to existing response
- const current = responses.get(message.serial) || '';
- responses.set(message.serial, current + message.data);
- break;
- case 'message.update':
- // Replace entire response content
- responses.set(message.serial, message.data);
- break;
- }
-});
-
-// Fetch history up until the point of attachment
-let page = await channel.history({ untilAttach: true });
-
-// Paginate through all historical messages
-while (page) {
- for (const message of page.items) {
- // message.data contains the full concatenated text
- responses.set(message.serial, message.data);
- }
-
- // Move to next page if available
- page = page.hasNext() ? await page.next() : null;
-}
-```
-```python
-channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}')
-
-# Track responses by message serial
-responses = {}
-
-# Subscribe to live messages (implicitly attaches the channel)
-def on_message(message):
- action = message.action
-
- if action == MessageAction.MESSAGE_CREATE:
- # New response started
- responses[message.serial] = message.data
- elif action == MessageAction.MESSAGE_APPEND:
- # Append token to existing response
- current = responses.get(message.serial, '')
- responses[message.serial] = current + message.data
- elif action == MessageAction.MESSAGE_UPDATE:
- # Replace entire response content
- responses[message.serial] = message.data
-
-await channel.subscribe(on_message)
-
-# Fetch history up until the point of attachment
-page = await channel.history(until_attach=True)
-
-# Paginate through all historical messages
-while page:
- for message in page.items:
- # message.data contains the full concatenated text
- responses[message.serial] = message.data
-
- # Move to next page if available
- page = await page.next() if page.has_next() else None
-```
-```java
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
-// Track responses by message serial
-Map responses = new ConcurrentHashMap<>();
-
-// Subscribe to live messages (implicitly attaches the channel)
-channel.subscribe(message -> {
- switch (message.action) {
- case MessageAction.MESSAGE_CREATE:
- // New response started
- responses.put(message.serial, (String) message.data);
- break;
- case MessageAction.MESSAGE_APPEND:
- // Append token to existing response
- String current = responses.getOrDefault(message.serial, "");
- responses.put(message.serial, current + (String) message.data);
- break;
- case MessageAction.MESSAGE_UPDATE:
- // Replace entire response content
- responses.put(message.serial, (String) message.data);
- break;
- }
-});
-
-// Fetch history up until the point of attachment
-PaginatedResult page = channel.history(new Param("untilAttach", "true"));
-
-// Paginate through all historical messages
-while (page != null) {
- for (Message message : page.items()) {
- // message.data contains the full concatenated text
- responses.put(message.serial, (String) message.data);
- }
-
- // Move to next page if available
- page = page.hasNext() ? page.next() : null;
-}
-```
-```react
-const [responses, setResponses] = useState(new Map());
-const hydrated = useRef(false);
-
-// Subscribe to live messages and get the history function
-const { history } = useChannel('ai:{{RANDOM_CHANNEL_NAME}}', (message) => {
- setResponses((prev) => {
- const next = new Map(prev);
- switch (message.action) {
- case 'message.create':
- next.set(message.serial, message.data);
- break;
- case 'message.append':
- next.set(message.serial, (next.get(message.serial) || '') + message.data);
- break;
- case 'message.update':
- next.set(message.serial, message.data);
- break;
- }
- return next;
- });
-});
-
-// Fetch history on mount
-useEffect(() => {
- if (hydrated.current) return;
- hydrated.current = true;
-
- (async () => {
- let page = await history({ untilAttach: true });
- while (page) {
- for (const message of page.items) {
- // message.data contains the full concatenated text
- setResponses((prev) => new Map(prev).set(message.serial, message.data));
- }
- page = page.hasNext() ? await page.next() : null;
- }
- })();
-}, [history]);
-```
-
-
-### Hydrating an in-progress response
-
-A common pattern is to persist complete model responses in your database while using Ably for streaming in-progress responses.
-
-The client loads completed responses from your database, then uses Ably to catch up on any response that was still in progress.
-
-You can hydrate in-progress responses using either the [rewind](#rewind) or [history](#history) pattern.
-
-#### Publishing with correlation metadata
-
-To correlate Ably messages with your database records, include the `responseId` in the message [`extras`](/docs/messages#properties) when publishing:
-
-
-```javascript
-// Publish initial message with responseId in extras
-const { serials: [msgSerial] } = await channel.publish({
- name: 'response',
- data: '',
- extras: {
- headers: {
- responseId: 'resp_abc123' // Your database response ID
- }
- }
-});
-
-// Append tokens, including extras to preserve headers
-for await (const event of stream) {
- if (event.type === 'token') {
- channel.appendMessage({ serial: msgSerial, data: event.text, extras: {
- headers: {
- responseId: 'resp_abc123'
- }
- }});
- }
-}
-```
-```python
-# Publish initial message with responseId in extras
-message = Message(
- name='response',
- data='',
- extras={
- 'headers': {
- 'responseId': 'resp_abc123' # Your database response ID
- }
- }
-)
-result = await channel.publish(message)
-msg_serial = result.serials[0]
-
-# Append tokens, including extras to preserve headers
-async for event in stream:
- if event['type'] == 'token':
- asyncio.create_task(channel.append_message(
- serial=msg_serial,
- data=event['text'],
- extras={
- 'headers': {
- 'responseId': 'resp_abc123'
- }
- }
- ))
-```
-```java
-// Publish initial message with responseId in extras
-JsonObject extras = new JsonObject();
-JsonObject headers = new JsonObject();
-headers.addProperty("responseId", "resp_abc123"); // Your database response ID
-extras.add("headers", headers);
-
-CompletableFuture publishFuture = new CompletableFuture<>();
-channel.publish("response", "", extras, new Callback() {
- @Override
- public void onSuccess(PublishResult result) {
- publishFuture.complete(result);
- }
-
- @Override
- public void onError(ErrorInfo reason) {
- publishFuture.completeExceptionally(AblyException.fromErrorInfo(reason));
- }
-});
-String msgSerial = publishFuture.get().serials[0];
-
-// Append tokens, including extras to preserve headers
-for (Event event : stream) {
- if (event.getType().equals("token")) {
- JsonObject appendExtras = new JsonObject();
- JsonObject appendHeaders = new JsonObject();
- appendHeaders.addProperty("responseId", "resp_abc123");
- appendExtras.add("headers", appendHeaders);
-
- channel.appendMessage(msgSerial, event.getText(), appendExtras);
- }
-}
-```
-
-
-
-
-#### Hydrate using rewind
-
-When hydrating, load completed responses from your database, then use [rewind](/docs/channels/options/rewind) to catch up on any in-progress response. Check the `responseId` from message extras to skip responses already loaded from your database:
-
-
-```javascript
-// Load completed responses from your database
-// completedResponses is a Set of responseIds
-const completedResponses = await loadResponsesFromDatabase();
-
-// Use rewind to receive recent historical messages
-const channel = realtime.channels.get('ai:responses', {
- params: { rewind: '2m' }
-});
-
-// Track in-progress responses by responseId
-const inProgressResponses = new Map();
-
-await channel.subscribe((message) => {
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) {
- console.warn('Message missing responseId');
- return;
- }
-
- // Skip messages for responses already loaded from database
- if (completedResponses.has(responseId)) {
- return;
- }
-
- switch (message.action) {
- case 'message.create':
- // New response started
- inProgressResponses.set(responseId, message.data);
- break;
- case 'message.append':
- // Append token to existing response
- const current = inProgressResponses.get(responseId) || '';
- inProgressResponses.set(responseId, current + message.data);
- break;
- case 'message.update':
- // Replace entire response content
- inProgressResponses.set(responseId, message.data);
- break;
- }
-});
-```
-```python
-# Load completed responses from your database
-# completed_responses is a set of responseIds
-completed_responses = await load_responses_from_database()
-
-# Use rewind to receive recent historical messages
-channel = realtime.channels.get('ai:responses', params={'rewind': '2m'})
-
-# Track in-progress responses by responseId
-in_progress_responses = {}
-
-def on_message(message):
- response_id = message.extras.get('headers', {}).get('responseId')
-
- if not response_id:
- print('Message missing responseId')
- return
-
- # Skip messages for responses already loaded from database
- if response_id in completed_responses:
- return
-
- action = message.action
-
- if action == MessageAction.MESSAGE_CREATE:
- # New response started
- in_progress_responses[response_id] = message.data
- elif action == MessageAction.MESSAGE_APPEND:
- # Append token to existing response
- current = in_progress_responses.get(response_id, '')
- in_progress_responses[response_id] = current + message.data
- elif action == MessageAction.MESSAGE_UPDATE:
- # Replace entire response content
- in_progress_responses[response_id] = message.data
-
-await channel.subscribe(on_message)
-```
-```java
-// Load completed responses from your database
-// completedResponses is a Set of responseIds
-Set completedResponses = loadResponsesFromDatabase();
-
-// Use rewind to receive recent historical messages
-ChannelOptions options = new ChannelOptions();
-options.params = Map.of("rewind", "2m");
-Channel channel = realtime.channels.get("ai:responses", options);
-
-// Track in-progress responses by responseId
-Map inProgressResponses = new ConcurrentHashMap<>();
-
-channel.subscribe(message -> {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers != null ? headers.get("responseId").getAsString() : null;
-
- if (responseId == null) {
- System.err.println("Message missing responseId");
- return;
- }
-
- // Skip messages for responses already loaded from database
- if (completedResponses.contains(responseId)) {
- return;
- }
-
- switch (message.action) {
- case MessageAction.MESSAGE_CREATE:
- // New response started
- inProgressResponses.put(responseId, (String) message.data);
- break;
- case MessageAction.MESSAGE_APPEND:
- // Append token to existing response
- String current = inProgressResponses.getOrDefault(responseId, "");
- inProgressResponses.put(responseId, current + (String) message.data);
- break;
- case MessageAction.MESSAGE_UPDATE:
- // Replace entire response content
- inProgressResponses.put(responseId, (String) message.data);
- break;
- }
-});
-```
-```react
-// Ensure the outer ChannelProvider has options={{ params: { rewind: '2m' } }}
-
-// Load completed responses from your database (Set of responseIds)
-const completedResponses = useCompletedResponses();
-
-const [inProgressResponses, setInProgressResponses] = useState(new Map());
-
-// Receive both recent historical and live messages
-useChannel('ai:responses', (message) => {
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) return;
-
- // Skip messages for responses already loaded from database
- if (completedResponses.has(responseId)) return;
-
- setInProgressResponses((prev) => {
- const next = new Map(prev);
- switch (message.action) {
- case 'message.create':
- next.set(responseId, message.data);
- break;
- case 'message.append':
- next.set(responseId, (next.get(responseId) || '') + message.data);
- break;
- case 'message.update':
- next.set(responseId, message.data);
- break;
- }
- return next;
- });
-});
-```
-
-
-
-
-#### Hydrate using history
-
-Load completed responses from your database, then use [channel history](/docs/storage-history/history) with the [`untilAttach` option](/docs/storage-history/history#continuous-history) to catch up on any in-progress responses. Use the timestamp of the last completed response as a lower bound, so that only messages after that timestamp are retrieved, ensuring continuity with live message delivery.
-
-
-```javascript
-// Load completed responses from database (sorted by timestamp, oldest first)
-const completedResponses = await loadResponsesFromDatabase();
-
-// Get the timestamp of the latest completed response
-const latestTimestamp = completedResponses.latest().timestamp;
-
-const channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}');
-
-// Track in progress responses by ID
-const inProgressResponses = new Map();
-
-// Subscribe to live messages (implicitly attaches)
-await channel.subscribe((message) => {
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) {
- console.warn('Message missing responseId');
- return;
- }
-
- // Skip messages for responses already loaded from database
- if (completedResponses.has(responseId)) {
- return;
- }
-
- switch (message.action) {
- case 'message.create':
- // New response started
- inProgressResponses.set(responseId, message.data);
- break;
- case 'message.append':
- // Append token to existing response
- const current = inProgressResponses.get(responseId) || '';
- inProgressResponses.set(responseId, current + message.data);
- break;
- case 'message.update':
- // Replace entire response content
- inProgressResponses.set(responseId, message.data);
- break;
- }
-});
-
-// Fetch history from the last completed response until attachment
-let page = await channel.history({
- untilAttach: true,
- start: latestTimestamp,
-});
-
-// Paginate through all missed messages
-while (page) {
- for (const message of page.items) {
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) {
- console.warn('Message missing responseId');
- continue;
- }
-
- // message.data contains the full concatenated text so far
- inProgressResponses.set(responseId, message.data);
- }
-
- // Move to next page if available
- page = page.hasNext() ? await page.next() : null;
-}
-```
-```python
-# Load completed responses from database (sorted by timestamp, oldest first)
-completed_responses = await load_responses_from_database()
-
-# Get the timestamp of the latest completed response
-latest_timestamp = completed_responses.latest().timestamp
-
-channel = realtime.channels.get('ai:{{RANDOM_CHANNEL_NAME}}')
-
-# Track in progress responses by ID
-in_progress_responses = {}
-
-# Subscribe to live messages (implicitly attaches)
-def on_message(message):
- response_id = message.extras.get('headers', {}).get('responseId')
-
- if not response_id:
- print('Message missing responseId')
- return
-
- # Skip messages for responses already loaded from database
- if response_id in completed_responses:
- return
-
- action = message.action
-
- if action == MessageAction.MESSAGE_CREATE:
- # New response started
- in_progress_responses[response_id] = message.data
- elif action == MessageAction.MESSAGE_APPEND:
- # Append token to existing response
- current = in_progress_responses.get(response_id, '')
- in_progress_responses[response_id] = current + message.data
- elif action == MessageAction.MESSAGE_UPDATE:
- # Replace entire response content
- in_progress_responses[response_id] = message.data
-
-await channel.subscribe(on_message)
-
-# Fetch history from the last completed response until attachment
-page = await channel.history(
- until_attach=True,
- start=latest_timestamp,
-)
-
-# Paginate through all missed messages
-while page:
- for message in page.items:
- response_id = message.extras.get('headers', {}).get('responseId')
-
- if not response_id:
- print('Message missing responseId')
- continue
-
- # message.data contains the full concatenated text so far
- in_progress_responses[response_id] = message.data
-
- # Move to next page if available
- page = await page.next() if page.has_next() else None
-```
-```java
-// Load completed responses from database (sorted by timestamp, oldest first)
-List completedResponses = loadResponsesFromDatabase();
-
-// Get the timestamp of the latest completed response
-long latestTimestamp = completedResponses.get(completedResponses.size() - 1).getTimestamp();
-
-Channel channel = realtime.channels.get("ai:{{RANDOM_CHANNEL_NAME}}");
-
-// Track in progress responses by ID
-Map inProgressResponses = new ConcurrentHashMap<>();
-
-// Subscribe to live messages (implicitly attaches)
-channel.subscribe(message -> {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers != null ? headers.get("responseId").getAsString() : null;
-
- if (responseId == null) {
- System.err.println("Message missing responseId");
- return;
- }
-
- // Skip messages for responses already loaded from database
- if (completedResponses.stream().anyMatch(r -> r.getId().equals(responseId))) {
- return;
- }
-
- switch (message.action) {
- case MessageAction.MESSAGE_CREATE:
- // New response started
- inProgressResponses.put(responseId, (String) message.data);
- break;
- case MessageAction.MESSAGE_APPEND:
- // Append token to existing response
- String current = inProgressResponses.getOrDefault(responseId, "");
- inProgressResponses.put(responseId, current + (String) message.data);
- break;
- case MessageAction.MESSAGE_UPDATE:
- // Replace entire response content
- inProgressResponses.put(responseId, (String) message.data);
- break;
- }
-});
-
-// Fetch history from the last completed response until attachment
-PaginatedResult page = channel.history(
- new Param("untilAttach", "true"),
- new Param("start", String.valueOf(latestTimestamp))
-);
-
-// Paginate through all missed messages
-while (page != null) {
- for (Message message : page.items()) {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers != null ? headers.get("responseId").getAsString() : null;
-
- if (responseId == null) {
- System.err.println("Message missing responseId");
- continue;
- }
-
- // message.data contains the full concatenated text so far
- inProgressResponses.put(responseId, (String) message.data);
- }
-
- // Move to next page if available
- page = page.hasNext() ? page.next() : null;
-}
-```
-```react
-// Load completed responses and latest timestamp from your database
-const { completedResponses, latestTimestamp } = useCompletedResponses();
-
-const [inProgressResponses, setInProgressResponses] = useState(new Map());
-const hydrated = useRef(false);
-
-// Subscribe to live messages and get the history function
-const { history } = useChannel('ai:{{RANDOM_CHANNEL_NAME}}', (message) => {
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) return;
- if (completedResponses.has(responseId)) return;
-
- setInProgressResponses((prev) => {
- const next = new Map(prev);
- switch (message.action) {
- case 'message.create':
- next.set(responseId, message.data);
- break;
- case 'message.append':
- next.set(responseId, (next.get(responseId) || '') + message.data);
- break;
- case 'message.update':
- next.set(responseId, message.data);
- break;
- }
- return next;
- });
-});
-
-// Fetch history from the last completed response until attachment
-useEffect(() => {
- if (hydrated.current) return;
- hydrated.current = true;
-
- (async () => {
- let page = await history({ untilAttach: true, start: latestTimestamp });
- while (page) {
- for (const message of page.items) {
- const responseId = message.extras?.headers?.responseId;
- if (responseId) {
- setInProgressResponses((prev) => new Map(prev).set(responseId, message.data));
- }
- }
- page = page.hasNext() ? await page.next() : null;
- }
- })();
-}, [history]);
-```
-
-
-
diff --git a/src/pages/docs/ai-transport/token-streaming/message-per-token.mdx b/src/pages/docs/ai-transport/token-streaming/message-per-token.mdx
deleted file mode 100644
index dfe7c012e9..0000000000
--- a/src/pages/docs/ai-transport/token-streaming/message-per-token.mdx
+++ /dev/null
@@ -1,1345 +0,0 @@
----
-title: Message per token
-meta_description: "Stream individual tokens from AI models as separate messages over Ably."
----
-
-Token streaming with message-per-token is a pattern where every token generated by your model is published as an independent Ably message. Each token then appears as one message in the channel history. This uses [Ably Pub/Sub](/docs/basics) for realtime communication between agents and clients.
-
-This pattern is useful when clients only care about the most recent part of a response and you are happy to treat the channel history as a short sliding window rather than a full conversation log. For example:
-
-- Backend-stored responses: The backend writes complete responses to a database and clients load those full responses from there, while Ably is used only to deliver live tokens for the current in-progress response.
-- Live transcription, captioning, or translation: A viewer who joins a live stream only needs sufficient tokens for the current "frame" of subtitles, not the entire transcript so far.
-- Code assistance in an editor: Streamed tokens become part of the file on disk as they are accepted, so past tokens do not need to be replayed from Ably.
-- Autocomplete: A fresh response is streamed for each change a user makes to a document, with only the latest suggestion being relevant.
-
-## Publishing tokens
-
-Publish tokens from a [Realtime](/docs/api/realtime-sdk) client, which maintains a persistent connection to the Ably service. This allows you to publish at very high message rates with the lowest possible latencies, while preserving guarantees around message delivery order. For more information, see [Realtime and REST](/docs/basics#realtime-and-rest).
-
-[Channels](/docs/channels) separate message traffic into different topics. For token streaming, each conversation or session typically has its own channel.
-
-Use the [`get()`](/docs/api/realtime-sdk/channels#get) method to create or retrieve a channel instance:
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-```
-
-
-When publishing tokens, don't await the `channel.publish()` call. Ably rolls up acknowledgments and debounces them for efficiency, which means awaiting each publish would unnecessarily slow down your token stream. Messages are still published in the order that `publish()` is called, so delivery order is not affected.
-
-
-```javascript
-// ✅ Do this - publish without await for maximum throughput
-for await (const event of stream) {
- if (event.type === 'token') {
- channel.publish('token', event.text);
- }
-}
-
-// ❌ Don't do this - awaiting each publish reduces throughput
-for await (const event of stream) {
- if (event.type === 'token') {
- await channel.publish('token', event.text);
- }
-}
-```
-```python
-# ✅ Do this - use create_task for fire-and-forget throughput
-async for event in stream:
- if event['type'] == 'token':
- message = Message(name='token', data=event['text'])
- asyncio.create_task(channel.publish(message))
-
-# ❌ Don't do this - awaiting each publish reduces throughput
-async for event in stream:
- if event['type'] == 'token':
- message = Message(name='token', data=event['text'])
- await channel.publish(message)
-```
-```java
-// ✅ Do this - publish without blocking for maximum throughput
-for (Event event : stream) {
- if (event.getType().equals("token")) {
- channel.publish("token", event.getText());
- }
-}
-
-// ❌ Don't do this - blocking on each publish reduces throughput
-for (Event event : stream) {
- if (event.getType().equals("token")) {
- channel.publish("token", event.getText()).get(); // blocking call
- }
-}
-```
-
-
-This approach maximizes throughput while maintaining ordering guarantees, allowing you to stream tokens as fast as your AI model generates them.
-
-### Handling publish failures
-
-The examples above publish successive tokens by pipelining the publish operations — that is, the agent publishes a token without waiting for prior operations to complete. This is necessary in order to avoid the publish rate being capped by the round-trip time from the agent to the Ably endpoint. However, this means that the agent does not await the outcome of each publish operation, and that can result in the agent continuing to publish tokens after an earlier publish has failed. For example, if a rate limit is exceeded, a single token may be rejected while the following tokens continue to be accepted.
-
-The agent needs to obtain the outcome of each publish operation, and take corrective action in the event that any operation failed for some reason. A simple but effective way to do this is to ensure that, if streaming fails for any reason, a recovery message containing the complete response text is published once it is available. This means that although the streaming experience is disrupted in the case of failure, subscribers can replace their accumulated tokens with the complete response.
-
-To detect publish failures, keep a reference to each publish operation and check for rejections after the stream completes:
-
-
-```javascript
-// Track all publish operations
-const publishPromises = [];
-
-// Track the full response for recovery
-let fullResponse = '';
-
-for await (const event of stream) {
- if (event.type === 'token') {
- fullResponse += event.text;
- publishPromises.push(channel.publish('token', event.text));
- }
-}
-
-// Check for any failures after the stream completes
-const results = await Promise.allSettled(publishPromises);
-const failed = results.some(result => result.status === 'rejected');
-
-if (failed) {
- // Publish the complete response as a recovery message
- await channel.publish('response-complete', fullResponse);
-}
-```
-
-```python
-# Track all publish tasks
-publish_tasks = []
-
-# Track the full response for recovery
-full_response = ''
-
-async for event in stream:
- if event['type'] == 'token':
- full_response += event['text']
- publish_tasks.append(
- asyncio.create_task(channel.publish(Message(name='token', data=event['text'])))
- )
-
-# Check for any failures after the stream completes
-results = await asyncio.gather(*publish_tasks, return_exceptions=True)
-failed = any(isinstance(r, Exception) for r in results)
-
-if failed:
- # Publish the complete response as a recovery message
- await channel.publish(Message(name='response-complete', data=full_response))
-```
-
-```java
-// Track all publish operations
-List> publishFutures = new ArrayList<>();
-
-// Track the full response for recovery
-StringBuilder fullResponse = new StringBuilder();
-
-for (Event event : stream) {
- if (event.getType().equals("token")) {
- fullResponse.append(event.getText());
- CompletableFuture future = new CompletableFuture<>();
- channel.publish("token", event.getText(), new Callback() {
- @Override
- public void onSuccess(PublishResult result) {
- future.complete(null);
- }
-
- @Override
- public void onError(ErrorInfo reason) {
- future.completeExceptionally(AblyException.fromErrorInfo(reason));
- }
- });
- publishFutures.add(future);
- }
-}
-
-// Check for any failures after the stream completes
-boolean failed = false;
-for (CompletableFuture future : publishFutures) {
- try {
- future.get();
- } catch (Exception e) {
- failed = true;
- }
-}
-
-if (failed) {
- // Publish the complete response as a recovery message
- channel.publish("response-complete", fullResponse.toString());
-}
-```
-
-
-If any publish fails, publish a new message with a different event type (such as `response-complete`) containing the full response. Subscribers should handle this event by replacing any tokens they have accumulated for that response:
-
-
-```javascript
-let response = '';
-
-// Handle individual tokens
-await channel.subscribe('token', (message) => {
- response += message.data;
-});
-
-// Handle recovery message that replaces all previous tokens
-await channel.subscribe('response-complete', (message) => {
- response = message.data;
-});
-```
-
-```python
-response = ''
-
-# Handle individual tokens
-def on_token(message):
- global response
- response += message.data
-
-await channel.subscribe('token', on_token)
-
-# Handle recovery message that replaces all previous tokens
-def on_recovery(message):
- global response
- response = message.data
-
-await channel.subscribe('response-complete', on_recovery)
-```
-
-```java
-StringBuilder response = new StringBuilder();
-
-// Handle individual tokens
-channel.subscribe("token", message -> {
- response.append((String) message.data);
-});
-
-// Handle recovery message that replaces all previous tokens
-channel.subscribe("response-complete", message -> {
- response.setLength(0);
- response.append((String) message.data);
-});
-```
-
-```react
-const [response, setResponse] = useState('');
-
-// Handle individual tokens and recovery messages
-useChannel('{{RANDOM_CHANNEL_NAME}}', (message) => {
- if (message.name === 'token') {
- setResponse((prev) => prev + message.data);
- } else if (message.name === 'response-complete') {
- // Recovery message replaces all previous tokens
- setResponse(message.data);
- }
-});
-```
-
-
-When streaming multiple concurrent responses, include a `responseId` in message [`extras`](/docs/messages#properties) so subscribers can correctly associate the recovery message with the tokens it replaces. See [token stream with multiple responses](#multiple-responses) for details on correlating messages.
-
-
-
-
-
-## Streaming patterns
-
-
-
-
-
-Ably is a pub/sub messaging platform, so you can structure your messages however works best for your application. Below are common patterns for streaming tokens, each showing both agent-side publishing and client-side subscription. Choose the approach that fits your use case, or create your own variation.
-
-### Continuous token stream
-
-For simple streaming scenarios such as live transcription, where all tokens are part of a continuous stream, simply publish each token as a message.
-
-#### Publish tokens
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Example: stream returns events like { type: 'token', text: 'Hello' }
-for await (const event of stream) {
- if (event.type === 'token') {
- channel.publish('token', event.text);
- }
-}
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-# Example: stream returns events like { 'type': 'token', 'text': 'Hello' }
-async for event in stream:
- if event['type'] == 'token':
- asyncio.create_task(channel.publish('token', event['text']))
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Example: stream returns events like { type: 'token', text: 'Hello' }
-for (Event event : stream) {
- if (event.getType().equals("token")) {
- channel.publish("token", event.getText());
- }
-}
-```
-
-
-#### Subscribe to tokens
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Subscribe to token messages
-await channel.subscribe('token', (message) => {
- const token = message.data;
- console.log(token); // log each token as it arrives
-});
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-# Subscribe to token messages
-def on_token(message):
- token = message.data
- print(token) # log each token as it arrives
-
-await channel.subscribe('token', on_token)
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Subscribe to token messages
-channel.subscribe("token", message -> {
- String token = (String) message.data;
- System.out.println(token); // log each token as it arrives
-});
-```
-
-```react
-const [response, setResponse] = useState('');
-
-// Subscribe to token messages
-useChannel('{{RANDOM_CHANNEL_NAME}}', 'token', (message) => {
- const token = message.data;
- setResponse((prev) => prev + token);
-});
-```
-
-
-This pattern is simple and works well when you're displaying a single, continuous stream of tokens.
-
-### Token stream with multiple responses
-
-For applications with multiple responses, such as chat conversations, include a `responseId` in message [`extras`](/docs/messages#properties) to correlate tokens together that belong to the same response.
-
-#### Publish tokens
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Example: stream returns events like { type: 'token', text: 'Hello', responseId: 'resp_abc123' }
-for await (const event of stream) {
- if (event.type === 'token') {
- channel.publish({
- name: 'token',
- data: event.text,
- extras: {
- headers: {
- responseId: event.responseId
- }
- }
- });
- }
-}
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-# Example: stream returns events like { 'type': 'token', 'text': 'Hello', 'responseId': 'resp_abc123' }
-async for event in stream:
- if event['type'] == 'token':
- asyncio.create_task(channel.publish(Message(
- name='token',
- data=event['text'],
- extras={
- 'headers': {
- 'responseId': event['responseId']
- }
- }
- )))
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Example: stream returns events like { type: 'token', text: 'Hello', responseId: 'resp_abc123' }
-for (Event event : stream) {
- if (event.getType().equals("token")) {
- JsonObject extras = new JsonObject();
- JsonObject headers = new JsonObject();
- headers.addProperty("responseId", event.getResponseId());
- extras.add("headers", headers);
-
- channel.publish(new Message("token", event.getText(), new MessageExtras(extras)));
- }
-}
-```
-
-
-#### Subscribe to tokens
-
-Use the `responseId` header in message extras to correlate tokens. The `responseId` allows you to group tokens belonging to the same response and correctly handle token delivery for distinct responses, even when delivered concurrently.
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Track responses by ID
-const responses = new Map();
-
-await channel.subscribe('token', (message) => {
- const token = message.data;
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) {
- console.warn('Token missing responseId');
- return;
- }
-
- // Create an empty response
- if (!responses.has(responseId)) {
- responses.set(responseId, '');
- }
-
- // Append token to response
- responses.set(responseId, responses.get(responseId) + token);
-});
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-# Track responses by ID
-responses = {}
-
-def on_token(message):
- token = message.data
- response_id = message.extras.get('headers', {}).get('responseId')
-
- if not response_id:
- print('Token missing responseId')
- return
-
- # Create an empty response
- if response_id not in responses:
- responses[response_id] = ''
-
- # Append token to response
- responses[response_id] += token
-
-await channel.subscribe('token', on_token)
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Track responses by ID
-Map responses = new ConcurrentHashMap<>();
-
-channel.subscribe("token", message -> {
- String token = (String) message.data;
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers != null ? headers.get("responseId").getAsString() : null;
-
- if (responseId == null) {
- System.err.println("Token missing responseId");
- return;
- }
-
- // Create an empty response
- responses.putIfAbsent(responseId, "");
-
- // Append token to response
- responses.put(responseId, responses.get(responseId) + token);
-});
-```
-
-```react
-const [responses, setResponses] = useState(new Map());
-
-// Track responses by ID
-useChannel('{{RANDOM_CHANNEL_NAME}}', 'token', (message) => {
- const token = message.data;
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) return;
-
- setResponses((prev) => {
- const next = new Map(prev);
- next.set(responseId, (next.get(responseId) || '') + token);
- return next;
- });
-});
-```
-
-
-### Token stream with explicit start/stop events
-
-In some cases, your AI model response stream may include explicit events to mark response boundaries. You can indicate the event type, such as a response start/stop event, using the Ably message [`name`](/docs/messages#properties).
-
-#### Publish tokens
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-// Example: stream returns events like:
-// { type: 'message_start', responseId: 'resp_abc123' }
-// { type: 'message_delta', responseId: 'resp_abc123', text: 'Hello' }
-// { type: 'message_stop', responseId: 'resp_abc123' }
-
-for await (const event of stream) {
- if (event.type === 'message_start') {
- // Publish response start
- channel.publish({
- name: 'start',
- extras: {
- headers: {
- responseId: event.responseId
- }
- }
- });
- } else if (event.type === 'message_delta') {
- // Publish tokens
- channel.publish({
- name: 'token',
- data: event.text,
- extras: {
- headers: {
- responseId: event.responseId
- }
- }
- });
- } else if (event.type === 'message_stop') {
- // Publish response stop
- channel.publish({
- name: 'stop',
- extras: {
- headers: {
- responseId: event.responseId
- }
- }
- });
- }
-}
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-# Example: stream returns events like:
-# { 'type': 'message_start', 'responseId': 'resp_abc123' }
-# { 'type': 'message_delta', 'responseId': 'resp_abc123', 'text': 'Hello' }
-# { 'type': 'message_stop', 'responseId': 'resp_abc123' }
-
-async for event in stream:
- if event['type'] == 'message_start':
- # Publish response start
- asyncio.create_task(channel.publish(Message(
- name='start',
- extras={
- 'headers': {
- 'responseId': event['responseId']
- }
- }
- )))
- elif event['type'] == 'message_delta':
- # Publish tokens
- asyncio.create_task(channel.publish(Message(
- name='token',
- data=event['text'],
- extras={
- 'headers': {
- 'responseId': event['responseId']
- }
- }
- )))
- elif event['type'] == 'message_stop':
- # Publish response stop
- asyncio.create_task(channel.publish(Message(
- name='stop',
- extras={
- 'headers': {
- 'responseId': event['responseId']
- }
- }
- )))
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-// Example: stream returns events like:
-// { type: 'message_start', responseId: 'resp_abc123' }
-// { type: 'message_delta', responseId: 'resp_abc123', text: 'Hello' }
-// { type: 'message_stop', responseId: 'resp_abc123' }
-
-for (Event event : stream) {
- JsonObject extras = new JsonObject();
- JsonObject headers = new JsonObject();
- headers.addProperty("responseId", event.getResponseId());
- extras.add("headers", headers);
-
- if (event.getType().equals("message_start")) {
- // Publish response start
- channel.publish(new Message("start", null, new MessageExtras(extras)));
- } else if (event.getType().equals("message_delta")) {
- // Publish tokens
- channel.publish(new Message("token", event.getText(), new MessageExtras(extras)));
- } else if (event.getType().equals("message_stop")) {
- // Publish response stop
- channel.publish(new Message("stop", null, new MessageExtras(extras)));
- }
-}
-```
-
-
-#### Subscribe to tokens
-
-Handle each event type to manage response lifecycle:
-
-
-```javascript
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}');
-
-const responses = new Map();
-
-// Handle response start
-await channel.subscribe('start', (message) => {
- const responseId = message.extras?.headers?.responseId;
- responses.set(responseId, '');
-});
-
-// Handle tokens
-await channel.subscribe('token', (message) => {
- const responseId = message.extras?.headers?.responseId;
- const token = message.data;
-
- const currentText = responses.get(responseId) || '';
- responses.set(responseId, currentText + token);
-});
-
-// Handle response stop
-await channel.subscribe('stop', (message) => {
- const responseId = message.extras?.headers?.responseId;
- const finalText = responses.get(responseId);
- console.log('Response complete:', finalText);
-});
-```
-```python
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}')
-
-responses = {}
-
-# Handle response start
-def on_start(message):
- response_id = message.extras.get('headers', {}).get('responseId')
- responses[response_id] = ''
-
-await channel.subscribe('start', on_start)
-
-# Handle tokens
-def on_token(message):
- response_id = message.extras.get('headers', {}).get('responseId')
- token = message.data
-
- current_text = responses.get(response_id, '')
- responses[response_id] = current_text + token
-
-await channel.subscribe('token', on_token)
-
-# Handle response stop
-def on_stop(message):
- response_id = message.extras.get('headers', {}).get('responseId')
- final_text = responses.get(response_id)
- print('Response complete:', final_text)
-
-await channel.subscribe('stop', on_stop)
-```
-```java
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}");
-
-Map responses = new ConcurrentHashMap<>();
-
-// Handle response start
-channel.subscribe("start", message -> {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers.get("responseId").getAsString();
- responses.put(responseId, "");
-});
-
-// Handle tokens
-channel.subscribe("token", message -> {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers.get("responseId").getAsString();
- String token = (String) message.data;
-
- String currentText = responses.getOrDefault(responseId, "");
- responses.put(responseId, currentText + token);
-});
-
-// Handle response stop
-channel.subscribe("stop", message -> {
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers.get("responseId").getAsString();
- String finalText = responses.get(responseId);
- System.out.println("Response complete: " + finalText);
-});
-```
-
-```react
-const [responses, setResponses] = useState(new Map());
-
-// Handle response lifecycle events
-useChannel('{{RANDOM_CHANNEL_NAME}}', (message) => {
- const responseId = message.extras?.headers?.responseId;
-
- if (message.name === 'start') {
- setResponses((prev) => new Map(prev).set(responseId, ''));
- } else if (message.name === 'token') {
- setResponses((prev) => {
- const next = new Map(prev);
- next.set(responseId, (next.get(responseId) || '') + message.data);
- return next;
- });
- } else if (message.name === 'stop') {
- setResponses((prev) => {
- console.log('Response complete:', prev.get(responseId));
- return prev;
- });
- }
-});
-```
-
-
-## Client hydration
-
-When clients connect or reconnect, such as after a page refresh, they often need to catch up on tokens that were published while they were offline or before they joined. Ably provides several approaches to hydrate client state depending on your application's requirements.
-
-
-
-
-
-### Using rewind for recent history
-
-The simplest approach is to use Ably's [rewind](/docs/channels/options/rewind) channel option to attach to the channel at some point in the recent past, and automatically receive all tokens since that point:
-
-
-```javascript
-// Use rewind to receive recent historical messages
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}', {
- params: { rewind: '2m' } // or rewind: 100 for message count
-});
-
-// Subscribe to receive both recent historical and live messages,
-// which are delivered in order to the subscription
-await channel.subscribe('token', (message) => {
- const token = message.data;
-
- // Process tokens from both recent history and live stream
- console.log('Token received:', token);
-});
-```
-```python
-# Use rewind to receive recent historical messages
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}', params={'rewind': '2m'}) # or rewind: 100 for message count
-
-# Subscribe to receive both recent historical and live messages,
-# which are delivered in order to the subscription
-def on_token(message):
- token = message.data
-
- # Process tokens from both recent history and live stream
- print('Token received:', token)
-
-await channel.subscribe('token', on_token)
-```
-```java
-// Use rewind to receive recent historical messages
-ChannelOptions options = new ChannelOptions();
-options.params = Map.of("rewind", "2m"); // or rewind: 100 for message count
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}", options);
-
-// Subscribe to receive both recent historical and live messages,
-// which are delivered in order to the subscription
-channel.subscribe("token", message -> {
- String token = (String) message.data;
-
- // Process tokens from both recent history and live stream
- System.out.println("Token received: " + token);
-});
-```
-
-```react
-// Ensure the outer ChannelProvider has options={{ params: { rewind: '2m' } }}
-
-const [response, setResponse] = useState('');
-
-// Receive both recent historical and live messages
-useChannel('{{RANDOM_CHANNEL_NAME}}', 'token', (message) => {
- const token = message.data;
- setResponse((prev) => prev + token);
-});
-```
-
-
-Rewind supports two formats:
-
-- **Time-based**: Use a time interval like `'30s'` or `'2m'` to retrieve messages from that time period
-- **Count-based**: Use a number like `50` or `100` to retrieve the most recent N messages (maximum 100)
-
-
-
-By default, rewind is limited to the last 2 minutes of messages. This is usually sufficient for scenarios where clients need only recent context, such as for continuous token streaming, or when the response stream from a given model request does not exceed 2 minutes. If you need more than 2 minutes of history, see [Using history for longer persistence](#history).
-
-### Using history for older messages
-
-For applications that need to retrieve tokens beyond the 2-minute rewind window, enable [persistence](/docs/storage-history/storage#all-message-persistence) on your channel. Use [channel history](/docs/storage-history/history) with the [`untilAttach` option](/docs/storage-history/history#continuous-history) to paginate back through history to obtain historical tokens, while preserving continuity with the delivery of live tokens:
-
-
-```javascript
-// Use a channel in a namespace called 'persisted', which has persistence enabled
-const channel = realtime.channels.get('persisted:{{RANDOM_CHANNEL_NAME}}');
-
-let response = '';
-
-// Subscribe to live messages (implicitly attaches the channel)
-await channel.subscribe('token', (message) => {
- // Append the token to the end of the response
- response += message.data;
-});
-
-// Fetch history up until the point of attachment
-let page = await channel.history({ untilAttach: true });
-
-// Paginate backwards through history
-while (page) {
- // Messages are newest-first, so prepend them to response
- for (const message of page.items) {
- response = message.data + response;
- }
-
- // Move to next page if available
- page = page.hasNext() ? await page.next() : null;
-}
-```
-```python
-# Use a channel in a namespace called 'persisted', which has persistence enabled
-channel = realtime.channels.get('persisted:{{RANDOM_CHANNEL_NAME}}')
-
-response = ''
-
-# Subscribe to live messages (implicitly attaches the channel)
-def on_token(message):
- global response
- # Append the token to the end of the response
- response += message.data
-
-await channel.subscribe('token', on_token)
-
-# Fetch history up until the point of attachment
-page = await channel.history(until_attach=True)
-
-# Paginate backwards through history
-while page:
- # Messages are newest-first, so prepend them to response
- for message in page.items:
- response = message.data + response
-
- # Move to next page if available
- page = await page.next() if page.has_next() else None
-```
-```java
-// Use a channel in a namespace called 'persisted', which has persistence enabled
-Channel channel = realtime.channels.get("persisted:{{RANDOM_CHANNEL_NAME}}");
-
-StringBuilder response = new StringBuilder();
-
-// Subscribe to live messages (implicitly attaches the channel)
-channel.subscribe("token", message -> {
- // Append the token to the end of the response
- response.append((String) message.data);
-});
-
-// Fetch history up until the point of attachment
-PaginatedResult page = channel.history(new Param("untilAttach", "true"));
-
-// Paginate backwards through history
-while (page != null) {
- // Messages are newest-first, so prepend them to response
- for (Message message : page.items()) {
- response.insert(0, (String) message.data);
- }
-
- // Move to next page if available
- page = page.hasNext() ? page.next() : null;
-}
-```
-
-```react
-// Ensure the outer ChannelProvider uses a channel in a 'persisted' namespace, which has persistence enabled
-
-const [response, setResponse] = useState('');
-const hydrated = useRef(false);
-
-// Subscribe to live messages and get the history function
-const { history } = useChannel('persisted:{{RANDOM_CHANNEL_NAME}}', 'token', (message) => {
- setResponse((prev) => prev + message.data);
-});
-
-// Fetch history on mount
-useEffect(() => {
- if (hydrated.current) return;
- hydrated.current = true;
-
- (async () => {
- let historicalTokens = '';
- let page = await history({ untilAttach: true });
- while (page) {
- // Messages are newest-first, so prepend them to response
- for (const message of page.items) {
- historicalTokens = message.data + historicalTokens;
- }
- page = page.hasNext() ? await page.next() : null;
- }
- setResponse((prev) => historicalTokens + prev);
- })();
-}, [history]);
-```
-
-
-### Hydrating an in-progress response
-
-A common pattern is to persist complete model responses in your database while using Ably for live token delivery of the in-progress response.
-
-The client loads completed responses from your database, then reaches back into Ably channel history until it encounters a token for a response it's already loaded.
-
-You can retrieve partial history using either the [rewind](#rewind) or [history](#history) pattern.
-
-#### Hydrate using rewind
-
-Load completed responses from your database, then use rewind to catch up on any in-progress responses, skipping any tokens that belong to a response that was already loaded:
-
-
-```javascript
-// Load completed responses from database
-const completedResponses = await loadResponsesFromDatabase();
-
-// Use rewind to receive recent historical messages
-const channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}', {
- params: { rewind: '2m' }
-});
-
-// Track in progress responses by ID
-const inProgressResponses = new Map();
-
-// Subscribe to receive both recent historical and live messages,
-// which are delivered in order to the subscription
-await channel.subscribe('token', (message) => {
- const token = message.data;
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) {
- console.warn('Token missing responseId');
- return;
- }
-
- // Skip tokens for responses already hydrated from database
- if (completedResponses.has(responseId)) {
- return;
- }
-
- // Create an empty in-progress response
- if (!inProgressResponses.has(responseId)) {
- inProgressResponses.set(responseId, '');
- }
-
- // Append tokens for new responses
- inProgressResponses.set(responseId, inProgressResponses.get(responseId) + token);
-});
-```
-```python
-# Load completed responses from database
-completed_responses = await load_responses_from_database()
-
-# Use rewind to receive recent historical messages
-channel = realtime.channels.get('{{RANDOM_CHANNEL_NAME}}', params={'rewind': '2m'})
-
-# Track in progress responses by ID
-in_progress_responses = {}
-
-# Subscribe to receive both recent historical and live messages,
-# which are delivered in order to the subscription
-def on_token(message):
- token = message.data
- response_id = message.extras.get('headers', {}).get('responseId')
-
- if not response_id:
- print('Token missing responseId')
- return
-
- # Skip tokens for responses already hydrated from database
- if response_id in completed_responses:
- return
-
- # Create an empty in-progress response
- if response_id not in in_progress_responses:
- in_progress_responses[response_id] = ''
-
- # Append tokens for new responses
- in_progress_responses[response_id] += token
-
-await channel.subscribe('token', on_token)
-```
-```java
-// Load completed responses from database
-Set completedResponses = loadResponsesFromDatabase();
-
-// Use rewind to receive recent historical messages
-ChannelOptions options = new ChannelOptions();
-options.params = Map.of("rewind", "2m");
-Channel channel = realtime.channels.get("{{RANDOM_CHANNEL_NAME}}", options);
-
-// Track in progress responses by ID
-Map inProgressResponses = new ConcurrentHashMap<>();
-
-// Subscribe to receive both recent historical and live messages,
-// which are delivered in order to the subscription
-channel.subscribe("token", message -> {
- String token = (String) message.data;
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers != null ? headers.get("responseId").getAsString() : null;
-
- if (responseId == null) {
- System.err.println("Token missing responseId");
- return;
- }
-
- // Skip tokens for responses already hydrated from database
- if (completedResponses.contains(responseId)) {
- return;
- }
-
- // Create an empty in-progress response
- inProgressResponses.putIfAbsent(responseId, "");
-
- // Append tokens for new responses
- inProgressResponses.put(responseId, inProgressResponses.get(responseId) + token);
-});
-```
-
-```react
-// Ensure the outer ChannelProvider has options={{ params: { rewind: '2m' } }}
-
-// Load completed responses from your database (Set of responseIds)
-const completedResponses = useCompletedResponses();
-
-const [inProgressResponses, setInProgressResponses] = useState(new Map());
-
-// Receive both recent historical and live messages
-useChannel('{{RANDOM_CHANNEL_NAME}}', 'token', (message) => {
- const token = message.data;
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) return;
-
- // Skip tokens for responses already hydrated from database
- if (completedResponses.has(responseId)) return;
-
- setInProgressResponses((prev) => {
- const next = new Map(prev);
- next.set(responseId, (next.get(responseId) || '') + token);
- return next;
- });
-});
-```
-
-
-#### Hydrate using history
-
-Load completed responses from your database, then paginate backwards through history to catch up on in-progress responses until you reach a token that belongs to a response you've already loaded:
-
-
-```javascript
-// Load completed responses from database
-const completedResponses = await loadResponsesFromDatabase();
-
-// Use a channel in a namespace called 'persisted', which has persistence enabled
-const channel = realtime.channels.get('persisted:{{RANDOM_CHANNEL_NAME}}');
-
-// Track in progress responses by ID
-const inProgressResponses = new Map();
-
-// Subscribe to live tokens (implicitly attaches)
-await channel.subscribe('token', (message) => {
- const token = message.data;
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) {
- console.warn('Token missing responseId');
- return;
- }
-
- // Skip tokens for responses already hydrated from database
- if (completedResponses.has(responseId)) {
- return;
- }
-
- // Create an empty in-progress response
- if (!inProgressResponses.has(responseId)) {
- inProgressResponses.set(responseId, '');
- }
-
- // Append live tokens for in-progress responses
- inProgressResponses.set(responseId, inProgressResponses.get(responseId) + token);
-});
-
-// Paginate backwards through history until we encounter a hydrated response
-let page = await channel.history({ untilAttach: true });
-
-// Paginate backwards through history
-let done = false;
-while (page && !done) {
- // Messages are newest-first, so prepend them to response
- for (const message of page.items) {
- const token = message.data;
- const responseId = message.extras?.headers?.responseId;
-
- // Stop when we reach a response already loaded from database
- if (completedResponses.has(responseId)) {
- done = true;
- break;
- }
-
- // Create an empty in-progress response
- if (!inProgressResponses.has(responseId)) {
- inProgressResponses.set(responseId, '');
- }
-
- // Prepend historical tokens for in-progress responses
- inProgressResponses.set(responseId, token + inProgressResponses.get(responseId));
- }
-
- // Move to next page if available
- page = page.hasNext() ? await page.next() : null;
-}
-```
-```python
-# Load completed responses from database
-completed_responses = await load_responses_from_database()
-
-# Use a channel in a namespace called 'persisted', which has persistence enabled
-channel = realtime.channels.get('persisted:{{RANDOM_CHANNEL_NAME}}')
-
-# Track in progress responses by ID
-in_progress_responses = {}
-
-# Subscribe to live tokens (implicitly attaches)
-def on_token(message):
- token = message.data
- response_id = message.extras.get('headers', {}).get('responseId')
-
- if not response_id:
- print('Token missing responseId')
- return
-
- # Skip tokens for responses already hydrated from database
- if response_id in completed_responses:
- return
-
- # Create an empty in-progress response
- if response_id not in in_progress_responses:
- in_progress_responses[response_id] = ''
-
- # Append live tokens for in-progress responses
- in_progress_responses[response_id] += token
-
-await channel.subscribe('token', on_token)
-
-# Paginate backwards through history until we encounter a hydrated response
-page = await channel.history(until_attach=True)
-
-# Paginate backwards through history
-done = False
-while page and not done:
- # Messages are newest-first, so prepend them to response
- for message in page.items:
- token = message.data
- response_id = message.extras.get('headers', {}).get('responseId')
-
- # Stop when we reach a response already loaded from database
- if response_id in completed_responses:
- done = True
- break
-
- # Create an empty in-progress response
- if response_id not in in_progress_responses:
- in_progress_responses[response_id] = ''
-
- # Prepend historical tokens for in-progress responses
- in_progress_responses[response_id] = token + in_progress_responses[response_id]
-
- # Move to next page if available
- page = await page.next() if page.has_next() else None
-```
-```java
-// Load completed responses from database
-Set completedResponses = loadResponsesFromDatabase();
-
-// Use a channel in a namespace called 'persisted', which has persistence enabled
-Channel channel = realtime.channels.get("persisted:{{RANDOM_CHANNEL_NAME}}");
-
-// Track in progress responses by ID
-Map inProgressResponses = new ConcurrentHashMap<>();
-
-// Subscribe to live tokens (implicitly attaches)
-channel.subscribe("token", message -> {
- String token = (String) message.data;
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers != null ? headers.get("responseId").getAsString() : null;
-
- if (responseId == null) {
- System.err.println("Token missing responseId");
- return;
- }
-
- // Skip tokens for responses already hydrated from database
- if (completedResponses.contains(responseId)) {
- return;
- }
-
- // Create an empty in-progress response
- inProgressResponses.putIfAbsent(responseId, "");
-
- // Append live tokens for in-progress responses
- inProgressResponses.put(responseId, inProgressResponses.get(responseId) + token);
-});
-
-// Paginate backwards through history until we encounter a hydrated response
-PaginatedResult page = channel.history(new Param("untilAttach", "true"));
-
-// Paginate backwards through history
-boolean done = false;
-while (page != null && !done) {
- // Messages are newest-first, so prepend them to response
- for (Message message : page.items()) {
- String token = (String) message.data;
- JsonObject headers = message.extras.asJsonObject().get("headers").getAsJsonObject();
- String responseId = headers != null ? headers.get("responseId").getAsString() : null;
-
- // Stop when we reach a response already loaded from database
- if (completedResponses.contains(responseId)) {
- done = true;
- break;
- }
-
- // Create an empty in-progress response
- inProgressResponses.putIfAbsent(responseId, "");
-
- // Prepend historical tokens for in-progress responses
- inProgressResponses.put(responseId, token + inProgressResponses.get(responseId));
- }
-
- // Move to next page if available
- page = page.hasNext() ? page.next() : null;
-}
-```
-
-```react
-// Ensure the outer ChannelProvider uses a channel in a 'persisted' namespace, which has persistence enabled
-
-// Load completed responses from your database (Set of responseIds)
-const completedResponses = useCompletedResponses();
-
-const [inProgressResponses, setInProgressResponses] = useState(new Map());
-const hydrated = useRef(false);
-
-// Subscribe to live messages and get the history function
-const { history } = useChannel('persisted:{{RANDOM_CHANNEL_NAME}}', 'token', (message) => {
- const token = message.data;
- const responseId = message.extras?.headers?.responseId;
-
- if (!responseId) return;
- if (completedResponses.has(responseId)) return;
-
- setInProgressResponses((prev) => {
- const next = new Map(prev);
- next.set(responseId, (next.get(responseId) || '') + token);
- return next;
- });
-});
-
-// Fetch history on mount
-useEffect(() => {
- if (hydrated.current) return;
- hydrated.current = true;
-
- (async () => {
- let page = await history({ untilAttach: true });
- let done = false;
- while (page && !done) {
- for (const message of page.items) {
- const token = message.data;
- const responseId = message.extras?.headers?.responseId;
-
- // Stop when we reach a response already loaded from database
- if (completedResponses.has(responseId)) {
- done = true;
- break;
- }
-
- // Prepend historical tokens (newest-first order)
- setInProgressResponses((prev) => {
- const next = new Map(prev);
- next.set(responseId, token + (next.get(responseId) || ''));
- return next;
- });
- }
- page = page.hasNext() ? await page.next() : null;
- }
- })();
-}, [history, completedResponses]);
-```
-
diff --git a/src/pages/docs/ai-transport/token-streaming/token-rate-limits.mdx b/src/pages/docs/ai-transport/token-streaming/token-rate-limits.mdx
deleted file mode 100644
index b6f095d170..0000000000
--- a/src/pages/docs/ai-transport/token-streaming/token-rate-limits.mdx
+++ /dev/null
@@ -1,86 +0,0 @@
----
-title: Token streaming limits
-meta_description: "Learn how token streaming interacts with Ably message limits and how to ensure your application delivers consistent performance."
----
-
-LLM token streaming introduces high-rate or bursty traffic patterns to your application, with some models outputting upwards of 150 distinct events (that is, tokens or response deltas) per second. Output rates can vary unpredictably over the lifetime of a response stream, and you have limited control over third-party model behaviour. AI Transport provides functionality to help you stay within your [rate limits](/docs/platform/pricing/limits) while delivering a great experience to your users.
-
-Ably's limits divide into two categories:
-
-1. Limits relating to usage across an account, such as the total number of messages sent in a month, or the aggregate instantaneous message rate across all connections and channels
-2. Limits relating to the capacity of a single resource, such as a connection or a channel
-
-Limits in the first category exist to provide protection in the case of accidental spikes or deliberate abuse. Provided that your package is sized correctly for your use-case, these limits should not be hit as a result of valid traffic.
-
-The limits in the second category, however, cannot be increased arbitrarily and exist to protect the integrity of the service. The limits associated with individual connections or channels can be relevant to LLM token streaming use-cases. The following sections discuss these limits in particular.
-
-## Message-per-response
-
-The [message-per-response](/docs/ai-transport/token-streaming/message-per-response) pattern includes automatic rate limit protection. AI Transport prevents a single response stream from reaching the message rate limit for a connection by rolling up multiple appends into a single published message:
-
-1. Your agent streams tokens to the channel at the model's output rate
-2. Ably publishes the first token immediately, then automatically rolls up subsequent tokens on receipt
-3. Clients receive the same content, delivered in fewer discrete messages
-
-By default, Ably delivers a single response stream at 25 messages per second or the model output rate, whichever is lower. This means you can publish two simultaneous response streams on the same channel or connection with any [Ably package](/docs/platform/pricing#packages), because each stream uses half of the [connection inbound message rate](/docs/platform/pricing/limits#connection). Ably charges for the number of published messages, not for the number of streamed tokens.
-
-### Configure rollup behaviour
-
-Ably concatenates all appends for a single response that are received during the rollup window into one published message. You can specify the rollup window for a particular connection by setting the `appendRollupWindow` [transport parameter](/docs/api/realtime-sdk#client-options). This allows you to determine how much of the connection message rate can be consumed by a single response stream and control your consumption costs.
-
-
-| `appendRollupWindow` | Maximum message rate for a single response |
-|---|---|
-| 0ms | Model output rate |
-| 20ms | 50 messages/s |
-| 40ms *(default)* | 25 messages/s |
-| 100ms | 10 messages/s |
-| 500ms *(max)* | 2 messages/s |
-
-The following example code demonstrates establishing a connection to Ably with `appendRollupWindow` set to 100ms:
-
-
-```javascript
-const ably = new Ably.Realtime(
- {
- key: 'your-api-key',
- transportParams: { appendRollupWindow: 100 }
- }
-);
-```
-```python
-ably = AblyRealtime(
- key='your-api-key',
- transport_params={'appendRollupWindow': 100}
-)
-```
-```java
-ClientOptions options = new ClientOptions();
-options.key = "your-api-key";
-options.transportParams = Map.of("appendRollupWindow", "100");
-AblyRealtime ably = new AblyRealtime(options);
-```
-
-
-
-
-## Message-per-token
-
-The [message-per-token](/docs/ai-transport/token-streaming/message-per-token) pattern requires you to manage rate limits directly. Each token publishes as a separate message, so high-speed model output can cause per-connection or per-channel rate limits to be hit, as well as consuming overall message allowances quickly.
-
-To stay within limits:
-
-- Calculate your headroom by comparing your model's peak output rate against your package's [connection inbound message rate](/docs/platform/pricing/limits#connection)
-- Account for concurrency by multiplying peak rates by the maximum number of simultaneous streams your application supports
-- If required, batch tokens in your agent before publishing to the SDK, reducing message count while maintaining delivery speed
-- Enable [server-side batching](/docs/messages/batch#server-side) to reduce the number of messages delivered to your subscribers
-
-If your application requires higher message rates than your current package allows, [contact Ably](/contact) to discuss options.
-
-## Next steps
-
-- Review [Ably platform limits](/docs/platform/pricing/limits) to understand rate limit thresholds for your package
-- Learn about the [message-per-response](/docs/ai-transport/token-streaming/message-per-response) pattern for automatic rate limit protection
-- Learn about the [message-per-token](/docs/ai-transport/token-streaming/message-per-token) pattern for fine-grained control
diff --git a/src/pages/docs/ai-transport/use-cases/support-chat.mdx b/src/pages/docs/ai-transport/use-cases/support-chat.mdx
new file mode 100644
index 0000000000..969828b347
--- /dev/null
+++ b/src/pages/docs/ai-transport/use-cases/support-chat.mdx
@@ -0,0 +1,6 @@
+---
+title: "Support chat"
+meta_description: "Build an AI-powered support chat using AI Transport, with realtime streaming, human takeover, and session management."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/ai-transport/why-ai-transport.mdx b/src/pages/docs/ai-transport/why-ai-transport.mdx
new file mode 100644
index 0000000000..a51d8020e5
--- /dev/null
+++ b/src/pages/docs/ai-transport/why-ai-transport.mdx
@@ -0,0 +1,6 @@
+---
+title: "Why AI Transport"
+meta_description: "Learn why AI Transport is the best way to connect your AI agents to users in realtime, with built-in support for streaming, recovery, and multi-device sessions."
+---
+
+This page is a stub. Content to be written separately.
diff --git a/src/pages/docs/platform/architecture/connection-recovery.mdx b/src/pages/docs/platform/architecture/connection-recovery.mdx
index c02bb43774..ff125b3479 100644
--- a/src/pages/docs/platform/architecture/connection-recovery.mdx
+++ b/src/pages/docs/platform/architecture/connection-recovery.mdx
@@ -13,7 +13,7 @@ Ably minimizes the impact of these disruptions by providing an effective recover
Applications built with Ably will continue to function normally during disruptions. They will maintain their state and all messages will be received by the client in the correct order. This is particularly important for applications where messages delivery guarantees are crucial, such as in applications where client state is hydrated and maintained incrementally by messages.
-Connection recovery is especially important for AI applications, where a network interruption during token streaming can disrupt the user experience. Ably [AI Transport](/docs/ai-transport) builds on this mechanism to enable [resumable token streaming](/docs/ai-transport/token-streaming) from language models, ensuring users can reconnect mid-stream and continue from where they left off.
+Connection recovery is especially important for AI applications, where a network interruption during token streaming can disrupt the user experience. Ably [AI Transport](/docs/ai-transport) builds on this mechanism to enable [resumable token streaming](/docs/ai-transport/features/token-streaming) from language models, ensuring users can reconnect mid-stream and continue from where they left off.
Ably achieves a reliable connection recovery mechanism with the following:
diff --git a/src/pages/docs/platform/pricing/examples/ai-chatbot.mdx b/src/pages/docs/platform/pricing/examples/ai-chatbot.mdx
index bb94028280..3edc9ef26f 100644
--- a/src/pages/docs/platform/pricing/examples/ai-chatbot.mdx
+++ b/src/pages/docs/platform/pricing/examples/ai-chatbot.mdx
@@ -29,7 +29,7 @@ One user prompt sent to an agent = 2 messages (one sent to Ably + one delivered
**How rollup reduces token messages**
-Without rollup, each streamed token would be a separate message: 300 tokens = 300 messages per response. [Rollup batches tokens together](/docs/ai-transport/token-streaming/token-rate-limits#rollup), reducing this to 100 messages per response (a 66% saving). Because the append rule is required to use this pattern, persistence is automatically enabled and those messages are stored.
+Without rollup, each streamed token would be a separate message: 300 tokens = 300 messages per response. [Rollup batches tokens together](/docs/ai-transport/going-to-production/limits#rollup), reducing this to 100 messages per response (a 66% saving). Because the append rule is required to use this pattern, persistence is automatically enabled and those messages are stored.
#### Messages generated by this scenario