@@ -98,6 +144,7 @@ export const PROGRESS_STATES = [
},
{
id: "review",
+ text: "In Review",
jsx: (
@@ -107,6 +154,7 @@ export const PROGRESS_STATES = [
},
{
id: "done",
+ text: "Done",
jsx: (
@@ -114,11 +162,11 @@ export const PROGRESS_STATES = [
),
},
-] as const;
-
-export type ProgressState = (typeof PROGRESS_STATES)[number]["id"];
-export type PriorityState = (typeof PRIORITY_STATES)[number]["id"];
-export type Label = (typeof LABELS)[number]["id"];
+] as const satisfies readonly {
+ id: IssueProgressId;
+ text: string;
+ jsx: ReactNode;
+}[];
const ROOM_PREFIX = "liveblocks:examples:nextjs-project-manager-";
@@ -133,8 +181,8 @@ export function getIssueId(roomId: string) {
export type Metadata = {
issueId: string;
title: string;
- progress: ProgressState;
- priority: PriorityState;
+ progress: IssueProgressId;
+ priority: IssuePriorityId;
assignedTo: string | "none";
labels: string[];
};
diff --git a/examples/nextjs-linear-like-issue-tracker/src/database.ts b/examples/nextjs-linear-like-issue-tracker/src/database.ts
index 0a34d667596..6c6e8190c08 100644
--- a/examples/nextjs-linear-like-issue-tracker/src/database.ts
+++ b/examples/nextjs-linear-like-issue-tracker/src/database.ts
@@ -1,4 +1,14 @@
+export const AI_USER_INFO: Liveblocks["UserMeta"] = {
+ id: "__AI_AGENT",
+ info: {
+ name: "AI Assistant",
+ color: "#6366f1",
+ avatar: `https://liveblocks.io/api/avatar?u=__AI_AGENT&agent=true`,
+ },
+};
+
const USER_INFO: Liveblocks["UserMeta"][] = [
+ AI_USER_INFO,
{
id: "charlie.layne@example.com",
info: {
@@ -66,7 +76,8 @@ const USER_INFO: Liveblocks["UserMeta"][] = [
];
export function getRandomUser() {
- return USER_INFO[Math.floor(Math.random() * 10) % USER_INFO.length];
+ const realUsers = USER_INFO.filter(({ id }) => id !== AI_USER_INFO.id);
+ return realUsers[Math.floor(Math.random() * realUsers.length)];
}
export function getUser(id: string) {
diff --git a/examples/nextjs-linear-like-issue-tracker/src/globals.css b/examples/nextjs-linear-like-issue-tracker/src/globals.css
index a7ae41e63b2..1410dcfa448 100644
--- a/examples/nextjs-linear-like-issue-tracker/src/globals.css
+++ b/examples/nextjs-linear-like-issue-tracker/src/globals.css
@@ -10,3 +10,7 @@ select {
background-size: 1.5em 1.5em;
padding-right: 1.8rem;
}
+
+#liveblocks-badge {
+ display: none;
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/icons/AiBrainIcon.tsx b/examples/nextjs-linear-like-issue-tracker/src/icons/AiBrainIcon.tsx
new file mode 100644
index 00000000000..4fb12db011f
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/icons/AiBrainIcon.tsx
@@ -0,0 +1,18 @@
+export function AiBrainIcon() {
+ return (
+
+ );
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/icons/AiCommentChevronIcon.tsx b/examples/nextjs-linear-like-issue-tracker/src/icons/AiCommentChevronIcon.tsx
new file mode 100644
index 00000000000..08a3b554678
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/icons/AiCommentChevronIcon.tsx
@@ -0,0 +1,24 @@
+export function AiCommentChevronIcon({
+ rotate = false,
+ size = 17,
+}: {
+ rotate?: boolean;
+ size?: number;
+}) {
+ return (
+
+ );
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/icons/IssueThreadBranchIcon.tsx b/examples/nextjs-linear-like-issue-tracker/src/icons/IssueThreadBranchIcon.tsx
new file mode 100644
index 00000000000..01837e4a807
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/icons/IssueThreadBranchIcon.tsx
@@ -0,0 +1,24 @@
+import { ComponentProps } from "react";
+
+/** L-shaped thread connector (Linear-style nested reference). */
+export function IssueThreadBranchIcon(props: ComponentProps<"svg">) {
+ return (
+
+ );
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/icons/SparklesIcon.tsx b/examples/nextjs-linear-like-issue-tracker/src/icons/SparklesIcon.tsx
new file mode 100644
index 00000000000..53d222311f5
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/icons/SparklesIcon.tsx
@@ -0,0 +1,17 @@
+export function SparklesIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/icons/SpinnerIcon.tsx b/examples/nextjs-linear-like-issue-tracker/src/icons/SpinnerIcon.tsx
new file mode 100644
index 00000000000..a116428c16a
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/icons/SpinnerIcon.tsx
@@ -0,0 +1,21 @@
+export function SpinnerIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/ai-comment-bridge.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-comment-bridge.ts
new file mode 100644
index 00000000000..616a43dc3b7
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-comment-bridge.ts
@@ -0,0 +1,71 @@
+import { AI_USER_INFO } from "@/database";
+import { liveblocks } from "@/liveblocks.server.config";
+import { markdownToCommentBody } from "@liveblocks/node";
+
+export type CommentLocation = {
+ roomId: string;
+ threadId: string;
+ commentId: string;
+};
+
+// Placeholder comment whilst AI generates
+export async function createAiPlaceholderComment({
+ roomId,
+ threadId,
+ feedId,
+}: {
+ roomId: string;
+ threadId: string;
+ feedId: string;
+}) {
+ return await liveblocks.createComment({
+ roomId,
+ threadId,
+ data: {
+ userId: AI_USER_INFO.id,
+ metadata: { feedId },
+ body: markdownToCommentBody("Thinking…"),
+ },
+ });
+}
+
+// Updates the placeholder comment with AI response
+export async function updateAiPlaceholderComment({
+ roomId,
+ threadId,
+ commentId,
+ feedId,
+ response,
+ referencedIssueIdsCsv,
+}: CommentLocation & {
+ feedId: string;
+ response: string;
+ referencedIssueIdsCsv?: string;
+}) {
+ const trimmed = response.trim();
+ const body =
+ trimmed.length === 0
+ ? {
+ version: 1 as const,
+ content: [
+ { type: "paragraph" as const, children: [{ text: "\u00a0" }] },
+ ],
+ }
+ : markdownToCommentBody(trimmed);
+
+ return await liveblocks.editComment({
+ roomId,
+ threadId,
+ commentId,
+ data: {
+ metadata: {
+ feedId,
+ ...(referencedIssueIdsCsv !== undefined &&
+ referencedIssueIdsCsv.length > 0
+ ? { referencedIssueIds: referencedIssueIdsCsv }
+ : {}),
+ },
+ body,
+ },
+ });
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/ai-editing-presence-types.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-editing-presence-types.ts
new file mode 100644
index 00000000000..5f13cbbea86
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-editing-presence-types.ts
@@ -0,0 +1,13 @@
+/** Keys sent in AI server presence `editingTypes` — match `AiPresenceEditFrame` `editingType` props. */
+export const AI_EDITING_TYPE = {
+ TITLE: "title",
+ CONTENT: "content",
+ LABELS: "labels",
+ LINKS: "links",
+ PROGRESS: "progress",
+ PRIORITY: "priority",
+ ASSIGNED_TO: "assignedTo",
+} as const;
+
+export type AiEditingPresenceType =
+ (typeof AI_EDITING_TYPE)[keyof typeof AI_EDITING_TYPE];
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/ai-feed-messages.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-feed-messages.ts
new file mode 100644
index 00000000000..a4c990f233f
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-feed-messages.ts
@@ -0,0 +1,60 @@
+import { liveblocks } from "@/liveblocks.server.config";
+
+// Different feed types
+
+type FeedTarget = {
+ roomId: string;
+ feedId: string;
+};
+
+export async function writeFeedThinking(
+ target: FeedTarget,
+ deltaText: string,
+ totalReasoning: string
+): Promise
{
+ await liveblocks.createFeedMessage({
+ roomId: target.roomId,
+ feedId: target.feedId,
+ data: { stage: "thinking", responsePart: deltaText, response: totalReasoning },
+ });
+}
+
+export async function writeFeedWriting(
+ target: FeedTarget,
+ deltaText: string,
+ totalText: string
+): Promise {
+ await liveblocks.createFeedMessage({
+ roomId: target.roomId,
+ feedId: target.feedId,
+ data: { stage: "writing", responsePart: deltaText, response: totalText },
+ });
+}
+
+export async function writeFeedStatus(
+ target: FeedTarget,
+ label: string
+): Promise {
+ await liveblocks.createFeedMessage({
+ roomId: target.roomId,
+ feedId: target.feedId,
+ data: { stage: "status", label },
+ });
+}
+
+// Final message with full response
+export async function writeFeedComplete(
+ target: FeedTarget,
+ payload: { response: string; reasoning: string; thinkingTime: number }
+): Promise {
+ await liveblocks.createFeedMessage({
+ roomId: target.roomId,
+ feedId: target.feedId,
+ data: {
+ stage: "complete",
+ response: payload.response,
+ reasoning: payload.reasoning,
+ thinkingTime: payload.thinkingTime,
+ },
+ });
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-assistant-prompt.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-assistant-prompt.ts
new file mode 100644
index 00000000000..9fdac5458ed
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-assistant-prompt.ts
@@ -0,0 +1,116 @@
+import {
+ ISSUE_PRIORITY_IDS,
+ ISSUE_PROGRESS_IDS,
+ LABELS,
+ PRIORITY_STATES,
+ PROGRESS_STATES,
+} from "@/config";
+
+const ISSUE_PROGRESS_PROMPT_LABEL = Object.fromEntries(
+ PROGRESS_STATES.map((s) => [s.id, s.text])
+) as Record<(typeof ISSUE_PROGRESS_IDS)[number], string>;
+
+const ISSUE_PRIORITY_PROMPT_LABEL = Object.fromEntries(
+ PRIORITY_STATES.map((s) => [s.id, s.text])
+) as Record<(typeof ISSUE_PRIORITY_IDS)[number], string>;
+
+export type AiIssueAssistantSystemPromptInput = {
+ aiUserId: string;
+ currentIssueId: string;
+ assignableUsersLines: string;
+ allUsersLines: string;
+ issueContextMd: string;
+ stringifiedComment: string;
+};
+
+export function buildAiIssueAssistantSystemPrompt({
+ aiUserId,
+ currentIssueId,
+ assignableUsersLines,
+ allUsersLines,
+ issueContextMd,
+ stringifiedComment,
+}: AiIssueAssistantSystemPromptInput): string {
+ const labelsValidLines = LABELS.map((l) => `- \`${l.id}\` — ${l.text}`).join(
+ "\n"
+ );
+ const progressValidLines = ISSUE_PROGRESS_IDS.map(
+ (id) => `- \`${id}\` — ${ISSUE_PROGRESS_PROMPT_LABEL[id]}`
+ ).join("\n");
+ const priorityValidLines = ISSUE_PRIORITY_IDS.map(
+ (id) => `- \`${id}\` — ${ISSUE_PRIORITY_PROMPT_LABEL[id]}`
+ ).join("\n");
+
+ const assignableUsersBlock =
+ assignableUsersLines.length > 0
+ ? assignableUsersLines
+ : "_No human users in this demo database._";
+
+ return `You are an assistant that helps collaborators on issues in a Linear-like tracker.
+
+## Info
+
+- Threads are comments on a single issue.
+- Your user ID is: ${aiUserId}
+- **Current issue id** (this thread’s issue): \`${currentIssueId}\` — do not use **link_issues_in_reply** with this same id.
+- Thread messages are prefixed with user id and time.
+- You may create a new issue with the **create_issue** tool when the user clearly asks for a new ticket, bug, task, or follow-up item that should be tracked separately. That tool can set an initial **description** (markdown), **labels** (array of ids), **links** (URLs), and **progress** / **priority** / **assignedTo** in one step — use **exact ids** from **Valid ids (tools)** below for labels, progress, and priority. Use those fields when the user wants them on the new issue so you do not rely on a second room. Put the summary in **title** only. **NEVER** start \`descriptionMarkdown\` with any markdown heading (\`#\`, \`##\`, \`###\`, etc.) — the **title** field is the issue’s title; the body must open with plain content (paragraph, list, quote, etc.), not a heading line. In the body, **start each new paragraph with a blank line** (two newlines before the next paragraph), not a single newline between lines of prose.
+- You **can** edit the **issue description** (the main Lexical document): call **insert_issue_description_markdown** with GitHub-flavored markdown (lists, links, quotes, fenced code; headings only **after** an opening paragraph or two if you need subsections). Use **append** to add at the end; use **replace** only when the user explicitly wants to overwrite the whole description — **replace** clears the existing body first, so any content you omit from your markdown is removed. **NEVER** begin the inserted markdown with a heading line — the **title** property is shown above the body like an H1. **Separate paragraphs with a blank line** (two newlines), not one, so lines are not merged or parsed as accidental links.
+- You **can** set **assignee**: call **update_issue_properties** with \`assignedTo\`. Use \`none\` to clear. Otherwise use an exact id from the list below. Thread messages are prefixed with \`userId at …\` — use that id when the user says "me", "assign to me", or refers to the author of a message.
+- You may update other **issue fields** (title, progress, priority, labels) with **update_issue_properties**. Only include keys you are changing. For \`progress\`, \`priority\`, and \`labels\`, use **exact ids** from **Valid ids (tools)** below.
+- You **can** add URLs to this issue’s **Links** sidebar (not the description): call **append_issue_links** with plain \`https://…\` URLs. Duplicates are skipped; the list is capped at 30 links.
+- You **can** list other issues with **list_recent_issues** — it returns the 20 most recent issues (newest first) and takes no parameters. Use nanoids from those results (or context) with **link_issues_in_reply** and \`issueIds\`: they are stored in \`referencedIssueIds\` metadata (comma-separated, max 10); multiple calls merge (deduped). **create_issue** prepends the new issue id to the same \`referencedIssueIds\` for inline previews—do **not** pass that same nanoid again in **link_issues_in_reply** (redundant; merge still dedupes). Never include this thread’s issue id in **link_issues_in_reply**.
+
+**Assignable users** — use the exact \`id\` for \`assignedTo\` (create_issue or update_issue_properties), or \`none\` to clear:
+
+${assignableUsersBlock}
+
+## All users (ids and display names)
+
+Use these ids in thread context, assignees, and mentions:
+
+${allUsersLines}
+
+## Valid ids (tools)
+
+Use these exact string ids for \`labels\` (array of ids), \`progress\`, and \`priority\` when calling **create_issue** or **update_issue_properties**.
+
+### Labels
+
+${labelsValidLines}
+
+### Progress
+
+${progressValidLines}
+
+### Priority
+
+${priorityValidLines}
+
+- Below, **Current issue** is markdown exported from the issue editor and fields (for grounding). Your **thread reply** in **Respond** is saved as markdown and converted to rich comments (paragraphs, **bold**, _italic_, \`code\`, links, \`@mentions\`, line breaks)—not the same surface as the issue description, but markdown is allowed and encouraged when it helps readability.
+- In thread comments you may link to **other issues** with a relative path: \`[readable label](/issue/ISSUE_NANOID)\` (use the target issue’s nanoid from context or **list_recent_issues**). Prefer a short human-readable label, not the raw id as the link text unless the user asked for it.
+
+## Rules
+
+- Your **thread reply** (what collaborators read in the comment) is written as **markdown** and rendered as rich text (emphasis, links, \`@mentions\`, etc.). Stay concise; avoid long \`#\` heading stacks in comments—unsupported blocks are flattened to text. Tools still use markdown where noted (e.g. **insert_issue_description_markdown** for the issue body).
+- **Do not** paste **comment** ids (opaque Liveblocks identifiers for thread messages) into your reply text—readers should never see them. Refer to messages in plain language (“your note above”, “the earlier comment”), quote a short snippet, or use \`@mentions\` when appropriate.
+- **Linking issues you found:** If the user asks about **other issues**, what exists, **recent** issues, related tickets, duplicates, overlaps, or anything where you **identify** specific issues that answer the question (including after **list_recent_issues** or from ids in context), you **must** call **link_issues_in_reply** with those nanoid \`issueIds\` in the **same** reply. Inline previews under your comment come **only** from \`referencedIssueIds\` metadata (filled by **create_issue** and **link_issues_in_reply**)—markdown alone does not attach them. **Do not** ask “should I link…?” or “would you like me to link…?”—**link every** relevant issue you are calling out (respect max 10: not this thread’s id; if you used **create_issue** in this reply, its new id is already in \`referencedIssueIds\`—omit it from **link_issues_in_reply**).
+- You MUST reply concisely and to the point.
+- You MUST NOT start your messages with "${aiUserId} at ...".
+- Call create_issue at most once per reply. If you create an issue, briefly acknowledge it in your comment (markdown ok).
+- Prefer **append** for description edits unless the user clearly asked to replace the entire document (**replace** overwrites all existing body content).
+- For **insert_issue_description_markdown** and **create_issue**’s \`descriptionMarkdown\`: **NEVER** start with a markdown heading (\`#\` through \`######\`). Open with a normal paragraph, list, or blockquote first; use \`##\` / \`###\` only later if the user needs subsections inside the body. The **title** field is the only top-level title.
+- For those same issue-body tools: **always break paragraphs with a blank line** (insert **two** newline characters between paragraphs). Do **not** use a single newline between prose lines — that can run text together or produce bad autolinks. Good: first paragraph, blank line, second paragraph. Bad: \`one\\ntwo\` on adjacent lines with no blank line between. Good: \`one\\n\\ntwo\`.
+- Your avatar is already shown in the room while you work (presence); collaborators see it during description or property edits too.
+
+## Current issue (markdown)
+
+${issueContextMd}
+
+## Respond
+
+Respond to the following comment:
+
+${stringifiedComment}
+`;
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-assistant-tools.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-assistant-tools.ts
new file mode 100644
index 00000000000..a36dea4fa24
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-assistant-tools.ts
@@ -0,0 +1,421 @@
+import { AI_USER_INFO, getUsers } from "@/database";
+import { applyIssueDescriptionMarkdown } from "@/lib/apply-issue-description-markdown";
+import { appendIssueLinks } from "@/lib/apply-issue-links";
+import { applyIssuePropertyUpdates } from "@/lib/apply-issue-property-updates";
+import {
+ createIssueRoomForAi,
+ type CreateIssueRoomOptions,
+} from "@/lib/create-issue-room";
+import type { IssuePropertyUpdates } from "@/lib/apply-issue-property-updates";
+import {
+ getIssueId,
+ getRoomId,
+ ISSUE_LABEL_IDS,
+ ISSUE_PRIORITY_IDS,
+ ISSUE_PROGRESS_IDS,
+ type Metadata,
+} from "@/config";
+import { liveblocks } from "@/liveblocks.server.config";
+import { tool } from "ai";
+import { z } from "zod";
+
+const MAX_REFERENCED_ISSUES_IN_REPLY = 10;
+
+function mergeReferencedIssueIdsCsv(
+ previous: string | undefined,
+ additions: string[],
+ currentThreadIssueId: string,
+ mode: "append" | "prepend"
+): string {
+ const seen = new Set();
+ const out: string[] = [];
+ const push = (raw: string) => {
+ const id = raw.trim();
+ if (!id || seen.has(id)) {
+ return;
+ }
+ if (id === currentThreadIssueId) {
+ return;
+ }
+ seen.add(id);
+ out.push(id);
+ };
+ if (mode === "prepend") {
+ for (const id of additions) {
+ push(id);
+ }
+ if (previous) {
+ for (const p of previous.split(",")) {
+ push(p);
+ }
+ }
+ } else {
+ if (previous) {
+ for (const p of previous.split(",")) {
+ push(p);
+ }
+ }
+ for (const id of additions) {
+ push(id);
+ }
+ }
+ return out.slice(0, MAX_REFERENCED_ISSUES_IN_REPLY).join(",");
+}
+
+export type AiIssueAssistantToolRunState = {
+ referencedIssueIdsCsv?: string; // Comma-separated issue IDs for comment previews
+ editorMarkdownApplied: boolean;
+ issuePropertiesUpdated: boolean;
+ issueLinksUpdated: boolean;
+};
+
+function createIssueTool(
+ roomId: string,
+ state: AiIssueAssistantToolRunState
+) {
+ return tool({
+ description:
+ "Create a new tracked issue. Required: title. Optional: descriptionMarkdown (GFM body — NEVER start with #/##/###; use blank lines between paragraphs / two newlines; title is the title field), labels, links, progress, priority, assignedTo. The new issue’s nanoid is prepended to comment `referencedIssueIds` for inline previews—do not pass that same id to link_issues_in_reply in the same reply.",
+ inputSchema: z.object({
+ title: z
+ .string()
+ .describe("Concise issue title shown in the issue list"),
+ descriptionMarkdown: z
+ .string()
+ .optional()
+ .describe(
+ "Body markdown: open with a paragraph, list, or quote — NEVER start with a heading line (# through ######); use the title field for the issue name. Headings allowed deeper in the body after opening text if needed. Between prose paragraphs use a blank line (two newlines), not a single newline."
+ ),
+ labels: z
+ .array(z.enum(ISSUE_LABEL_IDS))
+ .optional()
+ .describe(
+ "Initial labels for the new issue (full set). Omit for none."
+ ),
+ links: z
+ .array(z.string().min(1).max(4000))
+ .max(30)
+ .optional()
+ .describe(
+ "URLs to add under the issue’s Links section (plain https URLs, like the UI)."
+ ),
+ progress: z.enum(ISSUE_PROGRESS_IDS).optional(),
+ priority: z.enum(ISSUE_PRIORITY_IDS).optional(),
+ assignedTo: z
+ .union([z.literal("none"), z.string().min(1)])
+ .optional()
+ .describe(
+ 'Initial assignee: "none" or an exact human user id from the prompt.'
+ ),
+ }),
+ execute: async (input) => {
+ const humanIds = new Set(
+ getUsers()
+ .filter((u) => u.id !== AI_USER_INFO.id)
+ .map((u) => u.id)
+ );
+
+ let assignedTo: CreateIssueRoomOptions["assignedTo"];
+ if (input.assignedTo === undefined) {
+ assignedTo = undefined;
+ } else if (input.assignedTo === "none") {
+ assignedTo = "none";
+ } else {
+ assignedTo = humanIds.has(input.assignedTo)
+ ? input.assignedTo
+ : "none";
+ }
+
+ const { issueId } = await createIssueRoomForAi(input.title, {
+ descriptionMarkdown: input.descriptionMarkdown,
+ labels: input.labels,
+ links: input.links,
+ progress: input.progress,
+ priority: input.priority,
+ assignedTo,
+ });
+ const current = getIssueId(roomId);
+ state.referencedIssueIdsCsv = mergeReferencedIssueIdsCsv(
+ state.referencedIssueIdsCsv,
+ [issueId],
+ current,
+ "prepend"
+ );
+ return { issueId };
+ },
+ });
+}
+
+function appendIssueLinksTool(
+ roomId: string,
+ state: AiIssueAssistantToolRunState
+) {
+ return tool({
+ description:
+ "Append one or more URLs to this issue’s Links section (plain https URLs, deduped; max 30 links total on an issue). Use when the user wants references attached to the ticket, not only markdown links in the body.",
+ inputSchema: z.object({
+ urls: z
+ .array(z.string().min(1).max(4000))
+ .min(1)
+ .max(30)
+ .describe(
+ "URLs to add (trimmed; duplicates and the cap are handled server-side)."
+ ),
+ }),
+ execute: async ({ urls }) => {
+ const { added } = await appendIssueLinks(roomId, urls);
+ if (added > 0) {
+ state.issueLinksUpdated = true;
+ }
+ return { added };
+ },
+ });
+}
+
+function insertIssueDescriptionMarkdownTool(
+ roomId: string,
+ state: AiIssueAssistantToolRunState
+) {
+ return tool({
+ description:
+ "Insert markdown into this issue's main description (Lexical). The title property is the only top-level title — NEVER begin markdown with #/##/###. Prefer append. Use blank lines (two newlines) between paragraphs.",
+ inputSchema: z.object({
+ markdown: z
+ .string()
+ .describe(
+ "Markdown for the body: first line must NOT be a heading (# … ######). Start with a paragraph or list; headings only after opening non-heading content if needed. Separate paragraphs with a blank line (two newlines), not a single newline between prose lines."
+ ),
+ mode: z
+ .enum(["append", "replace"])
+ .default("append")
+ .describe(
+ "append: add after existing content. replace: clear the description then set from markdown only if the user asked to replace everything."
+ ),
+ }),
+ execute: async ({ markdown, mode }) => {
+ await applyIssueDescriptionMarkdown(roomId, markdown, mode);
+ state.editorMarkdownApplied = true;
+ return { applied: true as const };
+ },
+ });
+}
+
+function updateIssuePropertiesTool(
+ roomId: string,
+ state: AiIssueAssistantToolRunState
+) {
+ return tool({
+ description:
+ "Update this issue's title, progress, priority, assignee, and/or labels in storage (and room metadata for lists). Only pass fields you are changing.",
+ inputSchema: z.object({
+ title: z
+ .string()
+ .optional()
+ .describe("Issue title (storage meta + room metadata)."),
+ progress: z.enum(ISSUE_PROGRESS_IDS).optional(),
+ priority: z.enum(ISSUE_PRIORITY_IDS).optional(),
+ assignedTo: z
+ .union([z.literal("none"), z.string().min(1)])
+ .optional()
+ .describe(
+ 'Use "none" or an exact user id from the system prompt list.'
+ ),
+ labels: z
+ .array(z.enum(ISSUE_LABEL_IDS))
+ .optional()
+ .describe("Full label set to apply (replaces existing)."),
+ }),
+ execute: async (patch) => {
+ const { title, progress, priority, assignedTo, labels } = patch;
+ const updates: IssuePropertyUpdates = {};
+ if (title !== undefined) updates.title = title;
+ if (progress !== undefined) updates.progress = progress;
+ if (priority !== undefined) updates.priority = priority;
+ if (assignedTo !== undefined) updates.assignedTo = assignedTo;
+ if (labels !== undefined) updates.labels = labels;
+ if (Object.keys(updates).length === 0) {
+ return { updated: false as const };
+ }
+ await applyIssuePropertyUpdates(roomId, updates);
+ state.issuePropertiesUpdated = true;
+ return { updated: true as const };
+ },
+ });
+}
+
+function updateIssuePropertyFieldsTool(
+ roomId: string,
+ state: AiIssueAssistantToolRunState
+) {
+ return tool({
+ description:
+ "Update this issue's progress, priority, and/or assignee in storage (and room metadata). Only pass fields you are changing.",
+ inputSchema: z.object({
+ progress: z.enum(ISSUE_PROGRESS_IDS).optional(),
+ priority: z.enum(ISSUE_PRIORITY_IDS).optional(),
+ assignedTo: z
+ .union([z.literal("none"), z.string().min(1)])
+ .optional()
+ .describe(
+ 'Use "none" or an exact user id from the system prompt list.'
+ ),
+ }),
+ execute: async (patch) => {
+ const { progress, priority, assignedTo } = patch;
+ const updates: IssuePropertyUpdates = {};
+ if (progress !== undefined) updates.progress = progress;
+ if (priority !== undefined) updates.priority = priority;
+ if (assignedTo !== undefined) updates.assignedTo = assignedTo;
+ if (Object.keys(updates).length === 0) {
+ return { updated: false as const };
+ }
+ await applyIssuePropertyUpdates(roomId, updates);
+ state.issuePropertiesUpdated = true;
+ return { updated: true as const };
+ },
+ });
+}
+
+function updateIssueLabelsTool(
+ roomId: string,
+ state: AiIssueAssistantToolRunState
+) {
+ return tool({
+ description:
+ "Set this issue's labels (replaces the existing label set).",
+ inputSchema: z.object({
+ labels: z
+ .array(z.enum(ISSUE_LABEL_IDS))
+ .describe("Full label set to apply (replaces existing)."),
+ }),
+ execute: async ({ labels }) => {
+ await applyIssuePropertyUpdates(roomId, { labels });
+ state.issuePropertiesUpdated = true;
+ return { updated: true as const };
+ },
+ });
+}
+
+function listRecentIssuesTool() {
+ return tool({
+ description:
+ "List the 20 most recently created issues (newest first). Takes no input. When your answer names specific issues from the results, you must also call **link_issues_in_reply** with those nanoid ids (same reply)—do not only list them in text.",
+ inputSchema: z.object({}),
+ execute: async () => {
+ const page = await liveblocks.getRooms({
+ limit: 20,
+ query: { roomId: { startsWith: getRoomId("") } },
+ });
+
+ const issues = page.data.map((room) => {
+ const meta = (room.metadata ?? {}) as Partial;
+ return {
+ issueId: meta.issueId ?? getIssueId(room.id),
+ title: meta.title ?? "Untitled",
+ progress: meta.progress ?? "none",
+ priority: meta.priority ?? "none",
+ };
+ });
+
+ return { issues };
+ },
+ });
+}
+
+function linkIssuesInReplyTool(
+ roomId: string,
+ state: AiIssueAssistantToolRunState
+) {
+ return tool({
+ description:
+ "Required whenever you surface specific other issues in your answer (e.g. after list_recent_issues): attach them as inline previews under this comment via comment metadata (comma-separated issueIds, max 10 after merge). Do not ask the user for permission to link—call this tool with the nanoid ids you are discussing. Multiple calls merge (deduped). Each id must be a real issue room.",
+ inputSchema: z.object({
+ issueIds: z
+ .array(z.string().min(1))
+ .min(1)
+ .max(MAX_REFERENCED_ISSUES_IN_REPLY)
+ .describe(
+ "Issue ids (nanoids), not full room ids. Duplicates and the current thread id are ignored."
+ ),
+ }),
+ execute: async ({ issueIds }) => {
+ const current = getIssueId(roomId);
+ const validated: string[] = [];
+ for (const raw of issueIds) {
+ const id = raw.trim();
+ if (!id || id === current) {
+ continue;
+ }
+ try {
+ await liveblocks.getRoom(getRoomId(id));
+ } catch {
+ return {
+ linked: false as const,
+ error: `No issue found for issueId: ${id}`,
+ };
+ }
+ validated.push(id);
+ }
+ if (validated.length === 0) {
+ return {
+ linked: false as const,
+ error: "No valid issue ids to link.",
+ };
+ }
+ state.referencedIssueIdsCsv = mergeReferencedIssueIdsCsv(
+ state.referencedIssueIdsCsv,
+ validated,
+ current,
+ "append"
+ );
+ return {
+ linked: true as const,
+ issueIds: state.referencedIssueIdsCsv.split(",").filter(Boolean),
+ };
+ },
+ });
+}
+
+export function createAiIssueAssistantTools(
+ roomId: string,
+ state: AiIssueAssistantToolRunState
+) {
+ return {
+ create_issue: createIssueTool(roomId, state),
+ append_issue_links: appendIssueLinksTool(roomId, state),
+ insert_issue_description_markdown: insertIssueDescriptionMarkdownTool(
+ roomId,
+ state
+ ),
+ update_issue_properties: updateIssuePropertiesTool(roomId, state),
+ list_recent_issues: listRecentIssuesTool(),
+ link_issues_in_reply: linkIssuesInReplyTool(roomId, state),
+ };
+}
+
+export function createButtonLinksTools(
+ roomId: string,
+ state: AiIssueAssistantToolRunState
+) {
+ return {
+ append_issue_links: appendIssueLinksTool(roomId, state),
+ };
+}
+
+export function createButtonPropertiesTools(
+ roomId: string,
+ state: AiIssueAssistantToolRunState
+) {
+ return {
+ update_issue_properties: updateIssuePropertyFieldsTool(roomId, state),
+ };
+}
+
+export function createButtonLabelsTools(
+ roomId: string,
+ state: AiIssueAssistantToolRunState
+) {
+ return {
+ update_issue_labels: updateIssueLabelsTool(roomId, state),
+ };
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-assistant.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-assistant.ts
new file mode 100644
index 00000000000..5f3286dd6c5
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-assistant.ts
@@ -0,0 +1,261 @@
+import { AI_USER_INFO, getUsers } from "@/database";
+import {
+ createAiPlaceholderComment,
+ updateAiPlaceholderComment,
+ type CommentLocation,
+} from "@/lib/ai-comment-bridge";
+import {
+ writeFeedComplete,
+ writeFeedThinking,
+ writeFeedWriting,
+} from "@/lib/ai-feed-messages";
+import {
+ hideAiPresence,
+ leaveAiReactionOnComment,
+ showAiPresence,
+} from "@/lib/ai-remote-presence";
+import {
+ createAiIssueAssistantTools,
+ type AiIssueAssistantToolRunState,
+} from "@/lib/ai-issue-assistant-tools";
+import { buildAiIssueAssistantSystemPrompt } from "@/lib/ai-issue-assistant-prompt";
+import { buildIssueContextMarkdown } from "@/lib/issue-context-markdown";
+import { getIssueId } from "@/config";
+import { liveblocks } from "@/liveblocks.server.config";
+import {
+ getMentionsFromCommentBody,
+ type CommentData,
+ type ThreadData,
+} from "@liveblocks/node";
+import { stringifyCommentBody } from "@liveblocks/client";
+import { anthropic, AnthropicLanguageModelOptions } from "@ai-sdk/anthropic";
+import { ModelMessage, stepCountIs, streamText } from "ai";
+
+// Entry point for the comment reply assistant
+export async function runAiIssueAssistant(
+ commentLocation: CommentLocation
+): Promise<{ status: number; body?: string; error?: string }> {
+ const { roomId, threadId, commentId } = commentLocation;
+ const feedId = `comment-reply-${roomId}-${threadId}-${commentId}`;
+
+ try {
+ const { thread, comment } = await getThreadAndComment(commentLocation);
+
+ if (!thread || !comment) {
+ throw new Error("Thread or comment not found");
+ }
+
+ if (!comment.body) {
+ throw new Error("Comment deleted");
+ }
+
+ if (!(await isAiMentionedInComment(comment))) {
+ return { status: 200, body: "AI is not mentioned in the comment" };
+ }
+
+ const placeholderComment = await createAiPlaceholderComment({
+ roomId,
+ threadId,
+ feedId,
+ });
+ const placeholderCommentLocation: CommentLocation = {
+ ...commentLocation,
+ commentId: placeholderComment.id,
+ };
+
+ await Promise.all([
+ liveblocks.createFeed({
+ roomId,
+ feedId,
+ metadata: {
+ type: "ai-comment-reply",
+ threadId,
+ commentId: placeholderComment.id,
+ },
+ }),
+ showAiPresence(roomId),
+ leaveAiReactionOnComment(commentLocation),
+ ]);
+
+ const {
+ response,
+ referencedIssueIdsCsv,
+ editorMarkdownApplied,
+ issuePropertiesUpdated,
+ issueLinksUpdated,
+ } = await streamCommentThreadReply({ roomId, feedId, thread, comment });
+
+ const hasOutput =
+ (response !== undefined && response.trim().length > 0) ||
+ (referencedIssueIdsCsv !== undefined &&
+ referencedIssueIdsCsv.length > 0) ||
+ editorMarkdownApplied ||
+ issuePropertiesUpdated ||
+ issueLinksUpdated;
+
+ if (!hasOutput) {
+ await hideAiPresence(roomId).catch(() => undefined);
+ return { status: 500, error: "Failed to generate response" };
+ }
+
+ await updateAiPlaceholderComment({
+ ...placeholderCommentLocation,
+ feedId,
+ response,
+ referencedIssueIdsCsv,
+ });
+
+ // Let the last AI presence stay for a couple secs
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ await hideAiPresence(roomId);
+
+ return { status: 200, body: "AI replied to comment" };
+ } catch (err) {
+ await hideAiPresence(roomId).catch(() => undefined);
+
+ return { status: 400, error: `${err}` };
+ }
+}
+
+async function streamCommentThreadReply({
+ roomId,
+ feedId,
+ thread,
+ comment,
+}: {
+ roomId: string;
+ feedId: string;
+ thread: ThreadData;
+ comment: CommentData;
+}) {
+ const stringifiedComment = comment.body
+ ? await stringifyCommentBody(comment.body)
+ : "Deleted comment";
+
+ const issueContextMd = await buildIssueContextMarkdown(roomId);
+
+ const assignableUsersLines = getUsers()
+ .filter((u) => u.id !== AI_USER_INFO.id)
+ .map(
+ (u) =>
+ `- \`${u.id}\` — ${typeof u.info === "object" && u.info && "name" in u.info ? String(u.info.name) : u.id}`
+ )
+ .join("\n");
+
+ const allUsersLines = getUsers()
+ .map((u) => {
+ const name =
+ typeof u.info === "object" && u.info && "name" in u.info
+ ? String(u.info.name)
+ : u.id;
+ const tag = u.id === AI_USER_INFO.id ? " — assistant" : "";
+ return `- \`${u.id}\` — ${name}${tag}`;
+ })
+ .join("\n");
+
+ const toolRunState: AiIssueAssistantToolRunState = {
+ editorMarkdownApplied: false,
+ issuePropertiesUpdated: false,
+ issueLinksUpdated: false,
+ };
+
+ const system = buildAiIssueAssistantSystemPrompt({
+ aiUserId: AI_USER_INFO.id,
+ currentIssueId: getIssueId(roomId),
+ assignableUsersLines,
+ allUsersLines,
+ issueContextMd,
+ stringifiedComment,
+ });
+
+ const messages: ModelMessage[] = [];
+
+ for (const c of thread.comments) {
+ const buildMessageContent = (content: string) =>
+ `${c.userId} at ${c.createdAt}:
+
+${content}
+`;
+ messages.push({
+ role: c.userId === AI_USER_INFO.id ? "assistant" : "user",
+ content: c.body
+ ? buildMessageContent(await stringifyCommentBody(c.body))
+ : buildMessageContent("Deleted comment"),
+ });
+ }
+
+ const result = streamText({
+ model: anthropic("claude-haiku-4-5"),
+ system,
+ messages,
+ stopWhen: stepCountIs(16),
+ tools: createAiIssueAssistantTools(roomId, toolRunState),
+ providerOptions: {
+ anthropic: {
+ sendReasoning: true,
+ thinking: { type: "enabled", budgetTokens: 10000 },
+ } satisfies AnthropicLanguageModelOptions,
+ },
+ });
+
+ let totalReasoning = "";
+ let totalText = "";
+ const thinkingStartedAt = performance.now();
+ // Feed writes are fire-and-forget during streaming so model output is not paced
+ // by Liveblocks API latency; we drain them before the final `complete` message.
+ const feedWrites: Promise[] = [];
+
+ for await (const part of result.fullStream) {
+ if (part.type === "reasoning-delta") {
+ totalReasoning += part.text;
+ feedWrites.push(
+ writeFeedThinking({ roomId, feedId }, part.text, totalReasoning)
+ );
+ } else if (part.type === "text-delta") {
+ totalText += part.text;
+ feedWrites.push(
+ writeFeedWriting({ roomId, feedId }, part.text, totalText)
+ );
+ }
+ }
+
+ const thinkingEndedAt = performance.now();
+ await Promise.all(feedWrites);
+
+ await writeFeedComplete(
+ { roomId, feedId },
+ {
+ response: totalText,
+ reasoning: totalReasoning,
+ thinkingTime: (thinkingEndedAt - thinkingStartedAt) / 1000,
+ }
+ );
+
+ return {
+ response: totalText,
+ reasoning: totalReasoning,
+ referencedIssueIdsCsv: toolRunState.referencedIssueIdsCsv,
+ editorMarkdownApplied: toolRunState.editorMarkdownApplied,
+ issuePropertiesUpdated: toolRunState.issuePropertiesUpdated,
+ issueLinksUpdated: toolRunState.issueLinksUpdated,
+ };
+}
+
+async function getThreadAndComment({
+ roomId,
+ threadId,
+ commentId,
+}: CommentLocation) {
+ const thread = await liveblocks.getThread({ roomId, threadId });
+ const c = thread?.comments.find((x) => x.id === commentId);
+ return { thread, comment: c };
+}
+
+async function isAiMentionedInComment(comment: CommentData) {
+ if (!comment.body) {
+ return false;
+ }
+
+ const mentions = getMentionsFromCommentBody(comment.body);
+ return mentions.map((m) => m.id).includes(AI_USER_INFO.id);
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-button-prompts.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-button-prompts.ts
new file mode 100644
index 00000000000..355a813f3c9
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-button-prompts.ts
@@ -0,0 +1,86 @@
+import {
+ ISSUE_PRIORITY_IDS,
+ ISSUE_PROGRESS_IDS,
+ LABELS,
+} from "@/config";
+
+export type AiIssueButtonKind = "links" | "properties" | "labels";
+
+export function buildButtonLinksSystemPrompt(issueContextMd: string): string {
+ return `You help collaborators by adding **relevant external links** to an issue’s Links sidebar only.
+
+## Rules
+
+- Use **append_issue_links** with plain \`https://…\` URLs only. Duplicates are skipped server-side; the list is capped at 30 links total.
+- Read the issue snapshot below. Prefer documentation, specs, standards, or clearly related references implied by the title or description. Do **not** add URLs that are already listed under Links.
+- Add a small, sensible set of links (typically 1–5) unless the issue clearly needs more.
+- Do **not** write any reply text — only call the tool. The result is shown by the Links sidebar updating; no comment is posted.
+
+## Issue snapshot
+
+${issueContextMd}`;
+}
+
+export function buildButtonPropertiesSystemPrompt(
+ issueContextMd: string,
+ assignableUsersLines: string
+): string {
+ const progressLines = ISSUE_PROGRESS_IDS.map(
+ (id) => `- \`${id}\``
+ ).join("\n");
+ const priorityLines = ISSUE_PRIORITY_IDS.map(
+ (id) => `- \`${id}\``
+ ).join("\n");
+
+ const assignableBlock =
+ assignableUsersLines.length > 0
+ ? assignableUsersLines
+ : "_No assignable users in this demo._";
+
+ return `You fill in **missing** issue fields: **progress**, **priority**, and **assignedTo** only.
+
+## Rules
+
+- Use **update_issue_properties** with only the keys you are changing. Use **exact ids** below for \`progress\` and \`priority\`. For \`assignedTo\`, use \`none\` or an exact user id from the assignable list.
+- If **progress** is \`none\` and the issue clearly needs triage, set it to **todo**. Do not overwrite \`progress\`, \`review\`, \`done\`, or \`progress\` (in progress) unless the snapshot is clearly wrong.
+- If **priority** is \`none\`, pick a sensible priority from the list. If it is already set, leave it unless obviously wrong.
+- Set **assignedTo** only when the title or description clearly implies a specific person and you can map them to an id from the list; otherwise omit \`assignedTo\`.
+- Do **not** write any reply text — only call the tool. The result is shown by the property fields updating; no comment is posted.
+
+### Progress ids
+
+${progressLines}
+
+### Priority ids
+
+${priorityLines}
+
+### Assignable users
+
+${assignableBlock}
+
+## Issue snapshot
+
+${issueContextMd}`;
+}
+
+export function buildButtonLabelsSystemPrompt(issueContextMd: string): string {
+ const labelsValidLines = LABELS.map(
+ (l) => `- \`${l.id}\` — ${l.text}`
+ ).join("\n");
+
+ return `You set **labels** for this issue only (the full label set via **update_issue_labels** \`labels\` array).
+
+## Rules
+
+- Choose **label ids** from the list below that match the title and description (e.g. bug vs feature). If the current labels already fit, you may leave them unchanged.
+- Do **not** write any reply text — only call the tool. The result is shown by the label list updating; no comment is posted.
+
+### Valid label ids
+
+${labelsValidLines}
+
+## Issue snapshot
+
+${issueContextMd}`;
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-button.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-button.ts
new file mode 100644
index 00000000000..82cc78288ba
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-issue-button.ts
@@ -0,0 +1,230 @@
+import { AI_USER_INFO, getUsers } from "@/database";
+import {
+ writeFeedComplete,
+ writeFeedStatus,
+} from "@/lib/ai-feed-messages";
+import { hideAiPresence, showAiPresence } from "@/lib/ai-remote-presence";
+import {
+ createButtonLabelsTools,
+ createButtonLinksTools,
+ createButtonPropertiesTools,
+ type AiIssueAssistantToolRunState,
+} from "@/lib/ai-issue-assistant-tools";
+import {
+ buildButtonLabelsSystemPrompt,
+ buildButtonLinksSystemPrompt,
+ buildButtonPropertiesSystemPrompt,
+ type AiIssueButtonKind,
+} from "@/lib/ai-issue-button-prompts";
+import { buildIssueContextMarkdown } from "@/lib/issue-context-markdown";
+import { getRoomId } from "@/config";
+import { liveblocks } from "@/liveblocks.server.config";
+import { anthropic, AnthropicLanguageModelOptions } from "@ai-sdk/anthropic";
+import { ModelMessage, stepCountIs, streamText } from "ai";
+import { nanoid } from "nanoid";
+
+export type AiIssueButtonRunContext = {
+ roomId: string;
+ feedId: string;
+ kind: AiIssueButtonKind;
+};
+
+function isAllowedRequester(userId: string): boolean {
+ if (userId === AI_USER_INFO.id) {
+ return false;
+ }
+ return getUsers().some((u) => u.id === userId);
+}
+
+// Sets up context for AI buttons on page
+export async function prepareAiIssueButton(input: {
+ issueId: string;
+ requestedByUserId: string;
+ kind: AiIssueButtonKind;
+}): Promise<
+ | { ok: true; ctx: AiIssueButtonRunContext }
+ | { ok: false; error: string }
+> {
+ const { issueId, requestedByUserId, kind } = input;
+
+ if (!isAllowedRequester(requestedByUserId)) {
+ return { ok: false, error: "Invalid user." };
+ }
+
+ const roomId = getRoomId(issueId);
+ const feedId = `issue-button-${kind}-${nanoid(10)}`;
+
+ try {
+ await Promise.all([
+ liveblocks.createFeed({
+ roomId,
+ feedId,
+ metadata: { type: "ai-issue-button", kind },
+ }),
+ showAiPresence(roomId),
+ ]);
+
+ await writeFeedStatus({ roomId, feedId }, "Starting…");
+
+ return { ok: true, ctx: { roomId, feedId, kind } };
+ } catch (err) {
+ return { ok: false, error: `${err}` };
+ }
+}
+
+function statusLabelsForToolInput(toolName: string, input: unknown): string[] {
+ if (toolName === "append_issue_links") {
+ return ["Adding links…"];
+ }
+ if (toolName === "update_issue_labels") {
+ return ["Updating labels…"];
+ }
+ if (toolName === "update_issue_properties") {
+ if (!input || typeof input !== "object") {
+ return ["Updating…"];
+ }
+ const o = input as Record;
+ const ordered: [string, string][] = [
+ ["assignedTo", "Assigning user…"],
+ ["priority", "Updating priority…"],
+ ["progress", "Updating progress…"],
+ ];
+ const out: string[] = [];
+ for (const [key, label] of ordered) {
+ if (o[key] !== undefined) {
+ out.push(label);
+ }
+ }
+ return out.length > 0 ? out : ["Updating…"];
+ }
+ return [`Running ${toolName}…`];
+}
+
+async function streamButtonToFeed(ctx: AiIssueButtonRunContext) {
+ const { roomId, feedId, kind } = ctx;
+
+ const issueContextMd = await buildIssueContextMarkdown(roomId);
+
+ const assignableUsersLines = getUsers()
+ .filter((u) => u.id !== AI_USER_INFO.id)
+ .map(
+ (u) =>
+ `- \`${u.id}\` — ${typeof u.info === "object" && u.info && "name" in u.info ? String(u.info.name) : u.id}`
+ )
+ .join("\n");
+
+ const toolRunState: AiIssueAssistantToolRunState = {
+ editorMarkdownApplied: false,
+ issuePropertiesUpdated: false,
+ issueLinksUpdated: false,
+ };
+
+ const system =
+ kind === "links"
+ ? buildButtonLinksSystemPrompt(issueContextMd)
+ : kind === "properties"
+ ? buildButtonPropertiesSystemPrompt(
+ issueContextMd,
+ assignableUsersLines
+ )
+ : buildButtonLabelsSystemPrompt(issueContextMd);
+
+ const userMessage: ModelMessage =
+ kind === "links"
+ ? {
+ role: "user",
+ content:
+ "Find and add relevant https links for this issue using your tools.",
+ }
+ : kind === "properties"
+ ? {
+ role: "user",
+ content:
+ "Fill in missing progress, priority, and/or assignee using your tools.",
+ }
+ : {
+ role: "user",
+ content: "Set appropriate labels using your tools.",
+ };
+
+ const tools =
+ kind === "links"
+ ? createButtonLinksTools(roomId, toolRunState)
+ : kind === "properties"
+ ? createButtonPropertiesTools(roomId, toolRunState)
+ : createButtonLabelsTools(roomId, toolRunState);
+
+ const result = streamText({
+ // Cheap model for small button edits
+ model: anthropic("claude-haiku-4-5"),
+ system,
+ messages: [userMessage],
+ stopWhen: stepCountIs(8),
+ tools,
+ providerOptions: {
+ anthropic: {
+ sendReasoning: true,
+ thinking: { type: "enabled", budgetTokens: 8000 },
+ } satisfies AnthropicLanguageModelOptions,
+ },
+ });
+
+ let totalReasoning = "";
+ let totalText = "";
+ const thinkingStartedAt = performance.now();
+
+ let sentThinking = false;
+ const reportedToolCalls = new Set();
+
+ for await (const part of result.fullStream) {
+ if (part.type === "reasoning-delta") {
+ totalReasoning += part.text;
+ if (!sentThinking) {
+ sentThinking = true;
+ await writeFeedStatus({ roomId, feedId }, "Thinking…");
+ }
+ } else if (part.type === "text-delta") {
+ totalText += part.text;
+ } else if (part.type === "tool-result") {
+ if (reportedToolCalls.has(part.toolCallId)) {
+ continue;
+ }
+ reportedToolCalls.add(part.toolCallId);
+ const labels = statusLabelsForToolInput(part.toolName, part.input);
+ for (const label of labels) {
+ await writeFeedStatus({ roomId, feedId }, label);
+ }
+ }
+ }
+
+ const thinkingEndedAt = performance.now();
+
+ await writeFeedStatus({ roomId, feedId }, "Done…");
+
+ await writeFeedComplete(
+ { roomId, feedId },
+ {
+ response: totalText,
+ reasoning: totalReasoning,
+ thinkingTime: (thinkingEndedAt - thinkingStartedAt) / 1000,
+ }
+ );
+}
+
+// Main entry point for button
+export async function runAiIssueButtonStream(
+ ctx: AiIssueButtonRunContext
+): Promise<{ status: number; error?: string }> {
+ try {
+ await streamButtonToFeed(ctx);
+
+ // Let the AI editing-type outlines linger briefly, then clear presence.
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ await hideAiPresence(ctx.roomId);
+
+ return { status: 200 };
+ } catch (err) {
+ await hideAiPresence(ctx.roomId).catch(() => undefined);
+ return { status: 400, error: `${err}` };
+ }
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/ai-remote-presence.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-remote-presence.ts
new file mode 100644
index 00000000000..ee5acfd7cee
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/ai-remote-presence.ts
@@ -0,0 +1,45 @@
+import { AI_USER_INFO } from "@/database";
+import type { AiEditingPresenceType } from "@/lib/ai-editing-presence-types";
+import { liveblocks } from "@/liveblocks.server.config";
+
+// Show AI with just avatar
+export async function showAiPresence(roomId: string): Promise {
+ await setAiRemotePresenceEditing(roomId, []);
+}
+
+// Make AI presence expire ASAP (2 secs)
+export async function hideAiPresence(roomId: string): Promise {
+ await liveblocks.setPresence(roomId, {
+ ttl: 2,
+ userId: AI_USER_INFO.id,
+ userInfo: { ...AI_USER_INFO.info },
+ data: { editingTypes: [] },
+ });
+}
+
+// Show what the AI is editing on the page
+export async function setAiRemotePresenceEditing(
+ roomId: string,
+ editingTypes: AiEditingPresenceType[] | string[]
+): Promise {
+ await liveblocks.setPresence(roomId, {
+ userId: AI_USER_INFO.id,
+ userInfo: { ...AI_USER_INFO.info },
+ data: { editingTypes: [...editingTypes] },
+ ttl: 15,
+ });
+}
+
+// Add 👀 reaction to comment
+export async function leaveAiReactionOnComment(loc: {
+ roomId: string;
+ threadId: string;
+ commentId: string;
+}): Promise {
+ await liveblocks.addCommentReaction({
+ roomId: loc.roomId,
+ threadId: loc.threadId,
+ commentId: loc.commentId,
+ data: { emoji: "👀", userId: AI_USER_INFO.id, createdAt: new Date() },
+ });
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/apply-issue-description-markdown.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/apply-issue-description-markdown.ts
new file mode 100644
index 00000000000..65960d33862
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/apply-issue-description-markdown.ts
@@ -0,0 +1,51 @@
+import { withLexicalDocument } from "@liveblocks/node-lexical";
+import { $convertFromMarkdownString, TRANSFORMERS } from "@lexical/markdown";
+import { $getRoot } from "lexical";
+import { liveblocks } from "@/liveblocks.server.config";
+import { ISSUE_LEXICAL_NODES } from "@/lib/issue-lexical-nodes";
+import { AI_EDITING_TYPE } from "@/lib/ai-editing-presence-types";
+import { setAiRemotePresenceEditing } from "@/lib/ai-remote-presence";
+
+export type IssueDescriptionMarkdownMode = "append" | "replace";
+
+// Adds markdown content to Lexical
+export async function applyIssueDescriptionMarkdown(
+ roomId: string,
+ markdown: string,
+ mode: IssueDescriptionMarkdownMode
+): Promise {
+ const text = markdown.trim();
+ if (!text) {
+ return;
+ }
+
+ await setAiRemotePresenceEditing(roomId, [AI_EDITING_TYPE.CONTENT]);
+ await withLexicalDocument(
+ {
+ roomId,
+ client: liveblocks,
+ nodes: [...ISSUE_LEXICAL_NODES],
+ },
+ async (doc) => {
+ await doc.update(() => {
+ const root = $getRoot();
+
+ if (mode === "replace") {
+ root.clear();
+ $convertFromMarkdownString(text, TRANSFORMERS);
+ return;
+ }
+
+ const last = root.getLastChild();
+ if (last !== null) {
+ last.selectEnd();
+ }
+ const prefix =
+ root.getChildrenSize() > 0 && root.getTextContent().trim().length > 0
+ ? "\n\n"
+ : "";
+ $convertFromMarkdownString(prefix + text, TRANSFORMERS);
+ });
+ }
+ );
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/apply-issue-links.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/apply-issue-links.ts
new file mode 100644
index 00000000000..5b7dcc0fef4
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/apply-issue-links.ts
@@ -0,0 +1,46 @@
+import { liveblocks } from "@/liveblocks.server.config";
+import { AI_EDITING_TYPE } from "@/lib/ai-editing-presence-types";
+import { setAiRemotePresenceEditing } from "@/lib/ai-remote-presence";
+
+const MAX_ISSUE_LINKS = 30;
+
+const MAX_URL_LENGTH = 4000;
+
+// Add to list of links
+export async function appendIssueLinks(
+ roomId: string,
+ urls: string[]
+): Promise<{ added: number }> {
+ const normalized = urls
+ .map((u) => u.trim())
+ .filter((u) => u.length > 0 && u.length <= MAX_URL_LENGTH);
+
+ if (normalized.length === 0) {
+ return { added: 0 };
+ }
+
+ await setAiRemotePresenceEditing(roomId, [AI_EDITING_TYPE.LINKS]);
+
+ let added = 0;
+ await liveblocks.mutateStorage(roomId, ({ root }) => {
+ const list = root.get("links");
+ const existing = new Set();
+ for (let i = 0; i < list.length; i++) {
+ existing.add(String(list.get(i)));
+ }
+
+ for (const url of normalized) {
+ if (list.length >= MAX_ISSUE_LINKS) {
+ break;
+ }
+ if (existing.has(url)) {
+ continue;
+ }
+ list.push(url);
+ existing.add(url);
+ added += 1;
+ }
+ });
+
+ return { added };
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/apply-issue-property-updates.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/apply-issue-property-updates.ts
new file mode 100644
index 00000000000..643b83a19f2
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/apply-issue-property-updates.ts
@@ -0,0 +1,98 @@
+import {
+ getIssueId,
+ type IssueLabelId,
+ type IssuePriorityId,
+ type IssueProgressId,
+} from "@/config";
+import type { ImmutableStorage } from "@/liveblocks.config";
+import { liveblocks } from "@/liveblocks.server.config";
+import {
+ AI_EDITING_TYPE,
+ type AiEditingPresenceType,
+} from "@/lib/ai-editing-presence-types";
+import { setAiRemotePresenceEditing } from "@/lib/ai-remote-presence";
+
+export type IssuePropertyUpdates = {
+ title?: string;
+ progress?: IssueProgressId;
+ priority?: IssuePriorityId;
+ assignedTo?: string | "none";
+ labels?: IssueLabelId[];
+};
+
+async function syncRoomMetadataFromStorage(roomId: string): Promise {
+ const doc = (await liveblocks.getStorageDocument(
+ roomId,
+ "json"
+ )) as unknown as ImmutableStorage;
+
+ await liveblocks.updateRoom(roomId, {
+ metadata: {
+ issueId: getIssueId(roomId),
+ title: doc.meta.title,
+ progress: doc.properties.progress,
+ priority: doc.properties.priority,
+ assignedTo: doc.properties.assignedTo,
+ labels: [...doc.labels],
+ },
+ });
+}
+
+function editingTypesFromPropertyUpdates(
+ updates: IssuePropertyUpdates
+): AiEditingPresenceType[] {
+ const types: AiEditingPresenceType[] = [];
+ if (updates.title !== undefined) types.push(AI_EDITING_TYPE.TITLE);
+ if (updates.progress !== undefined) types.push(AI_EDITING_TYPE.PROGRESS);
+ if (updates.priority !== undefined) types.push(AI_EDITING_TYPE.PRIORITY);
+ if (updates.assignedTo !== undefined) {
+ types.push(AI_EDITING_TYPE.ASSIGNED_TO);
+ }
+ if (updates.labels !== undefined) types.push(AI_EDITING_TYPE.LABELS);
+ return types;
+}
+
+// Updates storage values and sets presence
+export async function applyIssuePropertyUpdates(
+ roomId: string,
+ updates: IssuePropertyUpdates
+): Promise {
+ const keys = Object.keys(updates) as (keyof IssuePropertyUpdates)[];
+ if (keys.length === 0) {
+ return;
+ }
+
+ await setAiRemotePresenceEditing(
+ roomId,
+ editingTypesFromPropertyUpdates(updates)
+ );
+
+ await liveblocks.mutateStorage(roomId, ({ root }) => {
+ if (updates.title !== undefined) {
+ root.get("meta").set("title", updates.title);
+ }
+
+ const properties = root.get("properties");
+ if (updates.progress !== undefined) {
+ properties.set("progress", updates.progress);
+ }
+ if (updates.priority !== undefined) {
+ properties.set("priority", updates.priority);
+ }
+ if (updates.assignedTo !== undefined) {
+ properties.set("assignedTo", updates.assignedTo);
+ }
+
+ if (updates.labels !== undefined) {
+ const list = root.get("labels");
+ while (list.length > 0) {
+ list.delete(0);
+ }
+ for (const id of updates.labels) {
+ list.push(id);
+ }
+ }
+ });
+
+ await syncRoomMetadataFromStorage(roomId);
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/create-issue-room.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/create-issue-room.ts
new file mode 100644
index 00000000000..64d48b7844b
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/create-issue-room.ts
@@ -0,0 +1,85 @@
+import "@/liveblocks.config";
+import { nanoid } from "nanoid";
+import { LiveList, LiveObject, toPlainLson } from "@liveblocks/client";
+import { withLexicalDocument } from "@liveblocks/node-lexical";
+import {
+ getRoomId,
+ type IssueLabelId,
+ type IssuePriorityId,
+ type IssueProgressId,
+ type Metadata,
+} from "@/config";
+import { hideAiPresence } from "@/lib/ai-remote-presence";
+import { applyIssueDescriptionMarkdown } from "@/lib/apply-issue-description-markdown";
+import { ISSUE_LEXICAL_NODES } from "@/lib/issue-lexical-nodes";
+import { liveblocks } from "@/liveblocks.server.config";
+
+export type CreateIssueRoomOptions = {
+ descriptionMarkdown?: string;
+ labels?: IssueLabelId[];
+ links?: string[];
+ progress?: IssueProgressId;
+ priority?: IssuePriorityId;
+ assignedTo?: string | "none";
+};
+
+export async function createIssueRoomForAi(
+ title: string,
+ options?: CreateIssueRoomOptions
+): Promise<{ issueId: string }> {
+ const issueId = nanoid();
+ const roomId = getRoomId(issueId);
+ const trimmed = title.trim();
+ const displayTitle = trimmed.length > 0 ? trimmed : "Untitled";
+
+ const progress = options?.progress ?? "none";
+ const priority = options?.priority ?? "none";
+ const assignedTo = options?.assignedTo ?? "none";
+ const labelIds = [...(options?.labels ?? [])];
+ const linkStrs = (options?.links ?? [])
+ .map((l) => l.trim())
+ .filter((l) => l.length > 0);
+
+ const metadata: Metadata = {
+ issueId,
+ title: displayTitle,
+ progress,
+ priority,
+ assignedTo,
+ labels: [...labelIds],
+ };
+
+ await liveblocks.createRoom(roomId, {
+ defaultAccesses: ["room:write"],
+ metadata,
+ });
+
+ const initialStorage: LiveObject = new LiveObject({
+ meta: new LiveObject({ title: displayTitle }),
+ properties: new LiveObject({ progress, priority, assignedTo }),
+ labels: new LiveList(labelIds),
+ links: new LiveList(linkStrs),
+ });
+
+ await liveblocks.initializeStorageDocument(
+ roomId,
+ toPlainLson(initialStorage) as any
+ );
+
+ const md = options?.descriptionMarkdown?.trim();
+ if (md) {
+ await applyIssueDescriptionMarkdown(roomId, md, "replace");
+ } else {
+ // Initialize Lexical with empty document
+ await withLexicalDocument(
+ { roomId, client: liveblocks, nodes: [...ISSUE_LEXICAL_NODES] },
+ async (doc) => {
+ await doc.update(() => {});
+ }
+ );
+ }
+
+ await hideAiPresence(roomId);
+
+ return { issueId };
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/issue-context-markdown.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/issue-context-markdown.ts
new file mode 100644
index 00000000000..2bde3026b5b
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/issue-context-markdown.ts
@@ -0,0 +1,111 @@
+import { withLexicalDocument } from "@liveblocks/node-lexical";
+import { LABELS } from "@/config";
+import { liveblocks } from "@/liveblocks.server.config";
+import { ISSUE_LEXICAL_NODES } from "@/lib/issue-lexical-nodes";
+
+const LABEL_DISPLAY: Record = Object.fromEntries(
+ LABELS.map((l) => [l.id, l.text])
+);
+
+type RoomMetadataFields = {
+ issueId?: string;
+ title?: string;
+ progress?: string;
+ priority?: string;
+ assignedTo?: string;
+ labels?: string[];
+};
+
+type StorageJson = {
+ meta: { title: string };
+ properties: {
+ progress: string;
+ priority: string;
+ assignedTo: string;
+ };
+ labels: string[];
+ links: string[];
+};
+
+function formatLabelIds(ids: string[]): string {
+ if (!ids.length) {
+ return "_None_";
+ }
+ return ids
+ .map((id) => {
+ const text = LABEL_DISPLAY[id];
+ return text ? `- **${id}**: ${text}` : `- \`${id}\``;
+ })
+ .join("\n");
+}
+
+// Markdown snapshot of the issue: room metadata, storage fields, labels,
+export async function buildIssueContextMarkdown(roomId: string): Promise {
+ let storage: StorageJson;
+ try {
+ storage = (await liveblocks.getStorageDocument(
+ roomId,
+ "json"
+ )) as StorageJson;
+ } catch {
+ return "## Current issue\n\n_Unable to load storage._\n";
+ }
+
+ let roomMetadata: RoomMetadataFields = {};
+ try {
+ const room = await liveblocks.getRoom(roomId);
+ roomMetadata = (room.metadata ?? {}) as RoomMetadataFields;
+ } catch {
+ // continue without room metadata
+ }
+
+ let descriptionMd = "_No description could be loaded._";
+ try {
+ descriptionMd = await withLexicalDocument(
+ {
+ roomId,
+ client: liveblocks,
+ nodes: [...ISSUE_LEXICAL_NODES],
+ },
+ async (doc) => {
+ const md = doc.toMarkdown().trim();
+ return md.length > 0 ? md : "_Empty._";
+ }
+ );
+ } catch {
+ // Lexical unavailable for this room, should not happen
+ }
+
+ const labels = Array.isArray(storage.labels) ? storage.labels : [];
+ const links = Array.isArray(storage.links) ? storage.links : [];
+ const linksBlock =
+ links.length === 0 ? "_None_" : links.map((l) => `- ${l}`).join("\n");
+
+ return [
+ "### Storage title",
+ storage.meta?.title ?? "_Untitled_",
+ "",
+ "### Room metadata",
+ `- **issueId**: ${roomMetadata.issueId ?? "—"}`,
+ `- **title**: ${roomMetadata.title ?? "—"}`,
+ `- **progress**: ${roomMetadata.progress ?? "—"}`,
+ `- **priority**: ${roomMetadata.priority ?? "—"}`,
+ `- **assignedTo**: ${String(roomMetadata.assignedTo ?? "—")}`,
+ `- **labels**: ${(roomMetadata.labels ?? []).join(", ") || "—"}`,
+ "",
+ "### Live properties (storage)",
+ `- **progress**: ${storage.properties?.progress ?? "—"}`,
+ `- **priority**: ${storage.properties?.priority ?? "—"}`,
+ `- **assignedTo**: ${String(storage.properties?.assignedTo ?? "—")}`,
+ "",
+ "### Labels",
+ formatLabelIds(labels),
+ "",
+ "### Links",
+ linksBlock,
+ "",
+ "### Description (Lexical → markdown)",
+ "",
+ descriptionMd,
+ ].join("\n");
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/issue-lexical-nodes.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/issue-lexical-nodes.ts
new file mode 100644
index 00000000000..13e6fd9bb6a
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/issue-lexical-nodes.ts
@@ -0,0 +1,14 @@
+import { HeadingNode, QuoteNode } from "@lexical/rich-text";
+import { ListItemNode, ListNode } from "@lexical/list";
+import { LinkNode } from "@lexical/link";
+import { CodeNode } from "@lexical/code";
+
+// Lexical nodes used in issue content
+export const ISSUE_LEXICAL_NODES = [
+ CodeNode,
+ LinkNode,
+ ListNode,
+ ListItemNode,
+ HeadingNode,
+ QuoteNode,
+] as const;
diff --git a/examples/nextjs-linear-like-issue-tracker/src/lib/liveblocks-webhook-handlers.ts b/examples/nextjs-linear-like-issue-tracker/src/lib/liveblocks-webhook-handlers.ts
new file mode 100644
index 00000000000..1e359bf7c78
--- /dev/null
+++ b/examples/nextjs-linear-like-issue-tracker/src/lib/liveblocks-webhook-handlers.ts
@@ -0,0 +1,44 @@
+import type { WebhookEvent } from "@liveblocks/node";
+import { Metadata } from "@/config";
+import { liveblocks } from "@/liveblocks.server.config";
+import { runAiIssueAssistant } from "@/lib/ai-issue-assistant";
+
+type StorageUpdatedWebhookEvent = Extract<
+ WebhookEvent,
+ { type: "storageUpdated" }
+>;
+
+type CommentCreatedWebhookEvent = Extract<
+ WebhookEvent,
+ { type: "commentCreated" }
+>;
+
+// Syncs issue fields from Storage into room metadata
+export async function handleStorageUpdatedEvent(
+ event: StorageUpdatedWebhookEvent
+): Promise {
+ const { roomId } = event.data;
+
+ const { meta, properties, labels } = await liveblocks.getStorageDocument(
+ roomId,
+ "json"
+ );
+
+ const metadata: Partial = {
+ title: meta.title,
+ progress: properties.progress,
+ priority: properties.priority,
+ assignedTo: properties.assignedTo,
+ labels: labels as string[],
+ };
+
+ await liveblocks.updateRoom(roomId, { metadata });
+}
+
+/** Runs the issue AI assistant when a new comment is created (e.g. @mention). */
+export function runAiReplyForCommentCreatedEvent(
+ event: CommentCreatedWebhookEvent
+) {
+ const { roomId, threadId, commentId } = event.data;
+ return runAiIssueAssistant({ roomId, threadId, commentId });
+}
diff --git a/examples/nextjs-linear-like-issue-tracker/src/liveblocks.config.ts b/examples/nextjs-linear-like-issue-tracker/src/liveblocks.config.ts
index 9dac20e72c5..78764bb27b6 100644
--- a/examples/nextjs-linear-like-issue-tracker/src/liveblocks.config.ts
+++ b/examples/nextjs-linear-like-issue-tracker/src/liveblocks.config.ts
@@ -1,5 +1,5 @@
import { LiveList, LiveObject, ToJson } from "@liveblocks/client";
-import { Metadata, PriorityState, ProgressState } from "@/config";
+import { Metadata, IssuePriorityId, IssueProgressId } from "@/config";
declare global {
interface Liveblocks {
@@ -12,18 +12,62 @@ declare global {
avatar: string;
}; // Accessible through `user.info`
};
+
+ CommentMetadata: {
+ // Feed ID attached to Ai comments
+ feedId?: string;
+
+ // Comma-separated issue IDs that we display as links below comments
+ referencedIssueIds?: string;
+ };
+
+ FeedMetadata:
+ | {
+ type: "ai-comment-reply";
+ threadId: string;
+ commentId: string;
+ }
+ | {
+ type: "ai-issue-button";
+ kind: "links" | "properties" | "labels";
+ };
+
+ FeedMessageData:
+ | {
+ stage: "thinking";
+ response: string;
+ responsePart: string;
+ }
+ | {
+ stage: "writing";
+ response: string;
+ responsePart: string;
+ }
+ | {
+ stage: "status";
+ label: string;
+ }
+ | {
+ stage: "complete";
+ response: string;
+ reasoning: string;
+ thinkingTime: number;
+ };
Storage: {
meta: LiveObject<{
title: string;
}>;
properties: LiveObject<{
- progress: ProgressState;
- priority: PriorityState;
+ progress: IssueProgressId;
+ priority: IssuePriorityId;
assignedTo: string | "none";
}>;
labels: LiveList;
links: LiveList;
};
+ Presence: {
+ editingTypes: string[];
+ };
RoomInfo: {
id: string;
metadata: Metadata;
diff --git a/examples/nextjs-linear-like-issue-tracker/src/liveblocks.css b/examples/nextjs-linear-like-issue-tracker/src/liveblocks.css
index 045f6837a15..833ce5afd69 100644
--- a/examples/nextjs-linear-like-issue-tracker/src/liveblocks.css
+++ b/examples/nextjs-linear-like-issue-tracker/src/liveblocks.css
@@ -15,6 +15,15 @@
min-width: 130px;
}
+.editor-styles li p {
+ margin: 0;
+}
+
+.editor-styles ul > li:not(:first-child),
+.editor-styles ol > li:not(:first-child) {
+ margin-top: 0.25rem;
+}
+
.editor-styles p {
margin: 0.8rem 0;
}
@@ -65,8 +74,8 @@
.editor-styles ul {
display: block;
list-style-type: disc;
- margin-block-start: 1rem;
- margin-block-end: 1rem;
+ margin-block-start: 0.5rem;
+ margin-block-end: 0.5rem;
margin-inline-start: 0;
margin-inline-end: 0;
padding-inline-start: 40px;
@@ -76,8 +85,8 @@
.editor-styles ol {
display: block;
list-style-type: decimal;
- margin-block-start: 1rem;
- margin-block-end: 1rem;
+ margin-block-start: 0.5rem;
+ margin-block-end: 0.5rem;
margin-inline-start: 0;
margin-inline-end: 0;
padding-inline-start: 40px;
diff --git a/guides/pages/about-the-new-storage-engine.mdx b/guides/pages/about-the-new-storage-engine.mdx
index 8ed17a46ade..d5eae4a7c26 100644
--- a/guides/pages/about-the-new-storage-engine.mdx
+++ b/guides/pages/about-the-new-storage-engine.mdx
@@ -5,17 +5,19 @@ meta:
"Learn about our improved v2 realtime data storage engine and its benefits"
---
-Since v3.14, rooms can be powered by our new v2 realtime data storage engine—a
+
+ As of May 2026, every Liveblocks room runs on the v2 realtime data storage
+ engine. The background migration of existing v1 rooms is finished—there is
+ nothing you need to do.
+
+
+Since v3.14, rooms are powered by our new v2 realtime data storage engine—a
ground-up rearchitecture that removes server-side memory limits, enabling faster
-initial loads and support for much larger documents. Switching is **seamless and
-requires no code changes**. Rooms on v1 or v2 behave identically from your app’s
-perspective—the two sync engines we
+initial loads and support for much larger documents. The switch was **seamless
+and required no code changes**. The two sync engines we
support—[Liveblocks Storage](/docs/ready-made-features/multiplayer/sync-engine/liveblocks-storage)
-and [Yjs](/docs/ready-made-features/multiplayer/sync-engine/liveblocks-yjs) all
-work the same way.
-
-Since March 10, 2026, the v2 engine is the default for all newly created rooms.
-Existing rooms remain on v1 for now.
+and [Yjs](/docs/ready-made-features/multiplayer/sync-engine/liveblocks-yjs)—both
+run on top of the v2 engine.
## What are the benefits?
@@ -49,17 +51,14 @@ raised significantly for rooms on the v2 engine:
## How it works
-The engine version is assigned at room creation time and cannot be changed
-afterward. Since March 10, 2026, all newly created rooms automatically use the
-v2 engine. No opt-in or code changes are required.
+Since March 10, 2026, all newly created rooms automatically use the v2 engine.
+No opt-in or code changes are required.
If you're on an older SDK version, we recommend upgrading to v3.14+ to take full
advantage of the v2 engine: `npx liveblocks@latest upgrade`
### Migrating existing rooms
-Starting April 15, 2026, we are transparently migrating all existing room data
-from the v1 to the v2 engine. This happens in the background, and you can keep
-using your rooms like you normally would. We expect the majority of storage rooms
-to be migrated by the end of April, though the long tail may take until the end
-of May. We will update this document once the migration is complete for everyone.
+Between April 15 and May 2026, we transparently migrated all existing v1 room
+data over to the v2 engine in the background. **The migration is now complete**,
+and every room across the platform runs on v2.