Lesson 48

Desktop App Integration

How Claude Code bridges the terminal to Claude Desktop, integrates with IDEs, talks to browsers via native messaging, and acquires computer-use capabilities — all from one CLI binary.

01 Overview

Claude Code is a terminal application, but it has a surprisingly deep surface area of desktop integration: it can hand off a running session to Claude Desktop, auto-detect the IDE it was launched from (VS Code, Cursor, JetBrains family), wire a Chrome extension through a native messaging host, and expose a full computer-use MCP server. All of these are implemented as discrete subsystems that share no global state — each activates only when its conditions are met.

Source files covered
commands/desktop/index.ts · commands/desktop/desktop.tsx · components/DesktopHandoff.tsx · components/DesktopUpsell/DesktopUpsellStartup.tsx · utils/desktopDeepLink.ts · utils/claudeDesktop.ts · utils/ide.ts · utils/claudeInChrome/ · utils/computerUse/ · entrypoints/cli.tsx

There are four distinct integration layers, each with its own detection logic, activation path, and fallback behavior:

Layer 1

Claude Desktop Handoff

The /desktop command flushes the session and opens it in the desktop app via a deep link URL.

Layer 2

IDE Integration

Auto-detects VS Code, Cursor, Windsurf, and 14+ JetBrains IDEs by reading lockfiles in ~/.claude/ide/.

Layer 3

Claude in Chrome

Installs a native messaging host manifest so a browser extension can call Claude Code tools directly.

Layer 4

Computer Use MCP

Gated by Max/Pro subscription and a GrowthBook flag; spawns a separate MCP server that drives the OS.

02 The /desktop Command and Deep Links

Command registration

The /desktop command (aliased /app) lives in commands/desktop/index.ts. Its availability is locked to the claude-ai product — it does not appear in SDK or enterprise console builds. The command also gate-checks the platform at runtime:

// commands/desktop/index.ts
function isSupportedPlatform(): boolean {
  if (process.platform === 'darwin') return true
  if (process.platform === 'win32' && process.arch === 'x64') return true
  return false
}

const desktop = {
  type: 'local-jsx',
  name: 'desktop',
  aliases: ['app'],
  description: 'Continue the current session in Claude Desktop',
  availability: ['claude-ai'],
  isEnabled: isSupportedPlatform,
  get isHidden() { return !isSupportedPlatform() },
  load: () => import('./desktop.js'),
}

When enabled, the command renders the DesktopHandoff React/Ink component. On Windows only x64 is supported; ARM Windows and Linux are excluded.

The handoff state machine

DesktopHandoff in components/DesktopHandoff.tsx is a small state machine with six states:

stateDiagram-v2 [*] --> checking checking --> prompt_download : not-installed or version too old checking --> flushing : ready prompt_download --> [*] : user picks N or dismisses flushing --> opening : session flushed opening --> success : deep link opened opening --> error : OS failed to open URL success --> [*] : 500ms then onDone() error --> [*] : keypress

The minimum required desktop version is hard-coded as MIN_DESKTOP_VERSION = '1.1.2396' in utils/desktopDeepLink.ts. Older installs are caught and the user is prompted to update before the handoff proceeds.

Deep link construction

The actual URL that kicks off the handoff is built by buildDesktopDeepLink:

// utils/desktopDeepLink.ts
function buildDesktopDeepLink(sessionId: string): string {
  // dev builds use 'claude-dev://', prod uses 'claude://'
  const protocol = isDevMode() ? 'claude-dev' : 'claude'
  const url = new URL(`${protocol}://resume`)
  url.searchParams.set('session', sessionId)
  url.searchParams.set('cwd', getCwd())
  return url.toString()
}

The URL format is claude://resume?session=<uuid>&cwd=<path>. Claude Desktop registers the claude:// protocol handler via Electron's setAsDefaultProtocolClient. In development, AppleScript is used instead of open because the dev Electron binary registers itself rather than the app bundle:

