Business KPIs

The overview already tells you how the operator is doing — runs, spend, grades, what it changed in itself. It cannot know how the business is doing: revenue, signups, views, reply time live in your database or behind your APIs. Declare the few numbers you open the dashboard for, each with a read function, and the overview shows them as its Business band — the value in its unit, the move against the period before, the line it travelled, and where it stands against its goal.

Declaring them

// kpis.ts — a module of its own, so `read` may use your typed env
import { defineKpi } from '@omnislash-ai/config/public';
import { db } from './db.ts';
import { env } from './env.ts';

export const kpis = [
  defineKpi({
    id: 'mrr',
    label: 'MRR',
    unit: 'usd',
    period: 'this month',
    goal: 50_000,
    description: 'Active subscriptions, from Stripe.',
    read: async ({ now, signal }) => {
      const res = await fetch(`https://api.stripe.com/...`, {
        headers: { authorization: `Bearer ${env.STRIPE_KEY}` },
        signal,
      });
      const { current, previous, daily } = await res.json();
      return { value: current, previous, series: daily };
    },
  }),
  defineKpi({
    id: 'reply-time',
    label: 'Reply time',
    unit: 'ms',
    direction: 'down',
    period: 'median, 7 days',
    goal: 30 * 60_000,
    read: async ({ now }) => {
      const { median, before } = await db.replyTimes(now);
      return { value: median, previous: before };
    },
  }),
];
// omnislash.config.ts
import { defineProject } from '@omnislash-ai/config/public';
import { kpis } from './kpis.ts';

export default defineProject({ name: 'acme', kpis });

The config file itself still never imports env (contract.md § 9 and the config/env boundary); a read is brain code carried by reference, like tools and ingest.sources, and may read env in its body. Changing the list is a config change — it ships with your next deploy.

The declaration

KeyWhat it does
idrequiredkebab-case, unique, ≤ 48 chars
labelrequiredwhat you call it, sentence case, ≤ 40 chars
unitrequiredhow the value prints: count (1.6M), usd ($18.4k), pct — percent units, 42 prints 42% and moves in points — or ms, a duration (24m)
directionupwhich way is better. down for churn, reply time, cost: a fall is then the good news, and the goal is a ceiling
period—the span the value covers, as you would say it (last 7 days, this month); printed beside the label; previous is read as the same span just before it
goal—a target in the value’s unit: a level to reach (up) or to stay under (down). The cell shows how far along it is, in words and as a bar
description—what the number is and where it comes from — shown when the cell is opened
timeoutMs8 000how long read may take (≤ 60 000); past it the read’s signal aborts and the cell says it timed out
readrequired(ctx) => Promise<reading> — below

At most six KPIs — the band is a glance, not a report. A seventh, a duplicate id or a malformed declaration fails the config load with the offending KPI named.

What read gets and returns

read({ now, timezone, signal }) → { value, previous?, series?, note? }
  • now — when the dashboard asked; look back from here, never from Date.now().
  • timezone — your locale.timezone, for “today” and “this week”.
  • signal — aborted at timeoutMs; hand it to fetch and your DB driver.
Field
valuethe number now, in the declared unit. null = nothing measured yet (no traffic, no sales) — the cell says so in words instead of showing 0
previousthe same number over the period before; omit or null for no comparison
series[{ at, value }], oldest → newest, at a Date or ISO string. Drawn as the sparkline (with a readout per point); the newest 90 points are kept
noteone short line under the number (17 of 21 slots, from Stripe), ≤ 120 chars

The answer is checked strictly: a misspelled key (prev), a non-finite number or a missing value is that KPI’s error, named — never a silently empty cell.

When a read fails

Each KPI is read on its own, in parallel, under its own deadline. A read that throws, times out or answers the wrong shape shows its error in its cell — the exception’s message, or the timeout — and every other KPI still reads. The band’s header counts the failures; opening the cell shows the full message and a Read again button.

The dashboard reads the list when the overview opens and every five minutes while it stays open, plus on demand from the band’s refresh button. Your agents read the same list through the kpi_list tool (capability runtime:read) — the same op the band calls, so an agent and the page see the same numbers. Every read calls your code, so keep read cheap — cache inside it if the source is expensive or rate-limited.

Where it shows

The overview (/), under the brief and above Self-evolving: one cell per KPI. Pressing a cell opens its detail in place — the description, the line at full size with a per-point readout, the exact figures (now, the period before, the change, the goal, the line’s range) and how long the read took. No KPI declared, no band.

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.