Custom Controller Creator
An open source browser-based touchscreen controller designer that demonstrates OAuth callback handling in a pure web app, CephableApi device management, macro event dispatch, and command token sessions with the Cephable Web SDK.
Cephable Custom Controller Creator is an open source, browser-based tool for designing personalized touchscreen controllers — buttons, joysticks, and trackpads — that dispatch real keyboard, mouse, and macro events to a paired computer via the Cephable platform. It is built as a complete reference implementation of the Cephable Web SDK in a standard Vite + React web app (no Electron).
What this sample demonstrates
- OAuth in a plain web app — redirect-based sign-in with automatic callback detection, state-parameter CSRF protection, and hash-preservation across the OAuth roundtrip
- Session-storage token refresh — the SDK stores the refresh token in
localStoragebut the access token insessionStorage(per-tab); the sample shows how to callrefreshToken()on startup so reopened tabs don't send staleBearer nullheaders CephableApidevice management — listing a user's verified devices, fetching per-device custom controls, and generating command tokens via the SDK's staticCephableApisurface- Command sessions — minting a token, holding it for a play session, and using it to dispatch commands without re-authenticating on every button press
- Macro event dispatch — sending key-press, key-release, type, and multi-step macro events via
sendDeviceCommandrather than named voice commands - Custom control phrases — binding a button to a
CustomControlphrase so the Cephable app on the target device executes the right action - URL-based layout sharing — encoding the full controller layout in the URL hash for shareable links, with automatic restoration after the OAuth redirect
Tech stack
| Layer | Technology |
|---|---|
| Build tool | Vite 6 |
| UI | React 19, Radix UI |
| State | Zustand |
| Styling | Custom CSS (no framework) |
| Voice / accessibility | @cephable/cephable-web |
| Language | TypeScript 5 |
Getting started
Prerequisites
- Node.js 20+
- npm 10+
- A Cephable project with SDK credentials — create one free at developers.cephable.com
1. Clone and install
git clone https://github.com/Cephable/Cephable-Custom-Controller.git
cd Cephable-Custom-Controller
npm install
2. Configure environment variables
cp .env.example .env.local
Open .env.local and set:
VITE_CEPHABLE_CLIENT_ID=your_client_id
VITE_CEPHABLE_CLIENT_SECRET=your_client_secret
Optional variables:
| Variable | Default | Purpose |
|---|---|---|
VITE_CEPHABLE_LOGIN_URL |
Cephable hosted sign-in | Override the OAuth entry URL |
VITE_CEPHABLE_LOCALE |
en-US |
Locale used when fetching device custom controls |
The redirect URI is derived at runtime from window.location so the same credentials work in development, staging, and production without per-environment config.
3. Run in development mode
npm run dev
How the Cephable SDK integration works
1. Install the SDK
npm install @cephable/cephable-web
2. Build the auth configuration
import { AuthenticationService } from '@cephable/cephable-web';
const redirectUri =
window.location.origin + window.location.pathname; // same URL, no hash/query
const authConfig = {
clientId: import.meta.env.VITE_CEPHABLE_CLIENT_ID,
clientSecret: import.meta.env.VITE_CEPHABLE_CLIENT_SECRET,
redirectUri,
autoRefresh: true,
};
const authService = new AuthenticationService(authConfig);
The client secret is included in the browser bundle. Treat the (clientId, clientSecret) pair as a public identifier and register only safe endpoints in your Cephable app settings.
3. Seed the access token on startup
The SDK stores the refresh token in localStorage (persistent) but the access token in sessionStorage (per-tab). A freshly reopened tab starts with a stale Bearer null Authorization header until a refresh happens. Call this once at app load:
async function refreshTokenIfAuthenticated(): Promise<void> {
if (!authService.isAuthenticated) return;
const result = await authService.refreshToken();
if (result.resultType !== 'Ok') {
console.warn('[cephable] startup refresh failed:', result);
}
}
// In your root component or entry point:
await refreshTokenIfAuthenticated();
4. OAuth sign-in (redirect flow)
Because this is a plain web app (no Electron), OAuth uses a full-page redirect rather than a deep link. The layout hash is saved to sessionStorage so shared layouts survive the roundtrip:
function startLogin(): void {
const state = crypto.randomUUID();
localStorage.setItem('CEPHABLE_AUTH_STATE_CHECK', state);
// Preserve the URL hash (e.g. a shared layout) across the redirect.
if (window.location.hash) {
sessionStorage.setItem('ccc_pending_hash', window.location.hash);
}
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
state,
});
window.location.assign(`${LOGIN_URL}?${params.toString()}`);
}
5. Handle the OAuth callback
On return, the URL contains ?code=&state=. Detect and exchange the code exactly once (React StrictMode runs effects twice in dev — the promise cache guards against a double exchange):
let callbackInFlight: Promise<{ ok: boolean; error?: string } | null> | null = null;
async function handleOAuthCallbackIfPresent() {
if (callbackInFlight) return callbackInFlight;
const url = new URL(window.location.href);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const errParam = url.searchParams.get('error');
if (!code && !state && !errParam) return null;
callbackInFlight = (async () => {
// Strip callback params from the address bar immediately.
url.searchParams.delete('code');
url.searchParams.delete('state');
url.searchParams.delete('error');
url.searchParams.delete('error_description');
window.history.replaceState(null, '', url.pathname + url.hash);
if (errParam) return { ok: false, error: errParam };
// Do NOT remove CEPHABLE_AUTH_STATE_CHECK here — the SDK reads it
// inside authenticateFromCode and clears it afterwards. Removing it
// early causes the SDK's own state check to fail.
const result = await authService.authenticateFromCode(code!, state!);
return result.resultType === 'Ok'
? { ok: true }
: { ok: false, error: result.errors?.join(', ') };
})();
return callbackInFlight;
}
Call this during your app's initialization, before rendering any authenticated UI.
6. List devices and fetch custom controls
Once authenticated, use CephableApi to discover what devices the user has and what controls are registered for each:
import { CephableApi } from '@cephable/cephable-web';
// Get the user's registered devices and filter to verified ones.
const devicesResult = await CephableApi.getUserDevices();
const devices = devicesResult.data?.filter(d => d.isVerified) ?? [];
// Fetch the custom controls for a selected device.
const controlsResult = await CephableApi.getDeviceCustomControls(
selectedDevice.userDeviceId,
'en-US',
);
const controls = controlsResult.data?.controls ?? [];
7. Start a command session
Before dispatching button presses, mint a command token for the selected device. Hold this token for the lifetime of the play session:
async function startPlaySession(userDeviceId: string): Promise<string | null> {
const result = await CephableApi.generateCommandToken(userDeviceId);
if (result.resultType === 'Unauthorized') {
// HTTP 403 — custom controllers are not enabled for this device.
console.error('Custom controllers not enabled for this device.');
return null;
}
if (result.resultType !== 'Ok' || !result.data) {
console.error('Failed to generate command token:', result.errors);
return null;
}
return result.data.token;
}
The SDK maps HTTP 403 → resultType: 'Unauthorized' and HTTP 401 → 'Unexpected'. Handle 'Unauthorized' separately — it means the device or Cephable connection doesn't have command access, not that the token expired.
8. Dispatch button presses
Each button on the canvas has a CephableBinding that describes what to send. Convert it to a (command, macro) pair and call sendDeviceCommand:
import { CephableApi } from '@cephable/cephable-web';
// Named custom control — the Cephable app executes the matching voice phrase.
async function sendCustomControl(
userDeviceId: string,
token: string,
phrase: string,
) {
await CephableApi.sendDeviceCommand(userDeviceId, phrase, token);
}
// Key press macro — sends physical key events to the paired computer.
async function sendKeyPress(
userDeviceId: string,
token: string,
keys: string[],
holdMs: number,
) {
await CephableApi.sendDeviceCommand(
userDeviceId,
`keypress:${keys.join('+')}`,
token,
{
name: 'Custom controller key press',
commands: [],
events: [{ eventType: 'KeyPress', keys, holdTimeMilliseconds: holdMs }],
},
);
}
// Typing macro — types text into the focused input on the paired computer.
async function sendTyped(
userDeviceId: string,
token: string,
text: string,
) {
await CephableApi.sendDeviceCommand(
userDeviceId,
'type',
token,
{
name: 'Custom controller typed input',
commands: [],
events: [{ eventType: 'Type', typedPhrase: text }],
},
);
}
The macro parameter accepts a MacroModel object. Setting commands: [] and populating events lets you drive arbitrary key/mouse sequences without needing a named voice command registered on the device.
Binding types
The sample defines five binding kinds that map buttons to Cephable actions:
| Kind | What it does | Payload |
|---|---|---|
custom-control |
Executes a named voice control phrase | command: phrase |
key-press |
Presses and holds keys for a duration | MacroModel with KeyPress event |
key-release |
Releases held keys | MacroModel with KeyRelease event |
type |
Types a string | MacroModel with Type event |
macro |
Multi-step sequence | MacroModel with multiple events |
URL-based layout sharing
The full controller layout is encoded in the URL hash using base64 + JSON, so layouts can be shared as a link with no server required. After an OAuth redirect strips the hash, the pending hash is restored from sessionStorage:
function restorePendingHash(): void {
const pending = sessionStorage.getItem('ccc_pending_hash');
if (!pending) return;
sessionStorage.removeItem('ccc_pending_hash');
window.history.replaceState(
null, '',
window.location.pathname + window.location.search + pending,
);
}
Call this after handleOAuthCallbackIfPresent() completes so the shared layout reappears without a page reload.
Adapting this for your own app
- Create a project at developers.cephable.com and copy your credentials into your environment file.
- Replace the full-page redirect OAuth with a popup or your framework's router if you prefer not to navigate away.
- If you only need to send named voice commands (no raw key events), skip the macro payload and pass just the
commandstring tosendDeviceCommand. - Add Electron deep-link handling if you move this to a desktop shell — see the Agent Dash sample for the pattern.
Next steps
- Agent Dash sample — Electron-based integration with AudioWorklet, CSP, prefix commands, and OAuth deep links
- SellLocal sample — simpler Electron integration covering guest mode and basic custom voice commands
- Web SDK Overview
- Web Quick Start