Navigate

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.

View on GitHub


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

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

  1. Create a project at developers.cephable.com and copy your credentials into .env.
  2. Replace the custom controls array with commands relevant to your app.
  3. Replace the custom entities with the named entity options your commands need.
  4. Update the action handler to map controlId values to your app's navigation or actions.
  5. If you're not using Electron, remove the deep-link handling from the main process and replace api.cephable.onDeepLink with whatever your framework provides for custom URL schemes.

Next steps