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.
What this sample demonstrates
- AudioWorklet setup — copying
RecognizerAudioProcessor.jsfrom 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:
- Matches "prompt claude to" as the control prefix
- Resolves "claude" as the
agententity - Passes "write unit tests for my auth module" as
additionalInputto 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
- SellLocal sample — simpler integration covering guest mode and basic OAuth
- Web SDK Overview
- Web Quick Start