City search with debounce, an HTTP request, a results list and a forecast screen — all described in one JSON file. The same schema runs on iOS and Android, with no Kotlin or Swift screen code. Here's how the data flows, and where the demo needs work.
Tested on BDUI Go 1.4.0 (Android and iOS).
Type a city name, pause, pick a result — the forecast screen loads API data. Both screens and their actions are defined in one JSON file.
I built BDUI Go, the runner used here. This is a walkthrough of its bundled Weather demo, not a production-ready weather app. I'll trace the schema from input to forecast, then separate shortcuts in the demo from limitations of the runner.
What you're looking at
BDUI Go is a runner — think Expo Go or a Figma prototype player, but for server-driven UI. It's a ready-made Compose Multiplatform app that interprets a JSON schema and renders it natively on both Android and iOS. It's not an app exporter and not an SDK you embed: your screens stay JSON, the runner executes them.
The weather app below is one of the demos bundled into the gallery inside BDUI Go, so you can open the exact thing being dissected on your own phone right now — no account, no QR, no signup.
The shape of the file
The whole app is a single weather.json (~37 KB, most of it a color theme and seven copy-pasted forecast cards — more on that later). The excerpts below are abbreviated for reading: comments and ellipses mark omissions, so use the linked file for runnable JSON. Skeleton:
weather.json — top level
{
"schemaVersion": 1,
"env": {
"allowedHosts": "geocoding-api.open-meteo.com,api.open-meteo.com",
"weatherIcons": "0=☀️,1-2=🌤,3=☁️,45-48=🌫,…,*=🌡️"
},
"theme": { /* colors, typography, shapes — omitted */ },
"startScreen": "screen_weather_search",
"screens": [ /* screen_weather_search, screen_weather_forecast */ ]
}
Three things worth noting. The non-empty allowedHosts list restricts this demo's request actions to the two declared hosts. It is a request allow-list, not a complete sandbox: an empty list disables this check in this runner version. env.weatherIcons maps numeric weather codes to emoji via a | map: filter. And both screens share the app-level env, but each has its own form state. Navigation passes values between them explicitly.
Typing → request → list
The first screen is a search field above a results list. The field is the whole trigger mechanism:
screens[0] — the search field (styles omitted)
{
"type": "search_field",
"apiFieldName": "citySearch",
"debounceMs": 500,
"onChange": {
"type": "request",
"method": "GET",
"url": "https://geocoding-api.open-meteo.com/v1/search?name={form.citySearch}&count=5&language=en",
"bindTo": "form",
"onError": { "type": "show_snackbar", "message": "Geocoding failed…" }
}
}
Every keystroke writes into form.citySearch; after 500 ms of quiet the onChange action fires an HTTP request. The URL interpolates the form value directly. bindTo: "form" merges the response's top-level fields into this screen's form. Values are stored as strings; arrays and objects are serialized as JSON, which list parses when it reads form.results. Missing response keys do not clear existing state — an important edge case we'll return to. A failed request just shows a snackbar.
The list underneath is bound to that same namespace:
screens[0] — results list with navigation (styles omitted)
{
"type": "list",
"items": "{form.results}",
"itemKey": "id",
"loadingTemplate": { /* spinner + "Searching cities…" */ },
"emptyTemplate": { /* "No cities found. Try a different search." */ },
"itemTemplate": {
"type": "card",
"content": [
{ "type": "text", "text": "{item.name}, {item.country}" },
{ "type": "button", "text": "View forecast",
"action": {
"type": "navigate",
"target": "screen_weather_forecast",
"params": {
"lat": "{item.latitude}",
"lon": "{item.longitude}",
"cityName": "{item.name}"
} } }
]
}
}
While a request is in flight the list renders its loadingTemplate; when {form.results} resolves to an empty (or missing) array it falls back to emptyTemplate; otherwise each element becomes {item.*} inside the card template. The button's navigate action pushes the second screen and passes the tapped item's fields as route params.
Tap → second screen
The forecast screen declares its params, pre-seeds placeholder state, and fires its own request on load:
screens[1] — params, state and onLoad
{
"id": "screen_weather_forecast",
"params": ["lat", "lon", "cityName"],
"state": { "currentTemp": "…", "day0Max": "…", /* 31 keys total */ },
"onLoad": {
"type": "request",
"method": "GET",
"url": "https://api.open-meteo.com/v1/forecast?latitude={route.lat}&longitude={route.lon}¤t=…&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=auto",
"onSuccess": {
"type": "set_state",
"values": {
"currentTemp": "{response.current.temperature_2m}",
"day0Max": "{response.daily.temperature_2m_max.0}",
"day0Code": "{response.daily.weather_code.0}"
// …days 1–6, same pattern
}
}
}
}
Two namespaces do the work here: {route.*} reads what navigate passed (the title bar is just "{route.cityName}"), and {response.*} reads the response passed into this request's callback chain, including array indices like daily.temperature_2m_max.0. It is not a global "last response" variable. Unlike the first screen, this one uses an explicit set_state to copy selected values into its own form state, where the UI can read them.
Rendering is ordinary templating, plus the lookup-table filter:
screens[1] — current weather card, and one of seven day cards
{ "type": "text", "text": "{form.currentTemp}°C" }
{ "type": "text", "text": "Wind {form.currentWind} km/h · {form.currentCode | map:weatherIcons}" }
{ "type": "card", "id": "day0_card", "content": [
{ "type": "text", "text": "{form.day0Date}" },
{ "type": "text", "text": "High {form.day0Max}°C" },
{ "type": "text", "text": "Low {form.day0Min}°C" },
{ "type": "text", "text": "{form.day0Code | map:weatherIcons}" }
] }
// day1_card … day6_card: the same card, six more times
{form.currentCode | map:weatherIcons} pipes the numeric code through the env.weatherIcons range map (61-67=🌧, 95-99=⛈️…) — so 61 renders as 🌧 with no conditional logic at all. Back navigation is a plain {"type": "pop"}.
form → request → response → form → list → item → route → request → response → form
Search-screen state → API data → selected city → forecast-screen state. The two form states are separate; route carries the selected city's parameters.
What this demo gets wrong
This is the part most demo posts skip. The schema works, but several of its shortcuts are worth calling out — each is a real limitation of either this schema or the runner today.
Demo shortcut — raw input in the URL
…/search?name={form.citySearch}&count=5 interpolates input before the URL is parsed. An & can start another query parameter; a # starts a fragment, so the remainder is not sent to the API. A literal + can be decoded as a space. Use the request's query map instead: the HTTP client encodes each value as data, not URL syntax.
Suggested replacement for onChange — not part of the bundled demo
{
"type": "request",
"method": "GET",
"url": "https://geocoding-api.open-meteo.com/v1/search",
"query": {
"name": "{form.citySearch}",
"count": "5",
"language": "en",
"format": "json"
},
"bindTo": "form",
"onError": {
"type": "show_snackbar",
"message": "Geocoding failed. Please check your internet connection."
}
}
Demo shortcut — merging responses can leave stale results
bindTo: "form" copies top-level fields, including generationtime_ms, but does not remove keys absent from the next response. Open-Meteo can omit results when there are no matches. After a successful search, a no-match or cleared query can therefore leave the previous cities on screen instead of showing the empty template. The query change above fixes encoding, not this state bug.
To check it, search for Berlin, wait for results, then try zzzzzzzzzzzzzz or clear the field. A safer implementation drops bindTo and copies only what it needs via onSuccess → set_state with "results": "{response.results}". A missing key renders as an empty string, which list treats as empty — so a no-match search shows the empty template.
Limitation — "No cities found" before you've searched
Until results exists, the list treats its source as empty and shows the empty template — so the screen opens with "No cities found" before you've typed anything. Clearing the field also fires onChange with an empty name. There's no "not searched yet" state in list; you'd work around it with a flag in form plus visibleCondition. The demo doesn't.
Runner limitation — search does not cancel an older request
The search field's debounced flow uses collect, not collectLatest, and waits for each action to finish. Within this field, requests do not race to overwrite one another, but an older response can still appear after you've changed the text, and the next search waits for it. Debounce reduces requests; it does not make results correspond to the latest input. A latest-query-wins policy needs cancellation or a query-version check in the runner, not just a shorter debounce.
Format limitation — 7 cards, 28 daily state keys
Open-Meteo returns parallel arrays: time[], temperature_2m_max[], temperature_2m_min[] and weather_code[]. A list can iterate an array or object, but it does not zip several arrays into one day record per row. This schema instead repeats day0_card through day6_card, with 28 daily values plus three current-weather values — 31 state keys in total.
With a backend you control, normalize the response into days: [{date, max, min, code}, …] and use one itemTemplate. Keeping this demo as a direct client of Open-Meteo means accepting the repetition or adding a data-transformation feature to the runner.
Limitation — errors are just a snackbar
If the forecast request fails, the screen keeps its "…" placeholders and shows a snackbar. There's no retry button — you can only go back. The loadingTemplate on the first screen is also bound to a single screen-wide isLoading flag, not to the list itself.
Limitation — hardcoded everything
°C and km/h, language=en, count=5, 7 days. Fine for a demo, but none is a user preference. The forecast request relies on Open-Meteo's defaults for units and duration, while the UI assumes °C, km/h and seven days. Set those API options explicitly before adding preferences. (One gallery-specific quirk I've cut from the fragments: the back button on the first screen is an open_schema action returning to the built-in demo gallery — in a standalone app it wouldn't exist.)
Note. Weather data by Open-Meteo. Its free hosted API is limited to non-commercial use and has request limits; the data licence is CC BY 4.0. Those are separate conditions: attribution alone does not grant commercial access to the free endpoint. Check the current terms before reusing this demo in a product. The search screen carries a "powered by Open-Meteo" label. Also, "current weather" here means weather-model data, not a live sensor reading.
Try it on your phone
Install BDUI Go, open the built-in demo gallery, tap Weather. No account, no QR — the app you're reading about is already inside.
The full, unmodified schema: weather.json.
What's next
Building mobile flows and want to describe your own screens in JSON instead of the built-in demos? The dashboard is in early access: request early access.


Top comments (0)