// macOS dev mode workaround
const { code } = await execFileNoThrow('osascript', [
  '-e',
  `tell application "Electron" to open location "${deepLinkUrl}"`,
])

Platform-specific install detection

Before opening the deep link, the code verifies the app is actually installed using OS-appropriate methods:

macOS

Path check

Checks /Applications/Claude.app exists. Version read via defaults read …/Info.plist CFBundleShortVersionString.

Linux

xdg-mime query

Runs xdg-mime query default x-scheme-handler/claude and checks stdout is non-empty (exit code alone is unreliable).

Windows

Registry query

Queries HKEY_CLASSES_ROOT\claude via reg query. Version discovered in %LOCALAPPDATA%\AnthropicClaude\app-*\.

Session flush: the secret ingredient

Before opening the deep link, the flushing state calls flushSessionStorage(). This forces all buffered conversation turns to be written to disk in ~/.claude/projects/ so that Claude Desktop can read the full session transcript when it opens the claude://resume URL. Without the flush, the desktop app would open an incomplete session.

03 Startup Upsell Dialog

Separate from the explicit /desktop command, Claude Code can proactively suggest migrating to the desktop app at startup. The logic lives in components/DesktopUpsell/DesktopUpsellStartup.tsx.

When the dialog appears

The function shouldShowDesktopUpsellStartup() gates the dialog:

export function shouldShowDesktopUpsellStartup(): boolean {
  if (!isSupportedPlatform()) return false
  // Requires GrowthBook flag 'tengu_desktop_upsell'.enable_startup_dialog
  if (!getDesktopUpsellConfig().enable_startup_dialog) return false
  const config = getGlobalConfig()
  if (config.desktopUpsellDismissed) return false   // "Never" was chosen
  if ((config.desktopUpsellSeenCount ?? 0) >= 3) return false  // capped at 3 showings
  return true
}

The dialog offers three options: Open in Claude Code Desktop (triggers the full handoff flow), Not now (increments the seen counter), and Never ask again (sets desktopUpsellDismissed: true in ~/.claude/config.json via saveGlobalConfig). The feature is controlled by the tengu_desktop_upsell GrowthBook experiment, which ships disabled by default.

Config persistence
Both desktopUpsellDismissed and desktopUpsellSeenCount are stored in the global config at ~/.claude/config.json. Deleting that file resets the suppression state.
04 Importing MCP Servers from Claude Desktop

Claude Code can read the MCP server configuration from Claude Desktop and import it. The utility is in utils/claudeDesktop.ts, which knows the config file locations on each platform:

// utils/claudeDesktop.ts
export async function getClaudeDesktopConfigPath(): Promise<string> {
  if (platform === 'macos') {
    return join(homedir(), 'Library', 'Application Support', 'Claude',
                'claude_desktop_config.json')
  }
  // WSL: tries USERPROFILE, then scans /mnt/c/Users/* for AppData/Roaming/Claude/
}

The file is parsed to extract the mcpServers map. Each entry is validated against McpStdioServerConfigSchema() (Zod) before being accepted. The MCPServerDesktopImportDialog component renders a multi-select list so users can cherry-pick which servers to copy into their Claude Code config.

WSL path conversion
On WSL, Windows paths like C:\Users\moiz\AppData\Roaming\Claude are converted to /mnt/c/Users/moiz/AppData/Roaming/Claude by stripping the drive letter and prepending /mnt/c.
05 IDE Integration

When Claude Code runs inside a terminal embedded in an IDE, it detects the IDE and adjusts behavior — for example, using the IDE's file-open API instead of the system editor, or routing "side queries" (quick questions) through a faster channel. Detection happens in utils/ide.ts.

Supported IDEs

The supportedIdeConfigs map in ide.ts covers two families, identified by the ideKind field:

VS Code family

cursor, windsurf, vscode

