# Agent Dash

> An open source Electron dashboard for managing CLI-based AI agents with full PTY terminal emulation and voice control — covering AudioWorklet setup, CSP, prefix commands, OAuth deep links, and known SDK behaviors.

Source: https://developers.cephable.com/docs/web-sdk/samples/agent-dash

---
**Agent Dash by Cephable** is an open source local dashboard for running and managing CLI-based AI agents — Claude Code, GitHub Copilot CLI, Aider, Goose, Gemini CLI, OpenAI Codex, Cephable, and any custom CLI — controlled by keyboard, mouse, or voice.

This is both a useful tool and a detailed SDK integration reference. The Cephable SDK section of the codebase covers topics not shown in the simpler samples: AudioWorklet setup, Content Security Policy, prefix voice commands, profile seeding, and known SDK behaviors with documented workarounds.

[View on GitHub](https://github.com/Cephable/agent-dash)

---

## What this sample demonstrates

- **AudioWorklet setup** — copying `RecognizerAudioProcessor.js` from the SDK package to your public directory so the Vosk engine can process microphone audio off the main thread
- **Content Security Policy** — the exact CSP directives required for WebAssembly (Vosk) and the AudioWorklet to load correctly
- **Guest vs. authenticated users** — both init paths and the profile-seeding workaround that prevents commands from being silently dropped
- **OAuth deep-link handling in Electron** — a robust pattern using a custom protocol (`agentdash://`), a single-instance lock, and a pending-promise resolver
- **Custom voice controls and named entities** — matching spoken agent names to app actions with NLP slot filling
- **Prefix voice commands** — open-ended commands like "prompt claude to [anything]" where the remainder of the utterance is passed to your handler
- **Remote commands** from the Cephable mobile app via `DeviceHubService`

---

## Tech stack

| Layer | Technology |
|---|---|
| Desktop shell | Electron via electron-vite |
| UI | React 19, Tailwind v4, Radix UI |
| Terminal | xterm.js + node-pty (full PTY emulation) |
| State | Zustand |
| Voice / accessibility | @cephable/cephable-web |
| Language | TypeScript throughout |

---

## Getting started

### Prerequisites

- Node.js 20+
- npm 10+
- Cephable SDK credentials (optional — guest voice mode works without them)

**Windows native dependency:** `node-pty` requires Visual Studio Build Tools with the **Desktop development with C++** workload and Python 3.10 or 3.11. Python 3.12+ removed `distutils`, which node-gyp still requires — install `setuptools` or point npm at an older interpreter: `npm config set python C:\path\to\python3.11.exe`.

**macOS:** `xcode-select --install` before `npm install`.

### 1. Clone and install

```bash
git clone https://github.com/cephable/agent-dash
cd agent-dash
cp .env.example .env
npm install   # also rebuilds node-pty and copies the AudioWorklet
npm run dev
```

On first launch the app scans your `PATH` for known CLIs and lists them in the sidebar. Click **Add custom agent** to register any executable.

### Environment variables

| Variable | Description |
|---|---|
| `VITE_CEPHABLE_CLIENT_ID` | OAuth client ID from the [developer portal](https://developers.cephable.com) |
| `VITE_CEPHABLE_CLIENT_SECRET` | OAuth client secret |
| `VITE_CEPHABLE_DEVICE_TYPE_ID` | Device type ID registered in your Cephable app |
| `VITE_CEPHABLE_REDIRECT_URI` | OAuth redirect (default: `agentdash://auth/callback`) |

All variables are optional. Guest voice mode works without credentials; sign-in enables Cephable profile sync.

---

## Cephable SDK integration

### Installation

```bash
npm install @cephable/cephable-web
```

### AudioWorklet setup

The Vosk engine processes microphone audio on a separate thread using the [Web Audio AudioWorklet API](https://developer.mozilla.org/en-US/docs/Web/API/AudioWorklet). The worklet script (`RecognizerAudioProcessor.js`) must be served as a static file from your app's origin — it cannot be bundled by Vite.

Copy it from the package after install using a `postinstall` script:

```js
// package.json
"scripts": {
  "copy-worklet": "node -e \"const fs=require('fs'),path=require('path'); fs.copyFileSync( path.join('node_modules','@cephable','cephable-web','dist','RecognizerAudioProcessor.js'), path.join('src','renderer','public','RecognizerAudioProcessor.js') ); console.log('Copied RecognizerAudioProcessor.js');\"",
  "postinstall": "npm run copy-worklet"
}
```

Then reference it in your voice config:

```ts
audioWorkletPath: './RecognizerAudioProcessor.js'
```

### Content Security Policy

The SDK requires CSP directives beyond a bare `default-src 'self'`:

```html
<meta http-equiv="Content-Security-Policy" content="
  default-src 'self' 'unsafe-inline' data: blob:;
  script-src  'self' 'unsafe-inline' 'wasm-unsafe-eval';
  connect-src 'self' https: wss: data:;
  worker-src  'self' blob:;
" />
```

| Directive | Why it's needed |
|---|---|
| `script-src 'wasm-unsafe-eval'` | Vosk uses WebAssembly — `instantiate()` is blocked without this |
| `connect-src data:` | The WASM binary is fetched as a `data:` URI at runtime |
| `connect-src https:` | Device registration and model fetches go to `services.cephable.com` and `models.cephable.com` |
| `worker-src blob:` | The AudioWorklet is registered from a blob URL internally |

### Initializing the service

```ts
import { CephableService } from '@cephable/cephable-web'

const service = new CephableService({
  authenticationConfiguration: {
    clientId:     'YOUR_CLIENT_ID',
    clientSecret: 'YOUR_CLIENT_SECRET',
    redirectUri:  'agentdash://auth/callback',
    autoRefresh:  true,
  },
  deviceName:               'Agent Dash',
  deviceTypeId:             'YOUR_DEVICE_TYPE_ID',
  locale:                   'en-US',
  includeDefaultControls:   true,
  enableIntelligentCommands: true,
  customControls:           MY_CONTROLS,
  customEntities:           MY_ENTITIES,
  onCustomControlAction:    handleControl,
})

const voiceConfig = {
  locale:             'en-US',
  audioWorkletPath:   './RecognizerAudioProcessor.js',
  onPartialResult:    (text) => console.log('partial:', text),
  onFinalResult:      (text) => console.log('final:', text),
  onListeningStarted: () => setStatus('listening'),
  onListeningStopped: () => setStatus('stopped'),
  onListeningPaused:  () => setStatus('paused'),
  onListeningResumed: () => setStatus('listening'),
}

const result = await service.initializeWithGuestUser(voiceConfig, null)
if (result.resultType !== 'Ok') {
  console.error(result.errors)
  return
}
```

### Guest vs. authenticated users

**Guest** — creates an anonymous device token so Cephable can identify the device. No sign-in required. Profiles and macros are not synced.

```ts
await service.initializeWithGuestUser(voiceConfig, null)
```

**Authenticated** — uses OAuth to sign in via the Cephable portal. The user's custom profiles (keybindings, macros, hotkeys) are loaded and available for matching.

```ts
// If the user already has a stored token:
await service.initializeWithExistingUser(voiceConfig, null)

// After exchanging a fresh OAuth code:
const authService = new AuthenticationService(authConfig)
await authService.authenticateFromCode(code, state)
await service.initializeWithExistingUser(voiceConfig, null)
```

### OAuth in Electron via deep link

Web-based OAuth flows rely on `window.location` for the redirect. In Electron there is no browser URL bar, so the redirect must return via a custom protocol scheme (`agentdash://`).

**1. Register the protocol (main process, before `app.whenReady`):**

```ts
import { app, shell } from 'electron'

app.setAsDefaultProtocolClient('agentdash')

// Single-instance lock — required on Windows/Linux
const gotLock = app.requestSingleInstanceLock()
if (!gotLock) {
  app.quit()
} else {
  app.on('second-instance', (_e, commandLine) => {
    const url = commandLine.filter(a => a.startsWith('agentdash://')).at(-1)
    if (url) resolveOAuthDeepLink(url)
  })
}

// macOS delivers the URL directly to the running instance
app.on('open-url', (event, url) => {
  event.preventDefault()
  resolveOAuthDeepLink(url)
})
```

**2. IPC handler — open the browser and wait for the callback:**

```ts
let pendingResolve: ((result: { code: string; state: string } | null) => void) | null = null

export function resolveOAuthDeepLink(url: string): boolean {
  if (!pendingResolve) return false
  const parsed = new URL(url)
  const code  = parsed.searchParams.get('code')
  const state = parsed.searchParams.get('state')
  const resolve = pendingResolve
  pendingResolve = null
  resolve(code && state ? { code, state } : null)
  return true
}

ipcMain.handle('auth:startOAuth', (_e, { authUrl }) => {
  return new Promise((resolve) => {
    if (pendingResolve) pendingResolve(null)
    pendingResolve = resolve
    setTimeout(() => { pendingResolve = null; resolve(null) }, 5 * 60 * 1000) // 5-min timeout
    shell.openExternal(authUrl).catch(() => { pendingResolve = null; resolve(null) })
  })
})
```

**3. Renderer — build the auth URL and exchange the code:**

```ts
const state = crypto.randomUUID()
const authUrl =
  `https://services.cephable.com/signin/` +
  `?client_id=${encodeURIComponent(CLIENT_ID)}` +
  `&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
  `&state=${state}`

const oauthResult = await window.api.auth.startOAuth(authUrl)
if (!oauthResult) return

await authService.authenticateFromCode(oauthResult.code, oauthResult.state)
await service.initializeWithExistingUser(voiceConfig, null)
```

**4. Register in `electron-builder.yml`** so packaged builds register the scheme at the OS level:

```yaml
protocols:
  - name: Agent Dash
    schemes:
      - agentdash
```

### Custom controls and named entities

```ts
import type { CustomControlModel, NamedEntities } from '@cephable/cephable-web'

const MY_CONTROLS: CustomControlModel[] = [
  {
    id:              'agent-dash:open-agent',
    name:            'Open Agent',
    description:     'Launch a terminal session for an agent',
    defaultCommands: ['open @agent', 'launch @agent', 'start @agent'],
  },
  {
    id:              'agent-dash:prompt-agent',
    name:            'Prompt Agent',
    description:     'Open an agent and send a task prompt',
    defaultCommands: ['prompt @agent to', 'ask @agent to'],
    isPrefixVoiceCommand: true,  // remainder of the utterance becomes additionalInput
  },
]

const MY_ENTITIES: NamedEntities = {
  agent: {
    options: {
      claude:   ['claude', 'claude code', 'cloud'],
      github:   ['github', 'copilot', 'github copilot'],
      aider:    ['aider', 'aida'],
      cephable: ['cephable', 'capable'],
      continue: ['continue', 'continue ai'],
    },
  },
}
```

### The `onCustomControlAction` handler

```ts
function handleControl(
  control:          CustomControlModel,
  command:          string,
  additionalInput?: string,   // text after the prefix phrase (prefix commands only)
  intent?:          string,
  entities?:        DetectedEntity[]
): boolean {
  const agentKey = entities?.find(e => e.entity === 'agent')?.option

  switch (control.id) {
    case 'agent-dash:open-agent': {
      if (agentKey) launchAgent(agentKey)
      return true
    }
    case 'agent-dash:prompt-agent': {
      const prompt = additionalInput?.trim()
      if (agentKey && prompt) {
        launchAgent(agentKey).then(() => {
          setTimeout(() => window.api.sessions.write(sessionId, prompt + '\n'), 1500)
        })
      }
      return true
    }
    default:
      return false  // fall through to SDK built-in DOM actions
  }
}
```

Return `true` if your app handled the command. Return `false` to let the SDK fall through to its built-in scroll/click/focus logic.

### Prefix voice commands

`isPrefixVoiceCommand: true` creates open-ended commands. When the user says "prompt claude to write unit tests for my auth module", the SDK:

1. Matches "prompt claude to" as the control prefix
2. Resolves "claude" as the `agent` entity
3. Passes "write unit tests for my auth module" as `additionalInput` to your handler

This is how Agent Dash implements free-form task prompting — the task text is written directly to the PTY.

### Known SDK behaviors and workarounds

These behaviors apply to `@cephable/cephable-web` v1.3.0.

**`startVoiceControls()` must be called manually.** `initializeWithGuestUser` and `initializeWithExistingUser` create the internal `VoiceService` but do not open the microphone. Call `voiceService.startVoiceControls()` explicitly after a successful init result.

```ts
const result = await service.initializeWithGuestUser(voiceConfig, null)
if (result.resultType !== 'Ok') return

const internals = service as unknown as { voiceService?: VoiceService }
await internals.voiceService?.startVoiceControls()
```

**`profileService.currentProfile` must be seeded.** The SDK skips command processing when `currentProfile` is `null`. For guest users, `loadProfiles()` returns early without setting it. For authenticated users, it fetches profiles but does not automatically set `currentProfile` to the first one. In both cases commands are silently dropped even though the microphone is running.

Seed the profile immediately after init:

```ts
const internals = service as unknown as {
  voiceService?:   VoiceService
  profileService?: { currentProfile: unknown; profiles: unknown[] }
}

if (!internals.profileService?.currentProfile) {
  const profile = internals.profileService?.profiles?.[0] ?? {
    id:            undefined,
    defaultLocale: 'en-US',
    name:          'Default',
    profileType:   'Default',
    configuration: {
      profileType:        'Default',
      commandKeyMappings: [],
      macros:             [],
      hotkeys:            [],
      dictationCommands:  [],
      audioEvents:        [],
    },
  }
  internals.profileService!.currentProfile = profile
}

await internals.voiceService?.startVoiceControls()
```

Custom controls are matched before the profile check, so they fire regardless. Seeding the profile ensures keybindings, macros, dictation, and NLP resolution all work consistently.

### Remote commands from the Cephable mobile app

Set `enableRemoteControls: true` in `CephableService` config. When enabled, the SDK opens a WebSocket to the Cephable hub and commands sent from the paired Cephable iOS/Android app trigger the same `onCustomControlAction` handler as voice commands — no additional code required.

---

## Supported voice commands

| Say | Action |
|---|---|
| "open claude" | Launch a Claude Code session |
| "open github" | Launch a GitHub Copilot session |
| "prompt claude to [task]" | Open Claude Code and send the task as a prompt |
| "ask aider to [task]" | Open Aider and send the task |
| "new session" | Open a new terminal session |
| "close session" | Close the active session |
| "next session" / "previous session" | Switch between open sessions |
| "clear terminal" | Clear the terminal scrollback |
| "scroll down" / "scroll up" | Scroll the focused element |
| "click [label]" | Click a UI element matching the text |
| "type [text]" | Type text into the focused field |

---

## Next steps

- [SellLocal sample](/docs/web-sdk/samples/local-crm) — simpler integration covering guest mode and basic OAuth
- [Web SDK Overview](/docs/web-sdk/overview)
- [Web Quick Start](/docs/web-sdk/quick-start)
