| name | build-dashboard |
| description | Build the visual layer of a holaOS dashboard app — TanStack Start + @holaboss/ui + workspace tokens. Use when an app has SDK primitives wired (via app-builder-sdk) AND needs a `src/client/` UI surface. NOT for marketing pages, NOT for snapshot HTML reports. |
build-dashboard
The agent before you reliably produces ugly dashboards because it starts from a blank page and reaches for default shapes (single-column full-width cards, KPI strips that don't fit, sidebars that don't earn their space). This skill exists to bypass that default. The visual decisions are already made — your job is to fill in the data and copy.
When to use
- The user asked for a dashboard, workspace pane, list view, kanban, calendar, or "let me see my X"
- The app's
app.ts already declares resource(...) rows via the SDK (set up via the app-builder-sdk skill first)
- The app dir needs a
src/client/ directory
Skip this skill when:
- Integration-only module (Slack/Discord/Stripe-style MCP-only) — those use only
app-builder-sdk
- Marketing landing page → use
frontend-design
- One-off static HTML report → not this product class
The two non-negotiables
- Copy the bundled reference. Don't invent.
reference/messaging-dashboard/src/client/ is the canonical starting point. Three files below are verbatim across every dashboard; the rest gets customized per shape.
- Two style imports, not one.
@holaboss/ui/styles.css only bakes in utilities used inside the library. Every Tailwind class your src/client/ writes needs your own app-side compile pass. The register-time lint workspace_app_missing_tailwind_compile rejects apps missing this.
The shape catalog — pick one
Look at the user's data, NOT at "what dashboards usually have". Most apps are shape 1.
| # | Shape | Pick when the data is… | Template |
|---|
| 1 | Queue / feed | scheduled items, drafts, an action queue, an activity log, anything time-ordered | reference/messaging-dashboard/ (full, ready to copy) |
| 2 | Dense table | flat records (CRM contacts, log rows, ticket list) that the user scans like a spreadsheet | Replace shape-1's messages-table.tsx with the <Table> primitive (see snippet below) |
| 3 | Kanban | rows that move between named statuses; user drags between columns | Replace shape-1's main column with horizontal status columns (see snippet below) |
| 4 | Detail / form | a single resource the user edits or watches in depth | Replace shape-1's main column with <Field> form (see snippet below) |
| 5 | Calendar week | rows with start_time + duration that pin to a day-grid | Replace shape-1's main column with @holaboss/ui's Calendar primitive |
Shapes 2–5 still keep shape 1's header, app.css, connection pill, status badge, and tokens. Only the main content area changes.
ASCII for shape 1 (the most common, read this even if you're using another shape):
┌────────────────────────────────────────────┐
│ Outgoing ● ● Connected · @jot │ ← header
│ 5 queued · agent will send on schedule │
│ │
│ NEEDS ATTENTION │
│ ┌──────────────────────────────────┐ │ ← attention strip
│ │ #ops · Failed · 3h ago │ │ (warning-bordered,
│ │ Composio retry exhausted… │ │ always-visible Retry)
│ │ [Retry] [Edit] │ │
│ └──────────────────────────────────┘ │
│ │
│ TODAY ───────────────────────────── 02 │ ← day divider
│ NOW · 08:42 ───────────────────────── │ ← "now" cursor on rail
│ 09:00 ● #general · ● Scheduled │ ← next-up marker
│ Heads-up: pricing page goes live… │
│ 17:00 · #growth · ● Draft │
│ Weekly recap — KPI strip… │
│ │
│ TOMORROW ────────────────────────── 02 │
│ … │
└────────────────────────────────────────────┘
(max-w-3xl centered on bg-background)
Foundation — paste verbatim into every dashboard
These five files do NOT vary per shape. Copy them exactly.
src/client/app.css (13 lines)
@import "tailwindcss";
@source "../client";
src/client/routes/__root.tsx
import "@holaboss/ui/styles.css"
import "../app.css"
import type { ReactNode } from "react"
export function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" data-theme="holaos-light">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Your App — holaOS</title>
</head>
<body className="antialiased">{children}</body>
</html>
)
}
src/client/components/connection-pill.tsx
import { StatusDot } from "@holaboss/ui"
type Props = {
state: "ready" | "needs_connect" | "needs_reauth" | "checking"
handle?: string
}
const COPY: Record<Props["state"], { label: string; tone: "success" | "warning" | "muted" }> = {
ready: { label: "Connected", tone: "success" },
needs_connect: { label: "Not connected", tone: "warning" },
needs_reauth: { label: "Reauth required", tone: "warning" },
checking: { label: "Checking…", tone: "muted" },
}
export function ConnectionPill({ state, handle }: Props) {
const { label, tone } = COPY[state]
return (
<span className="inline-flex items-center gap-1.5 text-xs text-fg-64">
<StatusDot variant={tone} size="sm" />
<span className="text-fg-80">{label}</span>
{handle ? <span className="text-fg-48">· {handle}</span> : null}
</span>
)
}
Wire state from getIntegrationStatus() — ready === true → "ready", code === "integration_not_connected" → "needs_connect", code === "integration_needs_reauth" → "needs_reauth". See app-builder-sdk skill for the helper.
src/client/components/header-bar.tsx
import { Button, StatusDot } from "@holaboss/ui"
import { Plus } from "lucide-react"
import type { ReactNode } from "react"
type Props = {
title: string
subtitle?: string
rightSlot?: ReactNode
onCompose?: () => void
}
export function HeaderBar({ title, subtitle, rightSlot, onCompose }: Props) {
return (
<header className="px-10 pt-12 pb-8">
<div className="flex items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<h1
className="font-serif text-[22px] leading-none text-foreground"
style={{ fontFamily: "'Source Serif 4', serif", fontWeight: 500 }}
>
{title}
</h1>
<StatusDot variant="success" size="sm" pulse />
</div>
{rightSlot}
<Button
variant="ghost"
size="sm"
onClick={onCompose}
className="h-7 gap-1.5 px-2 text-xs text-fg-64 hover:text-foreground"
>
<Plus className="size-3" />
Add draft
</Button>
</div>
{subtitle ? (
<p className="mt-2 truncate text-xs text-fg-48">{subtitle}</p>
) : null}
</header>
)
}
src/client/components/status-badge.tsx
import { StatusDot } from "@holaboss/ui"
type MyStatus = "draft" | "scheduled" | "sent" | "edited" | "failed"
const MAP: Record<MyStatus, { label: string; dot: "success" | "warning" | "destructive" | "muted" | "info" }> = {
draft: { label: "Draft", dot: "muted" },
scheduled: { label: "Scheduled", dot: "info" },
sent: { label: "Sent", dot: "success" },
edited: { label: "Edited", dot: "info" },
failed: { label: "Failed", dot: "destructive" },
}
export function StatusBadge({ status }: { status: MyStatus }) {
const { label, dot } = MAP[status]
return (
<span className="inline-flex items-center gap-1 text-[11px] text-fg-64">
<StatusDot variant={dot} size="sm" />
{label}
</span>
)
}
Shape 1: queue / feed (canonical, fully bundled)
The full implementation is bundled at reference/messaging-dashboard/src/client/ next to this skill. Two files vary per app — read them directly from the bundled reference:
routes/index.tsx — page composition (~100 lines). Sets up the 3-region layout: header → attention strip → grouped sections. Read this whole file before copying.
components/messages-table.tsx — the row + rail + attention list (~150 lines). The hardest file; spent the most iteration. Read this whole file before copying.
lib/sample-data.ts — mock data with the MessageRow shape (channel / text / status / bucket / timeLabel / authorHandle / errorReason). Replace this file entirely with TanStack Start server functions that read from your app.resource() rows.
What to swap when copying shape 1:
| File | Change | Keep |
|---|
lib/sample-data.ts | Replace entirely with server functions; rename to data.ts. | The row shape — your data should map to the same field set, or the table needs JSX changes too. |
routes/index.tsx | Page title ("Outgoing"), subtitle, nowLabel (real current time), day-divider labels. | The 3-region structure (header → attention → grouped sections), max-w-3xl, spatial sketch comment, useMemo grouping. |
messages-table.tsx | Column meta layout (#{channel} · author · time), body field, error-reason placement. | 3-col grid (64px_16px_1fr_auto), rail (bg-fg-32), marker treatment, attention strip styling. |
status-badge.tsx | MAP lookup → your states. | Component shape. |
header-bar.tsx, connection-pill.tsx, __root.tsx, app.css | nothing. | everything. |
Shape 2: dense table (CRM / log / ticket list)
When the data is naturally rows-and-columns and the user scans like a spreadsheet, replace shape-1's messages-table.tsx with the <Table> primitive. Header + connection pill + app.css setup stay.
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Badge } from "@holaboss/ui"
import { StatusBadge } from "./status-badge"
export function RecordsTable({ rows }: { rows: MyRow[] }) {
return (
<Table className="text-[13px]">
<TableHeader>
<TableRow className="text-fg-48">
<TableHead className="w-[180px] pl-6">Name</TableHead>
<TableHead>Email</TableHead>
<TableHead className="w-[140px]">Owner</TableHead>
<TableHead className="w-[120px]">Status</TableHead>
<TableHead className="w-[140px] text-right pr-6">Last touch</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id} className="hover:bg-muted/40">
<TableCell className="pl-6 text-foreground">{row.name}</TableCell>
<TableCell className="text-fg-64">{row.email}</TableCell>
<TableCell className="text-fg-64">{row.owner}</TableCell>
<TableCell><StatusBadge status={row.status} /></TableCell>
<TableCell className="text-right text-fg-64 tabular-nums pr-6">{row.lastTouchLabel}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}
Layout shape: drop the time-rail; keep max-w-3xl (or bump to max-w-5xl if 5+ columns). Day-divider sections from shape 1 become optional — usually one flat table is fine.
Shape 3: kanban (status board)
When rows move between named statuses and the user drags between them. Replace shape-1's main column with horizontally-arranged status columns. Header + connection pill + app.css stay.
import { Card } from "@holaboss/ui"
import { StatusBadge } from "./status-badge"
const COLUMNS = ["draft", "scheduled", "sent", "failed"] as const
export function KanbanBoard({ rows }: { rows: MyRow[] }) {
const byStatus = COLUMNS.map((status) => ({
status,
rows: rows.filter((r) => r.status === status),
}))
return (
<div className="grid grid-cols-4 gap-3 px-10 pb-12">
{byStatus.map(({ status, rows }) => (
<div key={status} className="flex flex-col gap-2">
<div className="flex items-baseline justify-between px-1">
<span className="text-[10px] tracking-wider text-fg-48 uppercase">{status}</span>
<span className="font-mono text-[10px] text-fg-32 tabular-nums">
{rows.length.toString().padStart(2, "0")}
</span>
</div>
<div className="flex flex-col gap-2">
{rows.map((row) => (
<Card key={row.id} size="sm" className="cursor-pointer hover:bg-muted/40">
<div className="px-3 py-2">
<div className="text-[11px] text-fg-48">#{row.channel}</div>
<p className="mt-1 line-clamp-3 text-sm leading-snug text-fg-80">{row.text}</p>
<div className="mt-2">
<StatusBadge status={row.status} />
</div>
</div>
</Card>
))}
</div>
</div>
))}
</div>
)
}
Layout shape: change max-w-3xl on the outer container to max-w-6xl for breathing room. Drop the attention strip (failed rows surface naturally in the "failed" column).
Shape 4: single-resource detail / form
For workflows where the user edits one resource at a time (settings, single-record CRM contact, single bookmark editor). Replace shape-1's main column with a <Field>-based form. Header + connection pill + app.css stay.
import { Button, Field, FieldDescription, FieldGroup, FieldLabel, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea } from "@holaboss/ui"
export function RecordForm({ record, onSave }: { record: MyRow; onSave: (r: MyRow) => void }) {
return (
<form className="mx-auto flex max-w-2xl flex-col gap-6 px-10 pb-12">
<FieldGroup>
<Field>
<FieldLabel>Title</FieldLabel>
<Input defaultValue={record.title} />
</Field>
<Field>
<FieldLabel>Owner</FieldLabel>
<Select defaultValue={record.owner}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="alice">Alice</SelectItem>
<SelectItem value="bob">Bob</SelectItem>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel>Notes</FieldLabel>
<Textarea rows={6} defaultValue={record.notes} />
<FieldDescription>Markdown is supported.</FieldDescription>
</Field>
</FieldGroup>
<div className="flex justify-end gap-2">
<Button variant="ghost" type="button">Cancel</Button>
<Button type="submit">Save</Button>
</div>
</form>
)
}
Layout shape: tighter column (max-w-2xl). No attention strip; surface validation errors inline via FieldError.
Shape 5: calendar week
When rows have a real start_time + duration that pin to a day-grid. Use @holaboss/ui's Calendar primitive. This shape is intentionally less battle-tested — extend the base only when calendar truly fits.
import { Calendar } from "@holaboss/ui"
export function WeekCalendar({ rows }: { rows: MyRow[] }) {
return (
<div className="px-10 pb-12">
<Calendar mode="single" />
{/* Layer events on top via absolute-positioned cards keyed by date. */}
</div>
)
}
Wiring the rest of the app
Everything below is the same as the app-builder-sdk skill describes for any app — repeat here only for the dashboard-specific gotchas.
Required deps in package.json
{
"dependencies": {
"@holaboss/app-builder-sdk": "latest",
"@holaboss/ui": "latest",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"lucide-react": "^0.542.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@vitejs/plugin-react": "^5.0.0",
"tailwindcss": "^4.2.1",
"vite": "^6.3.0"
}
}
Use "latest" literally for the two @holaboss/* packages — pre-1.0 caret semver drifts.
Required vite.config.ts
import tailwind from "@tailwindcss/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
plugins: [react(), tailwind()],
})
Without @tailwindcss/vite, the @import "tailwindcss" in app.css is a no-op and your custom utilities don't compile.
server.ts boots BOTH MCP and the dashboard
import { startMcpServer, SqliteStateBackend, createRuntimeBrokerTransport } from "@holaboss/app-builder-sdk"
import { buildMyApp } from "./app.ts"
const state = new SqliteStateBackend({ path: process.env.WORKSPACE_DB_PATH! })
const bridge = createRuntimeBrokerTransport({ provider: "<provider>" })
const app = buildMyApp({ state, bridge })
await startMcpServer({
port: Number(process.env.MCP_PORT),
app, bridge,
})
import { build } from "./client/build"
Bun.serve({ port: Number(process.env.PORT), fetch: build.fetch })
The desktop's AppSurfacePane iframe resolves to process.env.PORT — whatever you serve there is what the user sees.
Required setup checklist (lint-enforced)
The register-time lint rejects dashboard apps that fail any of these. Run through the list before declaring done.
| Check | Lint code (if fails) |
|---|
src/client/ has ≥3 distinct named imports from @holaboss/ui | workspace_app_holaboss_ui_named_imports_too_few |
At least one .css file under src/client/ contains @import "tailwindcss" | workspace_app_missing_tailwind_compile |
No hex / rgb() / hsl() / oklch() literals in src/client/**/*.css | workspace_app_parallel_design_system |
No custom --<token>: definitions in CSS (passthroughs like --mine: var(--background) allowed) | workspace_app_parallel_design_system |
If any lint fires, the runtime returns the file + line + suggested fix. Don't try to bypass — read the message and fix the root cause.
What you may NOT do (read once, internalize)
These are hard rules. The lint catches some; the rest are caught by review (or by the user noticing the dashboard looks alien).
- No
font-bold / font-semibold / font-extrabold / inline style={{ fontWeight: ... }}. Design system clamps all of those to 500. Hierarchy comes from size (text-2xl for hero numbers, text-base for headings, text-xs for labels) and color (text-foreground → text-fg-80 → text-fg-64 → text-fg-48).
- No hex /
rgb() / oklch() literals anywhere. Lint rejects in CSS; review catches in JSX. Use tokens: bg-background, bg-card, bg-muted, text-foreground, text-fg-{12,16,32,48,64,80,92}, border, border-warning, bg-warning/[0.06], text-primary.
- No second component library. No MUI, Ant, Chakra, raw Radix, Headless UI, react-aria. The
@holaboss/ui package wraps base-ui's shadcn-flavored primitives; that is the only allowed source.
- No
components/ui/ directory (shadcn-add copy). Import primitives from @holaboss/ui only.
- No
bg-gradient-* on cards, no hover:shadow-*, no hover:-translate-y-* lift effects. Subtle hover via hover:bg-muted/40 only.
- No per-app theme toggle. Theme is workspace-level; the app inherits via CSS variables.
- No custom CSS files beyond
app.css (and app.css must contain only @import "tailwindcss" + @source, possibly empty @layer blocks).
- No KPI strip "because dashboards usually have them". Add stat cards only when the user genuinely tracks ≥3 comparable numbers that need to sit side-by-side. The single count of "5 queued" goes in the subtitle, not in a card.
- No sidebar "because dashboards usually have them". Add one only when the user has a genuine second navigation axis that must stay on screen (multi-channel chat, multi-project switcher).
- No
Promise.all rendering gate. Each card / table / chart renders the moment its own data lands, with Skeleton during fetch and EmptyState if empty. A 0.5s skeleton beats a 4s blank page.
What you SHOULD do
- Read
reference/messaging-dashboard/ end-to-end (it lives next to this skill — 4 component files, 1 lib file, 1 routes file). Even if your shape isn't queue/feed, the patterns transfer.
- Spend 30 seconds on a one-line spatial sketch BEFORE writing JSX. Single sentence: "Header strip + grouped sections of rows" or "Header + 4-column kanban". If you can't say it in one sentence, pick from the shape catalog above.
- Replace
lib/sample-data.ts with TanStack Start server functions that read from the SDK's SqliteStateBackend (the table app.resource() declared). Never spin up a second DB. Never call MCP tools from the dashboard.
- Wire
ConnectionPill to getIntegrationStatus() (helper from @holaboss/app-builder-sdk). The four state values map directly to readiness codes.
- Test in both light AND dark. Workspace theme can be either; the dashboard inherits via
[data-theme]. Set data-theme="holaos-dark" on <html> to verify dark renders correctly.
Examples
User: "Build a GitHub work tracker dashboard"
→ Shape 1 (queue / feed). Rows = issues + PRs, grouped by today / this week / older.
Status mapping: open → info, in_progress → success, closed → muted, failed → destructive.
Replace `sample-data.ts` with a server function reading from your `app.resource("issue")` rows.
User: "Make me a Notion page tracker"
→ Shape 2 (dense table). One row per page, columns: title / database / author / last-edited / status.
No attention strip; just one flat sortable table.
User: "I want to see my Linear issues organized by status"
→ Shape 3 (kanban). Columns: backlog / todo / in-progress / done / canceled. Cards have title + assignee + priority.
User: "I want to manage my Mailchimp campaigns"
→ Shape 1 (queue / feed) grouped by send time — campaigns are time-ordered drafts → scheduled → sent, exactly the messaging metaphor.
User: "I want a calendar of my upcoming gcal events"
→ Shape 5 (calendar week). Compose from `Calendar` primitive; this is the least-tested shape, expect to iterate.
When you're done
Run through this list — if any item is uncertain, fix before declaring done:
bun install in the app dir → exit 0
bun run server.ts → "MCP server listening on :" AND dashboard responds on :$PORT
curl http://localhost:<PORT>/ returns a TanStack Start HTML response, NOT the SDK's "headless module" placeholder (search for headless module in the response body)
- Open the dashboard in the desktop iframe. Visually:
- Fonts are Inter + Source Serif 4 (NOT Times / system-ui)
- Connection pill shows correct state (Connected / Reauth / Not connected)
- Empty state renders when filtered to a status with no rows
- Hover affordances on rows (subtle bg shift, no shadow lift)
- Light mode AND dark mode both look intentional (set
data-theme="holaos-dark" on <html> to test)
- The dashboard looks like the rest of the workspace (same fonts, borders, radii, card surface color). If it looks alien, you've broken a token import, imported from outside
@holaboss/ui, or redefined a primitive.
If all 5 pass, you're done. Do not invoke interface-design. The build pass should already produce shipping-quality output via this skill + the bundled reference + the register-time lints.
If you later receive an [Auto-queued post-build polish pass] input for this app (the runtime queues one when a binding completes and unblocks pendingIntegrations), re-enter this skill — not interface-design — and re-evaluate src/client/ against the bundled reference now that real data is wired in. That second pass is the safety net for cases the build-time agent finished before the integrations were connected, not a license to add chrome that this skill didn't already endorse.