A minimal TypeScript dashboard inspired by Cindy
Welcome to another official UVF IT guide where we demystify cutting‑edge tools. In this build you’ll create a tiny AI‑agent dashboard that mimics the look‑and‑feel of Cindy, but runs entirely on your laptop with no paid services.
We’ll use Node 22, TypeScript, Express, and SQLite – all free on the developer tier – to spin up a local web UI that lists agents and lets you trigger a mock “run” action in under 45 minutes.
We start by creating a monorepo so the backend and frontend can live side‑by‑side while sharing tooling. pnpm makes workspace management painless and keeps node_modules tiny. The script below creates the folder structure, initializes each package, and adds the core dependencies we’ll need for a TypeScript‑based Express server and a React UI.
After running these commands you’ll have a backend folder with Express, TypeScript and SQLite support, and a frontend folder ready for Vite with TypeScript. All packages are linked via the pnpm workspace, so you can run scripts from the root.
mkdir -p tiny-agent-dashboard/backend/src
# Initialize pnpm workspace
pnpm init -y
# Add workspace config
jq '. + {"workspaces": ["backend", "frontend"]}' package.json > tmp.json && mv tmp.json package.json
# Backend package
pnpm init -y --workspace-root=false --dir backend
pnpm add -w typescript ts-node @types/node express @types/express better-sqlite3 @types/better-sqlite3
pnpm add -Dw @types/express @types/better-sqlite3
# Frontend with Vite (React + TS)
pnpm create vite@latest frontend -- --template react-ts
cd frontend && pnpm install && cd ..
# Root tsconfig referencing both packages
cat > tsconfig.json <<'EOF'
{
"files": [],
"references": [
{ "path": "./backend" },
{ "path": "./frontend" }
]
}
EOF
# Optional initial commit
git init && git add . && git commit -m "Scaffold monorepo"Now we give the backend a tiny SQLite store that holds AI‑agent records. Each agent has an id, a name, a short description, and a boolean enabled flag. The TypeScript type mirrors the table schema so the rest of the code can rely on static typing.
The helper functions below open (or create) the database, ensure the table exists, and expose simple CRUD operations. They are deliberately tiny – just enough to list agents and toggle the enabled flag for our mock dashboard.
backend/src/db.ts
import Database from 'better-sqlite3';
// SQLite file path (local)
const db = new Database('agents.db');
// Create agents table if it doesn't exist
db.exec(`
CREATE TABLE IF NOT EXISTS agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
enabled INTEGER NOT NULL DEFAULT 0
)
`);
// TypeScript representation of an agent
export type Agent = {
id: number;
name: string;
description?: string;
enabled: boolean;
};
const toAgent = (row: any): Agent => ({
id: row.id,
name: row.name,
description: row.description,
enabled: Boolean(row.enabled),
});
export const listAgents = (): Agent[] => {
const stmt = db.prepare('SELECT * FROM agents');
return stmt.all().map(toAgent);
};
export const createAgent = (name: string, description = ''): Agent => {
const stmt = db.prepare('INSERT INTO agents (name, description) VALUES (?, ?)');
const info = stmt.run(name, description);
return { id: Number(info.lastInsertRowid), name, description, enabled: false };
};
export const toggleAgent = (id: number, enabled: boolean): void => {
const stmt = db.prepare('UPDATE agents SET enabled = ? WHERE id = ?');
stmt.run(enabled ? 1 : 0, id);
};We add a tiny Express server that serves a hard‑coded list of agents at /agents and accepts a POST to /run/:id that simply returns a success message. The frontend is a Vite‑powered React app that fetches the list, renders each agent, and calls the run endpoint when the user clicks the button.
The API is deliberately minimal – no database, just an in‑memory array – so you can see the data flow end‑to‑end without any extra setup. The React page uses useEffect to load agents once, stores them in state, and maps them to simple cards with a Run button.
backend/src/server.ts
import express from 'express';
import cors from 'cors';
const app = express();
app.use(cors());
app.use(express.json());
// Mock agents – you can extend this later
const agents = [{ id: '1', name: 'Cindy‑Mock', description: 'Demo AI agent' }];
app.get('/agents', (_req, res) => {
res.json(agents);
});
app.post('/run/:id', (req, res) => {
const { id } = req.params;
const agent = agents.find(a => a.id === id);
if (!agent) return res.status(404).json({ error: 'Agent not found' });
// In a real app you would trigger the LLM here
res.json({ message: `run ${agent.name}` });
});
app.listen(3001, () => console.log('API on :3001'));frontend/src/main.tsx
import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
interface Agent { id: string; name: string; description: string; }
function Dashboard() {
const [agents, setAgents] = useState<Agent[]>([]);
useEffect(() => {
fetch('http://localhost:3001/agents')
.then(r => r.json())
.then(setAgents)
.catch(console.error);
}, []);
const runAgent = async (id: string) => {
const res = await fetch(`http://localhost:3001/run/${id}`, { method: 'POST' });
const data = await res.json();
alert(data.message);
};
return (
<div className="p-4">
<h1 className="text-2xl mb-4">AI Agent Dashboard</h1>
{agents.map(a => (
<div key={a.id} className="border p-2 mb-2 rounded">
<h2 className="font-bold">{a.name}</h2>
<p>{a.description}</p>
<button
className="mt-2 px-3 py-1 bg-blue-500 text-white rounded"
onClick={() => runAgent(a.id)}
>Run</button>
</div>
))}
</div>
);
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(<Dashboard />);First start the backend so the API is listening, then fire up the Vite dev server for the React UI. The two processes run independently; Vite proxies are not needed because we call the API directly on localhost:3001.
# In one terminal, start the backend
cd backend && npm install && npx ts-node src/server.ts &
# In another terminal, start the frontend
cd frontend && npm install && npm run devOpen http://localhost:5173 in your browser – you should see a card for "Cindy‑Mock" with a Run button. Clicking the button pops an alert confirming the POST succeeded. To add more agents, simply POST a JSON object to /agents (or edit the in‑memory array) and refresh the page.
# Example: add a new agent via curl (requires the server to accept POST – here we just demonstrate the call)
curl -X POST http://localhost:3001/agents \
-H 'Content-Type: application/json' \
-d '{"id":"2","name":"New‑Agent","description":"Another demo"}'| Check | Command / Why it matters |
|---|---|
| Backend server is listening on port 3001 | Look for console output "API on :3001" after running Phase 4 |
| Frontend UI loads at http://localhost:5173 | Browser shows a list with "Cindy‑Mock" |
| Clicking Run shows an alert "run" | Confirms the POST /run/:id endpoint responded |
Your tiny AI‑agent dashboard is up and running. Feel free to swap the stub service for a real LLM endpoint or add persistence features – the foundation is yours.