diff --git a/apps/browser-extension/entrypoints/content/chatgpt.ts b/apps/browser-extension/entrypoints/content/chatgpt.ts
index 51a04736..7c3d28aa 100644
--- a/apps/browser-extension/entrypoints/content/chatgpt.ts
+++ b/apps/browser-extension/entrypoints/content/chatgpt.ts
@@ -159,7 +159,7 @@ async function getRelatedMemoriesForChatGPT(actionSource: string) {
if (response?.success && response?.data) {
const promptElement = document.getElementById("prompt-textarea")
if (promptElement) {
- promptElement.dataset.supermemories = `
Supermemories of user (only for the reference): ${response.data}
`
+ promptElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}`
console.log(
"Prompt element dataset:",
promptElement.dataset.supermemories,
@@ -471,7 +471,7 @@ function updateChatGPTIconFeedback(
const promptElement = document.getElementById("prompt-textarea")
if (promptElement) {
- promptElement.dataset.supermemories = `
Supermemories of user (only for the reference): ${updatedMemories}
`
+ promptElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}`
}
content
@@ -647,7 +647,7 @@ function setupChatGPTPromptCapture() {
promptTextarea &&
!promptContent.includes("Supermemories of user")
) {
- promptTextarea.innerHTML = `${promptTextarea.innerHTML} ${storedMemories}`
+ promptTextarea.appendChild(document.createTextNode(storedMemories))
promptContent = promptTextarea.textContent || ""
}
diff --git a/apps/browser-extension/entrypoints/content/claude.ts b/apps/browser-extension/entrypoints/content/claude.ts
index 01016a40..d124c84a 100644
--- a/apps/browser-extension/entrypoints/content/claude.ts
+++ b/apps/browser-extension/entrypoints/content/claude.ts
@@ -230,7 +230,7 @@ async function getRelatedMemoriesForClaude(actionSource: string) {
) as HTMLElement
if (textareaElement) {
- textareaElement.dataset.supermemories = `
Supermemories of user (only for the reference): ${response.data}
`
+ textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}`
console.log(
"Text element dataset:",
textareaElement.dataset.supermemories,
@@ -442,7 +442,7 @@ function updateClaudeIconFeedback(
'div[contenteditable="true"]',
) as HTMLElement
if (textareaElement) {
- textareaElement.dataset.supermemories = `
Supermemories of user (only for the reference): ${updatedMemories}
`
+ textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}`
}
content
@@ -520,7 +520,7 @@ function setupClaudePromptCapture() {
contentEditableDiv &&
!promptContent.includes("Supermemories of user")
) {
- contentEditableDiv.innerHTML = `${contentEditableDiv.innerHTML} ${storedMemories}`
+ contentEditableDiv.appendChild(document.createTextNode(storedMemories))
promptContent =
contentEditableDiv.textContent || contentEditableDiv.innerText || ""
}
diff --git a/apps/browser-extension/entrypoints/content/t3.ts b/apps/browser-extension/entrypoints/content/t3.ts
index 4d284a35..c7bdb09a 100644
--- a/apps/browser-extension/entrypoints/content/t3.ts
+++ b/apps/browser-extension/entrypoints/content/t3.ts
@@ -238,13 +238,7 @@ async function getRelatedMemoriesForT3(actionSource: string) {
}
if (textareaElement) {
- if (textareaElement.tagName === "TEXTAREA") {
- ;(textareaElement as HTMLTextAreaElement).dataset.supermemories =
- ` Supermemories of user (only for the reference): ${response.data}`
- } else {
- ;(textareaElement as HTMLElement).dataset.supermemories =
- ` Supermemories of user (only for the reference): ${response.data}`
- }
+ textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${response.data}`
iconElement.dataset.memoriesData = response.data
@@ -450,7 +444,7 @@ function updateT3IconFeedback(
(document.querySelector("textarea") as HTMLTextAreaElement) ||
(document.querySelector('div[contenteditable="true"]') as HTMLElement)
if (textareaElement) {
- textareaElement.dataset.supermemories = `
Supermemories of user (only for the reference): ${updatedMemories}
`
+ textareaElement.dataset.supermemories = `\n\nSupermemories of user (only for the reference): ${updatedMemories}`
}
content
@@ -537,7 +531,7 @@ function setupT3PromptCapture() {
`${promptContent} ${storedMemories}`
promptContent = (textareaElement as HTMLTextAreaElement).value
} else {
- textareaElement.innerHTML = `${textareaElement.innerHTML} ${storedMemories}`
+ textareaElement.appendChild(document.createTextNode(storedMemories))
promptContent =
textareaElement.textContent || textareaElement.innerText || ""
}
diff --git a/apps/docs/docs.json b/apps/docs/docs.json
index 0f7b54c0..1e29f968 100644
--- a/apps/docs/docs.json
+++ b/apps/docs/docs.json
@@ -184,6 +184,7 @@
"integrations/agent-framework",
"integrations/mastra",
"integrations/voltagent",
+ "integrations/convex",
"integrations/langchain",
"integrations/crewai",
"integrations/agno",
diff --git a/apps/docs/integrations/convex.mdx b/apps/docs/integrations/convex.mdx
new file mode 100644
index 00000000..37130a84
--- /dev/null
+++ b/apps/docs/integrations/convex.mdx
@@ -0,0 +1,208 @@
+---
+title: "Convex"
+sidebarTitle: "Convex"
+description: "Add persistent memory to Convex apps with Supermemory"
+icon: "database"
+---
+
+Convex apps don't have built-in memory for AI. Supermemory fixes that. You get a memory layer that stores conversations, builds user profiles, and gives your AI context about who it's talking to.
+
+## What you can do
+
+- Store user interactions and retrieve them in future sessions
+- Build automatic user profiles from conversations
+- Search memories to give your AI relevant context
+- Keep everything in your Convex database for full visibility
+
+## Setup
+
+Install the packages:
+
+```bash
+npm install supermemory convex
+```
+
+For the AI chat example, also install the AI SDK packages:
+
+```bash
+npm install @supermemory/tools @ai-sdk/openai ai
+```
+
+Set up your environment variable in Convex:
+
+```bash
+npx convex env set SUPERMEMORY_API_KEY your-supermemory-api-key
+```
+
+Get your Supermemory API key from [console.supermemory.ai](https://console.supermemory.ai).
+
+## Basic integration
+
+Create simple helper functions for each Supermemory operation:
+
+```typescript
+// convex/memory.ts
+import { action } from "./_generated/server";
+import { v } from "convex/values";
+import Supermemory from "supermemory";
+
+const memory = new Supermemory({ apiKey: process.env.SUPERMEMORY_API_KEY });
+
+// Get user profile and relevant memories
+export const getProfile = action({
+ args: { userId: v.string(), query: v.optional(v.string()) },
+ handler: async (ctx, { userId, query }) => {
+ return await memory.profile({
+ containerTag: userId,
+ q: query,
+ });
+ },
+});
+
+// Add a memory
+export const addMemory = action({
+ args: { userId: v.string(), content: v.string() },
+ handler: async (ctx, { userId, content }) => {
+ return await memory.add({
+ content,
+ containerTag: userId,
+ });
+ },
+});
+
+// Search memories
+export const searchMemories = action({
+ args: { userId: v.string(), query: v.string(), limit: v.optional(v.number()) },
+ handler: async (ctx, { userId, query, limit }) => {
+ return await memory.search.memories({
+ q: query,
+ containerTag: userId,
+ searchMode: "hybrid",
+ limit: limit ?? 10,
+ });
+ },
+});
+```
+
+---
+
+## Example: AI chat with memory
+
+A chat endpoint using the Supermemory AI SDK middleware. It automatically injects context and saves memories.
+
+```typescript
+// convex/chat.ts
+import { action } from "./_generated/server";
+import { v } from "convex/values";
+import { generateText } from "ai";
+import { openai } from "@ai-sdk/openai";
+import { withSupermemory } from "@supermemory/tools/ai-sdk";
+
+export const chat = action({
+ args: { userId: v.string(), message: v.string() },
+ handler: async (ctx, { userId, message }) => {
+ // Wrap the model - automatically injects context and saves memories
+ const model = withSupermemory(openai("gpt-4o-mini"), {
+ containerTag: userId,
+ customId: `convex-chat-${userId}`,
+ mode: "full",
+ addMemory: "always",
+ });
+
+ const { text } = await generateText({
+ model,
+ system: "You are a helpful assistant.",
+ prompt: message,
+ });
+
+ return text;
+ },
+});
+```
+
+---
+
+## Storing memories in Convex tables
+
+Keep a local copy of memories in your Convex database for full visibility:
+
+```typescript
+// convex/schema.ts
+import { defineSchema, defineTable } from "convex/server";
+import { v } from "convex/values";
+
+export default defineSchema({
+ memories: defineTable({
+ userId: v.string(),
+ content: v.string(),
+ createdAt: v.number(),
+ }).index("by_user", ["userId"]),
+});
+```
+
+```typescript
+// convex/memory.ts
+import { action, mutation, query } from "./_generated/server";
+import { api } from "./_generated/api";
+import { v } from "convex/values";
+import Supermemory from "supermemory";
+
+const memory = new Supermemory({ apiKey: process.env.SUPERMEMORY_API_KEY });
+
+// Store in Convex
+export const storeMemory = mutation({
+ args: { userId: v.string(), content: v.string() },
+ handler: async (ctx, { userId, content }) => {
+ return await ctx.db.insert("memories", {
+ userId,
+ content,
+ createdAt: Date.now(),
+ });
+ },
+});
+
+// Add memory to both Supermemory and Convex
+export const addMemory = action({
+ args: { userId: v.string(), content: v.string() },
+ handler: async (ctx, { userId, content }) => {
+ // Add to Supermemory
+ await memory.add({ content, containerTag: userId });
+
+ // Store in Convex
+ // Note: in production, handle partial failures — if the Convex mutation
+ // fails after the Supermemory write succeeds, the two stores will be out of sync.
+ await ctx.runMutation(api.memory.storeMemory, { userId, content });
+ },
+});
+
+// List memories from Convex
+export const listMemories = query({
+ args: { userId: v.string() },
+ handler: async (ctx, { userId }) => {
+ return await ctx.db
+ .query("memories")
+ .withIndex("by_user", q => q.eq("userId", userId))
+ .order("desc")
+ .take(50);
+ },
+});
+```
+
+---
+
+## Related docs
+
+
+
+ How automatic profiling works
+
+
+ Filtering and search modes
+
+
+ Memory middleware for Next.js
+
+
+ Memory for LangChain apps
+
+
diff --git a/apps/web/app/(app)/onboarding/page.tsx b/apps/web/app/(app)/onboarding/page.tsx
index ac554055..ad35867c 100644
--- a/apps/web/app/(app)/onboarding/page.tsx
+++ b/apps/web/app/(app)/onboarding/page.tsx
@@ -6,7 +6,10 @@ import {
useCallback,
useEffect,
useMemo,
+ type Dispatch,
+ type RefObject,
type ReactNode,
+ type SetStateAction,
} from "react"
import { useRouter } from "next/navigation"
import { useAuth } from "@lib/auth-context"
@@ -30,11 +33,25 @@ import {
RaycastIcon,
} from "@/components/integration-icons"
import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons"
-import { Sparkles, ChevronLeft, ChevronRight } from "lucide-react"
+import {
+ Sparkles,
+ ChevronLeft,
+ ChevronRight,
+ AlertCircle,
+ CheckCircle2,
+ Loader2,
+} from "lucide-react"
import { analytics } from "@/lib/analytics"
+import { consumePendingConnectUrl } from "@/lib/constants"
type DetectedSource = "x" | "linkedin" | "resume" | null
type Status = "idle" | "processing" | "done" | "error"
+type AccountLookupStatus = "checking" | "found" | "not_found" | "error"
+type AccountLookup = {
+ source: "x" | "linkedin"
+ status: AccountLookupStatus
+ message: string
+}
type DocStatus =
| "unknown"
| "queued"
@@ -53,7 +70,7 @@ function XIcon({ className }: { className?: string }) {
fill="currentColor"
aria-hidden="true"
>
-
+
)
}
@@ -66,7 +83,7 @@ function LinkedInIcon({ className }: { className?: string }) {
fill="currentColor"
aria-hidden="true"
>
-
+
)
}
@@ -76,7 +93,7 @@ function SubmitArrow() {
@@ -121,8 +138,13 @@ const SOURCE_ICON: Record<
}
const SOURCE_LABEL: Record<"x" | "linkedin", string> = {
- x: "X profile detected — press Enter to continue",
- linkedin: "LinkedIn profile detected — press Enter to continue",
+ x: "X profile detected - checking account",
+ linkedin: "LinkedIn profile detected - checking account",
+}
+
+const SOURCE_NAME: Record<"x" | "linkedin", string> = {
+ x: "X",
+ linkedin: "LinkedIn",
}
type SpotlightItem = {
@@ -352,6 +374,167 @@ function buildSpotlightCatalog(
}
}
+function isAccountSource(source: DetectedSource): source is "x" | "linkedin" {
+ return source === "x" || source === "linkedin"
+}
+
+function useSpotlightAutoRotation(
+ status: Status,
+ pauseSpotlight: boolean,
+ setSpotlightCategory: Dispatch>,
+) {
+ useEffect(() => {
+ if (status !== "processing") return
+ if (pauseSpotlight) return
+ const n = SPOTLIGHT_CATEGORY_ORDER.length
+ if (n <= 1) return
+ const t = setInterval(() => {
+ setSpotlightCategory((cur) => {
+ const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(cur)
+ const from = i >= 0 ? i : 0
+ const next = (from + 1) % n
+ return SPOTLIGHT_CATEGORY_ORDER[next] ?? cur
+ })
+ }, 8000)
+ return () => clearInterval(t)
+ }, [status, pauseSpotlight, setSpotlightCategory])
+}
+
+function useInitialInputFocus(inputRef: RefObject) {
+ useEffect(() => {
+ const t = setTimeout(() => inputRef.current?.focus(), 500)
+ return () => clearTimeout(t)
+ }, [inputRef])
+}
+
+function useAccountLookup({
+ detected,
+ status,
+ value,
+}: {
+ detected: DetectedSource
+ status: Status
+ value: string
+}) {
+ const [accountLookup, setAccountLookup] = useState(null)
+
+ useEffect(() => {
+ if (status !== "idle") return
+
+ const source = isAccountSource(detected) ? detected : null
+ const trimmedValue = value.trim()
+
+ if (!source || !trimmedValue) {
+ setAccountLookup(null)
+ return
+ }
+
+ const controller = new AbortController()
+ setAccountLookup({
+ source,
+ status: "checking",
+ message: SOURCE_LABEL[source],
+ })
+
+ const timeout = setTimeout(async () => {
+ try {
+ const params = new URLSearchParams({
+ source,
+ value: trimmedValue,
+ })
+ const response = await fetch(
+ `/api/onboarding/account-status?${params.toString()}`,
+ { signal: controller.signal },
+ )
+ const data: {
+ found?: boolean
+ handle?: string
+ reason?: string
+ verified?: boolean
+ } = await response.json().catch(() => ({}))
+
+ if (controller.signal.aborted) return
+
+ if (response.ok && data.found === true) {
+ const account =
+ source === "x" && data.handle ? ` @${data.handle}` : ""
+ setAccountLookup({
+ source,
+ status: "found",
+ message: `${SOURCE_NAME[source]} account${account} found - press Enter to continue`,
+ })
+ return
+ }
+
+ if (
+ (response.ok && data.found === false) ||
+ data.reason === "invalid"
+ ) {
+ setAccountLookup({
+ source,
+ status: "not_found",
+ message: `${SOURCE_NAME[source]} account not found. Check the link and try again.`,
+ })
+ return
+ }
+
+ setAccountLookup({
+ source,
+ status: "error",
+ message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`,
+ })
+ } catch (err) {
+ if (controller.signal.aborted) return
+ console.error(err)
+ setAccountLookup({
+ source,
+ status: "error",
+ message: `Could not verify ${SOURCE_NAME[source]} account. You can still continue.`,
+ })
+ }
+ }, 450)
+
+ return () => {
+ clearTimeout(timeout)
+ controller.abort()
+ }
+ }, [detected, status, value])
+
+ return accountLookup
+}
+
+function usePollingCleanup(
+ pollingRef: RefObject | null>,
+) {
+ useEffect(() => {
+ return () => {
+ if (pollingRef.current) clearInterval(pollingRef.current)
+ }
+ }, [pollingRef])
+}
+
+function useDoneAnimation(
+ status: Status,
+ setStampLanded: Dispatch>,
+ setVisibleSnippets: Dispatch>,
+) {
+ useEffect(() => {
+ if (status !== "done") return
+ setStampLanded(false)
+ setVisibleSnippets(0)
+ const t1 = setTimeout(() => setStampLanded(true), 400)
+ const t2 = setTimeout(() => setVisibleSnippets(1), 900)
+ const t3 = setTimeout(() => setVisibleSnippets(2), 1200)
+ const t4 = setTimeout(() => setVisibleSnippets(3), 1500)
+ return () => {
+ clearTimeout(t1)
+ clearTimeout(t2)
+ clearTimeout(t3)
+ clearTimeout(t4)
+ }
+ }, [status, setStampLanded, setVisibleSnippets])
+}
+
export default function OnboardingPage() {
const router = useRouter()
const { user, organizations, refetchOrganizations, setActiveOrg } = useAuth()
@@ -374,6 +557,12 @@ export default function OnboardingPage() {
const skippingRef = useRef(false)
const [spotlightCategory, setSpotlightCategory] =
useState("productivity")
+
+ /** Navigate home, or back to the plugin connect page if one is pending. */
+ const goHomeOrPendingConnect = useCallback(() => {
+ const pendingPath = consumePendingConnectUrl()
+ router.push(pendingPath ?? "/")
+ }, [router])
const [pauseSpotlight, setPauseSpotlight] = useState(false)
const spotlightCatalog = useMemo(
@@ -395,48 +584,11 @@ export default function OnboardingPage() {
[spotlightCategory],
)
- useEffect(() => {
- if (status !== "processing") return
- if (pauseSpotlight) return
- const n = SPOTLIGHT_CATEGORY_ORDER.length
- if (n <= 1) return
- const t = setInterval(() => {
- setSpotlightCategory((cur) => {
- const i = SPOTLIGHT_CATEGORY_ORDER.indexOf(cur)
- const from = i >= 0 ? i : 0
- const next = (from + 1) % n
- return SPOTLIGHT_CATEGORY_ORDER[next] ?? cur
- })
- }, 8000)
- return () => clearInterval(t)
- }, [status, pauseSpotlight])
-
- useEffect(() => {
- const t = setTimeout(() => inputRef.current?.focus(), 500)
- return () => clearTimeout(t)
- }, [])
-
- useEffect(() => {
- return () => {
- if (pollingRef.current) clearInterval(pollingRef.current)
- }
- }, [])
-
- useEffect(() => {
- if (status !== "done") return
- setStampLanded(false)
- setVisibleSnippets(0)
- const t1 = setTimeout(() => setStampLanded(true), 400)
- const t2 = setTimeout(() => setVisibleSnippets(1), 900)
- const t3 = setTimeout(() => setVisibleSnippets(2), 1200)
- const t4 = setTimeout(() => setVisibleSnippets(3), 1500)
- return () => {
- clearTimeout(t1)
- clearTimeout(t2)
- clearTimeout(t3)
- clearTimeout(t4)
- }
- }, [status])
+ useSpotlightAutoRotation(status, pauseSpotlight, setSpotlightCategory)
+ useInitialInputFocus(inputRef)
+ const accountLookup = useAccountLookup({ detected, status, value })
+ usePollingCleanup(pollingRef)
+ useDoneAnimation(status, setStampLanded, setVisibleSnippets)
const handleChange = (v: string) => {
setValue(v)
@@ -467,7 +619,8 @@ export default function OnboardingPage() {
skippingRef.current = true
try {
await ensureOrg()
- router.push("/")
+ const pendingPath = consumePendingConnectUrl()
+ router.push(pendingPath ?? "/")
} catch (err) {
console.error(err)
skippingRef.current = false
@@ -597,7 +750,18 @@ export default function OnboardingPage() {
}
}
- const canSubmit = detected && detected !== "resume"
+ const hasDetectedAccount = detected === "x" || detected === "linkedin"
+ const currentAccountLookup =
+ accountLookup?.source === detected ? accountLookup : null
+ const isCheckingAccount =
+ hasDetectedAccount &&
+ (!currentAccountLookup || currentAccountLookup.status === "checking")
+ const canSubmit = Boolean(
+ hasDetectedAccount &&
+ currentAccountLookup &&
+ currentAccountLookup.status !== "checking" &&
+ currentAccountLookup.status !== "not_found",
+ )
return (
// biome-ignore lint/a11y/noStaticElementInteractions: full-surface drag-and-drop for resume PDF
@@ -705,6 +869,20 @@ export default function OnboardingPage() {
)}
/>
+
+ {isCheckingAccount && (
+
+
+
+ )}
+
+
{canSubmit && (
- {SOURCE_LABEL[detected as "x" | "linkedin"]}
+ {currentAccountLookup?.status === "found" && (
+
+ )}
+ {currentAccountLookup?.status === "not_found" && (
+
+ )}
+ {currentAccountLookup?.status === "error" && (
+
+ )}
+ {isCheckingAccount && (
+
+ )}
+
+ {currentAccountLookup?.message ??
+ SOURCE_LABEL[detected as "x" | "linkedin"]}
+
)}
@@ -809,7 +1013,7 @@ export default function OnboardingPage() {
Finishing your first save
- Most finish in under a minute. Below is optional — ways to add
+ Most finish in under a minute. Below is optional: ways to add
more later.
@@ -948,7 +1152,7 @@ export default function OnboardingPage() {
Your first save is ready. When you want more, use Integrations
- for browser, phone, editor, and AI tools — all in one place.
+ for browser, phone, editor, and AI tools, all in one place.
@@ -1077,7 +1281,7 @@ export default function OnboardingPage() {