Desktop AI, Your Way: Building a Claude/ChatGPT-Style Chat App with Electron
Posted on Sun 06 September 2026 in Article
Chat interfaces like Claude and ChatGPT feel deceptively simple: a message box, a scrolling conversation, a streaming response. But under the hood, there's a real desktop application architecture at work — one you can absolutely build yourself. This article walks through creating a native-feeling AI chat application using Electron, covering the core architecture, streaming responses, conversation persistence, and packaging for distribution.
Why Electron for an AI Chat App?
Electron lets you ship a single JavaScript/HTML/CSS codebase as a native app for Windows, macOS, and Linux. For an AI chat client specifically, it's a strong fit because:
-
You get a real OS-level window, system tray, native menus, and notifications — things a browser tab can't offer.
-
You can securely store API keys and conversation history on disk instead of relying on browser storage.
-
You can make direct HTTPS requests to LLM providers (Anthropic, OpenAI, etc.) from a Node.js backend process without CORS restrictions.
-
The UI layer is just web tech, so any frontend framework (React, Vue, or plain JS) works.
Core Architecture
A well-structured Electron AI chat app splits cleanly into three layers:
-
Main process (Node.js) — owns the app lifecycle, creates windows, handles filesystem access, and makes API calls to the LLM provider.
-
Renderer process (Chromium) — the actual chat UI: message list, input box, sidebar of conversations.
-
Preload script — a secure bridge between the two, exposing only specific, whitelisted functions to the renderer via contextBridge.
This separation matters for security. You never want your renderer (which loads like a webpage) to have direct access to Node.js APIs or your API key — that's how you end up with remote-code-execution vulnerabilities if any content injection ever occurs.
Project Setup
bash
mkdir ai-chat-desktop && cd ai-chat-desktop
npm init -y
npm install electron electron-store --save-dev
npm install node-fetch
Your folder structure will look roughly like:
ai-chat-desktop/
├── package.json
├── main.js # Main process
├── preload.js # Secure bridge
├── renderer/
│ ├── index.html
│ ├── renderer.js
│ └── styles.css
Step 1: The Main Process
The main process creates the browser window and handles all "backend" work: talking to the LLM API and reading/writing conversation history to disk.
javascript
// main.js
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const Store = require('electron-store');
const store = new Store(); // persists data to disk as JSON
function createWindow() {
const win = new BrowserWindow({
width: 1000,
height: 700,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false, // critical: never expose Node.js to the renderer directly
},
});
win.loadFile('renderer/index.html');
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
Step 2: The Preload Script — Your Security Boundary
The preload script exposes a minimal, controlled API to the renderer. This is the single most important security decision in the whole app.
javascript
// preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('chatAPI', {
sendMessage: (conversationId, messages) =>
ipcRenderer.invoke('send-message', conversationId, messages),
onStreamChunk: (callback) =>
ipcRenderer.on('stream-chunk', (_event, chunk) => callback(chunk)),
loadConversations: () => ipcRenderer.invoke('load-conversations'),
saveConversation: (conv) => ipcRenderer.invoke('save-conversation', conv),
});
The renderer can now call window.chatAPI.sendMessage(...) — but it has zero direct access to fetch, the filesystem, or your API key.
Step 3: Calling the LLM API with Streaming
Streaming is what makes a chat app feel alive — tokens appearing progressively rather than the whole reply landing at once. Here's a main-process handler using the Anthropic API as an example (the same pattern applies to OpenAI or other providers, swapping endpoint and payload shape):
javascript
// main.js (continued)
const fetch = require('node-fetch');
ipcMain.handle('send-message', async (event, conversationId, messages) => {
const response = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': store.get('apiKey'), // stored securely, never in renderer
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
messages,
stream: true,
}),
});
const reader = response.body;
reader.on('data', (chunk) => {
const lines = chunk.toString().split('\n').filter((l) => l.startsWith('data:'));
for (const line of lines) {
const payload = line.replace('data:', '').trim();
if (payload === '[DONE]') continue;
try {
const parsed = JSON.parse(payload);
if (parsed.delta?.text) {
event.sender.send('stream-chunk', parsed.delta.text);
}
} catch {
// ignore incomplete JSON fragments mid-stream
}
}
});
return { started: true };
});
Handle your API key storage carefully — electron-store writes to a JSON file on disk by default, so for anything beyond a personal prototype, encrypt the key at rest or use the OS keychain via a library like keytar.
Step 4: The Chat UI
The renderer doesn't need a heavy framework, but React (or Vue) makes managing message state and re-renders much easier as the conversation grows. A minimal vanilla JS version:
javascript
// renderer/renderer.js
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('send');
let currentMessages = [];
let assistantBuffer = '';
window.chatAPI.onStreamChunk((chunk) => {
assistantBuffer += chunk;
renderStreamingMessage(assistantBuffer);
});
sendBtn.addEventListener('click', async () => {
const text = inputEl.value.trim();
if (!text) return;
currentMessages.push({ role: 'user', content: text });
appendMessage('user', text);
inputEl.value = '';
assistantBuffer = '';
await window.chatAPI.sendMessage('default', currentMessages);
});
function appendMessage(role, content) {
const div = document.createElement('div');
div.className = `message ${role}`;
div.textContent = content;
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function renderStreamingMessage(text) {
let el = document.getElementById('streaming-msg');
if (!el) {
el = document.createElement('div');
el.id = 'streaming-msg';
el.className = 'message assistant';
messagesEl.appendChild(el);
}
el.textContent = text;
messagesEl.scrollTop = messagesEl.scrollHeight;
}
Step 5: Persisting Conversations
To mimic Claude's or ChatGPT's sidebar of past conversations, store each thread as a record keyed by ID:
javascript
ipcMain.handle('save-conversation', (event, conversation) => {
const all = store.get('conversations', []);
const idx = all.findIndex((c) => c.id === conversation.id);
if (idx >= 0) all[idx] = conversation;
else all.push(conversation);
store.set('conversations', all);
return true;
});
ipcMain.handle('load-conversations', () => {
return store.get('conversations', []);
});
For larger histories or full-text search across conversations, swap electron-store for a lightweight embedded database like SQLite (via better-sqlite3), which scales much better than a single JSON blob.
Design Details That Make It Feel Native
A few touches separate a "webpage in a window" from something that feels like a real desktop AI assistant:
-
Native window controls and menus via Electron's Menu module (New Chat, Settings, Quit).
-
Global keyboard shortcuts (e.g., Cmd/Ctrl+N for a new conversation) using globalShortcut or app-level accelerators.
-
System tray integration so the app can run in the background and be summoned instantly.
-
Markdown and code-block rendering in responses, typically with marked or react-markdown plus a syntax highlighter like highlight.js or shiki.
-
Auto-updates via electron-updater, so users get new versions without manual reinstalls.
Packaging and Distribution
Once the app works locally, package it with electron-builder:
bash
npm install electron-builder --save-dev
json
// package.json
"build": {
"appId": "com.yourname.aichat",
"mac": { "target": "dmg" },
"win": { "target": "nsis" },
"linux": { "target": "AppImage" }
},
"scripts": {
"dist": "electron-builder"
}
Running npm run dist produces installers for each target platform.
Security and Cost Checklist
Before shipping, run through this list:
-
contextIsolation: true and nodeIntegration: false are non-negotiable defaults.
-
Never let the renderer hold or transmit the raw API key.
-
Sanitize any rendered Markdown/HTML from model output to avoid injection when displaying it in the DOM.
-
Add request timeouts and error handling around the API call — network failures shouldn't crash the stream.
-
Track token usage per conversation if you're billing users or capping usage, since LLM API costs scale with tokens, not messages.
Wrapping Up
An Electron-based AI chat app boils down to three well-separated concerns: a secure main process that talks to the model API, a preload bridge that limits what the UI can touch, and a renderer focused purely on presenting the conversation. Once that skeleton is in place, everything else — themes, plugins, multi-model support, voice input — is additive. The architecture in this article is the same shape used by production desktop AI clients; scaling it up is a matter of refinement, not redesign.