Detected by process names like Cursor Helper, Code Helper. Transport: HTTP/WebSocket to a local port.

JetBrains family

intellij, pycharm, webstorm, phpstorm, rubymine, clion, goland, rider, datagrip, appcode, dataspell, aqua, gateway, fleet, androidstudio

15 IDEs total. Transport: SSE or WebSocket via a lockfile-advertised port.

Lockfile-based discovery

IDE plugins write a lockfile to ~/.claude/ide/<port>.lock when they start. Claude Code reads these to find which IDEs are running and which workspace folders they have open:

// utils/ide.ts — lockfile JSON schema
type LockfileJsonContent = {
  workspaceFolders?: string[]
  pid?: number
  ideName?: string
  transport?: 'ws' | 'sse'
  runningInWindows?: boolean
  authToken?: string
}

Lockfiles are sorted by modification time (newest first) so the most recently focused IDE is preferred. Stale locks are skipped — the code calls isProcessRunning(pid) via process.kill(pid, 0) to verify the PID is still alive before trusting the lockfile.

Terminal detection shortcut

Before scanning lockfiles, the code checks the TERM_PROGRAM environment variable (stored in env.terminal). If it matches a known IDE process name (e.g., vscode, cursor), isSupportedTerminal() returns true immediately without any filesystem I/O.

Windows-in-WSL path conversion

When the IDE is running in native Windows but Claude Code is running inside WSL, file paths need conversion. utils/idePathConversion.ts provides WindowsToWSLConverter which transforms C:\Users\moiz\project into /mnt/c/Users/moiz/project, and checkWSLDistroMatch validates that both processes are targeting the same WSL distribution before accepting a cross-boundary connection.

06 Claude in Chrome — Native Messaging

Claude Code can act as a native messaging host for a Chrome (or Chromium) extension. This allows the browser extension to invoke Claude Code tools from a webpage. The subsystem lives under utils/claudeInChrome/.

Entrypoint flags

Two special CLI flags are handled at the very top of entrypoints/cli.tsx, before any normal startup:

// entrypoints/cli.tsx
if (process.argv[2] === '--claude-in-chrome-mcp') {
  // Runs the MCP server that the Chrome extension connects to
  await runClaudeInChromeMcpServer()
  return
} else if (process.argv[2] === '--chrome-native-host') {
  // Acts as the Chrome native messaging host (stdin/stdout protocol)
  await runChromeNativeHost()
  return
}

Native messaging host manifest

Chrome requires a JSON manifest file that maps a host identifier to the binary path. Claude Code auto-installs this manifest at startup when shouldEnableClaudeInChrome() returns true:

const NATIVE_HOST_IDENTIFIER = 'com.anthropic.claude_code_browser_extension'

// In bundled (native binary) mode, a wrapper script is created that calls:
// `"${process.execPath}" --chrome-native-host`
// because native host manifests cannot contain CLI arguments directly.
const execCommand = `"${process.execPath}" --chrome-native-host`
void createWrapperScript(execCommand)
  .then(manifestBinaryPath => installChromeNativeHostManifest(manifestBinaryPath))

Supported browsers

The CHROMIUM_BROWSERS map in utils/claudeInChrome/common.ts covers multiple Chromium-based browsers, each with platform-specific data paths and native messaging host directories:

export const CHROMIUM_BROWSERS: Record<ChromiumBrowser, BrowserConfig> = {
  chrome:  { macos: { nativeMessagingPath: ['Library', 'Application Support',
                      'Google', 'Chrome', 'NativeMessagingHosts'] }, ... },
  // Also: chromium, brave, edge, opera, vivaldi, arc ...
}

Activation conditions

shouldEnableClaudeInChrome() checks in priority order:

  1. Disabled by default in non-interactive (SDK/CI) sessions
  2. --chrome / --no-chrome CLI flags override everything
  3. CLAUDE_CODE_ENABLE_CFC environment variable
  4. claudeInChromeDefaultEnabled field in ~/.claude/config.json
  5. Auto-enable: interactive session + extension already installed + tengu_chrome_auto_enable GrowthBook flag
