KPI app with a code function
This is the simplest complete build, and the one the other examples reuse. You end up with an app that shows two numbers computed by a function: a small, named piece of code that Studio runs for you and keeps a record of every time it runs.
What you will have at the end
An app called Test KPI app with two tiles:
- Today's orders, for example 1,284
- Failed orders, as a percentage, for example 3.4 %
Under the tiles: "as of 09:14 · Refresh". When someone opens the app, it shows the viewer's most recent result from the last six hours instead of computing again; Refresh computes fresh. Every computation is listed on Studio's Runs page.
The numbers come from a Python function called order_kpis, which reads one query (orders_by_day) that the app declares.
Why a function rather than a plain query in the app? For two tiles you would not need one. The point of this example is to learn the pattern, because the triage agent, the ML workflow and chat with functions all build on this function.
What the agent will ask you before it builds
A good agent does not start typing until it knows what you mean. Expect these questions, and answer them in plain words:
- What decision does this help with? ("I check every morning whether yesterday's failures need attention.")
- Who will open it? (You, or the whole operations team? This decides who the app is shared with later.)
- What counts as a failed order? Which status, which field. The agent will look at the model and propose a definition; you confirm it.
- Which time window is "today"? Today in which timezone? Most models are not up to the minute: the agent will read the model's data window (its
fromandtilldates) and tell you how fresh the data is. - Sample numbers. Before it writes any code, the agent runs the query and shows you something like: "For 20 September the model has 1,284 orders and 44 failed, which is 3.4 %. Does that match your dashboard?" Do not let it build on numbers you have not checked. If they do not match, work out why together (a different definition of failed, a different timezone, a data delay).
The agent should also tell you what it is about to create and which steps will need you (below), before it starts.
The pieces it creates
| Piece | Name | What it is |
|---|---|---|
| Function | order_kpis (kind: code) | Python that takes a day and returns {orders, failed_pct} |
| Declared query | orders_by_day | The semantic SQL that gives orders and failed orders for a day range; declared in the app, listed by the function |
| App | Test KPI app | Hand-built from the public template, one screen, two tiles |
| App import | order_kpis in bda.manifest.json | The pinned reference from the app to the function, fn:<tenant>/order_kpis@1 |
The agent works on your machine in a clone of the public template (github.com/BicycleAI/data-app-template) and talks to Studio through the MCP tools.
Steps, in order
Steps marked Only a person are refused to the agent by Studio; it must give you a link and wait. Steps marked Your call are ones the agent can do but should not do until you say yes.
1. Find the data (agent)
The agent lists the models (query_list_models), describes the retail orders model (query_describe_model, here m_retail_demo), searches its fields, and proves the query with query_run before anything else. It reads the model's data window and tells you how fresh "today" is.
The query it settles on looks like this (yours will use your model's names):
SELECT day, orders, failed_orders
FROM orders
WHERE day >= $from AND day <= $to
ORDER BY day
2. Write and test the function (agent)
The agent creates the function (function_create), writes function.json and main.py, and runs its tests (function_test) on the real executor with a fixture file, not live data.
{"schema": "bicycle.function/v1", "name": "order_kpis", "kind": "code",
"title": "Order KPIs for a day", "entrypoint": "main:handler", "image": "bda-python:3",
"mode": "async", "timeout_ms": 10000, "resources": {"class": "fn-xs"},
"input_schema": {"type": "object", "required": ["day"], "properties": {"day": {"type": "string"}}},
"output_schema": {"type": "object",
"properties": {"orders": {"type": "number"}, "failed_pct": {"type": "number"}}},
"capabilities": [{"id": "semantic.query", "queries": ["orders_by_day"]}],
"visibility": {"audience": "tenant",
"expose": {"apps": true, "workflows": true, "agents": false, "mcp": false}},
"tests": [{"name": "one day", "input": {"day": "2026-09-20"},
"fixtures": {"orders_by_day": "fixtures/day.json"}, "expect": {"orders": 120}}]}
# main.py
def handler(input, ctx):
res = ctx.query("orders_by_day", {"from": input["day"], "to": input["day"]})
rows = res.records()
orders = sum(r["orders"] for r in rows)
failed = sum(r["failed_orders"] for r in rows)
return {"orders": orders,
"failed_pct": round(100 * failed / orders, 2) if orders else 0}
Two things to notice, because the agent should say them out loud:
capabilitieslists everything the function may do. Here: run one named query. Nothing else is reachable.- The function names the query id but carries no SQL of its own. The SQL lives in the app that calls it (step 4). If the app forgets to declare
orders_by_day, the call fails withcapability_not_granted. This is the rule people most often miss.
function_test should report tested. If it does not, the agent fixes the code before going on.
3. Publish the function: Only a person
The agent calls function_publish. Because this is the function's first version, Studio answers with a sentence like "A person must confirm this in Studio before v1 ..." and a link to the function's page. That is not an error.
Only a person: open the link, read what the function may do (one query, no network, no model calls), and confirm. The agent waits, then reads the pinned reference fn:<tenant>/order_kpis@1. @1 is the version; the app pins it so a later change to the function cannot silently change the app.
4. Build the app (agent)
The agent:
- Registers the app (
dataapp_start(name, title, model)) and puts the returned app id inbda.manifest.json. - Declares the query in the manifest, with the same id the function lists:
{"queries": [{"id": "orders_by_day",
"sql": "SELECT day, orders, failed_orders FROM orders WHERE day >= $from AND day <= $to ORDER BY day",
"parameters": [{"name": "from", "type": "date"}, {"name": "to", "type": "date"}],
"columns": [{"name": "day", "type": "date"}, {"name": "orders", "type": "number"},
{"name": "failed_orders", "type": "number"}],
"maxLimit": 400}],
"functions": {"order_kpis": {"ref": "fn:<tenant>/order_kpis@1"}}}
- Fetches the app SDK files from Studio (
dataapp_sdk; the same files are atGET /api/data-apps/sdk) and putssrc/studio/fn.tsandsrc/studio/bda.tsnext to the template'ssrc/studio/types.ts. These give the appbda.fn, the only way an app may call a function. - Writes the screen. The important part:
import { bda } from './studio/bda.js'
// On page load: reuse this viewer's result from the last 6 hours, if there is one.
const inv = await bda.fn.call('order_kpis', { day: today }, { wait: false, reuse: '6h' })
showAsOf(inv.created_at) // "as of 09:14 · Refresh"
const out = bda.fn.outputOf(await bda.fn.watch(inv.invocation_id).done)
renderTiles(out.orders, out.failed_pct) // as text, never as HTML
// Refresh: always a new run.
refreshButton.onClick = async () => {
const fresh = await bda.fn.call('order_kpis', { day: today }, { wait: false, refresh: true })
...
}
Rules the agent must follow here, and you can check it did:
- The code names the local name
order_kpis, never thefn:reference. - The page-load call passes
reuse: '6h'(up to24h). Without it every visitor pays for a fresh run. - Refresh passes
refresh: true. - The button uses
onClick. The app runs in a sandboxed frame with no forms, so a form submit never fires. - Output is rendered as text.
- Runs
npm run build, zipsbda.manifest.json,dist/app.jsanddist/app.css, uploads (dataapp_upload_url, thendataapp_complete_upload). Studio compiles every declared query and boots the bundle; the version comes backvalidatedorinvalidwith per-query errors the agent fixes.
5. Publish the app: Only a person
The agent calls dataapp_publish. Because this version adds an import (the function), Studio asks a person to review it.
Only a person: open the link, see that the app imports order_kpis@1, and confirm. Any later version that adds or re-pins an import is reviewed the same way; a version that only changes the screen is not.
6. Share it: Your call
By default the app is yours. If the team should see it, ask the agent to share it (dataapp_share) or do it in Studio under the app's Share menu. Nothing in this example requires sharing.
How to test it
- Open the app from Apps in Studio. You should see the two tiles and "as of <time> · Refresh".
- Check the numbers against the sample the agent showed you in step 2 of the questions, and against your own dashboard.
- Reload the page. The time under the tiles should not change: the result was reused (no new run).
- Press Refresh. The time updates.
- Open Runs. You should see two runs of
order_kpis: one Done (reused) from the reload and one Done from Refresh, each started from the app, each with its own trace (input, output, how long it took). A code function usually finishes in under a second once warm; the first call of the day can take a few seconds while it starts.
If a tile shows an error instead of a number, the message is in words (for example query_not_allowed means the app did not declare a query the function needs). Give it to the agent; see Troubleshooting.
How to switch it off
- The app. In Studio, open the app and choose Unpublish. Viewers lose it; the versions stay so you can publish again.
- The function. Deleting a function is a disable: pinned callers keep working, nothing new can use it, and it moves to the Disabled tab (Enable restores it). Owners and admins do this in Studio on the function's page. The agent's tool for this is landing; until then it will tell you where the button is. Before you disable it, remember that examples 2, 4 and 6 depend on it.
What you learned
- A function declares everything it may do; the app that calls it declares the SQL for every query the function lists.
- The first publish of a function, and any app version that adds an import, is confirmed by a person.
- Page-load calls pass
reuse; Refresh passesrefresh: true; buttons useonClick. - Every run is on the Runs page with a trace.
Read more: the app manifest, a function's life from draft to disabled, using functions in apps, publishing and sharing.
Next: Triage button backed by an agent, which adds a page to this app.
Build me a Bicycle Studio data app on preview called "Test KPI app", on the retail orders model.
What I want: two tiles, today's orders and today's failed-order percentage, computed by a
Python code function named order_kpis, with "as of <time>" under the tiles and a Refresh button.
How to work with me:
- Before you build, ask me what counts as a failed order, which timezone "today" means, and who will
open the app. Read the model's data window and tell me how fresh the data is.
- Run the query first and show me one day's numbers so I can check them against my dashboard.
Do not build on numbers I have not confirmed.
- Tell me before each step that needs me (confirming the function's first publish, confirming
the app version that adds the import). Give me the link and wait; never work around it.
- The function lists the query id under semantic.query; the app must declare that query's SQL
in bda.manifest.json. Say this out loud and do it.
- Hand-build from the public template. Get src/studio/fn.ts and bda.ts from the dataapp_sdk tool.
The page-load call passes reuse: "6h"; Refresh passes refresh: true; use onClick, not a form;
render output as text.
- Only create things titled "Test ...". Never change an existing app.
- When done, tell me what to open, what I should see, and how to switch each piece off.