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
| Key | What it does | |
|---|---|---|
id | required | kebab-case, unique, ≤ 48 chars |
label | required | what you call it, sentence case, ≤ 40 chars |
unit | required | how the value prints: count (1.6M), usd ($18.4k), pct — percent units, 42 prints 42% and moves in points — or ms, a duration (24m) |
direction | up | which 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 |
timeoutMs | 8 000 | how long read may take (≤ 60 000); past it the read’s signal aborts and the cell says it timed out |
matures | — | days after a start before the number means anything — a cohort’s churn, 30-day retention. A bet’s checkpoint may not read it earlier than its since plus this (bets.md) |
read | required | (ctx) => Promise<reading> — below |
Declare as many as you track. The overview’s band shows the first six in
declaration order — the band is a glance, not a report; every KPI past them is
still read by kpi_list and recorded in the history below. 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 fromDate.now().timezone— yourlocale.timezone, for “today” and “this week”.signal— aborted attimeoutMs; hand it tofetchand your DB driver.
| Field | |
|---|---|
value | the number now, in the declared unit. null = nothing measured yet (no traffic, no sales) — the cell says so in words instead of showing 0 |
previous | the 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 |
note | one 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 (the band asks for its six with band: true; an agent gets every KPI), 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.
The history
Once a day the engine reads every KPI and keeps the answer: one reading per
KPI per day in its KPI history (omnislash.kpi_readings), kept with no
expiry — a season needs a year of it. The sample is the daily bets pass
(MANIFEST bets.schedule, default 0 6 * * * in your timezone,
bets.md); it runs whether or not the brain holds any bet, so the
history starts filling the day the engine ships it.
- A reading is
{ kpi_id, at, value, note, def_hash }. A read that fails, times out or answersnullis still recorded —valuenull, the reason or yournoteinnote— because a run of those is how a broken source shows (a bet reads it as stale). def_hashfingerprints the definition that produced the reading: thereadsource and theperiod. A changedreadmakes a new hash, so a series crossing the change shows the seam, and a bet pinned to the old definition notices. A formatting-only edit ofreadchanges it too.- Read it with
db_query(select … from omnislash.kpi_readings where kpi_id = 'mrr' order by at).
Shaping a KPI a bet can judge
A bet’s checkpoint reads the newest sample at or before its date, and judges that one number. So a KPI a bet reads should be:
- a rolling or closed window —
revenue-30d,replies-28d, “last 7 days”. A month-to-date number on the 5th is five days of data judged as a month, and it falls to zero every 1st; the engine cannot see what yourreadcomputes, so this rule is yours; matureswhen it is slow — a churn or retention figure that means nothing until a cohort is old enough;- read from a rail for money and other final outcomes — Stripe, a partner
API, a bank feed — not from tables your own agents write; say the source in
description. A number agents can write rows into, agents can move.
Where it shows
The overview (/), under the brief and above Self-evolving: one cell per
KPI, the first six declared. 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.