diff --git a/.agents/skills/agent-eval/SKILL.md b/.agents/skills/agent-eval/SKILL.md new file mode 100644 index 00000000..88bb1cfa --- /dev/null +++ b/.agents/skills/agent-eval/SKILL.md @@ -0,0 +1,14 @@ +--- +name: agent-eval +description: Implement or verify the independent black-box Eval package, versioned datasets/suites, jobs, baselines, Autoevals grading, telemetry and release gates. +--- + +# Black-box Agent Eval + +1. Work only through `EvalTarget` in `eval/src/contracts.ts`. Never import product runtime, prompts, context, kernel, model client, control plane or databases. `eval/targets/agent-os.ts` is a type-only integration placeholder until separately authorized. +2. Run `npm ci --prefix eval` then `npm run eval:check`. Tests use local HTTP servers; they are infrastructure verification, never Candidate quality baselines. No browser automation. +3. Candidate and Judge use explicit independent `EVAL_CANDIDATE_*` and `EVAL_JUDGE_*` configuration. Use Autoevals with an instance client, never globals, provider fallback or product credentials. +4. Live evaluation is explicit: follow `docs/agent-eval.md`. Never claim API validation without running it. Missing credentials/prices, unknown usage, incomplete jobs, evaluator errors and incompatible baselines must block release. +5. Version dataset/suite content immutably. Promote eligible completed runs only with a review reason; never replace an existing baseline. Review portable baseline files before using them in CI. +6. Persist bounded synthetic identifiers, scores, usage and failure codes in telemetry/reports; no raw prompts, answers, secrets or unrestricted error messages. Private local SQLite includes dataset inputs for reruns and must not be uploaded as a public artifact. +7. Report only owning tests, gate outcomes and material limitations. Agent runtime coverage belongs to its own integration tests, not an internal Eval adapter. diff --git a/.agents/skills/agent-eval/agents/openai.yaml b/.agents/skills/agent-eval/agents/openai.yaml new file mode 100644 index 00000000..39ac2ad7 --- /dev/null +++ b/.agents/skills/agent-eval/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Agent Eval" + short_description: "Maintain deterministic Agent Eval gates and reports" + default_prompt: "Use $agent-eval to update and verify deterministic Agent Eval coverage." diff --git a/.agents/skills/agent-runtime/SKILL.md b/.agents/skills/agent-runtime/SKILL.md new file mode 100644 index 00000000..cecb42a8 --- /dev/null +++ b/.agents/skills/agent-runtime/SKILL.md @@ -0,0 +1,23 @@ +--- +name: agent-runtime +description: Implement, debug, refactor, or review LingxiLoop Agent OS runtime behavior, work leasing, retries, kernels, Host Bridge actions, message delivery, or LLM ledger integration. Use when changes touch server/src/agent-os, server/agent-os, Agent work items, or Agent runtime contracts. +--- + +# Agent runtime + +Trace the work item from claim through model turn, `ipython`, Host Bridge effects, ledger writes, and final message before editing. + +## Preserve contracts + +- `ipython` is the only model-visible tool. Do not expose product or network tools directly. +- Product effects cross the authenticated Host Bridge and repeat server-side authorization using the acting user, company, project, and run. +- Claims use leases; retries are bounded; attempts are idempotent; stale owners cannot commit results. +- WuKongIM is the durable message source. PostgreSQL stores Agent work, audit, and LLM ledgers rather than shadow chat history. +- Every LLM path uses the shared client and records success/failure, model, tokens, latency, tenant scope, and correlation identifiers without prompt secrets. +- Kernel and Agent home isolation must survive cancellation, timeout, restart, and concurrent runs. + +## Verify + +Add deterministic integration coverage for changed runtime behavior and update Agent Eval when user-visible decisions or traces change. Run `npm run server:typecheck`, the owning unit and integration files, without coupling tests to the independent Eval package. Never run the complete test or Eval suites for a localized change. + +Report lease/retry impact, Host Bridge authorization impact, ledger impact, and the exact failure behavior. diff --git a/.agents/skills/agent-runtime/agents/openai.yaml b/.agents/skills/agent-runtime/agents/openai.yaml new file mode 100644 index 00000000..26d6de0b --- /dev/null +++ b/.agents/skills/agent-runtime/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Agent Runtime" + short_description: "Change the Agent OS runtime without breaking contracts" + default_prompt: "Use $agent-runtime to implement and verify an Agent OS runtime change." diff --git a/.agents/skills/better-layout/SKILL.md b/.agents/skills/better-layout/SKILL.md index 4eb51864..60105b8b 100644 --- a/.agents/skills/better-layout/SKILL.md +++ b/.agents/skills/better-layout/SKILL.md @@ -69,7 +69,7 @@ Never park a critical action where resizing or scrolling clips it. Keep it in th **Severity.** `HIGH` blocks content or an action at a supported viewport. `MEDIUM` harms hierarchy, reading order, or adaptability. `LOW` is isolated alignment or spacing polish. -**Verification.** Without a browser: logical properties in place of physical ones, container and media queries against the supported viewport list and DOM order against the intended reading order. With one: every supported width, 200% zoom and the RTL mirror. Report every check you could not run as `Not verified`. +**Verification.** Review logical properties in place of physical ones, container and media queries against the supported viewport list, and DOM order against the intended reading order. **Format.** Group findings under the principle each violates, ordered by severity, one row per root cause listing every location it appears in: diff --git a/.agents/skills/cloud/SKILL.md b/.agents/skills/cloud/SKILL.md deleted file mode 100644 index d9c1508a..00000000 --- a/.agents/skills/cloud/SKILL.md +++ /dev/null @@ -1,114 +0,0 @@ ---- -name: cloud -description: "Sets up assistant-ui Cloud persistence and authorization with the assistant-cloud package and AssistantCloud client. Use when adding cross-session thread/message persistence, multi-device chat history, file uploads, or auth to an assistant-ui app: passing the cloud option to useChatRuntime (with AssistantChatTransport from @assistant-ui/react-ai-sdk), configuring AssistantCloud with authToken (JWT), apiKey plus userId/workspaceId (server-side), or anonymous mode, and wiring auth providers like NextAuth, Clerk, or Firebase. Covers cloud.threads.list/get/create/update/delete, cloud.threads.messages.list/create/update(threadId, ...), cloud.files.generatePresignedUploadUrl and pdfToImages, cloud.projects, cloud.runs, the aui/v0 message format, custom adapters (CloudMessagePersistence, createFormattedPersistence, ThreadHistoryAdapter, RemoteThreadListAdapter), auto title generation, external_id/metadata mapping, and env vars NEXT_PUBLIC_ASSISTANT_BASE_URL and ASSISTANT_API_KEY. For the thread-list sidebar UI itself use thread-list." -license: MIT ---- - -# assistant-ui Cloud - -**Always consult [assistant-ui.com/llms.txt](https://www.assistant-ui.com/llms.txt) for the latest API.** - -Cloud persistence for threads, messages, and files. - -## References - -- [./references/persistence.md](./references/persistence.md) -- Thread and message persistence -- [./references/authorization.md](./references/authorization.md) -- Authentication patterns -- [./references/custom-persistence.md](./references/custom-persistence.md) -- Self-hosted message persistence -- [./references/auth-integrations.md](./references/auth-integrations.md) -- better-auth and Clerk integrations - -## Installation - -```bash -npm install assistant-cloud -``` - -## Quick Start - -```tsx -import { AssistantCloud } from "assistant-cloud"; -import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk"; -import { AssistantRuntimeProvider } from "@assistant-ui/react"; -import { Thread } from "@/components/assistant-ui/thread"; -import { ThreadList } from "@/components/assistant-ui/thread-list"; - -const cloud = new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, - authToken: async () => getAuthToken(), -}); - -function Chat() { - const runtime = useChatRuntime({ - transport: new AssistantChatTransport({ api: "/api/chat" }), - cloud, - }); - - return ( - - - - - ); -} -``` - -## Authentication Options - -```tsx -// JWT Token (recommended) -const cloud = new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, - authToken: async () => session?.accessToken, -}); - -// API Key (server-side) -const cloud = new AssistantCloud({ - baseUrl: process.env.ASSISTANT_BASE_URL, - apiKey: process.env.ASSISTANT_API_KEY, - userId: user.id, - workspaceId: user.workspaceId, -}); - -// Anonymous (public apps) -const cloud = new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, - anonymous: true, -}); -``` - -## Cloud API - -```tsx -const { threads } = await cloud.threads.list(); -const { thread_id } = await cloud.threads.create({ - title: "New Chat", - last_message_at: new Date(), // required -}); -await cloud.threads.update(threadId, { title: "Updated" }); -await cloud.threads.delete(threadId); - -// messages is a property on threads; every method takes threadId as its first argument -const { messages } = await cloud.threads.messages.list(threadId); - -const { signedUrl, publicUrl } = await cloud.files.generatePresignedUploadUrl({ - filename: "document.pdf", -}); -await fetch(signedUrl, { method: "PUT", body: file }); -``` - -## Environment Variables - -```env -NEXT_PUBLIC_ASSISTANT_BASE_URL=https://api.assistant-ui.com -ASSISTANT_API_KEY=your-api-key # Server-side only -``` - -## Common Gotchas - -**Threads not persisting** -- Pass `cloud` to runtime -- Check authentication - -**Auth errors** -- Verify `authToken` returns valid token -- Check `baseUrl` is correct diff --git a/.agents/skills/cloud/references/auth-integrations.md b/.agents/skills/cloud/references/auth-integrations.md deleted file mode 100644 index eba13691..00000000 --- a/.agents/skills/cloud/references/auth-integrations.md +++ /dev/null @@ -1,277 +0,0 @@ -# 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, - createUIMessageStreamResponse, - toUIMessageStream, -} 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 createUIMessageStreamResponse({ - stream: toUIMessageStream({ stream: result.stream }), - }); -} -``` - -`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, - createUIMessageStreamResponse, - toUIMessageStream, -} 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 createUIMessageStreamResponse({ - stream: toUIMessageStream({ stream: result.stream }), - }); -} -``` - -## 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/.agents/skills/cloud/references/authorization.md b/.agents/skills/cloud/references/authorization.md deleted file mode 100644 index 1ed818f4..00000000 --- a/.agents/skills/cloud/references/authorization.md +++ /dev/null @@ -1,258 +0,0 @@ -# Cloud Authorization - -Authentication and authorization patterns for assistant-cloud. - -## Auth Methods - -| Method | Use Case | Security | -|--------|----------|----------| -| JWT Token | Production apps | High | -| API Key | Server-side only | Medium | -| Anonymous | Public demos | Low | - -## JWT Token Authentication - -Recommended for production. Token is fetched dynamically. - -### Setup - -```tsx -const cloud = new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, - authToken: async () => { - const token = await getAuthToken(); - return token; - }, -}); -``` - -### With NextAuth - -```tsx -import { useSession } from "next-auth/react"; - -function Chat() { - const { data: session, status } = useSession(); - - const cloud = useMemo(() => { - if (status !== "authenticated") return null; - - return new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, - authToken: async () => session.accessToken, - }); - }, [session, status]); - - const runtime = useChatRuntime({ - transport: new AssistantChatTransport({ - api: "/api/chat", - }), - cloud: cloud ?? undefined, - }); - - if (status === "loading") return ; - if (!session) return ; - - return ( - - - - ); -} -``` - -### With Clerk - -```tsx -import { useAuth } from "@clerk/nextjs"; - -function Chat() { - const { getToken, isSignedIn } = useAuth(); - - const cloud = useMemo(() => { - if (!isSignedIn) return null; - - return new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, - authToken: async () => getToken(), - }); - }, [isSignedIn, getToken]); - - // ... -} -``` - -### With Firebase - -```tsx -import { useAuth } from "reactfire"; - -function Chat() { - const { data: user } = useAuth(); - - const cloud = useMemo(() => { - if (!user) return null; - - return new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, - authToken: async () => user.getIdToken(), - }); - }, [user]); - - // ... -} -``` - -## API Key Authentication - -For server-side operations only. Never expose API keys to clients. - -### Server Component - -```tsx -// app/api/threads/route.ts -import { AssistantCloud } from "assistant-cloud"; - -const cloud = new AssistantCloud({ - baseUrl: process.env.ASSISTANT_BASE_URL, - apiKey: process.env.ASSISTANT_API_KEY, - userId: "system", - workspaceId: process.env.ASSISTANT_WORKSPACE_ID, -}); - -export async function GET() { - const threads = await cloud.threads.list(); - return Response.json(threads); -} -``` - -### Per-User Operations - -```tsx -// app/api/chat/threads/route.ts -import { getServerSession } from "next-auth"; - -export async function GET() { - const session = await getServerSession(); - if (!session) return new Response("Unauthorized", { status: 401 }); - - const cloud = new AssistantCloud({ - baseUrl: process.env.ASSISTANT_BASE_URL, - apiKey: process.env.ASSISTANT_API_KEY, - userId: session.user.id, - workspaceId: session.user.workspaceId, - }); - - const threads = await cloud.threads.list(); - return Response.json(threads); -} -``` - -## Anonymous Authentication - -For public demos or unauthenticated access. - -```tsx -const cloud = new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, - anonymous: true, -}); -``` - -**Limitations:** -- No user isolation -- Limited features -- No cross-device sync - -## Token Refresh - -Handle expired tokens: - -```tsx -const cloud = new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, - authToken: async () => { - const token = getStoredToken(); - - if (isTokenExpired(token)) { - const newToken = await refreshToken(); - setStoredToken(newToken); - return newToken; - } - - return token; - }, -}); -``` - -## Error Handling - -```tsx -const cloud = new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, - authToken: async () => { - try { - return await getToken(); - } catch (error) { - console.error("Auth error:", error); - - window.location.href = "/login"; - return null; - } - }, -}); -``` - -## Workspace Isolation - -Users in different workspaces have separate data: - -```tsx -const cloud = new AssistantCloud({ - baseUrl: process.env.ASSISTANT_BASE_URL, - apiKey: process.env.ASSISTANT_API_KEY, - userId: user.id, - workspaceId: user.organization.id, // Isolates data by org -}); -``` - -## Role-Based Access - -Implement in your backend: - -```tsx -// app/api/threads/[id]/route.ts -export async function DELETE(req: Request, { params }) { - const session = await getServerSession(); - - const thread = await cloud.threads.get(params.id); - if (thread.metadata.ownerId !== session.user.id) { - if (!session.user.roles.includes("admin")) { - return new Response("Forbidden", { status: 403 }); - } - } - - await cloud.threads.delete(params.id); - return new Response(null, { status: 204 }); -} -``` - -## Environment Setup - -```env -# .env.local (client-side accessible) -NEXT_PUBLIC_ASSISTANT_BASE_URL=https://api.assistant-ui.com - -# .env (server-side only) -ASSISTANT_BASE_URL=https://api.assistant-ui.com -ASSISTANT_API_KEY=your-secret-key -ASSISTANT_WORKSPACE_ID=your-workspace -``` - -## Security Best Practices - -1. **Never expose API keys** - Use JWT tokens for client-side -2. **Validate tokens server-side** - Don't trust client tokens blindly -3. **Use short-lived tokens** - Implement refresh flow -4. **Scope workspaces** - Isolate user data by workspace -5. **Audit access** - Log thread operations for compliance diff --git a/.agents/skills/cloud/references/custom-persistence.md b/.agents/skills/cloud/references/custom-persistence.md deleted file mode 100644 index 28b6df65..00000000 --- a/.agents/skills/cloud/references/custom-persistence.md +++ /dev/null @@ -1,375 +0,0 @@ -# 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/.agents/skills/cloud/references/persistence.md b/.agents/skills/cloud/references/persistence.md deleted file mode 100644 index af268644..00000000 --- a/.agents/skills/cloud/references/persistence.md +++ /dev/null @@ -1,278 +0,0 @@ -# Cloud Persistence - -Thread and message persistence with assistant-cloud. - -## Overview - -Cloud persistence saves threads and messages to the assistant-ui cloud backend, enabling: -- Chat history across sessions -- Multi-device sync -- Thread management (archive, delete) -- Auto-generated titles - -## Basic Setup - -```tsx -import { AssistantCloud } from "assistant-cloud"; -import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/react-ai-sdk"; -import { AssistantRuntimeProvider } from "@assistant-ui/react"; -import { Thread } from "@/components/assistant-ui/thread"; -import { ThreadList } from "@/components/assistant-ui/thread-list"; - -const cloud = new AssistantCloud({ - baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL, - authToken: async () => getAuthToken(), -}); - -function Chat() { - const runtime = useChatRuntime({ - transport: new AssistantChatTransport({ - api: "/api/chat", - }), - cloud, // Enable persistence - }); - - return ( - - - - - ); -} -``` - -## Thread API - -### List Threads - -Paging is cursor based (`after`), not offset based. - -```tsx -const { threads } = await cloud.threads.list({ - is_archived: false, - limit: 50, - after: cursor, // id of the last thread from the previous page -}); - -// threads: Array<{ -// id: string; -// title: string; -// created_at: Date; -// updated_at: Date; -// last_message_at: Date; -// is_archived: boolean; -// external_id: string | null; -// metadata: unknown; -// project_id: string; -// workspace_id: string; -// }> -``` - -### Get Thread - -```tsx -const thread = await cloud.threads.get(threadId); -``` - -### Create Thread - -`last_message_at` is required; everything else is optional. - -```tsx -const { thread_id } = await cloud.threads.create({ - last_message_at: new Date(), // Required - title: "My New Chat", - external_id: "custom-id-123", // Optional external reference - metadata: { // Optional custom data - source: "web", - category: "support", - }, -}); -``` - -### Update Thread - -```tsx -await cloud.threads.update(threadId, { - title: "Updated Title", - is_archived: true, - metadata: { priority: "high" }, -}); -``` - -### Delete Thread - -```tsx -await cloud.threads.delete(threadId); -``` - -## Message API - -### List Messages - -`messages` is a property on `cloud.threads`, and each method takes `threadId` as its first argument. - -```tsx -const { messages } = await cloud.threads.messages.list(threadId, { - format: "aui/v0", -}); - -// messages: Array<{ -// id: string; -// parent_id: string | null; -// format: "aui/v0" | string; -// content: ReadonlyJSONObject; -// height: number; -// created_at: Date; -// updated_at: Date; -// }> -``` - -`list` accepts only `{ format? }`; there is no paging on the message endpoint. - -### Create Message - -```tsx -await cloud.threads.messages.create(threadId, { - parent_id: null, // Or parent message ID for branching - format: "aui/v0", - content: { - role: "user", - content: [{ type: "text", text: "Hello" }], - }, -}); -``` - -## Message Format - -assistant-ui uses `"aui/v0"` format: - -```typescript -interface AUIv0Message { - role: "user" | "assistant" | "system"; - content: MessagePart[]; - status?: "running" | "complete" | "incomplete" | "requires-action"; - attachments?: Attachment[]; -} - -type MessagePart = - | { type: "text"; text: string } - | { type: "image"; image: string } - | { - type: "tool-call"; - toolCallId: string; - toolName: string; - args: unknown; - argsText: string; - result?: unknown; - isError?: boolean; - artifact?: unknown; - } - | { type: "reasoning"; text: string } - | { - type: "source"; - sourceType: "url"; - id: string; - url: string; - title?: string; - }; -``` - -## Custom Persistence Adapters - -The simplest persistence is passing `cloud` to the runtime (see Basic Setup above). For full control over storage, assistant-ui exposes adapter interfaces (from `@assistant-ui/react`): - -- `ThreadHistoryAdapter` owns the messages of a single thread. -- `RemoteThreadListAdapter` owns the list of threads (create, rename, archive, delete). - -To back those adapters with assistant-cloud rather than your own database, use `CloudMessagePersistence` or `createFormattedPersistence` from `assistant-cloud`: - -```tsx -import { CloudMessagePersistence, createFormattedPersistence } from "assistant-cloud"; -``` - -For a database-backed example, see the custom thread persistence guide at -[assistant-ui.com/docs/integrations/persistence/custom-adapter](https://www.assistant-ui.com/docs/integrations/persistence/custom-adapter). - -## Auto-Save Behavior - -When `cloud` is passed to runtime: - -1. **New messages** are automatically saved -2. **Thread creation** happens on first message -3. **Thread metadata** (title, timestamps) updated automatically -4. **Message branching** (edits) preserved - -## Thread Title Generation - -Titles are auto-generated from conversation: - -```tsx -// Manual trigger -const item = api.threads.item({ id: threadId }); -item.generateTitle(); -``` - -The cloud backend uses the conversation to generate a concise title. - -## External ID Mapping - -Link threads to your system: - -```tsx -await cloud.threads.create({ - last_message_at: new Date(), - external_id: "your-system-id-123", -}); - -const { threads } = await cloud.threads.list(); -const thread = threads.find(t => t.external_id === "your-system-id-123"); -``` - -## Metadata - -Store custom data with threads: - -```tsx -await cloud.threads.create({ - last_message_at: new Date(), - metadata: { - userId: user.id, - category: "sales", - priority: 1, - tags: ["important", "follow-up"], - }, -}); - -await cloud.threads.update(threadId, { - metadata: { resolved: true }, -}); -``` - -## Caching and Sync - -Messages are loaded on thread switch: - -```tsx -// Thread list is cached in memory -// Messages loaded when switching threads -api.threads.switchToThread(threadId); -``` - -For real-time sync across devices, implement webhook handlers on your backend. - -## Error Handling - -```tsx -try { - const threads = await cloud.threads.list(); -} catch (error) { - if (error.status === 401) { - // Auth expired - refresh token - await refreshAuth(); - } else if (error.status === 429) { - // Rate limited - await delay(1000); - } -} -``` diff --git a/.agents/skills/copilots/SKILL.md b/.agents/skills/copilots/SKILL.md deleted file mode 100644 index 7b816d78..00000000 --- a/.agents/skills/copilots/SKILL.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -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 unstable_useInteractable / unstable_Interactables() / unstable_interactableTool (the legacy useAssistantInteractable and Interactables() are deprecated and scheduled for removal after 2026-09-14), 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 → unstable_useInteractable(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. - -Interactables have two generations. `unstable_useInteractable` / `unstable_Interactables()` / `unstable_interactableTool` is current. The legacy `useAssistantInteractable` / `Interactables()` / `useInteractableState` is deprecated as of 2026-06-14 and scheduled for removal on or after 2026-09-14. The two scopes are mutually exclusive in one `useAui` provider. - -```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 `` / `