07 Computer Use MCP — OS-Level Automation

The most powerful (and most gated) desktop integration is computer use — the ability to take screenshots, move the mouse, type keystrokes, and interact with arbitrary desktop apps. It is exposed as a separate MCP server under the codename "Chicago" / "Malort".

Entrypoint flag

// entrypoints/cli.tsx
if (feature('CHICAGO_MCP') && process.argv[2] === '--computer-use-mcp') {
  await runComputerUseMcpServer()
  return
}

The feature('CHICAGO_MCP') call is a build-time dead-code-elimination gate: the entire branch is removed from external builds that don't enable the flag.

Access gates

utils/computerUse/gates.ts implements a layered gate check:

export function getChicagoEnabled(): boolean {
  // Ant employees with monorepo access are excluded unless ALLOW_ANT_COMPUTER_USE_MCP=1
  if (process.env.USER_TYPE === 'ant' && process.env.MONOREPO_ROOT_DIR
      && !isEnvTruthy(process.env.ALLOW_ANT_COMPUTER_USE_MCP)) {
    return false
  }
  // External users need Max or Pro subscription + GrowthBook gate
  return hasRequiredSubscription() && readConfig().enabled
}

The GrowthBook experiment key is tengu_malort_pedway. It ships a ChicagoConfig object that includes fine-grained sub-gates:

type ChicagoConfig = CuSubGates & {
  enabled: boolean
  coordinateMode: 'pixels' | 'normalized'
  pixelValidation: boolean
  clipboardPasteMultiline: boolean
  mouseAnimation: boolean
  hideBeforeAction: boolean   // hide terminal before screenshot
  autoTargetDisplay: boolean
  clipboardGuard: boolean
}

Terminal bundle ID awareness

Because Claude Code is a terminal app with no window, the computer-use package needs to know which terminal emulator is hosting it so it can exclude that window from screenshots and avoid accidentally blocking its own keyboard input. utils/computerUse/common.ts resolves this via __CFBundleIdentifier (set by macOS LaunchServices) with a static fallback table for well-known terminals:

const TERMINAL_BUNDLE_ID_FALLBACK = {
  'iTerm.app':    'com.googlecode.iterm2',
  'Apple_Terminal': 'com.apple.Terminal',
  'ghostty':      'com.mitchellh.ghostty',
  'WarpTerminal': 'dev.warp.Warp-Stable',
  'vscode':       'com.microsoft.VSCode',
  // ...
}

The coordinate mode (pixels vs normalized) is frozen at first read — a live GrowthBook flip mid-session would otherwise cause the model to think in one coordinate space while the executor transforms in another.

08 CLI Entrypoint: Desktop Fast Paths

All desktop-related subsystems hook into the CLI via special argument checks at the very top of entrypoints/cli.tsx, before any module loading or config initialization. This keeps the code paths for native host modes lean:

flowchart TD A[claude argv] --> B{argv[2] == ?} B -->|--claude-in-chrome-mcp| C[runClaudeInChromeMcpServer] B -->|--chrome-native-host| D[runChromeNativeHost] B -->|--computer-use-mcp| E[runComputerUseMcpServer
feature CHICAGO_MCP required] B -->|--daemon-worker| F[runDaemonWorker] B -->|remote-control / rc| G[bridgeMain] B -->|normal| H[full CLI boot] C --> Z[exit] D --> Z E --> Z F --> Z G --> Z

Each fast path returns immediately after its handler completes, so there is zero overhead from the normal boot pipeline in host/server modes. This matters for Chrome native messaging — Chrome spawns the host binary on demand and expects it to respond within milliseconds.

09 The Native Installer

