# 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.

Source: https://developers.cephable.com/docs/web-sdk/samples/local-crm

---
**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](https://www.npmjs.com/package/@cephable/cephable-web) for voice navigation and control inside an Electron desktop app.

[View on GitHub](https://github.com/Cephable/local-crm)

---

## 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](https://developers.cephable.com)

### 1. Clone and install

```bash
git clone https://github.com/cephable/local-crm.git
cd local-crm
npm install
```

### 2. Configure environment variables

```bash
cp .env.example .env
```

Open `.env` and set:

```env
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](https://developers.cephable.com).

### 3. Run in development mode

```bash
npm run dev
```

### 4. Build a distributable

```bash
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

```bash
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.

```ts
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"`.

```ts
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

```ts
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:

```ts
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:**

```ts
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:**

```ts
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](https://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

- [Agent Dash sample](/docs/web-sdk/samples/agent-dash) — more advanced integration with AudioWorklet setup, CSP configuration, prefix commands, and known SDK workarounds
- [Web SDK Overview](/docs/web-sdk/overview)
- [Web Quick Start](/docs/web-sdk/quick-start)
