CtrlK
BlogDocsLog inGet started
Tessl Logo

build-dashboard

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.

64

Quality

76%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Fix and improve this skill with Tessl

tessl review fix ./runtime/harnesses/src/embedded-skills/build-dashboard/SKILL.md
SKILL.md
Quality
Evals
Security

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

  1. 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.
  2. 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.

#ShapePick when the data is…Template
1Queue / feedscheduled items, drafts, an action queue, an activity log, anything time-orderedreference/messaging-dashboard/ (full, ready to copy)
2Dense tableflat records (CRM contacts, log rows, ticket list) that the user scans like a spreadsheetReplace shape-1's messages-table.tsx with the <Table> primitive (see snippet below)
3Kanbanrows that move between named statuses; user drags between columnsReplace shape-1's main column with horizontal status columns (see snippet below)
4Detail / forma single resource the user edits or watches in depthReplace shape-1's main column with <Field> form (see snippet below)
5Calendar weekrows with start_time + duration that pin to a day-gridReplace 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)

/* App-local Tailwind compile entry.
 *
 * `@holaboss/ui/styles.css` only bakes in utilities used INSIDE the library.
 * Every Tailwind class your `src/client/` writes (max-w-3xl, grid-cols-*,
 * text-fg-48, bg-card, flex-1, etc.) needs an app-side compile pass to land
 * in the bundle. Without it the page renders mostly unstyled.
 *
 * Required by the register-time lint `workspace_app_missing_tailwind_compile`.
 */

@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"

// REPLACE this union with your resource's state machine.
type MyStatus = "draft" | "scheduled" | "sent" | "edited" | "failed"

// REPLACE this map: one entry per state with its label and dot variant.
// Use `success` for completed/healthy, `info` for in-flight/scheduled,
// `muted` for inert/draft, `warning` for soft problems, `destructive`
// for hard failures.
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:

FileChangeKeep
lib/sample-data.tsReplace 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.tsxPage 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.tsxColumn 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.tsxMAP lookup → your states.Component shape.
header-bar.tsx, connection-pill.tsx, __root.tsx, app.cssnothing.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[] }) {
  // Calendar is the base-ui primitive; for full week-view with custom
  // event rendering you'll need to compose it yourself. The skeleton:
  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 })

// MCP for the agent
await startMcpServer({
  port: Number(process.env.MCP_PORT),
  app, bridge,
})

// Dashboard for the user (iframe loads this URL)
// Use TanStack Start's production build output OR Vite's dev server.
// IMPORTANT: read from the SAME SqliteStateBackend the SDK uses; never
// spin up a second DB.
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.

CheckLint code (if fails)
src/client/ has ≥3 distinct named imports from @holaboss/uiworkspace_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/**/*.cssworkspace_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-foregroundtext-fg-80text-fg-64text-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:

  1. bun install in the app dir → exit 0
  2. bun run server.ts → "MCP server listening on :" AND dashboard responds on :$PORT
  3. 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)
  4. 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)
  5. 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.

Repository
holaboss-ai/holaOS
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.