diff --git a/packages/app/server/nginx-archy.conf b/packages/app/server/nginx-archy.conf
new file mode 100644
index 00000000..fdf539c9
--- /dev/null
+++ b/packages/app/server/nginx-archy.conf
@@ -0,0 +1,61 @@
+# AIUI nginx config for Archipelago deployment
+# Include this in your Archy nginx server block.
+#
+# Prerequisites:
+# - AIUI built and placed in /opt/archipelago/web-ui/aiui/
+# - Set $anthropic_api_key in nginx or via env (see below)
+#
+# Usage in nginx.conf:
+# include /opt/archipelago/web-ui/aiui/nginx-archy.conf;
+
+# Serve AIUI SPA
+location /aiui/ {
+ alias /opt/archipelago/web-ui/aiui/;
+ try_files $uri $uri/ /aiui/index.html;
+
+ # Cache static assets aggressively
+ location ~* /aiui/assets/ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+}
+
+# Proxy Claude API requests from AIUI iframe
+# AIUI fetches /api/claude/v1/messages → proxied to Anthropic API
+location /aiui/api/claude/ {
+ # Rewrite: strip /aiui/api/claude prefix, forward to Anthropic
+ rewrite ^/aiui/api/claude/(.*)$ /$1 break;
+
+ proxy_pass https://api.anthropic.com;
+ proxy_ssl_server_name on;
+ proxy_set_header Host api.anthropic.com;
+ proxy_set_header x-api-key $anthropic_api_key;
+ proxy_set_header anthropic-version "2023-06-01";
+ proxy_set_header Content-Type "application/json";
+
+ # SSE streaming support
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 300s;
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+
+ # Security: only allow from same origin (AIUI iframe)
+ # The iframe has sandbox="allow-same-origin" so requests come from Archy's origin
+}
+
+# Proxy OpenRouter API requests (optional, for multi-model support)
+location /aiui/api/openrouter/ {
+ rewrite ^/aiui/api/openrouter/(.*)$ /api/v1/chat/completions break;
+
+ proxy_pass https://openrouter.ai;
+ proxy_ssl_server_name on;
+ proxy_set_header Host openrouter.ai;
+ proxy_set_header Content-Type "application/json";
+
+ proxy_buffering off;
+ proxy_cache off;
+ proxy_read_timeout 300s;
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+}
diff --git a/packages/app/src/App.vue b/packages/app/src/App.vue
index 73bbefc8..ad7eecd0 100644
--- a/packages/app/src/App.vue
+++ b/packages/app/src/App.vue
@@ -12,9 +12,10 @@
diff --git a/packages/app/src/adapters/claude-adapter.ts b/packages/app/src/adapters/claude-adapter.ts
index c65c3ee9..3cf7f104 100644
--- a/packages/app/src/adapters/claude-adapter.ts
+++ b/packages/app/src/adapters/claude-adapter.ts
@@ -1,7 +1,8 @@
import type { AIAdapter, ChatMessage, ChatOptions } from './types'
import { getApiKey } from '@/utils/key-vault'
-const CLAUDE_PATH = '/api/claude/v1/messages'
+const BASE = import.meta.env.BASE_URL || '/'
+const CLAUDE_PATH = `${BASE}api/claude/v1/messages`
export const claudeAdapter: AIAdapter = {
id: 'claude',
diff --git a/packages/app/src/composables/useAI.ts b/packages/app/src/composables/useAI.ts
index 2fa03a6f..f6a1bb73 100644
--- a/packages/app/src/composables/useAI.ts
+++ b/packages/app/src/composables/useAI.ts
@@ -5,11 +5,14 @@ import { getApiKey } from '@/utils/key-vault'
import type { ImageAttachment } from '@aiui/core/types/message'
import { usePersonaStore } from '@/stores/personas'
import { useMemoryStore } from '@/stores/memory'
+import { useArchy } from '@/composables/useArchy'
type Provider = 'claude' | 'openrouter' | 'mock'
-const CLAUDE_PATH = '/api/claude/v1/messages'
-const OPENROUTER_PATH = '/api/openrouter'
+// API paths are relative to the base URL so they work both in dev (/) and Archy (/aiui/)
+const BASE = import.meta.env.BASE_URL || '/'
+const CLAUDE_PATH = `${BASE}api/claude/v1/messages`
+const OPENROUTER_PATH = `${BASE}api/openrouter`
import { mockFilms } from '@/mocks/films'
import { mockSongs } from '@/mocks/songs'
@@ -343,6 +346,10 @@ function buildSystemPrompt(chatStore: ReturnType): string {
**Web search:** You have access to WebSearch and WebFetch tools. Use them to look up current information, news, and facts when the user asks. You can search the web and fetch page content. Web search is enabled for this session—do not tell the user it is unavailable.`
}
+ // Append Archy node context when running embedded in Archipelago
+ const archy = useArchy()
+ prompt += archy.buildArchyContext()
+
return prompt
}
diff --git a/packages/app/src/composables/useArchy.ts b/packages/app/src/composables/useArchy.ts
new file mode 100644
index 00000000..8dc8a8f8
--- /dev/null
+++ b/packages/app/src/composables/useArchy.ts
@@ -0,0 +1,171 @@
+import { ref, readonly } from 'vue'
+import { archyBridge } from '@/services/archyBridge'
+
+type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files'
+
+interface ArchyApp {
+ id: string
+ name: string
+ state: string
+ status: string
+}
+
+interface ArchySystemInfo {
+ version?: string
+ name?: string
+}
+
+interface ArchyNetworkInfo {
+ connected?: boolean
+}
+
+// Singleton reactive state (shared across all components using this composable)
+const isEmbedded = ref(false)
+const isInitialized = ref(false)
+const permissions = ref([])
+const accentColor = ref(null)
+const installedApps = ref([])
+const systemInfo = ref({})
+const networkInfo = ref({})
+let cleanups: (() => void)[] = []
+
+/**
+ * Reactive composable wrapping archyBridge for Archy ↔ AIUI integration.
+ * Call `init()` once in App.vue when `?embedded=true` is detected.
+ */
+export function useArchy() {
+ /** Initialize the bridge and start listening for Archy messages */
+ function init() {
+ if (isInitialized.value) return
+
+ const embedded = !!(window as unknown as Record).__AIUI_EMBEDDED__
+ isEmbedded.value = embedded
+ if (!embedded || !archyBridge.isInArchy()) return
+
+ archyBridge.init()
+ isInitialized.value = true
+
+ // Listen for permission updates
+ const unsubPerms = archyBridge.onPermissionsUpdate((cats) => {
+ permissions.value = cats
+ // Auto-fetch context for newly permitted categories
+ fetchPermittedContext(cats)
+ })
+ cleanups.push(unsubPerms)
+
+ // Listen for theme updates
+ const unsubTheme = archyBridge.onThemeUpdate((theme) => {
+ accentColor.value = theme.accent
+ applyAccentColor(theme.accent)
+ })
+ cleanups.push(unsubTheme)
+
+ // Request theme on init
+ archyBridge.requestTheme()
+ }
+
+ /** Fetch context for all permitted categories */
+ async function fetchPermittedContext(cats: AIContextCategory[]) {
+ const fetches: Promise[] = []
+
+ if (cats.includes('apps')) {
+ fetches.push(
+ archyBridge.requestContext('apps').then((res) => {
+ if (res.permitted && Array.isArray(res.data)) {
+ installedApps.value = res.data as ArchyApp[]
+ }
+ }).catch(() => {}),
+ )
+ }
+
+ if (cats.includes('system')) {
+ fetches.push(
+ archyBridge.requestContext('system').then((res) => {
+ if (res.permitted && res.data) {
+ systemInfo.value = res.data as ArchySystemInfo
+ }
+ }).catch(() => {}),
+ )
+ }
+
+ if (cats.includes('network')) {
+ fetches.push(
+ archyBridge.requestContext('network').then((res) => {
+ if (res.permitted && res.data) {
+ networkInfo.value = res.data as ArchyNetworkInfo
+ }
+ }).catch(() => {}),
+ )
+ }
+
+ await Promise.all(fetches)
+ }
+
+ /** Refresh context data (call when user returns to chat) */
+ async function refreshContext() {
+ if (!isInitialized.value) return
+ await fetchPermittedContext(permissions.value)
+ }
+
+ /** Request Archy to perform an action */
+ async function requestAction(action: string, params: Record = {}) {
+ if (!isInitialized.value) return { success: false, error: 'Not initialized' }
+ return archyBridge.requestAction(action, params)
+ }
+
+ /** Apply accent color as CSS custom property */
+ function applyAccentColor(color: string) {
+ document.documentElement.style.setProperty('--color-accent', color)
+ }
+
+ /** Build context string for AI system prompt */
+ function buildArchyContext(): string {
+ if (!isInitialized.value) return ''
+
+ const sections: string[] = []
+
+ if (permissions.value.includes('apps') && installedApps.value.length > 0) {
+ const appList = installedApps.value
+ .map((a) => `- ${a.name} (${a.state}${a.status ? ', ' + a.status : ''})`)
+ .join('\n')
+ sections.push(`**Installed apps on this node:**\n${appList}`)
+ }
+
+ if (permissions.value.includes('system') && systemInfo.value.name) {
+ const sys = systemInfo.value
+ sections.push(`**System:** ${sys.name}${sys.version ? ' v' + sys.version : ''}`)
+ }
+
+ if (permissions.value.includes('network')) {
+ const net = networkInfo.value
+ sections.push(`**Network:** ${net.connected ? 'Connected' : 'Disconnected'}`)
+ }
+
+ if (sections.length === 0) return ''
+
+ return `\n\n**Archy Node Context** (this user is running AIUI on their Archipelago node):\n${sections.join('\n')}\n\nYou can help the user manage their node. Available actions: open an app (open-app), install an app (install-app), navigate in Archy (navigate). When recommending apps, check if they're already installed.`
+ }
+
+ /** Clean up on component unmount */
+ function destroy() {
+ for (const cleanup of cleanups) cleanup()
+ cleanups = []
+ archyBridge.destroy()
+ isInitialized.value = false
+ }
+
+ return {
+ isEmbedded: readonly(isEmbedded),
+ isInitialized: readonly(isInitialized),
+ permissions: readonly(permissions),
+ accentColor: readonly(accentColor),
+ installedApps: readonly(installedApps),
+ systemInfo: readonly(systemInfo),
+ networkInfo: readonly(networkInfo),
+ init,
+ destroy,
+ refreshContext,
+ requestAction,
+ buildArchyContext,
+ }
+}
diff --git a/packages/app/src/composables/useSimilarContent.ts b/packages/app/src/composables/useSimilarContent.ts
index be894b41..c5466ad8 100644
--- a/packages/app/src/composables/useSimilarContent.ts
+++ b/packages/app/src/composables/useSimilarContent.ts
@@ -56,7 +56,8 @@ export function useSimilarContent() {
const typeLabel = type === 'tv' ? 'TV series' : type
const prompt = `List exactly 3 ${typeLabel}s similar to "${title}". For each, respond with ONLY a JSON array like: [{"title":"Name","reason":"one sentence why"}]. No other text.`
- const res = await fetch('/api/claude/v1/messages', {
+ const base = import.meta.env.BASE_URL || '/'
+ const res = await fetch(`${base}api/claude/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
body: JSON.stringify({
diff --git a/packages/app/src/plugins/claude-provider.ts b/packages/app/src/plugins/claude-provider.ts
index 7a627403..5abae98d 100644
--- a/packages/app/src/plugins/claude-provider.ts
+++ b/packages/app/src/plugins/claude-provider.ts
@@ -7,7 +7,8 @@ import type {
PluginContext,
} from '@aiui/core'
-const CLAUDE_PATH = '/api/claude/v1/messages'
+const BASE = import.meta.env.BASE_URL || '/'
+const CLAUDE_PATH = `${BASE}api/claude/v1/messages`
async function* streamChat(
messages: ChatMessage[],
diff --git a/packages/app/src/services/archyBridge.ts b/packages/app/src/services/archyBridge.ts
new file mode 100644
index 00000000..de42350a
--- /dev/null
+++ b/packages/app/src/services/archyBridge.ts
@@ -0,0 +1,196 @@
+/**
+ * Archy Bridge — postMessage client for AIUI ↔ Archipelago communication.
+ *
+ * This is the ONLY way AIUI should communicate with the host Archy app.
+ * Never make direct HTTP requests to the host machine.
+ */
+
+type AIContextCategory = 'apps' | 'system' | 'network' | 'wallet' | 'files'
+
+interface ContextResponse {
+ data: unknown
+ permitted: boolean
+}
+
+export interface ActionResponse {
+ success: boolean
+ error?: string
+}
+
+interface ThemeInfo {
+ accent: string
+ mode: 'dark'
+}
+
+type PermissionsCallback = (categories: AIContextCategory[]) => void
+type ThemeCallback = (theme: ThemeInfo) => void
+
+let requestId = 0
+const pendingRequests = new Map void
+ reject: (reason: unknown) => void
+}>()
+
+const permissionsCallbacks: PermissionsCallback[] = []
+const themeCallbacks: ThemeCallback[] = []
+let currentPermissions: AIContextCategory[] = []
+let currentTheme: ThemeInfo | null = null
+let initialized = false
+
+function generateId(): string {
+ return `aiui-${++requestId}-${Date.now()}`
+}
+
+function postToParent(msg: unknown) {
+ if (window.parent === window) return // Not in iframe
+ window.parent.postMessage(msg, '*')
+}
+
+function handleMessage(event: MessageEvent) {
+ const msg = event.data
+ if (!msg || typeof msg.type !== 'string') return
+
+ switch (msg.type) {
+ case 'context:response': {
+ const pending = pendingRequests.get(msg.id)
+ if (pending) {
+ pendingRequests.delete(msg.id)
+ pending.resolve({ data: msg.data, permitted: msg.permitted })
+ }
+ break
+ }
+
+ case 'action:response': {
+ const pending = pendingRequests.get(msg.id)
+ if (pending) {
+ pendingRequests.delete(msg.id)
+ pending.resolve({ success: msg.success, error: msg.error })
+ }
+ break
+ }
+
+ case 'permissions:update': {
+ currentPermissions = msg.categories || []
+ for (const cb of permissionsCallbacks) cb(currentPermissions)
+ break
+ }
+
+ case 'theme:response': {
+ const theme: ThemeInfo | undefined = msg.theme
+ if (theme) {
+ currentTheme = theme
+ for (const cb of themeCallbacks) cb(theme)
+ }
+ break
+ }
+ }
+}
+
+export const archyBridge = {
+ /**
+ * Initialize the bridge. Call once on app mount.
+ * Sends 'ready' to Archy so it knows the iframe is loaded.
+ */
+ init() {
+ if (initialized) return
+ initialized = true
+ window.addEventListener('message', handleMessage)
+ postToParent({ type: 'ready' })
+ },
+
+ /** Clean up listeners */
+ destroy() {
+ window.removeEventListener('message', handleMessage)
+ pendingRequests.clear()
+ initialized = false
+ },
+
+ /** Check if running inside Archy iframe */
+ isInArchy(): boolean {
+ return window.parent !== window
+ },
+
+ /**
+ * Request context data from Archy.
+ * Returns { data, permitted }. If permitted is false, the user hasn't enabled this category.
+ */
+ requestContext(category: AIContextCategory, query?: string): Promise {
+ const id = generateId()
+ return new Promise((resolve, reject) => {
+ pendingRequests.set(id, { resolve: resolve as (v: unknown) => void, reject })
+ postToParent({
+ type: 'context:request',
+ id,
+ category,
+ query,
+ })
+
+ // Timeout after 10s
+ setTimeout(() => {
+ if (pendingRequests.has(id)) {
+ pendingRequests.delete(id)
+ reject(new Error(`Context request timed out: ${category}`))
+ }
+ }, 10000)
+ })
+ },
+
+ /**
+ * Request Archy to perform an action (install app, navigate, etc.)
+ */
+ requestAction(action: string, params: Record = {}): Promise {
+ const id = generateId()
+ return new Promise((resolve, reject) => {
+ pendingRequests.set(id, { resolve: resolve as (v: unknown) => void, reject })
+ postToParent({
+ type: 'action:request',
+ id,
+ action,
+ params,
+ })
+
+ setTimeout(() => {
+ if (pendingRequests.has(id)) {
+ pendingRequests.delete(id)
+ reject(new Error(`Action request timed out: ${action}`))
+ }
+ }, 30000)
+ })
+ },
+
+ /** Request Archy's theme info */
+ requestTheme() {
+ postToParent({ type: 'theme:request' })
+ },
+
+ /** Register callback for permission updates */
+ onPermissionsUpdate(callback: PermissionsCallback): () => void {
+ permissionsCallbacks.push(callback)
+ // If we already have permissions, fire immediately
+ if (currentPermissions.length > 0) callback(currentPermissions)
+ return () => {
+ const idx = permissionsCallbacks.indexOf(callback)
+ if (idx !== -1) permissionsCallbacks.splice(idx, 1)
+ }
+ },
+
+ /** Register callback for theme updates */
+ onThemeUpdate(callback: ThemeCallback): () => void {
+ themeCallbacks.push(callback)
+ if (currentTheme) callback(currentTheme)
+ return () => {
+ const idx = themeCallbacks.indexOf(callback)
+ if (idx !== -1) themeCallbacks.splice(idx, 1)
+ }
+ },
+
+ /** Get current permitted categories */
+ getPermissions(): AIContextCategory[] {
+ return [...currentPermissions]
+ },
+
+ /** Get current theme */
+ getTheme(): ThemeInfo | null {
+ return currentTheme
+ },
+}