Build Kopai observability dashboards from OpenTelemetry metrics, logs, and traces by composing tiles — time-series charts, stat/KPI numbers, histograms, log timelines, and trace-detail views — into a dashboard via the Kopai SDK. Use this skill whenever the user wants to visualize, chart, monitor, or 'keep an eye on' telemetry — e.g. 'make a dashboard', 'monitoring view', 'KPI panel', 'chart request latency and throughput', 'an incident board with error logs and a trace viewer' — even if they don't say the word 'dashboard' — and when another workflow needs to present telemetry visually (e.g. after a root-cause analysis). This creates Kopai dashboards specifically. Do NOT use it to investigate or root-cause an issue (use root-cause-analysis), to add instrumentation (use otel-instrumentation), to build Grafana/Prometheus dashboards, or to code React/UI chart components. When the Kopai connector is attached, use create-live-page instead unless the user asks for a Kopai dashboard.
94%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
The canonical home for this skill is kopai/create-dashboard
Build a dashboard by writing a short TypeScript script: design a uiTree, feed each
data tile with a type-safe kq query, and create it via client.createDashboard(...).
The CLI stays as a fallback for one-off creates.
!npx @kopai/cli dashboards schema 2>/dev/null || echo "ERROR: Cannot connect to Kopai backend. If running locally, start it with: npx @kopai/app start — If using a remote backend, check the url in your .kopairc file."
!npx @kopai/cli metrics discover --json 2>/dev/null || echo "ERROR: Cannot connect to Kopai backend. If running locally, start it with: npx @kopai/app start — If using a remote backend, check the url in your .kopairc file."
Connect — clientFromConfig() reads .kopairc just like the CLI:
import { clientFromConfig } from "@kopai/sdk/node";
const client = clientFromConfig(); // ./.kopairc → ~/.kopairc → http://localhost:8000Run — npx tsx dashboard.mts. Use .mts so top-level await works regardless
of the project's module type. Requires @kopai/sdk installed (npm i @kopai/sdk).
Output — console.log(dash.id) so the new dashboard id lands on stdout. Wrap the
build + create in try/catch.
Typed results — discoverMetrics() and client.query(q) return fully typed values
(metric name/type/unit, aggregate measure values as number, etc.). When you
probe/verify data, iterate the results directly — don't cast to any/any[].
kq.metrics("<Type>").raw()…build(), not .aggregate().
The tile renderers (MetricStat/MetricTimeSeries/MetricHistogram/MetricTable, LogTimeline,
TraceDetail) plot raw rows; an aggregate/summary/timeSeries query is rejected
at render with "this panel displays raw metric rows … use a raw metric query". Choose the
MetricType up front via kq.metrics("Gauge") (auto-pinned), add a MetricName filter and
a time window on metric tiles.null for unused ones. The render-time schema
requires nullable props to be present, not omitted. Omitting them passes
createDashboard but fails at render with "invalid layout: …props.description:
expected string, received undefined". So a Card is { title, description: null, padding: null }, a MetricTimeSeries is { height, showBrush: null, yAxisLabel: null, unit }, etc..timeRelative("1h") / .timeAbsolute(startISO, endISO).MetricType up front via kq.metrics("Gauge") — it's auto-pinned, no manual .where("MetricType", …) needed. Use the type from discoverMetrics(). Types: Gauge | Sum | Histogram | ExponentialHistogram | Summary.uiTreeVersion (the CLI flag is --tree-version). Current tree version: 0.14.0.const { metrics } = await client.discoverMetrics(); Each entry
is { name, type, unit, description, attributes, resourceAttributes }. Pass the metric's
type to kq.metrics("<Type>"), and set the tile unit prop from unit.kq.metrics("<Type>").raw() (or kq.logs.raw() /
kq.traces.raw()) — returns a KopaiQuery object; hold it in a variable.uiTree — embed the built query into the tile:
dataSource: { method: "query", params: <builtQuery> }. Set every prop (null for unused).const dash = await client.createDashboard({
name: "<name>",
uiTreeVersion: "0.14.0",
uiTree,
metadata: {},
});
console.log(dash.id);KopaiValidationError/KopaiError, re-run discoverMetrics() to recheck names
and types, fix the tree, and retry.<baseUrl>/?tab=metrics&dashboardId=<dash.id>.// create-cpu-dashboard.mts — run: npx tsx create-cpu-dashboard.mts
import { kq } from "@kopai/sdk";
import { clientFromConfig } from "@kopai/sdk/node";
const client = clientFromConfig();
// Tile data: a RAW metric query (tiles render raw rows, not aggregates).
const cpuSeries = kq
.metrics("Gauge") // MetricType chosen up front, auto-pinned
.raw()
.where((f) => f.eq("MetricName", "system.cpu.utilization"))
.timeRelative("1h")
.limit(500)
.build();
const uiTree = {
root: "stack-1",
elements: {
"stack-1": {
key: "stack-1",
type: "Stack",
props: { direction: "vertical", gap: "md", align: null },
children: ["card-1"],
parentKey: "",
},
"card-1": {
key: "card-1",
type: "Card",
props: { title: "CPU Usage", description: null, padding: null },
children: ["ts-1"],
parentKey: "stack-1",
},
"ts-1": {
key: "ts-1",
type: "MetricTimeSeries",
props: { height: 300, showBrush: null, yAxisLabel: null, unit: "1" },
children: [],
parentKey: "card-1",
dataSource: { method: "query", params: cpuSeries },
},
},
};
const dash = await client.createDashboard({
name: "CPU Dashboard",
uiTreeVersion: "0.14.0",
uiTree,
metadata: {},
});
console.log(`Created dashboard ${dash.id}`);Layout (have children, no dataSource): Stack, Grid, Card. Static:
Heading, Text, Badge, Divider, Empty.
Data tiles — feed each with a raw kq query via method: "query" (legacy methods
still work, listed for fallback):
| Component | Best for | Metric types | dataSource methods | kq params example (RAW) |
|---|---|---|---|---|
| MetricStat | KPI number | Sum, Gauge | searchMetricsPage, searchAggregatedMetrics, query | kq.metrics("Sum").raw().where(f=>f.eq("MetricName",NAME)).timeRelative("1h").limit(500).build() |
| MetricTimeSeries | Trend chart | Sum, Gauge, Histogram | searchMetricsPage, query | kq.metrics("Gauge").raw().where(f=>f.eq("MetricName",NAME)).timeRelative("1h").limit(500).build() |
| MetricHistogram | Distribution | Histogram, ExponentialHistogram | searchMetricsPage, query | kq.metrics("Histogram").raw().where(f=>f.eq("MetricName",NAME)).timeRelative("1h").limit(500).build() |
| MetricTable | Tabular | any | searchMetricsPage, query | kq.metrics("Gauge").raw().where(f=>f.eq("MetricName",NAME)).timeRelative("1h").limit(500).build() |
| LogTimeline | Log stream | n/a | searchLogsPage, query | kq.logs.raw().where(f=>f.gte("SeverityNumber",17)).timeRelative("1h").limit(100).build() |
| TraceDetail | Trace inspector | n/a | searchTracesPage, searchTraceSummariesPage, query | kq.traces.raw().where(f=>f.eq("StatusCode","Error")).timeRelative("1h").limit(50).build() |
Sizing/props: set height: 300 on MetricTimeSeries/MetricHistogram, height: 600 on
LogTimeline (smaller collapses it to a count badge); MetricStat needs no height. Set
unit on chart tiles to the metric's raw OTEL unit ("By", "s", "ms", "1"). And
remember rule #2: every declared prop must be present (null for unused).
workflow - Dashboard creation workflow (tree structure, dataSource rules, layout, error handling)Read rules/<rule-name>.md for details.
The CLI takes the tree on stdin and uses --tree-version (the SDK field is uiTreeVersion).
It cannot build kq queries, so tiles use the legacy searchMetricsPage/searchLogsPage form:
echo '{"uiTree":{…},"metadata":{}}' | npx @kopai/cli dashboards create --name "<name>" --tree-version "0.14.0" --jsonkopai-app/kopai-mono
58498b4
Canonical home
since Mar 20, 2026
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.