A local-first CRM for managing prospect outreach — built as an open source example of how to integrate the Cephable Web SDK for voice navigation and control inside a desktop app.
Want to add voice commands to your own app? Head to developers.cephable.com to create a project and get your SDK credentials.
- Registering custom voice commands that map to app-specific actions (navigate to a page, search contacts, create records)
- Defining named entities so the SDK can extract structured values from free-form speech ("go to contacts", "create campaign called Q3 Outreach")
- Supporting both guest mode (no sign-in required) and linked Cephable account (synced profiles and advanced controls)
- Handling the OAuth deep-link callback inside Electron so the browser-based sign-in flow completes cleanly
- Swapping the active microphone at runtime without restarting the voice service
| Layer | Technology |
|---|---|
| Desktop shell | Electron via electron-vite |
| UI | React 18, MUI v5, React Router v6 |
| Data layer | SQLite via better-sqlite3, mirrored to CSV |
| State | Zustand, TanStack Query |
| Voice / accessibility | @cephable/cephable-web |
| Language | TypeScript throughout |
- Node.js 20+
- npm 10+
- A Cephable project with SDK credentials — create one free at developers.cephable.com
git clone https://github.com/cephable/local-crm.git
cd local-crm
npm installCopy the example env file and fill in your Cephable credentials:
cp .env.example .envOpen .env and set:
VITE_CEPHABLE_CLIENT_ID=your_client_id
VITE_CEPHABLE_CLIENT_SECRET=your_client_secret # optional for public clients
VITE_CEPHABLE_DEVICE_TYPE_ID=your_device_type_id # optionalGet these values from your project dashboard at developers.cephable.com.
npm run devThis starts the Electron app with hot reload.
npm run package:win # Windows NSIS installer
npm run package:mac # macOS DMG + ZIP
npm run package:linux # AppImage + .debsrc/
main/ # Electron main process
ipc/ # IPC handlers (contacts, campaigns, templates, etc.)
db/ # SQLite connection + schema
preload/ # Context bridge — exposes typed API to renderer
renderer/
components/
voice/ # VoiceAssistant.tsx — the floating mic panel UI
context/
CephableVoiceContext.tsx # SDK initialization + state management
pages/ # Today queue, Contacts, Campaigns, Templates, Import, Settings
hooks/ # useKeyboardShortcut, useKeySequence
shared/
types.ts # Shared TypeScript types
ipcChannels.ts
npm install @cephable/cephable-webCustom controls tell the SDK what commands your app understands and which spoken phrases trigger them. Each control can include named entities (variables extracted from speech).
// src/renderer/context/CephableVoiceContext.tsx
const MY_CUSTOM_CONTROLS = [
{
id: 'nav_page',
name: 'Navigate',
description: 'Go to a page in the app',
defaultCommands: ['go to @page', 'navigate to @page', 'open @page'],
},
{
id: 'search_contacts',
name: 'Search Contacts',
defaultCommands: ['search contacts for @query', 'find contact @query'],
},
// ...more controls
];Named entities let the SDK extract structured values from a spoken phrase. For example, @page in "go to contacts" resolves to the string "contacts".
const MY_ENTITIES = {
page: {
options: {
contacts: ['contacts', 'people', 'leads'],
campaigns: ['campaigns', 'outreach'],
settings: ['settings', 'preferences'],
},
},
query: {
trim: [{ type: 'position', position: 'after', words: ['for'] }],
},
};import { CephableService } from '@cephable/cephable-web';
const service = new CephableService({
authenticationConfiguration: {
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET, // optional
redirectUri: 'myapp://auth/callback',
autoRefresh: true,
},
deviceName: 'My App',
locale: 'en-US',
includeDefaultControls: true, // built-in scroll, click, focus commands
enableIntelligentCommands: true,
customControls: MY_CUSTOM_CONTROLS,
customEntities: MY_ENTITIES,
onCustomControlAction: (control, _command, _input, intent, entities) => {
// dispatch to your app logic here
handleVoiceAction(control.id, entities);
return true;
},
});
// Guest mode — no sign-in required
const voiceConfig = {
locale: 'en-US',
onPartialResult: (text) => setTranscript(text),
onFinalResult: (text, result) => setLastCommand(text, result),
onListeningStarted: () => setStatus('listening'),
onListeningStopped: () => setStatus('idle'),
};
await service.initializeWithGuestUser(voiceConfig, null);
await service.voiceService.startVoiceControls();The onCustomControlAction callback fires when the SDK recognizes a command. Entities contain the extracted values:
function handleVoiceAction(controlId: string, entities: DetectedEntity[]) {
const getEntity = (name: string) => entities?.find(e => e.entity === name);
switch (controlId) {
case 'nav_page': {
const page = getEntity('page')?.option; // e.g. "contacts"
navigate(routes[page]);
break;
}
case 'search_contacts': {
const query = getEntity('query')?.utteranceText;
navigate(`/contacts?search=${encodeURIComponent(query)}`);
break;
}
}
}When a user links their Cephable account, the SDK opens the browser for sign-in. On completion, Cephable redirects to a custom protocol URL (selllocal://auth/callback). Electron catches this deep link and passes it back to the renderer:
// main process — src/main/index.ts
app.setAsDefaultProtocolClient('selllocal');
app.on('open-url', (event, url) => handleDeepLink(url)); // macOS
app.on('second-instance', (_e, argv) => { // Windows/Linux
const url = argv.find(a => a.startsWith('selllocal://'));
if (url) handleDeepLink(url);
});
function handleDeepLink(url: string) {
mainWindow.webContents.send('cephable:deep-link', url);
}
// renderer — src/renderer/context/CephableVoiceContext.tsx
api.cephable.onDeepLink(async (url) => {
const { code, state } = parseCallback(url);
await service.authenticationService.authenticateFromCode(code, state);
await service.initializeWithExistingUser(voiceConfig, null);
await service.voiceService.startVoiceControls();
});| Category | Example phrases |
|---|---|
| Navigate | "Go to today", "Open contacts", "Navigate to campaigns", "Show settings" |
| Contacts | "Search contacts for [name]", "Find [company]", "Create contact" |
| Campaigns | "Create campaign", "New campaign called [name]" |
| Today queue | "Send today's emails", "Open today queue", "Log a call" |
| Templates | "Create template", "New email template" |
| App controls | "Click [element]", "Scroll down", "Scroll to top", "Focus next" |
App controls (scroll, click, focus) come from the SDK's built-in controls via includeDefaultControls: true.
- Today queue — daily digest of emails due for each enrolled contact, opens a
mailto:link pre-filled with the rendered template - Contacts — manage prospects with status tracking (New → In Progress → Qualified / Lost) and BANT qualification fields
- Campaigns — multi-step drip sequences with configurable day spacing between steps
- Templates — email templates with
{{firstName}},{{company}}, and custom field merge tags - CSV import — drag-and-drop import with field mapping, auto-saved to the workspace folder
- Local-first — all data lives in a SQLite file inside a user-chosen workspace folder; contacts are mirrored to CSV for easy export
- Create a project at developers.cephable.com and copy your credentials into
.env. - Replace
SELLLOCAL_CUSTOM_CONTROLSinCephableVoiceContext.tsxwith commands relevant to your app. - Replace
SELLLOCAL_ENTITIESwith the named entity options your commands need. - Update
handleActioninVoiceAssistant.tsxto mapcontrolIdvalues to your app's navigation or actions. - If you're not using Electron, remove the deep-link handling in
main/index.tsand replaceapi.cephable.onDeepLinkwith whatever your framework provides for custom URL schemes.
Issues and pull requests are welcome. Please open an issue before submitting large changes so we can discuss the approach.
MIT — see LICENSE.