Example: a KPI function and a hand-built app that shows it
Source: studio_guide("example-kpi-app") (GET /api/studio/v1/guide/example-kpi-app) rendered at origin/platform (1afd83f), synced 2026-09-28. Do not edit this page here; change the source and run yarn sync:studio.
Build this when a screen needs one or two headline numbers and you want the same logic callable from a workflow too. The function holds the logic; the app declares the query and draws two tiles.
Person-only stops: a person with publish rights on functions confirms the first publish of demo_kpis in Studio (the link function_publish returns). Until then the app can import the tested draft, but the app cannot be published.
Files
demo_kpis/function.json
{
"schema": "bicycle.function/v1",
"name": "demo_kpis",
"title": "Daily orders KPIs",
"kind": "code",
"entrypoint": "main:handler",
"image": "bda-python:3",
"mode": "sync",
"timeout_ms": 10000,
"resources": {"class": "fn-xs"},
"input_schema": {"type": "object", "required": ["from", "to"],
"properties": {"from": {"type": "string"}, "to": {"type": "string"}}},
"output_schema": {"type": "object", "properties": {
"day": {"type": "string"}, "orders": {"type": "number"},
"attempts": {"type": "number"}, "success_pct": {"type": "number"}}},
"capabilities": [{"id": "semantic.query", "queries": ["orders_by_day"]}],
"visibility": {"audience": "tenant", "expose": {"apps": true, "workflows": true, "agents": false, "mcp": false}},
"docs": {"summary": "Latest day's orders, attempts and success rate from orders_by_day."},
"tests": [{"name": "latest-day", "input": {"from": "2026-09-24", "to": "2026-09-27"},
"fixtures": {"semantic.query:orders_by_day": "fixtures/day.json"},
"expect": {"/day": "2026-09-26", "/orders": 900, "/success_pct": 90.0}}]
}
demo_kpis/main.py
def handler(input, ctx):
res = ctx.query("orders_by_day", {"from": input["from"], "to": input["to"]})
rows = res.records()
if not rows:
return {"day": "", "orders": 0, "attempts": 0, "success_pct": 0}
last = rows[-1]
orders, attempts = last["orders"] or 0, last["attempts"] or 0
pct = round(100.0 * orders / attempts, 1) if attempts else 0
return {"day": str(last["day"])[:10], "orders": orders, "attempts": attempts, "success_pct": pct}
demo_kpis/fixtures/day.json
{"columns": [{"name": "day", "type": "string"}, {"name": "orders", "type": "number"}, {"name": "attempts", "type": "number"}],
"rows": [["2026-09-25", 850, 1000], ["2026-09-26", 900, 1000]], "row_count": 2, "truncated": false}
template/bda.manifest.json
{
"appId": "app_...",
"entry": "app.js",
"styles": ["app.css"],
"title": "Daily orders",
"queries": [{
"id": "orders_by_day",
"sql": "SELECT date_trunc('day', timestamp) AS day, <orders_metric> AS orders, <attempts_metric> AS attempts FROM <model> WHERE timestamp >= :from AND timestamp < :to ORDER BY day",
"parameters": [{"name": "from", "type": "date", "required": true}, {"name": "to", "type": "date", "required": true}],
"columns": [{"name": "day", "type": "timestamp"}, {"name": "orders", "type": "number"}, {"name": "attempts", "type": "number"}],
"maxLimit": 400
}]
}
Add "functions": {"kpis": {"ref": "fn:<tenant>/demo_kpis@1"}} to import the tested draft (step 10).
template/src/App.tsx
import { Panel } from './components/Panel.js'
import { SkeletonMetric } from './components/Skeleton.js'
import { useAppQuery } from './studio/hooks.js'
const RANGE = { from: '2026-09-20', to: '2026-09-27' } // inside the model's data window
export function App() {
const q = useAppQuery('orders_by_day', { parameters: RANGE })
if (q.error) return <div className="bda-state bda-state--error">{q.error.message}</div>
const rows = q.data?.rows ?? []
const last = rows[rows.length - 1] as [string, number, number] | undefined
const pct = last && last[2] ? ((100 * last[1]) / last[2]).toFixed(1) : '0'
const asOf = new Date(q.dataUpdatedAt).toLocaleTimeString()
return (
<main>
<h1 className="bda-title">Daily orders</h1>
<p className="bda-subtle">
{last ? `Day ${String(last[0]).slice(0, 10)} · as of ${asOf} · ` : ''}
<button type="button" className="bda-pill" onClick={() => void q.refetch()}>Refresh</button>
</p>
<Panel as="section" card={false} id="kpis" title="Daily orders KPIs" kind="kpi"
queryId="orders_by_day" rows={rows} busy={q.isPending}>
{last === undefined ? (<><SkeletonMetric /><SkeletonMetric /></>) : (
<>
<Tile value={last[1].toLocaleString()} label="Orders" />
<Tile value={`${pct}%`} label="Success rate" />
</>
)}
</Panel>
</main>
)
}
function Tile({ value, label }: { value: string; label: string }) {
return <div className="bda-metric"><strong className="bda-metric__value">{value}</strong><span className="bda-metric__label">{label}</span></div>
}
Calls, in order
query_list_models(),query_describe_model("<model>"): pick a model with an orders metric; read itsfrom/till(the data ended a day before the run).query_search_fields("<model>", "orders"): the exact metric column name.query_run("<model>", <the manifest SQL>, {"from": "2026-09-24", "to": "2026-09-27"}): one row per day withordersandattempts.function_create("demo_kpis", "Daily orders KPIs"): the function exists, no version yet.function_put_files("demo_kpis", {"function.json": ..., "main.py": ..., "fixtures/day.json": ...}): one version,validated. Leave out"schema"and you get'schema' is a required property.function_test("demo_kpis", 1):latest-daypassed, output{"day": "2026-09-26", "orders": 900, "attempts": 1000, "success_pct": 90.0}.function_try("demo_kpis", {"from": ..., "to": ...}):capability_not_granted (403), "only an app runtime declares queries". A tenant function that queries cannot be tried on its own; the test is your proof.function_publish("demo_kpis", 1): "A person must confirm this in Studio", with the link/<tenant>/apps/functions/demo_kpis?publish=v1(new capabilitysemantic.query). Give it to the person and stop.dataapp_start("demo_kpi_app", "Daily orders", "<model>"):app_..., version 1 awaiting upload, with the two upload steps.- Build the manifest with the
kpisimport, thennpm run build, zip, POST and PUT the zip as the answer says, thendataapp_complete_upload(app_id, 1, sha256, bytes):validated. An unpublished app version may import a tested draft of a function you can edit.dataapp_describe(app_id)listskpisasdraft (tested), not visible to chat. dataapp_publish(app_id, 1):function_unpublished (409), "a published app needs published function versions", namingfn:<tenant>/demo_kpis@1. Nothing moves. Stop. After the person publishesdemo_kpis, publish the same version again; it pins that version number, so you do not upload again.
Switch it off
function_disable("demo_kpis", reason="example cleanup")
Leave the app as an unpublished draft.
Verified on preview 2026-09-28: query_list_models, query_describe_model, query_search_fields, query_run, function_create, function_put_files, function_test, function_try (refused as shown), function_publish (link for a person), dataapp_start, dataapp_new_version, dataapp_complete_upload (the draft import: validated), dataapp_publish (refused: function_unpublished 409), dataapp_describe, function_disable ran; the draft import ran on a later version made with dataapp_new_version; no invocation ids were issued (the test ran on fixtures and the try was refused before it started).
guide_version bb2461328de4