diff --git a/.gitignore b/.gitignore
index 13dcca1b..111787bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -181,3 +181,6 @@ log.txt
# Testing iteration temp files
tmp/
+
+# Coding agent artifacts
+.agent/
diff --git a/apps/agent/.env.example b/apps/agent/.env.example
index fc776464..2c35a1f3 100644
--- a/apps/agent/.env.example
+++ b/apps/agent/.env.example
@@ -11,6 +11,7 @@ BROWSEROS_BINARY=/Applications/BrowserOS.app/Contents/MacOS/BrowserOS
VITE_PUBLIC_POSTHOG_KEY=
VITE_PUBLIC_POSTHOG_HOST=
VITE_PUBLIC_SENTRY_DSN=
+VITE_PUBLIC_INTERCOM_APP_ID=
# BrowserOS API URL
VITE_PUBLIC_BROWSEROS_API=https://api.browseros.com
diff --git a/apps/agent/entrypoints/app/main.tsx b/apps/agent/entrypoints/app/main.tsx
index 8b6e6cef..d2eab2a0 100644
--- a/apps/agent/entrypoints/app/main.tsx
+++ b/apps/agent/entrypoints/app/main.tsx
@@ -6,6 +6,7 @@ import { Toaster } from '@/components/ui/sonner'
import { AnalyticsProvider } from '@/lib/analytics/AnalyticsProvider.tsx'
import { AuthProvider } from '@/lib/auth/AuthProvider'
import { QueryProvider } from '@/lib/graphql/QueryProvider'
+import { IntercomProvider } from '@/lib/intercom/IntercomProvider'
import { sentryRootErrorHandler } from '@/lib/sentry/sentryRootErrorHandler.ts'
import { App } from './App'
@@ -17,10 +18,12 @@ if ($root) {
-
-
-
-
+
+
+
+
+
+
diff --git a/apps/agent/entrypoints/intercom.sandbox/index.html b/apps/agent/entrypoints/intercom.sandbox/index.html
new file mode 100644
index 00000000..bd724d92
--- /dev/null
+++ b/apps/agent/entrypoints/intercom.sandbox/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/agent/entrypoints/intercom.sandbox/main.ts b/apps/agent/entrypoints/intercom.sandbox/main.ts
new file mode 100644
index 00000000..b63266b7
--- /dev/null
+++ b/apps/agent/entrypoints/intercom.sandbox/main.ts
@@ -0,0 +1,101 @@
+import type { IntercomParentMessage } from '../../lib/intercom/intercom'
+
+type IntercomFn = ((...args: unknown[]) => void) & { q?: unknown[][] }
+
+declare global {
+ interface Window {
+ Intercom: IntercomFn
+ intercomSettings: Record
+ }
+}
+
+function sendToParent(message: { type: string }) {
+ parent.postMessage(message, '*')
+}
+
+function initIntercomStub() {
+ const i = ((...args: unknown[]) => {
+ i.c(args)
+ }) as ((...args: unknown[]) => void) & {
+ q: unknown[][]
+ c: (args: unknown[]) => void
+ }
+ i.q = []
+ i.c = (args: unknown[]) => {
+ i.q.push(args)
+ }
+ window.Intercom = i as IntercomFn
+}
+
+function loadIntercomScript(appId: string): Promise {
+ return new Promise((resolve, reject) => {
+ const script = document.createElement('script')
+ script.src = `https://widget.intercom.io/widget/${appId}`
+ script.async = true
+ script.onload = () => resolve()
+ script.onerror = () => reject(new Error('Failed to load Intercom script'))
+ document.head.appendChild(script)
+ })
+}
+
+let booted = false
+
+async function handleBoot(appId: string) {
+ if (booted) return
+ booted = true
+
+ initIntercomStub()
+ await loadIntercomScript(appId)
+
+ window.Intercom('boot', {
+ app_id: appId,
+ vertical_padding: 0,
+ horizontal_padding: 0,
+ })
+
+ window.Intercom('onShow', () => {
+ sendToParent({ type: 'intercom:messenger-opened' })
+ })
+
+ window.Intercom('onHide', () => {
+ sendToParent({ type: 'intercom:messenger-closed' })
+ })
+
+ sendToParent({ type: 'intercom:ready' })
+}
+
+function handleUpdate(userId: string, email: string, name: string) {
+ if (!window.Intercom) return
+ window.Intercom('update', {
+ user_id: userId,
+ email,
+ name,
+ })
+}
+
+function handleShutdown() {
+ if (!window.Intercom) return
+ window.Intercom('shutdown')
+}
+
+window.addEventListener('message', (event: MessageEvent) => {
+ const data = event.data as IntercomParentMessage
+ if (
+ !data ||
+ typeof data.type !== 'string' ||
+ !data.type.startsWith('intercom:')
+ )
+ return
+
+ switch (data.type) {
+ case 'intercom:boot':
+ handleBoot(data.appId)
+ break
+ case 'intercom:update':
+ handleUpdate(data.userId, data.email, data.name)
+ break
+ case 'intercom:shutdown':
+ handleShutdown()
+ break
+ }
+})
diff --git a/apps/agent/lib/env.ts b/apps/agent/lib/env.ts
index 5596f877..309ecbe3 100644
--- a/apps/agent/lib/env.ts
+++ b/apps/agent/lib/env.ts
@@ -5,6 +5,7 @@ const EnvSchema = z.object({
VITE_PUBLIC_POSTHOG_KEY: z.string().optional(),
VITE_PUBLIC_POSTHOG_HOST: z.string().optional(),
VITE_PUBLIC_SENTRY_DSN: z.string().optional(),
+ VITE_PUBLIC_INTERCOM_APP_ID: z.string().optional(),
VITE_PUBLIC_BROWSEROS_API: z.string().optional(),
PROD: z.boolean(),
})
diff --git a/apps/agent/lib/intercom/IntercomProvider.tsx b/apps/agent/lib/intercom/IntercomProvider.tsx
new file mode 100644
index 00000000..177a866d
--- /dev/null
+++ b/apps/agent/lib/intercom/IntercomProvider.tsx
@@ -0,0 +1,119 @@
+import type { FC, PropsWithChildren } from 'react'
+import { useEffect, useRef, useState } from 'react'
+import { useSessionInfo } from '@/lib/auth/sessionStorage'
+import { env } from '@/lib/env'
+import { sentry } from '@/lib/sentry/sentry'
+import {
+ INTERCOM_LAUNCHER_HEIGHT,
+ INTERCOM_LAUNCHER_WIDTH,
+ INTERCOM_MESSENGER_HEIGHT,
+ INTERCOM_MESSENGER_WIDTH,
+ isIntercomSandboxEvent,
+} from './intercom'
+
+export const IntercomProvider: FC = ({ children }) => {
+ const appId = env.VITE_PUBLIC_INTERCOM_APP_ID
+ if (!appId) return <>{children}>
+
+ return {children}
+}
+
+const IntercomProviderInner: FC> = ({
+ appId,
+ children,
+}) => {
+ const iframeRef = useRef(null)
+ const iframeWindowRef = useRef(null)
+ const [isMessengerOpen, setIsMessengerOpen] = useState(false)
+ const { sessionInfo, isLoading } = useSessionInfo()
+
+ const handleIframeLoad = () => {
+ iframeWindowRef.current = iframeRef.current?.contentWindow ?? null
+ try {
+ iframeWindowRef.current?.postMessage(
+ { type: 'intercom:boot', appId },
+ '*',
+ )
+ } catch (error) {
+ sentry.captureException(error, {
+ extra: { message: 'Failed to send boot command to Intercom sandbox' },
+ })
+ }
+ }
+
+ useEffect(() => {
+ const handler = (event: MessageEvent) => {
+ if (!isIntercomSandboxEvent(event.data)) return
+
+ switch (event.data.type) {
+ case 'intercom:ready':
+ break
+ case 'intercom:messenger-opened':
+ setIsMessengerOpen(true)
+ break
+ case 'intercom:messenger-closed':
+ setIsMessengerOpen(false)
+ break
+ }
+ }
+
+ window.addEventListener('message', handler)
+ return () => window.removeEventListener('message', handler)
+ }, [])
+
+ useEffect(() => {
+ if (isLoading) return
+
+ try {
+ if (sessionInfo.user) {
+ iframeWindowRef.current?.postMessage(
+ {
+ type: 'intercom:update',
+ userId: sessionInfo.user.id,
+ email: sessionInfo.user.email,
+ name: sessionInfo.user.name,
+ },
+ '*',
+ )
+ } else {
+ iframeWindowRef.current?.postMessage({ type: 'intercom:shutdown' }, '*')
+ }
+ } catch (error) {
+ sentry.captureException(error, {
+ extra: { message: 'Failed to sync identity with Intercom sandbox' },
+ })
+ }
+ }, [sessionInfo.user, isLoading])
+
+ const width = isMessengerOpen
+ ? INTERCOM_MESSENGER_WIDTH
+ : INTERCOM_LAUNCHER_WIDTH
+ const height = isMessengerOpen
+ ? INTERCOM_MESSENGER_HEIGHT
+ : INTERCOM_LAUNCHER_HEIGHT
+
+ return (
+ <>
+ {children}
+
+ >
+ )
+}
diff --git a/apps/agent/lib/intercom/intercom.ts b/apps/agent/lib/intercom/intercom.ts
new file mode 100644
index 00000000..d27cc518
--- /dev/null
+++ b/apps/agent/lib/intercom/intercom.ts
@@ -0,0 +1,57 @@
+export type IntercomBootCommand = {
+ type: 'intercom:boot'
+ appId: string
+}
+
+export type IntercomUpdateCommand = {
+ type: 'intercom:update'
+ userId: string
+ email: string
+ name: string
+}
+
+export type IntercomShutdownCommand = {
+ type: 'intercom:shutdown'
+}
+
+export type IntercomParentMessage =
+ | IntercomBootCommand
+ | IntercomUpdateCommand
+ | IntercomShutdownCommand
+
+export type IntercomReadyEvent = {
+ type: 'intercom:ready'
+}
+
+export type IntercomMessengerOpenedEvent = {
+ type: 'intercom:messenger-opened'
+}
+
+export type IntercomMessengerClosedEvent = {
+ type: 'intercom:messenger-closed'
+}
+
+export type IntercomSandboxEvent =
+ | IntercomReadyEvent
+ | IntercomMessengerOpenedEvent
+ | IntercomMessengerClosedEvent
+
+export const INTERCOM_LAUNCHER_WIDTH = 80
+export const INTERCOM_LAUNCHER_HEIGHT = 80
+export const INTERCOM_MESSENGER_WIDTH = 420
+export const INTERCOM_MESSENGER_HEIGHT = 650
+
+const KNOWN_SANDBOX_EVENT_TYPES = new Set([
+ 'intercom:ready',
+ 'intercom:messenger-opened',
+ 'intercom:messenger-closed',
+])
+
+export function isIntercomSandboxEvent(
+ data: unknown,
+): data is IntercomSandboxEvent {
+ if (typeof data !== 'object' || data === null) return false
+ const msg = data as { type?: unknown }
+ if (typeof msg.type !== 'string') return false
+ return KNOWN_SANDBOX_EVENT_TYPES.has(msg.type)
+}
diff --git a/apps/agent/wxt.config.ts b/apps/agent/wxt.config.ts
index 37198b09..d88b998c 100644
--- a/apps/agent/wxt.config.ts
+++ b/apps/agent/wxt.config.ts
@@ -67,6 +67,17 @@ export default defineConfig({
'https://duckduckgo.com/*',
'https://suggest.yandex.com/*',
],
+ content_security_policy: {
+ sandbox:
+ "sandbox allow-scripts allow-forms allow-popups allow-modals; " +
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://widget.intercom.io https://js.intercomcdn.com https://app.intercom.io; " +
+ "connect-src 'self' https://*.intercom.io https://*.intercomcdn.com https://*.intercom-messenger.com wss://*.intercom-messenger.com wss://*.intercom.io https://uploads.intercomusercontent.com; " +
+ "frame-src 'self' https://share.intercom.io https://intercom-sheets.com https://www.youtube.com https://player.vimeo.com; " +
+ "img-src 'self' blob: data: https://*.intercomcdn.com https://static.intercomassets.com https://uploads.intercomusercontent.com https://gifs.intercomcdn.com; " +
+ "font-src 'self' https://js.intercomcdn.com https://fonts.intercomcdn.com; " +
+ "style-src 'self' 'unsafe-inline'; " +
+ "media-src 'self' https://js.intercomcdn.com",
+ },
},
vite: () => ({
build: {