diff --git a/ui/desktop/eslint.config.js b/ui/desktop/eslint.config.js
index 458bcb0a58..7c0a5554c4 100644
--- a/ui/desktop/eslint.config.js
+++ b/ui/desktop/eslint.config.js
@@ -104,6 +104,10 @@ module.exports = [
MediaStream: 'readonly',
Blob: 'readonly',
FormData: 'readonly',
+ // Build-time injected globals
+ __WHITELABEL_CONFIG__: 'readonly',
+ MAIN_WINDOW_VITE_DEV_SERVER_URL: 'readonly',
+ MAIN_WINDOW_VITE_NAME: 'readonly',
},
},
plugins: {
diff --git a/ui/desktop/package.json b/ui/desktop/package.json
index c2c3cfd508..ff0c0ad88b 100644
--- a/ui/desktop/package.json
+++ b/ui/desktop/package.json
@@ -95,6 +95,7 @@
"tw-animate-css": "^1.4.0",
"unist-util-visit": "^5.1.0",
"uuid": "^13.0.0",
+ "yaml": "^2.8.1",
"zod": "^3.25.76"
},
"devDependencies": {
diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx
index 2e9b0655fb..bdece9ab00 100644
--- a/ui/desktop/src/App.tsx
+++ b/ui/desktop/src/App.tsx
@@ -39,6 +39,7 @@ import 'react-toastify/dist/ReactToastify.css';
import { useConfig } from './components/ConfigContext';
import { ModelAndProviderProvider } from './components/ModelAndProviderContext';
import { ThemeProvider } from './contexts/ThemeContext';
+import { WhiteLabelProvider } from './whitelabel/WhiteLabelContext';
import PermissionSettingsView from './components/settings/permission/PermissionSetting';
import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView';
@@ -701,14 +702,16 @@ export function AppInner() {
export default function App() {
return (
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/ui/desktop/src/components/Layout/NavigationPanel.tsx b/ui/desktop/src/components/Layout/NavigationPanel.tsx
index f8e1b63012..4766d5085a 100644
--- a/ui/desktop/src/components/Layout/NavigationPanel.tsx
+++ b/ui/desktop/src/components/Layout/NavigationPanel.tsx
@@ -9,6 +9,7 @@ import { CondensedRenderer } from './CondensedRenderer';
import { ExpandedRenderer } from './ExpandedRenderer';
import { NavigationOverlay } from './navigation';
import type { SessionStatus, DragHandlers } from './navigation/types';
+import { useWhiteLabel } from '../../whitelabel/WhiteLabelContext';
export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
const {
@@ -26,19 +27,21 @@ export const Navigation: React.FC<{ className?: string }> = ({ className }) => {
const location = useLocation();
const { extensionsList } = useConfig();
+ const { isNavItemEnabled } = useWhiteLabel();
const appsExtensionEnabled = !!extensionsList?.find((ext) => ext.name === 'apps')?.enabled;
const visibleItems = useMemo(() => {
return preferences.itemOrder
.filter((id) => preferences.enabledItems.includes(id))
+ .filter((id) => isNavItemEnabled(id))
.map((id) => getNavItemById(id))
.filter((item): item is NavItem => item !== undefined)
.filter((item) => {
if (item.path === '/apps') return appsExtensionEnabled;
return true;
});
- }, [preferences.itemOrder, preferences.enabledItems, appsExtensionEnabled]);
+ }, [preferences.itemOrder, preferences.enabledItems, appsExtensionEnabled, isNavItemEnabled]);
const isActive = useCallback((path: string) => location.pathname === path, [location.pathname]);
diff --git a/ui/desktop/src/components/common/Greeting.tsx b/ui/desktop/src/components/common/Greeting.tsx
index 704d2af8aa..fa5efd607a 100644
--- a/ui/desktop/src/components/common/Greeting.tsx
+++ b/ui/desktop/src/components/common/Greeting.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useTextAnimator } from '../../hooks/use-text-animator';
+import { useWhiteLabel } from '../../whitelabel/WhiteLabelContext';
interface GreetingProps {
className?: string;
@@ -10,46 +11,16 @@ export function Greeting({
className = 'mt-1 text-4xl font-light animate-in fade-in duration-300',
forceRefresh = false,
}: GreetingProps) {
- const prefixes = ['Hello!'];
- const messages = [
- ' Ready to get started?',
- ' What would you like to work on?',
- ' Ready to build something amazing?',
- ' What would you like to explore?',
- " What's on your mind?",
- ' What shall we create today?',
- ' What project needs attention?',
- ' What would you like to tackle?',
- ' What would you like to explore?',
- ' What needs to be done?',
- " What's the plan for today?",
- ' Ready to create something great?',
- ' What can be built today?',
- " What's the next challenge?",
- ' What progress can be made?',
- ' What would you like to accomplish?',
- ' What task awaits?',
- " What's the mission today?",
- ' What can be achieved?',
- ' What project is ready to begin?',
- ];
+ const { getRandomGreeting } = useWhiteLabel();
- // Using lazy initializer to generate random greeting on each component instance
- const greeting = useState(() => {
- const randomPrefixIndex = Math.floor(Math.random() * prefixes.length);
- const randomMessageIndex = Math.floor(Math.random() * messages.length);
-
- return {
- prefix: prefixes[randomPrefixIndex],
- message: messages[randomMessageIndex],
- };
- })[0];
+ const greeting = useState(() => ({
+ message: getRandomGreeting(),
+ }))[0];
const messageRef = useTextAnimator({ text: greeting.message });
return (
- {/* {greeting.prefix} */}
{greeting.message}
);
diff --git a/ui/desktop/src/components/settings/SettingsView.tsx b/ui/desktop/src/components/settings/SettingsView.tsx
index ba533da72f..ea48fc4a78 100644
--- a/ui/desktop/src/components/settings/SettingsView.tsx
+++ b/ui/desktop/src/components/settings/SettingsView.tsx
@@ -19,6 +19,7 @@ import KeyboardShortcutsSection from './keyboard/KeyboardShortcutsSection';
import LocalInferenceSection from './localInference/LocalInferenceSection';
import { CONFIGURATION_ENABLED } from '../../updates';
import { trackSettingsTabViewed } from '../../utils/analytics';
+import { useWhiteLabel } from '../../whitelabel/WhiteLabelContext';
export type SettingsViewOptions = {
deepLinkConfig?: ExtensionConfig;
@@ -35,7 +36,9 @@ export default function SettingsView({
setView: (view: View, viewOptions?: ViewOptions) => void;
viewOptions: SettingsViewOptions;
}) {
- const [activeTab, setActiveTab] = useState('models');
+ const { isSettingsTabEnabled, isSectionHidden, features } = useWhiteLabel();
+ const firstAvailableTab = features.settingsTabs[0] || 'models';
+ const [activeTab, setActiveTab] = useState(firstAvailableTab);
const [tunnelDisabled, setTunnelDisabled] = useState(false);
const hasTrackedInitialTab = useRef(false);
@@ -121,54 +124,72 @@ export default function SettingsView({
>
-
-
- Models
-
-
-
- Local Inference
-
-
-
- Chat
-
-
-
- Session
-
-
-
- Prompts
-
-
-
- Keyboard
-
-
-
- App
-
+ {isSettingsTabEnabled('models') && (
+
+
+ Models
+
+ )}
+ {isSettingsTabEnabled('local-inference') && (
+
+
+ Local Inference
+
+ )}
+ {isSettingsTabEnabled('chat') && (
+
+
+ Chat
+
+ )}
+ {isSettingsTabEnabled('sharing') && (
+
+
+ Session
+
+ )}
+ {isSettingsTabEnabled('prompts') && (
+
+
+ Prompts
+
+ )}
+ {isSettingsTabEnabled('keyboard') && (
+
+
+ Keyboard
+
+ )}
+ {isSettingsTabEnabled('app') && (
+
+
+ App
+
+ )}
@@ -199,12 +220,12 @@ export default function SettingsView({
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
>
-
-
- {!tunnelDisabled && (
+ {!isSectionHidden('session-sharing') &&
}
+ {!isSectionHidden('external-backend') &&
}
+ {!tunnelDisabled && !isSectionHidden('tunnel') && (
-
+ {!isSectionHidden('gateway') && }
)}
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index bfba32cb8d..dd64c7c7f2 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -49,8 +49,21 @@ import { UPDATES_ENABLED } from './updates';
import './utils/recipeHash';
import { Client } from './api/client';
import { GooseApp } from './api';
+import type { WhiteLabelConfig } from './whitelabel/types';
+import { DEFAULT_WHITELABEL_CONFIG } from './whitelabel/defaults';
+
+function getWhiteLabelConfig(): WhiteLabelConfig {
+ try {
+ return __WHITELABEL_CONFIG__;
+ } catch {
+ return DEFAULT_WHITELABEL_CONFIG;
+ }
+}
+
+const whiteLabelConfig = getWhiteLabelConfig();
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity';
+import { startWhiteLabelProcesses, stopAllWhiteLabelProcesses } from './whitelabel/processManager';
function shouldSetupUpdater(): boolean {
// Setup updater if either the flag is enabled OR dev updates are enabled
@@ -386,7 +399,7 @@ app.on('open-url', async (_event, url) => {
app.on('will-finish-launching', () => {
if (process.platform === 'darwin') {
app.setAboutPanelOptions({
- applicationName: 'Goose',
+ applicationName: whiteLabelConfig.branding.appName,
applicationVersion: app.getVersion(),
});
}
@@ -575,8 +588,8 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
const { baseUrl, workingDir, process: goosedProcess, errorLog } = goosedResult;
const mainWindowState = windowStateKeeper({
- defaultWidth: 940,
- defaultHeight: 800,
+ defaultWidth: whiteLabelConfig.window.width,
+ defaultHeight: whiteLabelConfig.window.height,
});
const mainWindow = new BrowserWindow({
@@ -588,8 +601,8 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => {
y: mainWindowState.y,
width: mainWindowState.width,
height: mainWindowState.height,
- minWidth: 450,
- resizable: true,
+ minWidth: whiteLabelConfig.window.minWidth,
+ resizable: whiteLabelConfig.window.resizable !== false,
useContentSize: true,
icon: path.join(__dirname, '../images/icon.icns'),
webPreferences: {
@@ -1731,6 +1744,12 @@ async function appMain() {
// Ensure Windows shims are available before any MCP processes are spawned
await ensureWinShims();
+ // Start any sidecar processes defined in white-label config
+ if (whiteLabelConfig.processes && whiteLabelConfig.processes.length > 0) {
+ const resourcesPath = app.isPackaged ? process.resourcesPath : process.cwd();
+ await startWhiteLabelProcesses(whiteLabelConfig.processes, resourcesPath);
+ }
+
registerUpdateIpcHandlers();
// Handle microphone permission requests
@@ -2478,6 +2497,8 @@ async function getAllowList(): Promise {
}
app.on('will-quit', async () => {
+ stopAllWhiteLabelProcesses();
+
for (const [windowId, blockerId] of windowPowerSaveBlockers.entries()) {
try {
powerSaveBlocker.stop(blockerId);
diff --git a/ui/desktop/src/updates.ts b/ui/desktop/src/updates.ts
index 88f257c8e3..12e0cc50c2 100644
--- a/ui/desktop/src/updates.ts
+++ b/ui/desktop/src/updates.ts
@@ -1,6 +1,20 @@
-export const UPDATES_ENABLED = true;
-export const COST_TRACKING_ENABLED = true;
-export const ANNOUNCEMENTS_ENABLED = false;
-export const CONFIGURATION_ENABLED = true;
-export const TELEMETRY_UI_ENABLED = true;
-export const DICTATION_ALLOWED_PROVIDERS: string[] | null = null;
+import type { WhiteLabelConfig } from './whitelabel/types';
+import { DEFAULT_WHITELABEL_CONFIG } from './whitelabel/defaults';
+
+function getWLConfig(): WhiteLabelConfig {
+ try {
+ return __WHITELABEL_CONFIG__;
+ } catch {
+ return DEFAULT_WHITELABEL_CONFIG;
+ }
+}
+
+const _wl = getWLConfig();
+
+export const UPDATES_ENABLED = _wl.features.updatesEnabled;
+export const COST_TRACKING_ENABLED = _wl.features.costTrackingEnabled;
+export const ANNOUNCEMENTS_ENABLED = _wl.features.announcementsEnabled;
+export const CONFIGURATION_ENABLED = _wl.features.configurationEnabled;
+export const TELEMETRY_UI_ENABLED = _wl.features.telemetryUiEnabled;
+export const DICTATION_ALLOWED_PROVIDERS: string[] | null =
+ _wl.features.dictationAllowedProviders ?? null;
diff --git a/ui/desktop/src/whitelabel/README.md b/ui/desktop/src/whitelabel/README.md
new file mode 100644
index 0000000000..6e654d7974
--- /dev/null
+++ b/ui/desktop/src/whitelabel/README.md
@@ -0,0 +1,88 @@
+# White-Label System
+
+The white-label system lets you declaratively configure the entire Goose desktop app from a single `whitelabel.yaml` file. When built, the config is baked into the binary — no runtime config files needed.
+
+## Quick Start
+
+1. Edit `ui/desktop/whitelabel.yaml` (or create a custom one)
+2. Build: `npm run make`
+3. The built app uses your config
+
+To use a custom config file:
+```bash
+WHITELABEL_CONFIG=/path/to/myconfig.yaml npm run make
+```
+
+## What You Can Configure
+
+### Branding
+- **App name** — shown in title bar, menus, about dialog
+- **Tagline** — welcome/loading screens
+- **Logo** — custom SVG/PNG for the app icon
+- **Greetings** — random messages on the home screen
+- **Starter prompts** — quick-action cards on home screen
+
+### Features (toggle on/off)
+- Updates, cost tracking, announcements, configuration UI, telemetry UI
+- **Navigation items** — choose which nav items to show and their order
+- **Settings tabs** — choose which settings tabs to show
+- **Hidden sections** — hide specific sections within settings tabs
+- **Allowed providers** — restrict to specific LLM providers
+
+### Defaults (pre-seed on first launch)
+- Default provider and model
+- Pre-installed extensions with config
+- Working directory
+- System prompt override
+- Goosehints content
+
+### Processes (sidecar management)
+- Launch external processes alongside the app
+- Auto-restart on crash
+- Wait for port readiness before proceeding
+
+### Window
+- Default dimensions and min width
+- Always-on-top, resizable
+
+## Architecture
+
+```
+whitelabel.yaml ← Your config file
+ │
+ ▼
+vite-plugin.ts ← Reads YAML at build time
+ │
+ ▼
+__WHITELABEL_CONFIG__ ← Baked into JS as a global constant
+ │
+ ├──▶ WhiteLabelContext.tsx ← React context for renderer
+ ├──▶ main.ts ← Electron main process
+ └──▶ updates.ts ← Feature flags
+```
+
+### Files
+- `whitelabel.yaml` — the config file
+- `types.ts` — TypeScript type definitions
+- `defaults.ts` — default values when no config is provided
+- `loader.ts` — Node.js YAML loader (build-time)
+- `vite-plugin.ts` — Vite plugin that injects config at build time
+- `WhiteLabelContext.tsx` — React context + `useWhiteLabel()` hook
+- `processManager.ts` — sidecar process lifecycle management
+- `global.d.ts` — TypeScript declaration for `__WHITELABEL_CONFIG__`
+
+## Usage in Components
+
+```tsx
+import { useWhiteLabel } from '../whitelabel/WhiteLabelContext';
+
+function MyComponent() {
+ const { branding, isNavItemEnabled, isSettingsTabEnabled } = useWhiteLabel();
+
+ return {branding.appName}
;
+}
+```
+
+## Example
+
+See `whitelabel.example.yaml` for a full example of a white-labeled "Acme Assistant" build.
diff --git a/ui/desktop/src/whitelabel/WhiteLabelContext.tsx b/ui/desktop/src/whitelabel/WhiteLabelContext.tsx
new file mode 100644
index 0000000000..05cd9d021f
--- /dev/null
+++ b/ui/desktop/src/whitelabel/WhiteLabelContext.tsx
@@ -0,0 +1,106 @@
+import React, { createContext, useContext, useMemo } from 'react';
+import type {
+ WhiteLabelConfig,
+ WhiteLabelBranding,
+ WhiteLabelFeatures,
+ WhiteLabelDefaults,
+ WhiteLabelWindow,
+} from './types';
+import { DEFAULT_WHITELABEL_CONFIG } from './defaults';
+
+interface WhiteLabelContextType {
+ config: WhiteLabelConfig;
+ branding: WhiteLabelBranding;
+ features: WhiteLabelFeatures;
+ defaults: WhiteLabelDefaults;
+ window: WhiteLabelWindow;
+ isFeatureEnabled: (feature: string) => boolean;
+ isNavItemEnabled: (itemId: string) => boolean;
+ isSettingsTabEnabled: (tabId: string) => boolean;
+ isSectionHidden: (sectionId: string) => boolean;
+ isProviderAllowed: (providerId: string) => boolean;
+ getRandomGreeting: () => string;
+}
+
+const WhiteLabelContext = createContext(undefined);
+
+function getConfig(): WhiteLabelConfig {
+ try {
+ return __WHITELABEL_CONFIG__;
+ } catch {
+ return DEFAULT_WHITELABEL_CONFIG;
+ }
+}
+
+export const WhiteLabelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const config = useMemo(() => getConfig(), []);
+
+ const value = useMemo(() => {
+ const { branding, features, defaults, window: windowConfig } = config;
+
+ const navSet = new Set(features.navigation);
+ const tabSet = new Set(features.settingsTabs);
+ const hiddenSections = new Set(features.hiddenSettingSections || []);
+ const allowedProviders = features.allowedProviders
+ ? new Set(features.allowedProviders.map((p) => p.toLowerCase()))
+ : null;
+
+ return {
+ config,
+ branding,
+ features,
+ defaults,
+ window: windowConfig,
+
+ isFeatureEnabled(feature: string): boolean {
+ switch (feature) {
+ case 'updates':
+ return features.updatesEnabled;
+ case 'costTracking':
+ return features.costTrackingEnabled;
+ case 'announcements':
+ return features.announcementsEnabled;
+ case 'configuration':
+ return features.configurationEnabled;
+ case 'telemetryUi':
+ return features.telemetryUiEnabled;
+ default:
+ return true;
+ }
+ },
+
+ isNavItemEnabled(itemId: string): boolean {
+ return navSet.has(itemId);
+ },
+
+ isSettingsTabEnabled(tabId: string): boolean {
+ return tabSet.has(tabId);
+ },
+
+ isSectionHidden(sectionId: string): boolean {
+ return hiddenSections.has(sectionId);
+ },
+
+ isProviderAllowed(providerId: string): boolean {
+ if (!allowedProviders) return true;
+ return allowedProviders.has(providerId.toLowerCase());
+ },
+
+ getRandomGreeting(): string {
+ const greetings = branding.greetings;
+ if (greetings.length === 0) return "What's on your mind?";
+ return greetings[Math.floor(Math.random() * greetings.length)];
+ },
+ };
+ }, [config]);
+
+ return {children};
+};
+
+export function useWhiteLabel(): WhiteLabelContextType {
+ const context = useContext(WhiteLabelContext);
+ if (!context) {
+ throw new Error('useWhiteLabel must be used within a WhiteLabelProvider');
+ }
+ return context;
+}
diff --git a/ui/desktop/src/whitelabel/__tests__/whitelabel.test.ts b/ui/desktop/src/whitelabel/__tests__/whitelabel.test.ts
new file mode 100644
index 0000000000..3c3daa6d82
--- /dev/null
+++ b/ui/desktop/src/whitelabel/__tests__/whitelabel.test.ts
@@ -0,0 +1,149 @@
+import { describe, it, expect } from 'vitest';
+import type { WhiteLabelConfig } from '../types';
+import { DEFAULT_WHITELABEL_CONFIG } from '../defaults';
+
+// Mock __WHITELABEL_CONFIG__ for tests
+const mockConfig: WhiteLabelConfig = {
+ ...DEFAULT_WHITELABEL_CONFIG,
+ branding: {
+ appName: 'TestApp',
+ greetings: ['Hello from TestApp!', 'Welcome to TestApp!'],
+ },
+ features: {
+ ...DEFAULT_WHITELABEL_CONFIG.features,
+ navigation: ['home', 'chat', 'settings'],
+ settingsTabs: ['models', 'app'],
+ hiddenSettingSections: ['tunnel', 'session-sharing'],
+ allowedProviders: ['openai', 'anthropic'],
+ updatesEnabled: false,
+ announcementsEnabled: true,
+ },
+ window: {
+ width: 1200,
+ height: 900,
+ minWidth: 500,
+ },
+};
+
+describe('WhiteLabelConfig defaults', () => {
+ it('has all required top-level keys', () => {
+ expect(DEFAULT_WHITELABEL_CONFIG.branding).toBeDefined();
+ expect(DEFAULT_WHITELABEL_CONFIG.features).toBeDefined();
+ expect(DEFAULT_WHITELABEL_CONFIG.defaults).toBeDefined();
+ expect(DEFAULT_WHITELABEL_CONFIG.window).toBeDefined();
+ });
+
+ it('defaults appName to Goose', () => {
+ expect(DEFAULT_WHITELABEL_CONFIG.branding.appName).toBe('Goose');
+ });
+
+ it('has default greetings', () => {
+ expect(DEFAULT_WHITELABEL_CONFIG.branding.greetings.length).toBeGreaterThan(0);
+ });
+
+ it('has all default navigation items', () => {
+ const nav = DEFAULT_WHITELABEL_CONFIG.features.navigation;
+ expect(nav).toContain('home');
+ expect(nav).toContain('chat');
+ expect(nav).toContain('recipes');
+ expect(nav).toContain('apps');
+ expect(nav).toContain('scheduler');
+ expect(nav).toContain('extensions');
+ expect(nav).toContain('settings');
+ });
+
+ it('has all default settings tabs', () => {
+ const tabs = DEFAULT_WHITELABEL_CONFIG.features.settingsTabs;
+ expect(tabs).toContain('models');
+ expect(tabs).toContain('local-inference');
+ expect(tabs).toContain('chat');
+ expect(tabs).toContain('sharing');
+ expect(tabs).toContain('prompts');
+ expect(tabs).toContain('keyboard');
+ expect(tabs).toContain('app');
+ });
+
+ it('has sensible default window dimensions', () => {
+ expect(DEFAULT_WHITELABEL_CONFIG.window.width).toBe(940);
+ expect(DEFAULT_WHITELABEL_CONFIG.window.height).toBe(800);
+ expect(DEFAULT_WHITELABEL_CONFIG.window.minWidth).toBe(450);
+ });
+
+ it('enables features by default', () => {
+ expect(DEFAULT_WHITELABEL_CONFIG.features.updatesEnabled).toBe(true);
+ expect(DEFAULT_WHITELABEL_CONFIG.features.costTrackingEnabled).toBe(true);
+ expect(DEFAULT_WHITELABEL_CONFIG.features.configurationEnabled).toBe(true);
+ });
+});
+
+describe('WhiteLabelConfig custom config', () => {
+ it('can override appName', () => {
+ expect(mockConfig.branding.appName).toBe('TestApp');
+ });
+
+ it('can restrict navigation items', () => {
+ expect(mockConfig.features.navigation).toEqual(['home', 'chat', 'settings']);
+ expect(mockConfig.features.navigation).not.toContain('recipes');
+ expect(mockConfig.features.navigation).not.toContain('extensions');
+ });
+
+ it('can restrict settings tabs', () => {
+ expect(mockConfig.features.settingsTabs).toEqual(['models', 'app']);
+ expect(mockConfig.features.settingsTabs).not.toContain('chat');
+ expect(mockConfig.features.settingsTabs).not.toContain('sharing');
+ });
+
+ it('can hide setting sections', () => {
+ expect(mockConfig.features.hiddenSettingSections).toContain('tunnel');
+ expect(mockConfig.features.hiddenSettingSections).toContain('session-sharing');
+ });
+
+ it('can restrict providers', () => {
+ expect(mockConfig.features.allowedProviders).toContain('openai');
+ expect(mockConfig.features.allowedProviders).toContain('anthropic');
+ expect(mockConfig.features.allowedProviders).not.toContain('ollama');
+ });
+
+ it('can disable features', () => {
+ expect(mockConfig.features.updatesEnabled).toBe(false);
+ expect(mockConfig.features.announcementsEnabled).toBe(true);
+ });
+
+ it('can customize window dimensions', () => {
+ expect(mockConfig.window.width).toBe(1200);
+ expect(mockConfig.window.height).toBe(900);
+ expect(mockConfig.window.minWidth).toBe(500);
+ });
+
+ it('can customize greetings', () => {
+ expect(mockConfig.branding.greetings).toHaveLength(2);
+ expect(mockConfig.branding.greetings[0]).toBe('Hello from TestApp!');
+ });
+});
+
+describe('WhiteLabelConfig type safety', () => {
+ it('extension defaults have required fields', () => {
+ const ext = {
+ name: 'test-ext',
+ type: 'sse',
+ uri: 'https://example.com/sse',
+ enabled: true,
+ };
+ expect(ext.name).toBeDefined();
+ expect(ext.type).toBeDefined();
+ expect(ext.enabled).toBeDefined();
+ });
+
+ it('process config has required fields', () => {
+ const proc = {
+ name: 'sidecar',
+ command: 'node',
+ args: ['server.js'],
+ restartOnCrash: true,
+ waitForPort: 9999,
+ waitTimeoutMs: 10000,
+ };
+ expect(proc.name).toBeDefined();
+ expect(proc.command).toBeDefined();
+ });
+});
diff --git a/ui/desktop/src/whitelabel/defaults.ts b/ui/desktop/src/whitelabel/defaults.ts
new file mode 100644
index 0000000000..fab1ddbbfc
--- /dev/null
+++ b/ui/desktop/src/whitelabel/defaults.ts
@@ -0,0 +1,34 @@
+import type { WhiteLabelConfig } from './types';
+
+export const DEFAULT_WHITELABEL_CONFIG: WhiteLabelConfig = {
+ branding: {
+ appName: 'Goose',
+ greetings: [
+ 'What would you like to work on?',
+ 'Ready to build something amazing?',
+ 'What shall we create today?',
+ "What's on your mind?",
+ 'What project needs attention?',
+ 'What would you like to tackle?',
+ 'What would you like to explore?',
+ 'What needs to be done?',
+ "What's the plan for today?",
+ 'Ready to create something great?',
+ ],
+ },
+ features: {
+ updatesEnabled: true,
+ costTrackingEnabled: true,
+ announcementsEnabled: false,
+ configurationEnabled: true,
+ telemetryUiEnabled: true,
+ navigation: ['home', 'chat', 'recipes', 'apps', 'scheduler', 'extensions', 'settings'],
+ settingsTabs: ['models', 'local-inference', 'chat', 'sharing', 'prompts', 'keyboard', 'app'],
+ },
+ defaults: {},
+ window: {
+ width: 940,
+ height: 800,
+ minWidth: 450,
+ },
+};
diff --git a/ui/desktop/src/whitelabel/global.d.ts b/ui/desktop/src/whitelabel/global.d.ts
new file mode 100644
index 0000000000..bff28d7c95
--- /dev/null
+++ b/ui/desktop/src/whitelabel/global.d.ts
@@ -0,0 +1,9 @@
+import type { WhiteLabelConfig } from './types';
+
+declare global {
+ /**
+ * Injected at build time by the whitelabel Vite plugin.
+ * Contains the fully resolved white-label configuration.
+ */
+ const __WHITELABEL_CONFIG__: WhiteLabelConfig;
+}
diff --git a/ui/desktop/src/whitelabel/index.ts b/ui/desktop/src/whitelabel/index.ts
new file mode 100644
index 0000000000..c50414dd56
--- /dev/null
+++ b/ui/desktop/src/whitelabel/index.ts
@@ -0,0 +1,12 @@
+export type {
+ WhiteLabelConfig,
+ WhiteLabelBranding,
+ WhiteLabelFeatures,
+ WhiteLabelDefaults,
+ WhiteLabelProcess,
+ WhiteLabelWindow,
+ WhiteLabelStarterPrompt,
+ WhiteLabelExtensionDefault,
+} from './types';
+
+export { DEFAULT_WHITELABEL_CONFIG } from './defaults';
diff --git a/ui/desktop/src/whitelabel/loader.ts b/ui/desktop/src/whitelabel/loader.ts
new file mode 100644
index 0000000000..dc5b8c6457
--- /dev/null
+++ b/ui/desktop/src/whitelabel/loader.ts
@@ -0,0 +1,51 @@
+/**
+ * Build-time loader for whitelabel.yaml.
+ * Used by the Vite plugin to read and merge config at build time.
+ * This file runs in Node.js context (not browser).
+ */
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import * as yaml from 'yaml';
+import type { WhiteLabelConfig } from './types';
+import { DEFAULT_WHITELABEL_CONFIG } from './defaults';
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+function deepMerge(target: any, source: any): any {
+ if (!source) return target;
+ const result = { ...target };
+ for (const key of Object.keys(source)) {
+ const sourceVal = source[key];
+ const targetVal = target[key];
+ if (
+ sourceVal !== undefined &&
+ sourceVal !== null &&
+ typeof sourceVal === 'object' &&
+ !Array.isArray(sourceVal) &&
+ typeof targetVal === 'object' &&
+ !Array.isArray(targetVal) &&
+ targetVal !== null
+ ) {
+ result[key] = deepMerge(targetVal, sourceVal);
+ } else if (sourceVal !== undefined) {
+ result[key] = sourceVal;
+ }
+ }
+ return result;
+}
+/* eslint-enable @typescript-eslint/no-explicit-any */
+
+export function loadWhiteLabelConfig(projectRoot: string): WhiteLabelConfig {
+ // Allow override via env var
+ const configPath = process.env.WHITELABEL_CONFIG || path.join(projectRoot, 'whitelabel.yaml');
+
+ if (!fs.existsSync(configPath)) {
+ console.log('[WhiteLabel] No whitelabel.yaml found, using defaults');
+ return DEFAULT_WHITELABEL_CONFIG;
+ }
+
+ console.log(`[WhiteLabel] Loading config from ${configPath}`);
+ const raw = fs.readFileSync(configPath, 'utf-8');
+ const parsed = yaml.parse(raw) as Partial;
+
+ return deepMerge(DEFAULT_WHITELABEL_CONFIG, parsed) as WhiteLabelConfig;
+}
diff --git a/ui/desktop/src/whitelabel/processManager.ts b/ui/desktop/src/whitelabel/processManager.ts
new file mode 100644
index 0000000000..acccb73392
--- /dev/null
+++ b/ui/desktop/src/whitelabel/processManager.ts
@@ -0,0 +1,113 @@
+/**
+ * Manages sidecar processes defined in whitelabel.yaml.
+ * Runs in the Electron main process.
+ */
+import { spawn, ChildProcess } from 'child_process';
+import * as net from 'node:net';
+import * as path from 'node:path';
+import log from '../utils/logger';
+import type { WhiteLabelProcess } from './types';
+
+interface ManagedProcess {
+ config: WhiteLabelProcess;
+ child: ChildProcess | null;
+ stopped: boolean;
+}
+
+const managedProcesses: ManagedProcess[] = [];
+
+function waitForPort(port: number, timeoutMs: number): Promise {
+ return new Promise((resolve, reject) => {
+ const start = Date.now();
+
+ const tryConnect = () => {
+ if (Date.now() - start > timeoutMs) {
+ reject(new Error(`Timed out waiting for port ${port} after ${timeoutMs}ms`));
+ return;
+ }
+
+ const socket = net.createConnection({ port, host: '127.0.0.1' }, () => {
+ socket.destroy();
+ resolve();
+ });
+
+ socket.on('error', () => {
+ socket.destroy();
+ setTimeout(tryConnect, 200);
+ });
+ };
+
+ tryConnect();
+ });
+}
+
+function startProcess(managed: ManagedProcess, resourcesPath: string): void {
+ const { config } = managed;
+ const cwd = config.cwd ? path.resolve(resourcesPath, config.cwd) : resourcesPath;
+
+ log.info(`[WhiteLabel] Starting process: ${config.name} (${config.command})`);
+
+ const child = spawn(config.command, config.args || [], {
+ cwd,
+ env: { ...process.env, ...(config.env || {}) },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ child.stdout?.on('data', (data) => {
+ log.info(`[${config.name}] ${data.toString().trim()}`);
+ });
+
+ child.stderr?.on('data', (data) => {
+ log.error(`[${config.name}] ${data.toString().trim()}`);
+ });
+
+ child.on('exit', (code, signal) => {
+ log.info(`[WhiteLabel] Process ${config.name} exited (code=${code}, signal=${signal})`);
+ managed.child = null;
+
+ if (!managed.stopped && config.restartOnCrash) {
+ log.info(`[WhiteLabel] Restarting ${config.name} in 2s...`);
+ setTimeout(() => {
+ if (!managed.stopped) {
+ startProcess(managed, resourcesPath);
+ }
+ }, 2000);
+ }
+ });
+
+ managed.child = child;
+}
+
+export async function startWhiteLabelProcesses(
+ processConfigs: WhiteLabelProcess[],
+ resourcesPath: string
+): Promise {
+ for (const config of processConfigs) {
+ const managed: ManagedProcess = { config, child: null, stopped: false };
+ managedProcesses.push(managed);
+
+ startProcess(managed, resourcesPath);
+
+ if (config.waitForPort) {
+ const timeout = config.waitTimeoutMs || 10000;
+ try {
+ await waitForPort(config.waitForPort, timeout);
+ log.info(`[WhiteLabel] Process ${config.name} is ready on port ${config.waitForPort}`);
+ } catch (err) {
+ log.error(`[WhiteLabel] Process ${config.name} failed to become ready: ${err}`);
+ }
+ }
+ }
+}
+
+export function stopAllWhiteLabelProcesses(): void {
+ for (const managed of managedProcesses) {
+ managed.stopped = true;
+ if (managed.child) {
+ log.info(`[WhiteLabel] Stopping process: ${managed.config.name}`);
+ managed.child.kill('SIGTERM');
+ managed.child = null;
+ }
+ }
+ managedProcesses.length = 0;
+}
diff --git a/ui/desktop/src/whitelabel/types.ts b/ui/desktop/src/whitelabel/types.ts
new file mode 100644
index 0000000000..57ac3748bc
--- /dev/null
+++ b/ui/desktop/src/whitelabel/types.ts
@@ -0,0 +1,80 @@
+/**
+ * White-label configuration types.
+ * These define the shape of whitelabel.yaml and are available at both
+ * build time (Vite plugin) and runtime (React context).
+ */
+
+export interface WhiteLabelStarterPrompt {
+ icon: string;
+ label: string;
+ prompt: string;
+}
+
+export interface WhiteLabelBranding {
+ appName: string;
+ tagline?: string;
+ logo?: string;
+ logoSmall?: string;
+ trayIcon?: string;
+ greetings: string[];
+ starterPrompts?: WhiteLabelStarterPrompt[];
+}
+
+export interface WhiteLabelFeatures {
+ updatesEnabled: boolean;
+ costTrackingEnabled: boolean;
+ announcementsEnabled: boolean;
+ configurationEnabled: boolean;
+ telemetryUiEnabled: boolean;
+ navigation: string[];
+ settingsTabs: string[];
+ hiddenSettingSections?: string[];
+ allowedProviders?: string[];
+ dictationAllowedProviders?: string[] | null;
+}
+
+export interface WhiteLabelExtensionDefault {
+ name: string;
+ type: string;
+ uri?: string;
+ cmd?: string;
+ args?: string[];
+ enabled: boolean;
+ envVars?: Record;
+}
+
+export interface WhiteLabelDefaults {
+ provider?: string;
+ model?: string;
+ extensions?: WhiteLabelExtensionDefault[];
+ workingDir?: string;
+ systemPrompt?: string;
+ goosehints?: string;
+}
+
+export interface WhiteLabelProcess {
+ name: string;
+ command: string;
+ args?: string[];
+ cwd?: string;
+ env?: Record;
+ restartOnCrash?: boolean;
+ waitForPort?: number;
+ waitTimeoutMs?: number;
+}
+
+export interface WhiteLabelWindow {
+ width: number;
+ height: number;
+ minWidth: number;
+ alwaysOnTop?: boolean;
+ resizable?: boolean;
+}
+
+export interface WhiteLabelConfig {
+ branding: WhiteLabelBranding;
+ features: WhiteLabelFeatures;
+ defaults: WhiteLabelDefaults;
+ processes?: WhiteLabelProcess[];
+ window: WhiteLabelWindow;
+}
diff --git a/ui/desktop/src/whitelabel/vite-plugin.ts b/ui/desktop/src/whitelabel/vite-plugin.ts
new file mode 100644
index 0000000000..f892e67be6
--- /dev/null
+++ b/ui/desktop/src/whitelabel/vite-plugin.ts
@@ -0,0 +1,20 @@
+/**
+ * Vite plugin that loads whitelabel.yaml at build time and injects
+ * the resolved config as a global constant `__WHITELABEL_CONFIG__`.
+ */
+import type { Plugin } from 'vite';
+import { loadWhiteLabelConfig } from './loader';
+
+export function whiteLabelPlugin(projectRoot: string): Plugin {
+ return {
+ name: 'whitelabel-config',
+ config() {
+ const config = loadWhiteLabelConfig(projectRoot);
+ return {
+ define: {
+ __WHITELABEL_CONFIG__: JSON.stringify(config),
+ },
+ };
+ },
+ };
+}
diff --git a/ui/desktop/vite.main.config.mts b/ui/desktop/vite.main.config.mts
index 7087ae8352..a13c01c61b 100644
--- a/ui/desktop/vite.main.config.mts
+++ b/ui/desktop/vite.main.config.mts
@@ -1,4 +1,5 @@
import { defineConfig } from 'vite';
+import { whiteLabelPlugin } from './src/whitelabel/vite-plugin';
// https://vitejs.dev/config
export default defineConfig({
@@ -7,4 +8,5 @@ export default defineConfig({
'process.env.GITHUB_REPO': JSON.stringify(process.env.GITHUB_REPO || 'goose'),
'process.env.GOOSE_BUNDLE_NAME': JSON.stringify(process.env.GOOSE_BUNDLE_NAME || 'Goose'),
},
+ plugins: [whiteLabelPlugin(__dirname)],
});
diff --git a/ui/desktop/vite.renderer.config.mts b/ui/desktop/vite.renderer.config.mts
index 9a3c558b7c..6fd65a9e53 100644
--- a/ui/desktop/vite.renderer.config.mts
+++ b/ui/desktop/vite.renderer.config.mts
@@ -1,5 +1,6 @@
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
+import { whiteLabelPlugin } from './src/whitelabel/vite-plugin';
// https://vitejs.dev/config
export default defineConfig({
@@ -9,7 +10,7 @@ export default defineConfig({
'process.env.GOOSE_TUNNEL': JSON.stringify(process.env.GOOSE_TUNNEL !== 'no' && process.env.GOOSE_TUNNEL !== 'none'),
},
- plugins: [tailwindcss()],
+ plugins: [tailwindcss(), whiteLabelPlugin(__dirname)],
build: {
target: 'esnext'
diff --git a/ui/desktop/whitelabel.example.yaml b/ui/desktop/whitelabel.example.yaml
new file mode 100644
index 0000000000..6d461494f8
--- /dev/null
+++ b/ui/desktop/whitelabel.example.yaml
@@ -0,0 +1,111 @@
+# =============================================================================
+# Example: "Acme Assistant" — a white-labeled Goose for Acme Corp
+# =============================================================================
+# Build with: WHITELABEL_CONFIG=whitelabel.example.yaml npm run make
+# =============================================================================
+
+branding:
+ appName: "Acme Assistant"
+ tagline: "AI-powered development, the Acme way"
+
+ # Custom logo files (place in your brand directory)
+ # logo: "./brand/acme-logo.svg"
+ # logoSmall: "./brand/acme-logo-small.svg"
+ # trayIcon: "./brand/acme-tray.png"
+
+ greetings:
+ - "Welcome to Acme! What can I help you build?"
+ - "Ready to ship some code?"
+ - "What's the next feature?"
+ - "Let's get productive."
+
+ starterPrompts:
+ - icon: "code"
+ label: "Code review"
+ prompt: "Review the current git diff and suggest improvements"
+ - icon: "folder-tree"
+ label: "Project setup"
+ prompt: "Set up a new project following our Acme standards"
+ - icon: "message-square"
+ label: "Write tests"
+ prompt: "Generate unit tests for the current file"
+
+features:
+ updatesEnabled: false # Acme manages updates internally
+ costTrackingEnabled: true
+ announcementsEnabled: false # No upstream announcements
+ configurationEnabled: false # Lock down config editing
+ telemetryUiEnabled: false # Hide telemetry settings
+
+ # Simplified navigation — no recipes, schedules, or apps
+ navigation:
+ - home
+ - chat
+ - extensions
+ - settings
+
+ # Only show relevant settings tabs
+ settingsTabs:
+ - models
+ - chat
+ - keyboard
+ - app
+
+ # Hide sections that aren't relevant
+ hiddenSettingSections:
+ - external-backend
+ - tunnel
+ - session-sharing
+ - telemetry
+ - update
+ - navigation-customization
+
+ # Lock to approved providers only
+ allowedProviders:
+ - anthropic
+ - openai
+
+defaults:
+ provider: "anthropic"
+ model: "claude-sonnet-4-20250514"
+
+ # Pre-install the team's custom MCP extensions
+ extensions:
+ - name: "acme-internal-tools"
+ type: "sse"
+ uri: "https://mcp.internal.acme.com/sse"
+ enabled: true
+ envVars:
+ ACME_TOKEN: ""
+
+ - name: "code-standards"
+ type: "stdio"
+ cmd: "npx"
+ args: ["-y", "@acme/code-standards-mcp"]
+ enabled: true
+
+ systemPrompt: |
+ You are Acme Assistant, an AI coding companion for Acme Corp engineers.
+ Follow Acme's coding standards. Prefer TypeScript. Always write tests.
+ Reference internal docs at https://docs.internal.acme.com when relevant.
+
+ goosehints: |
+ This is an Acme Corp project. Follow internal standards.
+ Use the acme-internal-tools extension for accessing internal APIs.
+
+# Start a local tool server alongside the app
+processes:
+ - name: "acme-proxy"
+ command: "node"
+ args: ["./tools/proxy.js"]
+ cwd: "."
+ env:
+ PROXY_PORT: "8899"
+ restartOnCrash: true
+ waitForPort: 8899
+ waitTimeoutMs: 15000
+
+window:
+ width: 1100
+ height: 850
+ minWidth: 500
diff --git a/ui/desktop/whitelabel.yaml b/ui/desktop/whitelabel.yaml
new file mode 100644
index 0000000000..87b8cc9cbc
--- /dev/null
+++ b/ui/desktop/whitelabel.yaml
@@ -0,0 +1,151 @@
+# =============================================================================
+# Goose White-Label Configuration
+# =============================================================================
+# This file controls the entire branding, feature set, and behavior of the
+# desktop app. When built, these values are baked into the binary.
+#
+# To create a white-labeled build:
+# 1. Copy this file and modify it
+# 2. Set WHITELABEL_CONFIG=path/to/your/config.yaml
+# 3. Build normally: npm run make
+#
+# Any field left unset uses the Goose defaults.
+# =============================================================================
+
+# ---------------------------------------------------------------------------
+# Branding
+# ---------------------------------------------------------------------------
+branding:
+ # App name shown in title bar, menus, about dialog, etc.
+ appName: "Goose"
+
+ # Tagline shown on welcome/loading screens (optional)
+ # tagline: "Your AI-powered assistant"
+
+ # Logo image paths (relative to ui/desktop/ or absolute)
+ # If not set, the default Goose logo SVG component is used.
+ # logo: "./brand/logo.svg"
+ # logoSmall: "./brand/logo-small.svg"
+ # trayIcon: "./brand/tray-icon.png"
+
+ # Greeting messages shown on the home screen. If empty, defaults are used.
+ greetings:
+ - "What would you like to work on?"
+ - "Ready to build something amazing?"
+ - "What shall we create today?"
+ - "What's on your mind?"
+
+ # Starter prompts shown as quick-action cards on the home screen
+ # starterPrompts:
+ # - icon: "folder-tree"
+ # label: "Organize files"
+ # prompt: "Help me organize my project files"
+ # - icon: "code"
+ # label: "Write code"
+ # prompt: "Help me write a Python script"
+
+# ---------------------------------------------------------------------------
+# Features — toggle major feature areas on/off
+# ---------------------------------------------------------------------------
+features:
+ # Top-level feature toggles
+ updatesEnabled: true
+ costTrackingEnabled: true
+ announcementsEnabled: false
+ configurationEnabled: true
+ telemetryUiEnabled: true
+
+ # Navigation items to show (order matters). Comment out to hide.
+ navigation:
+ - home
+ - chat
+ - recipes
+ - apps
+ - scheduler
+ - extensions
+ - settings
+
+ # Settings tabs to show. Comment out to hide specific tabs.
+ settingsTabs:
+ - models
+ - local-inference
+ - chat
+ - sharing
+ - prompts
+ - keyboard
+ - app
+
+ # Hide specific setting sections within tabs
+ # hiddenSettingSections:
+ # - "external-backend"
+ # - "tunnel"
+ # - "session-sharing"
+ # - "telemetry"
+ # - "update"
+ # - "navigation-customization"
+ # - "theme"
+
+ # Restrict to specific providers only (empty = all providers available)
+ # allowedProviders:
+ # - "openai"
+ # - "anthropic"
+
+ # Restrict dictation to specific providers (null = all allowed)
+ # dictationAllowedProviders:
+ # - "openai"
+
+# ---------------------------------------------------------------------------
+# Defaults — pre-seeded values for first launch
+# ---------------------------------------------------------------------------
+defaults:
+ # Default provider and model (sets GOOSE_DEFAULT_PROVIDER / GOOSE_DEFAULT_MODEL)
+ # provider: "anthropic"
+ # model: "claude-sonnet-4-20250514"
+
+ # Pre-configured extensions to install on first launch
+ # extensions:
+ # - name: "my-custom-tool"
+ # type: "sse"
+ # uri: "https://my-tool-server.example.com/sse"
+ # enabled: true
+ # envVars:
+ # API_KEY: ""
+ # - name: "internal-kb"
+ # type: "stdio"
+ # cmd: "npx"
+ # args: ["-y", "@company/kb-mcp-server"]
+ # enabled: true
+
+ # Working directory to start in (if not set, uses OS home)
+ # workingDir: "~/Projects"
+
+ # Default system prompt override
+ # systemPrompt: "You are a helpful coding assistant for Acme Corp."
+
+ # Default goosehints content (written to .goosehints if not present)
+ # goosehints: |
+ # Always follow our coding standards at https://internal.acme.com/standards
+
+# ---------------------------------------------------------------------------
+# Processes — external processes to manage alongside the app
+# ---------------------------------------------------------------------------
+# processes:
+# - name: "my-sidecar"
+# command: "node"
+# args: ["./sidecar/server.js"]
+# cwd: "." # relative to app resources
+# env:
+# PORT: "9999"
+# restartOnCrash: true
+# waitForPort: 9999 # app waits for this port before proceeding
+# waitTimeoutMs: 10000
+
+# ---------------------------------------------------------------------------
+# Window — default window appearance
+# ---------------------------------------------------------------------------
+window:
+ width: 940
+ height: 800
+ minWidth: 450
+ # alwaysOnTop: false
+ # resizable: true