Claude Code ships two distribution modes: an npm-installed JavaScript bundle and a pre-compiled native binary. The native installer in utils/nativeInstaller/ manages the lifecycle of the native binary alongside the npm installation.

Directory layout

# XDG-compliant layout
~/.local/share/claude/versions/   # permanent — one dir per installed version
~/.cache/claude/staging/          # temporary — download target before atomic rename
~/.local/state/claude/locks/      # PID-based lock files for update coordination
~/.local/bin/claude               # symlink → active version

The installer keeps VERSION_RETENTION_COUNT = 2 versions on disk, deleting older ones after a successful update. Updates are multi-process safe via PID-based lockfiles with a 7-day stale timeout (long enough to survive laptop sleep).

Platform targeting

getPlatform() in the installer module produces strings like darwin-arm64, linux-x64, linux-x64-musl, win32-x64. The musl variant is auto-detected via envDynamic.isMuslEnvironment() for Alpine Linux / Docker environments.

Portable session storage
utils/sessionStoragePortable.ts contains pure-Node.js utilities deliberately free of internal dependencies. This file is shared verbatim with the VS Code extension package at packages/claude-vscode/src/common-host/sessionStorage.ts — the same code reads session transcripts in both the CLI and the IDE plugin.
10 The Full Integration Map
graph LR CLI["Claude Code CLI
(terminal)"] subgraph Desktop["Claude Desktop (Electron)"] DP["claude://resume
deep link handler"] DS["Session reader
(~/.claude/projects/)"] end subgraph IDE["IDE Plugin (VS Code / JetBrains)"] LF["~/.claude/ide/*.lock"] RP["Local HTTP/WS
RPC server"] end subgraph Browser["Chrome Extension"] NM["Native Messaging
com.anthropic.claude_code_browser_extension"] MCP["MCP Server
(--claude-in-chrome-mcp)"] end subgraph OS["OS (macOS)"] CU["Computer Use MCP
(--computer-use-mcp)
screenshots, mouse, keyboard"] end CLI -->|"/desktop command\nflushSession + open URL"| DP DP --> DS CLI -->|"reads lockfiles\nauto-detects"| LF LF --> RP CLI -->|"installs manifest\nspawns on demand"| NM NM --> MCP CLI -->|"Max/Pro + GB flag"| CU

Key Takeaways

  • The /desktop command is a 6-state React machine that flushes the session transcript, builds a claude://resume?session=…&cwd=… URL, then hands off to the desktop app via OS-native URL opening.
  • Desktop install detection is OS-specific: path check on macOS, xdg-mime on Linux, registry query on Windows.
  • IDE detection uses ~/.claude/ide/<port>.lock files written by the VS Code and JetBrains plugins, supplemented by the TERM_PROGRAM environment variable.
  • Claude in Chrome works via Chrome's native messaging protocol — a JSON manifest maps the identifier com.anthropic.claude_code_browser_extension to a wrapper script that invokes the CLI with --chrome-native-host.
  • Computer use is triple-gated: build-time feature flag (CHICAGO_MCP), Max/Pro subscription check, and GrowthBook experiment key tengu_malort_pedway.
  • All desktop-mode fast paths (--chrome-native-host, --claude-in-chrome-mcp, --computer-use-mcp) are handled before any config or analytics initialization to keep startup overhead minimal.
  • utils/sessionStoragePortable.ts is intentionally dependency-free so it can be shared with the VS Code extension package without pulling in CLI-only modules.

Knowledge Check

Q1. What is the minimum Claude Desktop version required for the /desktop handoff to proceed?
Q2. Why does the DesktopHandoff component call flushSessionStorage() before opening the deep link?
Q3. On macOS dev mode, why does openDeepLink use AppleScript instead of the open command?
Q4. How does Claude Code detect which IDE it is running inside before reading any lockfiles?
Q5. What is the native messaging host identifier used for the Claude in Chrome integration?
Q6. The computer use coordinate mode is "frozen at first read." Why?
0/6