Skip to main content

Example: a KPI function and a hand-built app that shows it

Synced from bicycle-studio-api

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​

  1. query_list_models(), query_describe_model("<model>"): pick a model with an orders metric; read its from/till (the data ended a day before the run).
  2. query_search_fields("<model>", "orders"): the exact metric column name.
  3. query_run("<model>", <the manifest SQL>, {"from": "2026-09-24", "to": "2026-09-27"}): one row per day with orders and attempts.
  4. function_create("demo_kpis", "Daily orders KPIs"): the function exists, no version yet.
  5. 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.
  6. function_test("demo_kpis", 1): latest-day passed, output {"day": "2026-09-26", "orders": 900, "attempts": 1000, "success_pct": 90.0}.
  7. 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.
  8. function_publish("demo_kpis", 1): "A person must confirm this in Studio", with the link /<tenant>/apps/functions/demo_kpis?publish=v1 (new capability semantic.query). Give it to the person and stop.
  9. dataapp_start("demo_kpi_app", "Daily orders", "<model>"): app_..., version 1 awaiting upload, with the two upload steps.
  10. Build the manifest with the kpis import, then npm run build, zip, POST and PUT the zip as the answer says, then dataapp_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) lists kpis as draft (tested), not visible to chat.
  11. dataapp_publish(app_id, 1): function_unpublished (409), "a published app needs published function versions", naming fn:<tenant>/demo_kpis@1. Nothing moves. Stop. After the person publishes demo_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