From f217a39048d48838c3d81a80ce2d6086c0685f56 Mon Sep 17 00:00:00 2001 From: Harry Yep Date: Mon, 1 Jun 2026 13:48:59 +0800 Subject: [PATCH 1/3] feat: add copilots, markdown, observability, and react-mcp skills with reference sections four new skills plus new reference docs across the existing skills (runtime adapters/voice/concepts, primitives part-grouping/mentions, tools toolkits/mcp/generative-ui/registry, setup framework runtimes and registry/devtools, streaming resumable, cloud custom persistence and auth). registers the new skills in the marketplace, lists them in the readme, and pins the version to 0.0.2. --- .claude-plugin/marketplace.json | 8 +- README.md | 8 + assistant-ui/.claude-plugin/plugin.json | 2 +- .../cloud/references/auth-integrations.md | 263 +++++++++ .../cloud/references/custom-persistence.md | 375 +++++++++++++ assistant-ui/skills/copilots/SKILL.md | 88 +++ .../copilots/references/instructions.md | 146 +++++ .../copilots/references/interactables.md | 314 +++++++++++ .../copilots/references/model-context.md | 232 ++++++++ .../skills/copilots/references/visible.md | 141 +++++ assistant-ui/skills/markdown/SKILL.md | 59 ++ .../markdown/references/latex-mermaid.md | 169 ++++++ .../markdown/references/markdown-text.md | 126 +++++ .../skills/markdown/references/streamdown.md | 281 ++++++++++ .../references/syntax-highlighting.md | 234 ++++++++ assistant-ui/skills/observability/SKILL.md | 193 +++++++ .../observability/references/helicone.md | 90 +++ .../observability/references/langfuse.md | 135 +++++ .../observability/references/langsmith.md | 90 +++ .../observability/references/react-o11y.md | 140 +++++ .../skills/primitives/references/mentions.md | 487 ++++++++++++++++ .../primitives/references/part-grouping.md | 373 +++++++++++++ assistant-ui/skills/react-mcp/SKILL.md | 204 +++++++ .../skills/react-mcp/references/oauth.md | 147 +++++ .../skills/react-mcp/references/setup.md | 155 ++++++ .../skills/react-mcp/references/ui.md | 184 ++++++ .../skills/runtime/references/adapters.md | 524 ++++++++++++++++++ .../runtime/references/runtime-concepts.md | 255 +++++++++ .../skills/runtime/references/voice.md | 280 ++++++++++ .../skills/setup/references/ai-sdk-legacy.md | 166 ++++++ .../setup/references/cloudflare-agents.md | 217 ++++++++ .../skills/setup/references/devtools.md | 80 +++ .../skills/setup/references/google-adk.md | 329 +++++++++++ .../skills/setup/references/langchain.md | 184 ++++++ .../skills/setup/references/mastra.md | 208 +++++++ .../setup/references/registry-components.md | 246 ++++++++ .../skills/streaming/references/resumable.md | 307 ++++++++++ .../skills/tools/references/generative-ui.md | 293 ++++++++++ .../skills/tools/references/mcp-server.md | 180 ++++++ .../tools/references/registry-components.md | 206 +++++++ .../skills/tools/references/toolkits.md | 184 ++++++ 41 files changed, 8300 insertions(+), 3 deletions(-) create mode 100644 assistant-ui/skills/cloud/references/auth-integrations.md create mode 100644 assistant-ui/skills/cloud/references/custom-persistence.md create mode 100644 assistant-ui/skills/copilots/SKILL.md create mode 100644 assistant-ui/skills/copilots/references/instructions.md create mode 100644 assistant-ui/skills/copilots/references/interactables.md create mode 100644 assistant-ui/skills/copilots/references/model-context.md create mode 100644 assistant-ui/skills/copilots/references/visible.md create mode 100644 assistant-ui/skills/markdown/SKILL.md create mode 100644 assistant-ui/skills/markdown/references/latex-mermaid.md create mode 100644 assistant-ui/skills/markdown/references/markdown-text.md create mode 100644 assistant-ui/skills/markdown/references/streamdown.md create mode 100644 assistant-ui/skills/markdown/references/syntax-highlighting.md create mode 100644 assistant-ui/skills/observability/SKILL.md create mode 100644 assistant-ui/skills/observability/references/helicone.md create mode 100644 assistant-ui/skills/observability/references/langfuse.md create mode 100644 assistant-ui/skills/observability/references/langsmith.md create mode 100644 assistant-ui/skills/observability/references/react-o11y.md create mode 100644 assistant-ui/skills/primitives/references/mentions.md create mode 100644 assistant-ui/skills/primitives/references/part-grouping.md create mode 100644 assistant-ui/skills/react-mcp/SKILL.md create mode 100644 assistant-ui/skills/react-mcp/references/oauth.md create mode 100644 assistant-ui/skills/react-mcp/references/setup.md create mode 100644 assistant-ui/skills/react-mcp/references/ui.md create mode 100644 assistant-ui/skills/runtime/references/adapters.md create mode 100644 assistant-ui/skills/runtime/references/runtime-concepts.md create mode 100644 assistant-ui/skills/runtime/references/voice.md create mode 100644 assistant-ui/skills/setup/references/ai-sdk-legacy.md create mode 100644 assistant-ui/skills/setup/references/cloudflare-agents.md create mode 100644 assistant-ui/skills/setup/references/devtools.md create mode 100644 assistant-ui/skills/setup/references/google-adk.md create mode 100644 assistant-ui/skills/setup/references/langchain.md create mode 100644 assistant-ui/skills/setup/references/mastra.md create mode 100644 assistant-ui/skills/setup/references/registry-components.md create mode 100644 assistant-ui/skills/streaming/references/resumable.md create mode 100644 assistant-ui/skills/tools/references/generative-ui.md create mode 100644 assistant-ui/skills/tools/references/mcp-server.md create mode 100644 assistant-ui/skills/tools/references/registry-components.md create mode 100644 assistant-ui/skills/tools/references/toolkits.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f82a1b5..fc781bb 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "Agent skills for building AI chat interfaces with assistant-ui", - "version": "0.0.1" + "version": "0.0.2" }, "plugins": [ { @@ -22,7 +22,11 @@ "./skills/streaming", "./skills/cloud", "./skills/thread-list", - "./skills/update" + "./skills/update", + "./skills/copilots", + "./skills/react-mcp", + "./skills/observability", + "./skills/markdown" ] } ] diff --git a/README.md b/README.md index 983bd28..ac9d095 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,10 @@ npx skills add assistant-ui/skills | `/cloud` | Cloud persistence and authentication | | `/thread-list` | Multi-thread management | | `/update` | Update assistant-ui and AI SDK to latest versions | +| `/copilots` | Ground the assistant in your app (instructions, context, visible components, interactables) | +| `/markdown` | Markdown rendering (syntax highlighting, LaTeX, Mermaid, Streamdown) | +| `/react-mcp` | User-managed MCP server UIs (connect, OAuth, manage) | +| `/observability` | Backend tracing and telemetry (Langfuse, LangSmith, Helicone) | ## Usage @@ -36,6 +40,10 @@ After installation, use skills in Claude Code by typing `/` followed by the skil /cloud # Persistence and auth /thread-list # Multi-thread management /update # Update assistant-ui and AI SDK versions +/copilots # Ground the assistant in your app +/markdown # Markdown rendering and customization +/react-mcp # User-managed MCP servers +/observability # Backend tracing and telemetry ``` ## Links diff --git a/assistant-ui/.claude-plugin/plugin.json b/assistant-ui/.claude-plugin/plugin.json index cd10d26..49fb8fa 100644 --- a/assistant-ui/.claude-plugin/plugin.json +++ b/assistant-ui/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "assistant-ui", "description": "Skills for building AI chat interfaces with assistant-ui React library", - "version": "0.0.1", + "version": "0.0.2", "author": { "name": "assistant-ui" }, diff --git a/assistant-ui/skills/cloud/references/auth-integrations.md b/assistant-ui/skills/cloud/references/auth-integrations.md new file mode 100644 index 0000000..942c2f2 --- /dev/null +++ b/assistant-ui/skills/cloud/references/auth-integrations.md @@ -0,0 +1,263 @@ +# Auth Integrations + +Auth provider patterns beyond the bare JWT snippet: better-auth and full server-side Clerk, including 401 gating, per-user and per-org scoping, and reloading the thread list on auth transitions. + +## Contents + +- [Scope](#scope) +- [better-auth: mount the handler](#better-auth-mount-the-handler) +- [better-auth: gate the chat route](#better-auth-gate-the-chat-route) +- [better-auth: scope threads by user.id](#better-auth-scope-threads-by-userid) +- [better-auth: React client](#better-auth-react-client) +- [better-auth: reload threads on auth](#better-auth-reload-threads-on-auth) +- [Clerk: gate the chat route](#clerk-gate-the-chat-route) +- [Clerk: per-user thread scoping](#clerk-per-user-thread-scoping) +- [Clerk: per-org scoping](#clerk-per-org-scoping) +- [Clerk: ReloadOnAuth](#clerk-reloadonauth) +- [Pairing AssistantCloud with a backend token endpoint](#pairing-assistantcloud-with-a-backend-token-endpoint) +- [Verify](#verify) + +## Scope + +Two paths exist for auth. With AssistantCloud, the cloud handles the JWT exchange and gives you workspace-scoped threads with no DB code (see [authorization.md](./authorization.md) for the `authToken` client snippet and direct provider integration). Without AssistantCloud, you gate your own routes and scope queries against the signed-in user's id, pairing with [custom thread persistence](./custom-persistence.md). The sections below cover the non-cloud path for better-auth and Clerk, then show how to pair AssistantCloud with a backend token endpoint when you need custom workspace logic. + +## better-auth: mount the handler + +better-auth owns the session, user table, and cookie. Its catch-all handler must be wired so sign-in, sign-out, and session refresh have somewhere to land. + +```ts title="app/api/auth/[...all]/route.ts" +import { auth } from "@/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth); +``` + +## better-auth: gate the chat route + +Resolve the session server-side with `auth.api.getSession`. It takes the request headers (which carry the session cookie) and returns `null` for unauthenticated callers. Return 401 before calling the model so unauthenticated traffic does not burn provider credits. + +```ts title="app/api/chat/route.ts" +import { auth } from "@/auth"; +import { headers } from "next/headers"; +import { openai } from "@ai-sdk/openai"; +import { streamText, convertToModelMessages } from "ai"; +import type { UIMessage } from "ai"; + +export async function POST(req: Request) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return new Response("Unauthorized", { status: 401 }); + + const { messages }: { messages: UIMessage[] } = await req.json(); + const result = streamText({ + model: openai("gpt-5.4-nano"), + messages: await convertToModelMessages(messages), + }); + return result.toUIMessageStreamResponse(); +} +``` + +`headers()` from `next/headers` returns the active request's headers in App Router route handlers; always `await` it. + +## better-auth: scope threads by user.id + +With custom thread persistence, every thread endpoint filters by `session.user.id`. The `id` field comes from the user row better-auth manages, so no callback configuration is needed. + +```ts title="app/api/threads/route.ts" +import { auth } from "@/auth"; +import { headers } from "next/headers"; +import { db } from "@/db"; +import { threads } from "@/db/schema"; +import { eq, desc } from "drizzle-orm"; + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return new Response(null, { status: 401 }); + + const rows = await db + .select() + .from(threads) + .where(eq(threads.userId, session.user.id)) + .orderBy(desc(threads.updatedAt)); + + return Response.json(rows); +} +``` + +If better-auth's user schema and your threads table share a database, a foreign key from `threads` to `user.id` keeps deletes consistent. + +## better-auth: React client + +Create the client once and import it from a shared module so all hooks share state. + +```ts title="lib/auth-client.ts" +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient(); +``` + +## better-auth: reload threads on auth + +`useSession` from the React client tracks the live session. The first render may run before the session resolves, so drop a small effect inside `` that reloads the thread list once the user is signed in. + +```tsx title="app/components/ReloadOnAuth.tsx" +"use client"; + +import { useAui } from "@assistant-ui/react"; +import { authClient } from "@/lib/auth-client"; +import { useEffect } from "react"; + +export function ReloadOnAuth() { + const aui = useAui(); + const { data: session, isPending } = authClient.useSession(); + useEffect(() => { + if (!isPending && session) aui.threads().reload(); + }, [isPending, session?.user?.id]); + return null; +} +``` + +Mount it anywhere inside your `` subtree (typically next to the runtime provider in `MyProvider`). `reload()` discards in-flight responses from superseded calls, so it is safe to invoke on every auth transition. + +## Clerk: gate the chat route + +Clerk's `auth()` from `@clerk/nextjs/server` runs in any Next.js server context (server components, route handlers, server actions) and returns `userId` directly. Return 401 before calling the model. This does not reproduce `clerkMiddleware`; the guide assumes `` and `clerkMiddleware()` are already in place. + +```ts title="app/api/chat/route.ts" +import { auth } from "@clerk/nextjs/server"; +import { openai } from "@ai-sdk/openai"; +import { streamText, convertToModelMessages } from "ai"; +import type { UIMessage } from "ai"; + +export async function POST(req: Request) { + const { userId } = await auth(); + if (!userId) return new Response("Unauthorized", { status: 401 }); + + const { messages }: { messages: UIMessage[] } = await req.json(); + const result = streamText({ + model: openai("gpt-5.4-nano"), + messages: await convertToModelMessages(messages), + }); + return result.toUIMessageStreamResponse(); +} +``` + +## Clerk: per-user thread scoping + +With custom thread persistence, every thread-list endpoint filters by `userId`. Without scoping, any signed-in user can list everyone's threads. + +```ts title="app/api/threads/route.ts" +import { auth } from "@clerk/nextjs/server"; +import { db } from "@/db"; +import { threads } from "@/db/schema"; +import { eq, desc } from "drizzle-orm"; + +export async function GET() { + const { userId } = await auth(); + if (!userId) return new Response(null, { status: 401 }); + + const rows = await db + .select() + .from(threads) + .where(eq(threads.userId, userId)) + .orderBy(desc(threads.updatedAt)); + + return Response.json(rows); +} +``` + +## Clerk: per-org scoping + +For organization-scoped threads (Clerk Orgs), pull `orgId` from `auth()` and add it to the where clause. The `orgId` plus `userId` combination is a stable workspace key. + +```ts +import { and, eq } from "drizzle-orm"; + +const { userId, orgId } = await auth(); +if (!userId) return new Response(null, { status: 401 }); + +const rows = await db + .select() + .from(threads) + .where( + orgId + ? and(eq(threads.orgId, orgId), eq(threads.userId, userId)) + : eq(threads.userId, userId), + ); +``` + +Surface Clerk's `` (from `@clerk/nextjs`) and re-fetch threads on org change. + +## Clerk: ReloadOnAuth + +The first render of `` may run before Clerk resolves the user on the client. `useUser` from `@clerk/nextjs` exposes that state; reload once the user is loaded and signed in. + +```tsx title="app/components/ReloadOnAuth.tsx" +"use client"; + +import { useAui } from "@assistant-ui/react"; +import { useUser } from "@clerk/nextjs"; +import { useEffect } from "react"; + +export function ReloadOnAuth() { + const aui = useAui(); + const { isLoaded, isSignedIn, user } = useUser(); + useEffect(() => { + if (isLoaded && isSignedIn) aui.threads().reload(); + }, [isLoaded, isSignedIn, user?.id]); + return null; +} +``` + +Mount it inside your `` subtree. Because `reload()` discards superseded in-flight responses, it is safe on every transition including sign in, sign out, and organization switch. + +## Pairing AssistantCloud with a backend token endpoint + +When you want Cloud-managed threads but custom workspace logic (for example, to derive the workspace from better-auth's `session.user.id` or from Clerk's `orgId`), use the backend token endpoint instead of a direct provider integration. Resolve the user server-side, compute a `workspaceId`, mint a token with the server-side client from `assistant-cloud`, and return it. + +```ts title="app/api/assistant-ui-token/route.ts" +import { AssistantCloud } from "assistant-cloud"; +import { auth } from "@clerk/nextjs/server"; // or auth.api.getSession for better-auth + +export const POST = async (req: Request) => { + const { userId, orgId } = await auth(); + if (!userId) return new Response("Unauthorized", { status: 401 }); + + const workspaceId = orgId ? `${orgId}_${userId}` : userId; + + const assistantCloud = new AssistantCloud({ + apiKey: process.env.ASSISTANT_API_KEY!, + userId, + workspaceId, + }); + + const { token } = await assistantCloud.auth.tokens.create(); + return new Response(token); +}; +``` + +The frontend client (from `@assistant-ui/react`) fetches that endpoint and returns the body as its `authToken`. + +```tsx title="app/chat/page.tsx" +import { AssistantCloud } from "@assistant-ui/react"; +import { useChatRuntime } from "@assistant-ui/react-ai-sdk"; + +const cloud = new AssistantCloud({ + baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!, + authToken: () => + fetch("/api/assistant-ui-token", { method: "POST" }).then((r) => r.text()), +}); + +const runtime = useChatRuntime({ cloud }); +``` + +For better-auth, swap the `auth()` call for `await auth.api.getSession({ headers: await headers() })` and read `session.user.id`. Personal chats use `userId` as the workspace; org or project apps combine ids (`orgId_userId`, `projectId_userId`). + +## Verify + +Sign in, then check: + +- `/api/chat` returns `200` when authenticated and `401` when not. +- `/api/threads` returns only the current user's threads. +- A second user (incognito tab, different account) sees a different thread list. +- For Clerk Orgs, switching the active organization in `` triggers a reload when `orgId` is wired into the where clause. +- The session cookie travels with same-origin fetches. If you split the API onto another host, set `credentials: "include"` and configure CORS. diff --git a/assistant-ui/skills/cloud/references/custom-persistence.md b/assistant-ui/skills/cloud/references/custom-persistence.md new file mode 100644 index 0000000..46ce94d --- /dev/null +++ b/assistant-ui/skills/cloud/references/custom-persistence.md @@ -0,0 +1,375 @@ +# Custom Persistence + +Self-hosted message and thread persistence without AssistantCloud, backed by your own database through `RemoteThreadListAdapter` and `ThreadHistoryAdapter`. + +## Contents + +- [How the pieces fit](#how-the-pieces-fit) +- [The two adapters](#the-two-adapters) +- [withFormat, the variant useChatRuntime needs](#withformat-the-variant-usechatruntime-needs) +- [Database schema (Postgres/Drizzle)](#database-schema-postgresdrizzle) +- [Route handlers](#route-handlers) +- [Thread adapter with history](#thread-adapter-with-history) +- [Runtime provider](#runtime-provider) +- [API names](#api-names) + +## How the pieces fit + +Two adapters split the work. `RemoteThreadListAdapter` owns thread metadata (create, list, rename, archive, delete, generate title). `ThreadHistoryAdapter` owns the messages of a single thread (load on switch, append on each new message). You wire them together with `useRemoteThreadListRuntime`, passing `useChatRuntime` as the per-thread runtime hook. + +The storage contract is four columns per message: `id`, `parent_id`, `format`, `content`. The `parent_id` chain is what preserves branching (edits and regenerations). The `format` column records which encoder produced `content` so it can be decoded back later. + +Note: with `useChatRuntime` (AI SDK), the runtime always goes through `withFormat`. The top-level `load`/`append` on `ThreadHistoryAdapter` are required by the type but unused on that code path. + +## The two adapters + +```ts +import type { + RemoteThreadListAdapter, + ThreadHistoryAdapter, +} from "@assistant-ui/react"; +``` + +`ThreadHistoryAdapter` shape: + +```ts +interface ThreadHistoryAdapter { + load: () => Promise; + append: (item: ExportedMessageRepositoryItem) => Promise; + withFormat?: >( + formatAdapter: MessageFormatAdapter, + ) => GenericThreadHistoryAdapter; +} +``` + +`RemoteThreadListAdapter` shape: + +```ts +interface RemoteThreadListAdapter { + list: (params?: RemoteThreadListPageOptions) => Promise; + initialize: (threadId: string) => Promise; + rename: (remoteId: string, newTitle: string) => Promise; + archive: (remoteId: string) => Promise; + unarchive: (remoteId: string) => Promise; + delete: (remoteId: string) => Promise; + fetch: (threadId: string) => Promise; + generateTitle: (remoteId: string, unstable_messages: readonly ThreadMessage[]) => Promise; + unstable_Provider?: ComponentType; +} +``` + +`unstable_Provider` is the seam where you mount the per-thread `ThreadHistoryAdapter`, because that adapter needs access to the active thread's `remoteId`. + +## withFormat, the variant useChatRuntime needs + +`withFormat` takes a `MessageFormatAdapter` and returns a history adapter whose `load`/`append` move through the format's encode and decode. The format adapter is the bridge between a `UIMessage` and your four stored columns. + +```ts +interface MessageFormatAdapter { + format: string; + encode: (item: MessageFormatItem) => TStorageFormat; + decode: (stored: MessageStorageEntry) => MessageFormatItem; + getId: (message: TMessage) => string; +} +``` + +You do not construct this yourself; `withFormat` receives the active `fmt` and you call its methods: + +- `fmt.decode({ id, parent_id, format, content })` turns a stored row back into a `UIMessage`. +- `fmt.encode(item)` turns the appended item into the `content` you store. +- `fmt.getId(item.message)` extracts the message id for the `id` column. +- `fmt.format` is the format string (for example `"ai-sdk/v6"`) you write to the `format` column. + +## Database schema (Postgres/Drizzle) + +`db/schema.ts`. The four message columns `id`, `parent_id`, `format`, `content` are the contract `withFormat` writes against. + +```ts +import { pgTable, text, timestamp, jsonb, index } from "drizzle-orm/pg-core"; + +export const threads = pgTable( + "threads", + { + id: text("id").primaryKey(), + userId: text("user_id").notNull(), + title: text("title"), + status: text("status", { enum: ["regular", "archived"] }).notNull().default("regular"), + custom: jsonb("custom").$type>(), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, + (t) => [index("threads_user_idx").on(t.userId)], +); + +export const messages = pgTable( + "messages", + { + id: text("id").primaryKey(), + threadId: text("thread_id").notNull().references(() => threads.id, { onDelete: "cascade" }), + parentId: text("parent_id"), + format: text("format").notNull(), + content: jsonb("content").notNull(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (t) => [index("messages_thread_idx").on(t.threadId)], +); +``` + +## Route handlers + +These back the `fetch` calls the adapters make. Every handler scopes by the authenticated user so a thread id from one user cannot read another's messages. + +`app/api/threads/route.ts`: + +```ts +import { db } from "@/db"; +import { threads } from "@/db/schema"; +import { auth } from "@/auth"; +import { desc, eq } from "drizzle-orm"; +import { generateId } from "ai"; + +export async function GET() { + const session = await auth(); + if (!session?.user) return new Response(null, { status: 401 }); + const rows = await db.select().from(threads) + .where(eq(threads.userId, session.user.id)) + .orderBy(desc(threads.updatedAt)); + return Response.json(rows); +} + +export async function POST() { + const session = await auth(); + if (!session?.user) return new Response(null, { status: 401 }); + const id = generateId(); + await db.insert(threads).values({ id, userId: session.user.id }); + return Response.json({ id }); +} +``` + +`app/api/threads/[id]/route.ts`: + +```ts +import { db } from "@/db"; +import { threads } from "@/db/schema"; +import { auth } from "@/auth"; +import { and, eq } from "drizzle-orm"; + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const session = await auth(); + if (!session?.user) return new Response(null, { status: 401 }); + const patch = (await req.json()) as { title?: string; status?: "regular" | "archived" }; + await db.update(threads) + .set({ ...patch, updatedAt: new Date() }) + .where(and(eq(threads.id, id), eq(threads.userId, session.user.id))); + return new Response(null, { status: 204 }); +} +``` + +`app/api/threads/[id]/messages/route.ts`. The POST body is exactly the `{ id, parent_id, format, content }` contract: + +```ts +import { db } from "@/db"; +import { threads, messages } from "@/db/schema"; +import { auth } from "@/auth"; +import { and, asc, eq } from "drizzle-orm"; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const session = await auth(); + if (!session?.user) return new Response(null, { status: 401 }); + const [thread] = await db.select().from(threads) + .where(and(eq(threads.id, id), eq(threads.userId, session.user.id))); + if (!thread) return new Response(null, { status: 404 }); + const rows = await db.select().from(messages) + .where(eq(messages.threadId, id)) + .orderBy(asc(messages.createdAt)); + return Response.json(rows); +} + +export async function POST( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const session = await auth(); + if (!session?.user) return new Response(null, { status: 401 }); + const body = (await req.json()) as { + id: string; + parent_id: string | null; + format: string; + content: Record; + }; + await db.insert(messages).values({ + id: body.id, + threadId: id, + parentId: body.parent_id, + format: body.format, + content: body.content, + }); + return new Response(null, { status: 204 }); +} +``` + +## Thread adapter with history + +`app/runtime/thread-adapter.tsx`. The `RemoteThreadListAdapter` maps thread rows to and from the runtime, and its `unstable_Provider` mounts the per-thread `ThreadHistoryAdapter` through `RuntimeAdapterProvider`. The history's `withFormat` is where `fmt.encode` / `fmt.decode` run against the four columns. + +```tsx +"use client"; +import { + RuntimeAdapterProvider, + useAui, + type RemoteThreadListAdapter, + type ThreadHistoryAdapter, +} from "@assistant-ui/react"; +import { createAssistantStream } from "assistant-stream"; +import { useMemo } from "react"; + +export const threadListAdapter: RemoteThreadListAdapter = { + async list() { + const rows = await fetch("/api/threads").then((r) => r.json()); + return { + threads: rows.map((t: any) => ({ + status: t.status, + remoteId: t.id, + title: t.title ?? undefined, + })), + }; + }, + async initialize() { + const { id } = await fetch("/api/threads", { method: "POST" }).then((r) => r.json()); + return { remoteId: id }; + }, + async rename(remoteId, title) { + await fetch(`/api/threads/${remoteId}`, { + method: "PATCH", + body: JSON.stringify({ title }), + }); + }, + async archive(remoteId) { + await fetch(`/api/threads/${remoteId}`, { + method: "PATCH", + body: JSON.stringify({ status: "archived" }), + }); + }, + async unarchive(remoteId) { + await fetch(`/api/threads/${remoteId}`, { + method: "PATCH", + body: JSON.stringify({ status: "regular" }), + }); + }, + async delete(remoteId) { + await fetch(`/api/threads/${remoteId}`, { method: "DELETE" }); + }, + async fetch(remoteId) { + const t = await fetch(`/api/threads/${remoteId}`).then((r) => r.json()); + return { status: t.status, remoteId: t.id, title: t.title }; + }, + async generateTitle(remoteId, messages) { + return createAssistantStream(async (controller) => { + const { title } = await fetch(`/api/threads/${remoteId}/title`, { + method: "POST", + body: JSON.stringify({ messages }), + }).then((r) => r.json()); + controller.appendText(title); + }); + }, + unstable_Provider({ children }) { + const aui = useAui(); + const history = useMemo( + () => ({ + async load() { + return { messages: [] }; + }, + async append() {}, + withFormat: (fmt) => ({ + async load() { + const { remoteId } = aui.threadListItem().getState(); + if (!remoteId) return { messages: [] }; + const rows = await fetch(`/api/threads/${remoteId}/messages`).then((r) => r.json()); + return { + messages: rows.map((row: any) => + fmt.decode({ + id: row.id, + parent_id: row.parent_id, + format: row.format, + content: row.content, + }), + ), + }; + }, + async append(item) { + const { remoteId } = await aui.threadListItem().initialize(); + await fetch(`/api/threads/${remoteId}/messages`, { + method: "POST", + body: JSON.stringify({ + id: fmt.getId(item.message), + parent_id: item.parentId, + format: fmt.format, + content: fmt.encode(item), + }), + }); + }, + }), + }), + [aui], + ); + return ( + + {children} + + ); + }, +}; +``` + +Note: `append` awaits `aui.threadListItem().initialize()` so the thread row exists before its first message is written; `load` uses `getState()` and bails out when there is no `remoteId` yet. + +## Runtime provider + +`app/runtime/MyProvider.tsx`. `useRemoteThreadListRuntime` drives the thread list, and `runtimeHook` supplies the per-thread runtime. Because the history adapter is mounted inside `unstable_Provider`, `useChatRuntime` needs no extra wiring here. + +```tsx +"use client"; +import { + AssistantRuntimeProvider, + useRemoteThreadListRuntime, +} from "@assistant-ui/react"; +import { useChatRuntime } from "@assistant-ui/react-ai-sdk"; +import { threadListAdapter } from "./thread-adapter"; + +export function MyProvider({ children }: { children: React.ReactNode }) { + const runtime = useRemoteThreadListRuntime({ + runtimeHook: () => useChatRuntime(), + adapter: threadListAdapter, + }); + return ( + + {children} + + ); +} +``` + +## API names + +| Name | Purpose | +|------|---------| +| `RemoteThreadListAdapter` | Thread metadata: list, initialize, rename, archive, unarchive, delete, fetch, generateTitle, unstable_Provider | +| `ThreadHistoryAdapter` | Per-thread messages: load, append, withFormat | +| `withFormat(fmt)` | Returns a history adapter whose load/append run through `fmt`; required by `useChatRuntime` | +| `fmt.decode({ id, parent_id, format, content })` | Stored row to `UIMessage` | +| `fmt.encode(item)` | `UIMessage` to stored `content` | +| `fmt.getId(item.message)` | Extracts the message id | +| `fmt.format` | Format string written to the `format` column (for example `"ai-sdk/v6"`) | +| `aui.threadListItem().getState()` | Reads the active thread's `remoteId` for loading | +| `aui.threadListItem().initialize()` | Awaited before appending to ensure the thread row exists | +| `useRemoteThreadListRuntime` | Combines the thread list adapter with a per-thread `runtimeHook` | +| `RuntimeAdapterProvider` | Mounts `{ history }` for the active thread | diff --git a/assistant-ui/skills/copilots/SKILL.md b/assistant-ui/skills/copilots/SKILL.md new file mode 100644 index 0000000..55132bd --- /dev/null +++ b/assistant-ui/skills/copilots/SKILL.md @@ -0,0 +1,88 @@ +--- +name: copilots +description: "Grounding an assistant in your app with assistant-ui copilots (@assistant-ui/react). Use when steering assistant behavior with useAssistantInstructions, feeding lazy app-state context via useAssistantContext({ getContext }), exposing rendered components with makeAssistantVisible(Component, { clickable, editable }), building two-way interactable state with useAssistantInteractable and Interactables(), or registering instructions and tools imperatively through useAui().modelContext().register({ getModelContext }). Reach for this when the assistant should read the current page, click or edit UI, or read and update component state through auto-generated update_{name} tools. For LLM tools and tool-call UI use the tools skill; for runtime and thread state use the runtime skill." +license: MIT +--- + +# assistant-ui Copilots + +**Always consult [assistant-ui.com/llms.txt](https://www.assistant-ui.com/llms.txt) for the latest API.** + +Copilots ground an assistant in your running app: steer it with instructions, feed it lazy app state, let it read rendered components, click and edit UI, and read or update persistent interactable state. + +## References + +- [./references/instructions.md](./references/instructions.md) -- useAssistantInstructions +- [./references/model-context.md](./references/model-context.md) -- useAssistantContext and imperative modelContext().register +- [./references/visible.md](./references/visible.md) -- makeAssistantVisible +- [./references/interactables.md](./references/interactables.md) -- interactable components + +## Orientation + +All APIs ship from `@assistant-ui/react` and run inside `AssistantRuntimeProvider`. Pick the smallest tool for the job: + +``` +What do you need the assistant to know or do? +├─ Steer behavior with a system prompt → useAssistantInstructions("...") +├─ Feed read-only app state (page, selection, cart) → useAssistantContext({ getContext }) +├─ Let it read / click / edit a rendered component → makeAssistantVisible(Component, { clickable, editable }) +├─ Read AND write persistent component state via tools → useAssistantInteractable(name, config) +└─ Register instructions + tools together imperatively → useAui().modelContext().register({ getModelContext }) +``` + +Instructions and context are the lightweight starting point. Reach for `makeAssistantVisible` when the assistant needs to perceive or drive existing DOM, and for interactables when it needs structured two-way state it can mutate through auto-generated `update_{name}` tools. + +```tsx +import { useAssistantInstructions, useAssistantContext } from "@assistant-ui/react"; + +function CheckoutCopilot() { + useAssistantInstructions("You help users complete checkout. Be concise."); + useAssistantContext({ getContext: () => `Current page: ${window.location.href}` }); + return null; +} +``` + +`getContext` is evaluated fresh each time the model context is read, so it always reflects current state. Register imperatively when you need instructions and tools in one provider: + +```tsx +import { useAui, tool } from "@assistant-ui/react"; +import { useEffect } from "react"; + +function SearchCopilot() { + const aui = useAui(); + useEffect(() => { + return aui.modelContext().register({ + getModelContext: () => ({ + system: "You are a helpful search assistant.", + tools: { search: mySearchTool }, + }), + }); + }, [aui]); + return null; +} +``` + +`register` returns an unsubscribe function; returning it from `useEffect` cleans up the provider on unmount. Multiple providers compose: `system` strings concatenate and `tools` maps merge. + +## Common Gotchas + +**Assistant ignores instructions or context** +- The hook or `register` call must run inside `AssistantRuntimeProvider`. +- For `useAui().modelContext().register`, call it in `useEffect` and return the result so it unsubscribes; registering in render leaks providers. + +**Context is stale** +- Use the `getContext` callback form, not a captured value. It is re-read at send time, so closures over fresh state work; a precomputed string will not update. + +**makeAssistantVisible does nothing** +- Without options the component is read-only (exposes its `outerHTML`). Pass `{ clickable: true }` to allow clicks and `{ editable: true }` for `` / `