SellLocal (Local CRM)
An open source local-first CRM that demonstrates custom voice commands, named entities, guest vs. linked account modes, and Electron OAuth deep-link handling with the Cephable Web SDK.
SellLocal by Cephable is an open source, local-first CRM for managing prospect outreach. It is built as a complete example of how to integrate the Cephable Web SDK for voice navigation and control inside an Electron desktop app.
What this sample demonstrates
- 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 — e.g. "go to contacts", "create campaign called Q3 Outreach"
- Supporting both guest mode (no sign-in required) and a 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
Tech stack
| 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 |
Getting started
Prerequisites
- Node.js 20+
- npm 10+
- A Cephable project with SDK credentials — create one free at developers.cephable.com
1. Clone and install
git clone https://github.com/cephable/local-crm.git
cd local-crm
npm install
2. Configure environment variables
cp .env.example .env
Open .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
Get these values from your project dashboard at developers.cephable.com.
3. Run in development mode
npm run dev
4. Build a distributable
npm run package:win # Windows NSIS installer
npm run package:mac # macOS DMG + ZIP
npm run package:linux # AppImage + .deb
How the Cephable SDK integration works
1. Install the SDK
npm install @cephable/cephable-web
2. Define custom controls
Custom 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.
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'],
},
];
3. Define named entities
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'] }],
},
};
4. Initialize the service
import { CephableService } from '@cephable/cephable-web';
const service = new CephableService({
authenticationConfiguration: {
clientId: CLIENT_ID,
clientSecret: CLIENT_SECRET,
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) => {
handleVoiceAction(control.id, entities);
return true;
},
});
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();
5. Handle the action callback
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;
}
}
}
OAuth (linked account) flow in Electron
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 — register the custom protocol and forward deep links:
app.setAsDefaultProtocolClient('selllocal');
// macOS
app.on('open-url', (event, url) => handleDeepLink(url));
// Windows / Linux — requires single-instance lock
app.on('second-instance', (_e, argv) => {
const url = argv.find(a => a.startsWith('selllocal://'));
if (url) handleDeepLink(url);
});
function handleDeepLink(url: string) {
mainWindow.webContents.send('cephable:deep-link', url);
}
Renderer — receive the callback and complete authentication:
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();
});
Supported voice commands
| Category | Example phrases |
|---|---|
| Navigate | "Go to today", "Open contacts", "Navigate to campaigns" |
| Contacts | "Search contacts for [name]", "Find [company]", "Create contact" |
| Campaigns | "Create campaign", "New campaign called [name]" |
| Today queue | "Send today's emails", "Log a call" |
| Templates | "Create template", "New email template" |
| Built-in controls | "Click [element]", "Scroll down", "Focus next" |
Built-in controls (scroll, click, focus) come from the SDK via includeDefaultControls: true.
Adapting this for your own app
- Create a project at developers.cephable.com and copy your credentials into
.env. - Replace the custom controls array with commands relevant to your app.
- Replace the custom entities with the named entity options your commands need.
- Update the action handler to map
controlIdvalues to your app's navigation or actions. - If you're not using Electron, remove the deep-link handling from the main process and replace
api.cephable.onDeepLinkwith whatever your framework provides for custom URL schemes.
Next steps
- Agent Dash sample — more advanced integration with AudioWorklet setup, CSP configuration, prefix commands, and known SDK workarounds
- Web SDK Overview
- Web Quick Start