Building an AI Call Center: Dashboard
What the Dashboard Does
The telephony platform needed a control plane — somewhere to create agents, review call transcripts, claim phone numbers, and watch analytics. I built it as a TanStack Start app that talks to the API Lambda through API Gateway.
This is Part 3 of a three-part series. Part 1 covers the architecture, and Part 2 dives into the conversation engine.
Route Structure
The dashboard has nine routes across five sections:
| Route | Purpose |
|---|---|
/ | Overview with key metrics |
/agents | List all configured agents |
/agents/:agentId | Create or edit an agent |
/agents/:agentId/test | Chat interface for testing an agent |
/calls | Call history with filtering |
/calls/:contactId | Call detail with transcript and recording |
/phone-numbers | Claim, release, and reassign numbers |
/analytics | Outcome, sentiment, and usage charts |
/settings | Tenant config, webhooks, API keys |
Agent Management
The agent form is the most complex piece — around 60 fields across multiple sections. You configure the agent's name, system prompt, model, voice, tools, and guardrails.
The system prompt field has a character counter (10,000 char max), and the tools field accepts raw JSON with inline validation. Disclaimers are a dynamic list where you can add and remove items.
There's also a voice preview component that lets you hear how the agent will sound before deploying it. Pick a voice, pick an engine (standard or generative), and it plays a sample.
All validation runs through the same Zod schemas used on the backend:
const result = CreateAgentRequestSchema.safeParse(payload);
if (!result.success) {
const fieldErrors: FormErrors = {};
for (const issue of result.error.issues) {
fieldErrors[issue.path.join('.')] = issue.message;
}
setErrors(fieldErrors);
return;
}One schema, two runtimes. If the frontend validates it, the backend will accept it.
Call History and Transcripts
The calls page loads call history from the API with filters for date range, agent, outcome, and caller number. Each row shows the caller, agent, duration, outcome, and sentiment.
Clicking a row opens the call detail page with:
- Transcript viewer — scrollable conversation with color-coded speaker turns
- Recording playback — audio player with a presigned S3 URL
- Outcome and metadata — resolution status, duration, agent notes
The sentiment badge is a small shared component that maps sentiment values to colors
using Vanilla Extract styleVariants:
export const variant = styleVariants({
POSITIVE: { backgroundColor: themeContract.color.semantic.success.light },
NEGATIVE: { backgroundColor: themeContract.color.semantic.error.light },
NEUTRAL: { backgroundColor: themeContract.color.neutral['200'] },
MIXED: { backgroundColor: themeContract.color.semantic.warning.light },
});Shared Dialog Component
Several pages need confirmation dialogs — deleting an agent, releasing a phone number,
revoking an API key. Rather than duplicating the overlay/focus-trap/keyboard-handling
pattern five times, I extracted a shared Dialog component:
<Dialog
open={showDelete}
title="Delete Agent"
confirmLabel="Delete"
loading={deleting}
error={deleteError}
onClose={() => setShowDelete(false)}
onConfirm={handleDelete}
>
<p>This will permanently delete the agent and remove it from all phone numbers.</p>
</Dialog>It handles overlay click-to-close, Escape key, auto-focus, loading state on the confirm button, and error display. The calling component just manages the business logic.
Authentication
The dashboard uses Cognito with the OAuth2 PKCE flow. No client secret stored in the browser — just a code verifier and challenge:
- User clicks "Sign In" → redirect to Cognito hosted UI
- Cognito authenticates and redirects back with an authorization code
- The app exchanges the code + PKCE verifier for tokens
- Tokens are stored in
sessionStorage(cleared on tab close) - The API client injects the ID token as a Bearer header on every request
On 401, the client automatically attempts a single token refresh before redirecting to login. This handles the common case where a token expires mid-session.
Reducing Duplication
The dashboard started with a lot of copy-paste between routes. Over time I extracted the repeating patterns:
useFlashMessage— auto-clearing success messages (replaced 4setTimeoutpatterns)useFetch— generic data loading with loading/error state (used in analytics)formatDate,formatDuration— shared date/time formatting (was in 5 files)MODEL_LABELS— model ID to human name mapping (was in 2 files)
These are small extractions, but they compound. Each one removes a class of inconsistency.
The best refactoring isn't dramatic. It's noticing that three files format dates differently and fixing it in one place.
Deployment
The dashboard deploys through the DashboardStack using the SsrSite construct — the
same one that powers this portfolio. CloudFront serves static assets from S3 and proxies
everything else to a Lambda running the TanStack Start server.
The CDK app builds the frontend before synth:
{
"app": "pnpm -F @dawalnut/telephony build && pnpm tsx src/index.ts"
}Environment variables (API URL, Cognito config) are injected at deploy time as Lambda
environment variables, which Vite bakes into the client bundle via VITE_ prefixed vars.
Wrapping Up
Building the dashboard reinforced a few things:
- Shared schemas are powerful. The same Zod schemas validate on the frontend and backend. One source of truth, zero drift.
- Extract when it hurts. I didn't pre-plan the Dialog component or the formatting utils. I extracted them when the duplication became a maintenance burden.
- PKCE is the right auth flow for SPAs. No secrets in the browser, automatic refresh, and Cognito's hosted UI handles the heavy lifting.
The full source is on GitHub.