Navigate

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.

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


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

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

npm install @cephable/cephable-web

AudioWorklet setup

The Vosk engine processes microphone audio on a separate thread using the Web Audio AudioWorklet API. 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:

// 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:

audioWorkletPath: './RecognizerAudioProcessor.js'

Content Security Policy

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

<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

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.

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.

// 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):

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:

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:

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:

protocols:
  - name: Agent Dash
    schemes:
      - agentdash

Custom controls and named entities

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

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.

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:

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