Build Electron Desktop Apps with Claude Code (2026)
To build an Electron desktop app with Claude Code, scaffold the project with npm create electron-vite, then prompt Claude Code to wire the main process, preload script, and renderer using TypeScript. Claude Code understands Electron's security model — contextIsolation, nodeIntegration, and IPC channels — and generates correctly structured code for both sides of the process boundary. This guide covers project setup, IPC communication, packaging, auto-updates, and when to choose Tauri instead.
Electron Project Scaffolding
Start a new project using electron-vite, which gives you Vite's fast HMR in the renderer:
npm create electron-vite@latest my-ai-app -- --template vue-ts
# or for React:
npm create electron-vite@latest my-ai-app -- --template react-ts
cd my-ai-app && npm install
Then open Claude Code in the project root and use this prompt to establish the architecture:
I'm building an Electron desktop app with electron-vite and TypeScript.
The app will call the Claude API from the main process and display
results in a React renderer.
Set up the following structure:
- src/main/index.ts — main process entry point
- src/preload/index.ts — context bridge (no direct nodeIntegration)
- src/renderer/src/App.tsx — React renderer entry
Requirements:
- contextIsolation: true, nodeIntegration: false (secure by default)
- IPC channels: invoke/handle pattern for async Claude API calls
- TypeScript types shared between main and renderer via src/shared/types.ts
- Vite aliases so renderer can import from @shared/
Show all four files with the correct electron-vite configuration.
Claude Code generates a working scaffold with proper webPreferences security settings and a typed channel map — the most common source of bugs in new Electron projects.
IPC Communication Patterns
Electron's IPC bridge is where most developers hit friction. Claude Code handles the three canonical patterns:
1. Renderer → Main (invoke/handle) — for Claude API calls:
// src/shared/types.ts
export interface ClaudeRequest {
prompt: string;
model: 'claude-opus-4-5' | 'claude-sonnet-4-5' | 'claude-haiku-4-5';
}
export interface ClaudeResponse {
text: string;
inputTokens: number;
outputTokens: number;
}
export type IpcChannels = {
'claude:ask': [ClaudeRequest, ClaudeResponse];
};
// src/main/index.ts
import { app, BrowserWindow, ipcMain } from 'electron';
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
ipcMain.handle('claude:ask', async (_event, req: ClaudeRequest): Promise<ClaudeResponse> => {
const message = await client.messages.create({
model: req.model,
max_tokens: 1024,
messages: [{ role: 'user', content: req.prompt }],
});
const textBlock = message.content.find((b) => b.type === 'text');
return {
text: textBlock?.type === 'text' ? textBlock.text : '',
inputTokens: message.usage.input_tokens,
outputTokens: message.usage.output_tokens,
};
});
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';
import type { ClaudeRequest, ClaudeResponse } from '../shared/types';
contextBridge.exposeInMainWorld('electronAPI', {
askClaude: (req: ClaudeRequest): Promise<ClaudeResponse> =>
ipcRenderer.invoke('claude:ask', req),
});
// src/renderer/src/App.tsx
declare global {
interface Window {
electronAPI: {
askClaude: (req: import('@shared/types').ClaudeRequest) => Promise<import('@shared/types').ClaudeResponse>;
};
}
}
function App() {
const [response, setResponse] = React.useState('');
async function handleSubmit(prompt: string) {
const result = await window.electronAPI.askClaude({
prompt,
model: 'claude-haiku-4-5',
});
setResponse(result.text);
}
return <ChatUI onSubmit={handleSubmit} response={response} />;
}
2. Main → Renderer (webContents.send) — for streaming responses:
Add streaming support to the Claude IPC handler.
The main process should stream tokens from the Claude API using
client.messages.stream(), sending each chunk to the renderer
via mainWindow.webContents.send('claude:chunk', { text, done }).
The renderer should listen with ipcRenderer.on in the preload,
expose a subscription API via contextBridge, and update React
state incrementally. Show the complete implementation.
3. Two-way with cancellation:
Add a cancellation token to the claude:ask IPC handler.
The renderer should be able to call electronAPI.cancelClaude()
mid-stream. Use an AbortController in the main process tied to
the stream. Show how to clean up the listener when the component unmounts.
50+ expert prompts for Electron, desktop apps, and Claude API integration
Power Prompts ($29) includes ready-to-use prompts for IPC patterns, secure preload scripts, native OS integration, and packaging workflows — all tested with Claude Code.
Tauri as a Lightweight Alternative
Tauri uses the OS WebView instead of bundling Chromium, resulting in a dramatically smaller binary. For AI-powered desktop apps, the trade-off is mostly in ecosystem maturity.
| Feature | Electron | Tauri |
|---|---|---|
| Binary size | 80–150 MB | 3–10 MB |
| Memory usage | 150–400 MB | 30–80 MB |
| Language | Node.js (main) + JS (renderer) | Rust (backend) + JS (renderer) |
| IPC | ipcMain/ipcRenderer (mature) | Commands + Events (v2 stable) |
| Native APIs | electron APIs + node modules | Tauri plugins + Rust crates |
| Claude API calls | From main process (Node.js) | From Rust backend or renderer |
| Auto-updates | electron-updater (mature) | tauri-plugin-updater (v2) |
| Build tooling | electron-builder / electron-vite | Cargo + Vite (tauri-cli) |
| macOS notarization | Manual or electron-builder | Built into tauri-cli |
| Learning curve | Low (JS/TS throughout) | High (requires Rust) |
| Best for | Teams with JS/TS skills, complex integrations | Minimal footprint, security-critical apps |
For a Claude-powered desktop app where the team knows TypeScript, Electron remains the pragmatic choice. Choose Tauri if binary size or memory footprint is a hard requirement (e.g., distributing to corporate environments with strict size policies).
Tauri scaffold with Claude Code:
Scaffold a Tauri v2 desktop app with:
- Vite + React + TypeScript renderer
- Rust backend that calls the Claude API via reqwest
- A Tauri command: async fn ask_claude(prompt: String) -> Result<String, String>
- Environment variable ANTHROPIC_API_KEY loaded via dotenv in Rust
- Frontend that invokes the command with @tauri-apps/api/core invoke()
Show src-tauri/src/main.rs, src-tauri/Cargo.toml, and the React component.
Packaging and Distribution
Claude Code handles electron-builder configuration reliably:
Set up electron-builder for my Electron app with:
- macOS: universal DMG (arm64 + x64), code signing with Apple Developer ID,
hardened runtime, notarization via notarytool
- Windows: NSIS installer + portable EXE, code signing optional
- Linux: AppImage + deb
- Publish to GitHub Releases (provider: github)
- electron-builder.yml in project root (not package.json)
I have:
- APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID in env
- CSC_LINK, CSC_KEY_PASSWORD for Windows cert (optional)
Show the complete electron-builder.yml and the npm scripts.
Claude Code produces a build config that handles the notarization staple step and correctly sets hardenedRuntime: true — the two most common omissions that cause macOS Gatekeeper rejections.
Securing the API key at build time:
In my packaged Electron app, ANTHROPIC_API_KEY must not be bundled
in the renderer bundle. Show me how to:
1. Load the key from a local encrypted file in the user's app data directory
2. Store it securely with electron-store (encrypted: true)
3. Expose a settings screen in the renderer for the user to enter their key
4. Never expose the raw key to the renderer process
Auto-Updates
electron-updater is the standard for Electron auto-updates. Claude Code generates the full implementation:
Add auto-update support to my Electron app using electron-updater.
Requirements:
- Check for updates on app start (silent check)
- Show a non-blocking notification in the renderer when an update is available
- User clicks "Restart & Update" to apply
- Download progress shown as a percentage
- Publish channel: latest (macOS/Windows), with a beta channel option
- GitHub Releases as the update server
Show:
1. Main process auto-updater setup with all event handlers
2. IPC channels to notify the renderer (update-available, download-progress, update-ready)
3. The React component for the update notification banner
The generated main-process code:
// src/main/updater.ts
import { autoUpdater } from 'electron-updater';
import type { BrowserWindow } from 'electron';
export function setupAutoUpdater(win: BrowserWindow) {
autoUpdater.autoDownload = false;
autoUpdater.on('update-available', (info) => {
win.webContents.send('update:available', { version: info.version });
});
autoUpdater.on('download-progress', (progress) => {
win.webContents.send('update:progress', { percent: Math.round(progress.percent) });
});
autoUpdater.on('update-downloaded', () => {
win.webContents.send('update:ready');
});
autoUpdater.checkForUpdates().catch(console.error);
}
For Tauri, the equivalent uses tauri-plugin-updater — prompt Claude Code with your tauri.conf.json and it generates the Rust + TypeScript implementation.
For broader Claude Code usage patterns, see Claude Code Complete Guide. For building AI features on mobile instead of desktop, see Claude Code React Native Mobile App Guide. For choosing the right Claude model for your desktop app's API calls, see Claude Haiku vs Sonnet vs Opus.
Expert prompts for packaging, auto-updates, and native OS integration
Power Prompts ($29) includes 50+ battle-tested prompts for Electron and Tauri workflows, code signing, notarization, and distributing AI-powered desktop apps.
Frequently Asked Questions
How do I securely use the Anthropic API key in an Electron app?
Never put the API key in the renderer process or expose it via the preload script. Load it in the main process only — either from an environment variable set at launch, from a user-configurable encrypted store using electron-store with encryptionKey, or from a local config file in app.getPath('userData'). The renderer should call an IPC handler like claude:ask and receive only the response, never the key itself.
Can the renderer process make direct Claude API calls in Electron?
Technically yes if you enable nodeIntegration, but this is a serious security risk — any XSS in the renderer gains full Node.js access. The correct pattern is to keep contextIsolation: true and nodeIntegration: false, make all API calls from the main process via ipcMain.handle, and expose only the results through the context bridge. Claude Code will warn you if you try to set up direct renderer API calls.
How do I handle streaming Claude API responses in Electron IPC?
Use webContents.send for streaming: the main process streams tokens from client.messages.stream() and sends each chunk to the renderer via mainWindow.webContents.send('claude:chunk', { text, done }). The preload exposes onChunk(callback) and offChunk(callback) methods via the context bridge. The renderer subscribes to chunks and updates state incrementally. Claude Code generates this full pattern on request.
Is Electron or Tauri better for an AI-powered desktop app?
For most teams, Electron is the better starting point because the entire stack is TypeScript — no Rust required. The main downside is binary size (80–150 MB vs. 3–10 MB for Tauri) and higher memory usage. If you need the smallest possible distributable or are working in a security-sensitive environment, Tauri is worth the Rust learning curve. Claude Code supports scaffolding both — see the comparison table above for a full breakdown.
How do I add native OS features (tray icon, notifications, file dialogs) with Claude Code?
Describe what you need and Claude Code generates the Electron API calls: Tray, nativeImage, Notification, and dialog.showOpenDialog. For example: "Add a system tray icon that shows the last Claude response as a tooltip and has a menu with 'New Chat' and 'Quit'." Claude Code knows these APIs well and produces working code including the correct nativeImage loading pattern for macOS retina displays.