diff --git a/docs/pages/get-started/nextjs-comments-ai.mdx b/docs/pages/get-started/nextjs-comments-ai.mdx
new file mode 100644
index 00000000000..9de0e66eb69
--- /dev/null
+++ b/docs/pages/get-started/nextjs-comments-ai.mdx
@@ -0,0 +1,844 @@
+---
+meta:
+ title: "Get started with AI replies in Comments using Liveblocks and Next.js"
+ parentTitle: "Quickstart"
+ description:
+ "Learn how to add an AI agent that replies in Liveblocks Comments threads
+ using Next.js"
+---
+
+Liveblocks is a realtime collaboration infrastructure for building performant
+collaborative experiences. Follow the following steps to add an AI agent that
+replies in [Comments](/docs/ready-made-features/comments) when mentioned in a
+thread, using [`@liveblocks/node`](/docs/api-reference/liveblocks-node), the
+[Vercel AI SDK](https://ai-sdk.dev), and [Anthropic](https://anthropic.com), in
+your Next.js `/app` directory application.
+
+## Quickstart
+
+
+
+
+
+ Have a Comments app ready
+
+
+ To add AI replies to comment thread, you first need to have a Liveblocks
+ Comments app set up with secret key authentication and resolved users.
+ Open up your app, or set up comments if you haven’t already.
+
+
+
+
+
+
+
+ Install dependencies
+
+
+ Install [`@liveblocks/node`](/docs/api-reference/liveblocks-node) to verify
+ webhooks and write comments, along with the [Vercel AI SDK](https://ai-sdk.dev)
+ and the [Anthropic provider](https://ai-sdk.dev/providers/ai-sdk-providers/anthropic)
+ to generate AI responses.
+
+ ```bash trackEvent="install_liveblocks"
+ npm install @liveblocks/node ai @ai-sdk/anthropic
+ ```
+
+
+
+
+
+ Add your environment variables
+
+
+ Create a new `.env.local` file and add your Liveblocks secret key from
+ the [dashboard](/dashboard/apikeys), your Anthropic API key from the
+ [Anthropic dashboard](https://platform.claude.com/settings/keys), and a
+ placeholder for your Liveblocks webhook secret. You’ll create the
+ webhook secret in the final step.
+
+ ```env file=".env.local"
+ LIVEBLOCKS_SECRET_KEY="{{SECRET_KEY}}"
+ LIVEBLOCKS_WEBHOOK_SECRET_KEY="whsec_..."
+ ANTHROPIC_API_KEY="sk-ant-..."
+ ```
+
+
+
+
+
+ Add an AI user to your database
+
+
+ The AI agent posts replies like a regular user, so it needs a user ID
+ and info. Add a dedicated user for your agent next to your real users.
+ Where you resolve user info in
+ [`resolveUsers`](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveUsers) and
+ [`resolveMentionSuggestions`](/docs/api-reference/liveblocks-react#LiveblocksProviderResolveMentionSuggestions),
+ you should return this AI user so it can be `@`-mentioned and rendered
+ in threads.
+
+ ```tsx title="Find where you fetch data in your resolver functions…"
+ {
+ // +++
+ const users = await fetchUsers(userIds);
+ // +++
+ // ...
+ }}
+ resolveMentionSuggestions={async ({ text }) => {
+ // +++
+ const users = await fetchAllUsers();
+ // +++
+ // ...
+ }}
+ >
+ {children}
+
+ ```
+
+ ```ts title="…and modify the return values to include an AI user"
+ // +++
+ export const AI_USER_INFO = {
+ id: "__AI_AGENT",
+ info: {
+ name: "AI Assistant",
+ avatar: "https://liveblocks.io/api/avatar?u=__AI_AGENT&agent=true",
+ },
+ };
+ // +++
+
+ async function fetchUsers(userIds: string[]) {
+ const users = [];
+
+ for (const userId of userIds) {
+ // +++
+ if (userId === AI_USER_INFO.id) {
+ users.push(AI_USER_INFO);
+ continue;
+ }
+ // +++
+
+ const user = await __getUserFromDb__(userId);
+ users.push(user ? { id: userId, info: user.info } : undefined);
+ }
+
+ return users;
+ }
+
+ async function fetchAllUsers() {
+ const dbUsers = await __getAllUsersFromDb__();
+ // +++
+ return [AI_USER_INFO, ...dbUsers];
+ // +++
+ }
+ ```
+
+
+
+
+ Create the webhook endpoint
+
+
+ Create an API route to receive Liveblocks webhooks. We’ll verify the
+ request, only respond to
+ [`commentCreated`](/docs/platform/webhooks#CommentCreatedEvent) events,
+ and ignore comments that weren’t written by a human, otherwise the AI
+ would reply to its own messages.
+
+ ```ts file="app/api/liveblocks-webhook/route.ts"
+ import { WebhookHandler } from "@liveblocks/node";
+ import { NextResponse } from "next/server";
+ import { AI_USER_INFO } from "@/app/database";
+ import { handleAiCommentReply } from "@/app/ai-comment-reply";
+
+ const webhookHandler = new WebhookHandler(
+ process.env.LIVEBLOCKS_WEBHOOK_SECRET_KEY!
+ );
+
+ export async function POST(request: Request) {
+ const body = await request.json();
+
+ let event;
+ try {
+ event = webhookHandler.verifyRequest({
+ headers: request.headers,
+ rawBody: JSON.stringify(body),
+ });
+ } catch (err) {
+ return new Response("Could not verify webhook call", { status: 400 });
+ }
+
+ if (event.type === "commentCreated") {
+ // Ignore comments posted by the AI itself
+ if (event.data.createdBy === AI_USER_INFO.id) {
+ return NextResponse.json({ message: "Ignored AI comment" });
+ }
+
+ // Run the AI reply in the background so the webhook responds quickly
+ handleAiCommentReply(event.data).catch(console.error);
+ }
+
+ return NextResponse.json({ message: "Received" });
+ }
+ ```
+
+
+
+
+
+ Create an AI reply
+
+
+ Next, create the AI response. There are a few steps to follow for a full user experience:
+
+ 1. Get the thread with [`getThread`](/docs/api-reference/liveblocks-node#get-rooms-roomId-threads-threadId)
+ 2. Check if the AI user was `@`-mentioned with [`getMentionsFromCommentBody`](/docs/api-reference/liveblocks-node#get-mentions-from-comment-body).
+ 3. Add a “👀” reaction to the comment that mentioned the user with [`addCommentReaction`](/docs/api-reference/liveblocks-node#post-rooms-roomId-threads-threadId-comments-commentId-add-reaction).
+ 4. Show AI presence in avatar stacks and [`useOthers`](/docs/api-reference/liveblocks-react#useOthers) with [`setPresence`](/docs/api-reference/liveblocks-node#post-rooms-roomId-presence).
+ 5. Create a placeholder comment, “Thinking…”, with [`createComment`](/docs/api-reference/liveblocks-node#post-rooms-roomId-threads-threadId-comments).
+ 6. Convert the thread into chat messages and generate a response from Claude.
+ 7. Update the placeholder comment with the AI response using [`editComment`](/docs/api-reference/liveblocks-node#post-rooms-roomId-threads-threadId-comments-commentId).
+
+```ts file="app/ai-comment-reply.ts"
+import { Liveblocks, getMentionsFromCommentBody } from "@liveblocks/node";
+import { stringifyCommentBody } from "@liveblocks/client";
+import { generateText, type ModelMessage } from "ai";
+import { anthropic } from "@ai-sdk/anthropic";
+import { markdownToCommentBody } from "@liveblocks/node";
+import { AI_USER_INFO } from "@/app/database";
+
+const liveblocks = new Liveblocks({
+ secret: process.env.LIVEBLOCKS_SECRET_KEY!,
+});
+
+export async function handleAiCommentReply(data: {
+ roomId: string;
+ threadId: string;
+ commentId: string;
+}) {
+ const { roomId, threadId, commentId } = data;
+
+ // Get the thread and the comment that triggered the webhook
+ const thread = await liveblocks.getThread({ roomId, threadId });
+ const comment = thread.comments.find((c) => c.id === commentId);
+ if (!comment?.body) return;
+
+ // Only reply if the AI user was @-mentioned
+ const mentions = getMentionsFromCommentBody(comment.body);
+ if (!mentions.some((m) => m.id === AI_USER_INFO.id)) return;
+
+ // Add a “👀” reaction to the comment that mentioned the user
+ liveblocks.addCommentReaction({
+ roomId,
+ threadId,
+ commentId,
+ data: {
+ emoji: "👀",
+ userId: AI_USER_INFO.id,
+ },
+ });
+
+ // If you have an avatar stack, show AI presence for 30 secs
+ liveblocks.setPresence(roomId, {
+ userId: AI_USER_INFO.id,
+ data: {},
+ userInfo: AI_USER_INFO,
+ ttl: 30,
+ });
+
+ // Create a placeholder comment as the AI thinks
+ const aiComment = await liveblocks.createComment({
+ roomId,
+ threadId,
+ data: {
+ userId: AI_USER_INFO.id,
+ body: markdownToCommentBody("Thinking…"),
+ metadata: {},
+ },
+ });
+
+ // Convert the thread into chat messages
+ const messages: ModelMessage[] = await Promise.all(
+ thread.comments.map(async (c) => ({
+ role: c.userId === AI_USER_INFO.id ? "assistant" : "user",
+ content: c.body ? await stringifyCommentBody(c.body) : "Deleted comment",
+ }))
+ );
+
+ // Generate a response from Claude
+ const { text } = await generateText({
+ model: anthropic("claude-sonnet-4-5"),
+ system: `You are a helpful assistant replying inside a Liveblocks comment thread.
+
+ - Reply concisely and to the point.
+ - Reply in plain text. Do not use markdown.
+ - Your user ID is ${AI_USER_INFO.id}.`,
+ messages,
+ });
+
+ // Update the comment with the AI response
+ await liveblocks.editComment({
+ roomId,
+ threadId,
+ commentId: aiComment.id,
+ data: {
+ userId: AI_USER_INFO.id,
+ body: markdownToCommentBody(text),
+ metadata: {},
+ },
+ });
+}
+```
+
+
+
+
+
+
+
+ Set up Liveblocks webhooks
+
+
+
+ The final step is to configure Liveblocks webhooks so the AI is
+ notified when new comments are created.
+
+ 1. Follow the guide on
+ [testing webhooks locally](/docs/guides/how-to-test-webhooks-on-localhost).
+ 2. In the [dashboard](/dashboard), create a webhook endpoint that
+ points to `/api/liveblocks-webhook` and enables the
+ [`commentCreated`](/docs/platform/webhooks#CommentCreatedEvent)
+ event.
+ 3. Copy your **webhook secret** (`whsec_...`) and add it to
+ `.env.local` as `LIVEBLOCKS_WEBHOOK_SECRET_KEY`.
+
+ Now whenever a user `@`-mentions your AI in a thread, your endpoint
+ will generate a response and post it back as a reply.
+
+
+
+
+
+
+
+
+ Complete!
+
+ You now have an AI agent capable of replying to mentions in comment threads.
+ When it’s mentioned in a acomment, it’ll leave a placeholder comment, and
+ edit it after generating a response.
+
+
+
+
+ (Optional) Add streaming to AI replies
+
+
+ To stream the AI’s response into the comment in realtime, like in the
+ [AI Comments example](/examples/ai-comments/nextjs-comments-ai), we can
+ take advantage of [Feeds](/docs/get-started/nextjs-feeds), streaming each
+ chunk of reasoning and writing into the comment as it arrives.
+
+ To get
+ started, [type your data](/docs/api-reference/liveblocks-react#Typing-your-data)
+ in `liveblocks.config.ts` so the
+ [`CommentMetadata`](/docs/api-reference/liveblocks-react#CommentMetadata),
+ feed, and feed message types are available across your app.
+
+ ```ts file="liveblocks.config.ts" isCollapsed isCollapsable
+ declare global {
+ interface Liveblocks {
+ UserMeta: {
+ id: string;
+ info: {
+ name: string;
+ avatar: string;
+ color: string;
+ };
+ };
+
+ // +++
+ CommentMetadata: {
+ feedId?: string;
+ feedComplete?: boolean;
+ };
+
+ FeedMetadata: {
+ type: "ai-comment-reply";
+ threadId: string;
+ commentId: string;
+ };
+
+ FeedMessageData:
+ | {
+ stage: "thinking";
+ response: string;
+ responsePart: string;
+ }
+ | {
+ stage: "writing";
+ response: string;
+ responsePart: string;
+ }
+ | {
+ stage: "complete";
+ response: string;
+ reasoning: string;
+ thinkingTime: number;
+ };
+ // +++
+ }
+ }
+
+ export {};
+ ```
+
+ Next, extend the endpoint to write streaming updates into the feed.
+
+ ```ts file="app/ai-comment-reply.ts" isCollapsed isCollapsable
+ import {
+ Liveblocks,
+ getMentionsFromCommentBody,
+ markdownToCommentBody,
+ } from "@liveblocks/node";
+ import { stringifyCommentBody } from "@liveblocks/client";
+ // +++
+ import { streamText, type ModelMessage } from "ai";
+ import {
+ anthropic,
+ type AnthropicLanguageModelOptions,
+ } from "@ai-sdk/anthropic";
+ // +++
+ import { AI_USER_INFO } from "@/app/database";
+
+ const liveblocks = new Liveblocks({
+ secret: process.env.LIVEBLOCKS_SECRET_KEY!,
+ });
+
+ export async function handleAiCommentReply(data: {
+ roomId: string;
+ threadId: string;
+ commentId: string;
+ }) {
+ const { roomId, threadId, commentId } = data;
+
+ const thread = await liveblocks.getThread({ roomId, threadId });
+ const comment = thread.comments.find((c) => c.id === commentId);
+ if (!comment?.body) return;
+
+ const mentions = getMentionsFromCommentBody(comment.body);
+ if (!mentions.some((m) => m.id === AI_USER_INFO.id)) return;
+
+ liveblocks.addCommentReaction({
+ roomId,
+ threadId,
+ commentId,
+ data: { emoji: "👀", userId: AI_USER_INFO.id },
+ });
+
+ liveblocks.setPresence(roomId, {
+ userId: AI_USER_INFO.id,
+ data: {},
+ userInfo: AI_USER_INFO,
+ ttl: 30,
+ });
+
+ // +++
+ // Create a feed to hold streaming AI updates
+ const feedId = `comment-reply-${roomId}-${threadId}-${commentId}`;
+ await liveblocks.createFeed({
+ roomId,
+ feedId,
+ metadata: { type: "ai-comment-reply", threadId, commentId },
+ });
+ // +++
+
+ // Create a placeholder comment for the AI response
+ const aiComment = await liveblocks.createComment({
+ roomId,
+ threadId,
+ data: {
+ userId: AI_USER_INFO.id,
+ body: markdownToCommentBody("Thinking…"),
+ // +++
+ metadata: { feedId, feedComplete: false },
+ // +++
+ },
+ });
+
+ // Convert the thread into chat messages
+ const messages: ModelMessage[] = await Promise.all(
+ thread.comments.map(async (c) => ({
+ role: c.userId === AI_USER_INFO.id ? "assistant" : "user",
+ content: c.body
+ ? await stringifyCommentBody(c.body)
+ : "Deleted comment",
+ }))
+ );
+
+ // +++
+ // Stream the response from Claude with reasoning enabled
+ const startedAt = performance.now();
+ const result = streamText({
+ model: anthropic("claude-sonnet-4-5"),
+ system: `You are a helpful assistant replying inside a Liveblocks comment thread.
+
+- Reply concisely and to the point.
+- You can use inline markdown.
+- Your user ID is ${AI_USER_INFO.id}.`, messages, providerOptions: { anthropic:
+ { sendReasoning: true, thinking: { type: "enabled", budgetTokens: 10000 }, }
+ satisfies AnthropicLanguageModelOptions, }, });
+
+ // Push each reasoning + text delta into the feed
+ let reasoning = "";
+ let response = "";
+
+ for await (const part of result.fullStream) {
+ if (part.type === "reasoning-delta") {
+ reasoning += part.text;
+ await liveblocks.createFeedMessage({
+ roomId,
+ feedId,
+ data: {
+ stage: "thinking",
+ responsePart: part.text,
+ response: reasoning,
+ },
+ });
+ } else if (part.type === "text-delta") {
+ response += part.text;
+ await liveblocks.createFeedMessage({
+ roomId,
+ feedId,
+ data: {
+ stage: "writing",
+ responsePart: part.text,
+ response,
+ },
+ });
+ }
+ }
+
+ // Send a final “complete” message so the UI can swap to the finished render
+ await liveblocks.createFeedMessage({
+ roomId,
+ feedId,
+ data: {
+ stage: "complete",
+ response,
+ reasoning,
+ thinkingTime: (performance.now() - startedAt) / 1000,
+ },
+ });
+ // +++
+
+ // Update the placeholder comment with the final response
+ await liveblocks.editComment({
+ roomId,
+ threadId,
+ commentId: aiComment.id,
+ data: {
+ userId: AI_USER_INFO.id,
+ // +++
+ metadata: { feedId, feedComplete: true },
+ // +++
+ body: markdownToCommentBody(response),
+ },
+ });
+ }
+ ```
+
+ In React, render the streaming feed by creating an `AiComment`
+ component that reads from
+ [`useFeedMessages`](/docs/api-reference/liveblocks-react#useFeedMessages).
+ It renders live status updates from the feed, including reasoning and the
+ final response, and then switches back to the default
+ [`Comment`](/docs/api-reference/liveblocks-react-ui#Comment) once the
+ placeholder has been edited.
+
+ ```tsx file="app/components/AiComment.tsx" isCollapsed isCollapsable
+ "use client";
+
+ import { useState } from "react";
+ import { useFeedMessages, ClientSideSuspense } from "@liveblocks/react";
+ import { useUser } from "@liveblocks/react/suspense";
+ import {
+ Comment,
+ CommentProps,
+ } from "@liveblocks/react-ui";
+ import { Comment as CommentPrimitive } from "@liveblocks/react-ui/primitives";
+ import { Markdown } from "@liveblocks/react-ui/_private";
+ import Link from "next/link";
+
+ export function AiComment({
+ feedId,
+ commentProps,
+ }: {
+ feedId: string;
+ commentProps: CommentProps;
+ }) {
+ const { messages } = useFeedMessages(feedId);
+ const lastMessage = messages?.[messages.length - 1];
+
+ if (!messages || !lastMessage) {
+ return (
+
+ );
+ }
+
+ // Thinking stage: reasoning is being written
+ if (lastMessage.data.stage === "thinking") {
+ return (
+
+ );
+ }
+
+ // Writing stage: the actual response is being streamed
+ if (lastMessage.data.stage === "writing") {
+ return (
+
+ );
+ }
+
+ // Complete stage: show the final response + reasoning
+ return (
+
+ );
+ }
+
+ // Inline comment shown while the AI is thinking or writing
+ function StreamingComment({
+ commentProps,
+ title,
+ responsePart,
+ response,
+ }: {
+ commentProps: CommentProps;
+ title: string;
+ responsePart: string;
+ response: string;
+ }) {
+ const [open, setOpen] = useState(false);
+ const trimmedResponsePart = responsePart.trim();
+
+ return (
+ setOpen(!open)}>
+
+ {title}
+
+ {trimmedResponsePart.length ? `…${trimmedResponsePart}` : ""}
+
+
+