---
title: Build a JS app
slug: bar/dev/js-apps/build
description: Build Rates, a simple currency exchange app for BUSY Bar. It shows live exchange rates, lets you switch currencies with the scroll wheel, and has its own settings
image: https://cdn.images.archbee.com/3StCFqarJkJQZV-7N79yY/K1X_qOmHp6PEqgG1Z86US_busy-docs-prev.jpg
createdAt: 2026-10-28T13:47:18.423Z
---

![](https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/1DgXJtxEnp_Kj_zVlCe7l_build-js.png)

In this guide, you’ll build **Rates**, a simple currency exchange app for BUSY Bar. It shows live exchange rates, lets you switch currencies with the scroll wheel, and has its own settings.

In this guide:

- [Get your app running%section%](docId:68xNbWmB5YW2ohmzMjEuA) — Set up your computer, create a project, and install Rates on BUSY Bar, step by step.
- [Inside the Rates app%section%](docId:68xNbWmB5YW2ohmzMjEuA) — A walkthrough of the code you installed: from drawing and input handling to live data, settings, storage, and performance.
- [How to share your app%section%](docId:68xNbWmB5YW2ohmzMjEuA)
- [Troubleshooting%section%](docId:68xNbWmB5YW2ohmzMjEuA)

:::hint{type="info"}
:inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/JzzAMWPfMWluW80KVupgz-20260311-152018.png" alt caption width="76" height="88"}  <font color="#2B7EFF">**What you’ll need**</font>

- BUSY Bar with firmware 1.3.1 or later, connected to Wi-Fi
- A computer and a USB cable
- Basic knowledge of JavaScript or TypeScript and the Terminal
:::

***

# Get your app running

In this part, you’ll set up your computer, create a project from the app template, and install the Rates app on your BUSY Bar. By the end, BUSY Bar shows live exchange rates, and you can switch currencies with the scroll wheel.

## <font color="#ED0018">Step 1.</font> Connect BUSY Bar

::::WorkflowBlock
:::WorkflowBlockItem
Connect your BUSY Bar to your computer via USB.
:::

:::WorkflowBlockItem
Open [http://10.0.4.20](http://10.0.4.20) in a web browser. The local web interface opens.
:::

:::WorkflowBlockItem
On the **Firmware** tab, check that the firmware version is **1.3.1** or later. If it’s older, [update the firmware](https://docs.busy.app/bar/basics/firmware-update).
:::
::::

If the web interface doesn’t open, see [Local web interface issues](https://docs.busy.app/bar/troubleshooting/local-web-interface).



## <font color="#ED0018">Step 2.</font> Check Node.js version

In Terminal, run:

```bash
node -v
```

It should print a version that starts with `v24`, for example `v24.11.0`. If it doesn’t, download Node.js 24 from [nodejs.org](https://nodejs.org/en/download).

:::hint{type="warning"}
Node.js 26 isn’t supported yet.
:::



## <font color="#ED0018">Step 3.</font> Create the project

In Terminal, run the command for your package manager. The project will use the same package manager for all later commands. If you’re not sure, use `npm`:&#x20;

::::Tabs
:::Tab{title="npm"}
```bash
npm create @busy-app/app rates
```

If npm asks to install `@busy-app/create-app`, type `y` and press **Enter**.
:::

:::Tab{title="pnpm"}
```bash
pnpm create @busy-app/app rates
```
:::

:::Tab{title="yarn"}
```bash
yarn create @busy-app/app rates
```
:::

:::Tab{title="bun"}
```bash
bun create @busy-app/app rates
```
:::
::::



The generator asks a few questions:&#x20;

- Press **Enter** to accept a suggested answer.
  *or*
- Enter your options, then press **Enter**.

| **Question**   | **What to enter**                                                         |
| -------------- | ------------------------------------------------------------------------- |
| `Author`       | Your name or nickname                                                     |
| `App id`       | A unique id, for example `app.jane_doe.rates`. The generator suggests one |
| `Display name` | `Rates`, the name in the **Apps** menu                                    |
| `Description`  | One line about the app                                                    |
| `API version`  | Keep the default                                                          |

:::hint{type="info"}
:inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/JzzAMWPfMWluW80KVupgz-20260311-152018.png" alt caption width="76" height="88"} <font color="#2B7EFF">**Choose the** `app id` **once**</font>

The id identifies your app on every BUSY Bar: up to 32 characters, Latin letters, digits, `_`, `-`, and dots. If you change it later, BUSY Bar treats your app as a different one.
:::



## <font color="#ED0018">Step 4.</font> Run the template app

Go to the project folder:

```bash
cd rates
```

In Terminal, run the commands for the package manager you used in Step 3:

::::Tabs
:::Tab{title="npm"}
```bash
npm run build && npm run app:install && npm run app:run
```
:::

:::Tab{title="pnpm"}
```bash
pnpm build && pnpm app:install && pnpm app:run
```
:::

:::Tab{title="yarn"}
```bash
yarn build && yarn app:install && yarn app:run
```
:::

:::Tab{title="bun"}
```bash
bun run build && bun run app:install && bun run app:run
```
:::
::::

This line runs three commands, one after another:

- `build` packs the app into `dist/<your-app-id>.tgz`.
- `app:install` installs it on BUSY Bar over USB.
- `app:run` starts it.

Terminal should end with:

```text
◆  Installed Rates (app.jane_doe.rates) in 0.9s
...
◆  Launched app.jane_doe.rates
```

BUSY Bar shows “Hello” and a number that grows every second:

![](https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/kChSDuwDNOA67GDrmCIrn_create-app-1.jpg)

To quit, press :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/acizC_ktW4Oi3wrSX9lwA-20260316-105319.png" alt caption width="80" height="96"} **Back**.&#x20;

You can also start the app right on BUSY Bar:&#x20;

::::WorkflowBlock
:::WorkflowBlockItem
Switch the :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/kBlpLStuBhGmON3SCi0uz-20260316-105319.png" alt caption width="96" height="96"} **mode selector** to :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/M6Io6N1BMSKTHyrniD6_Q_apps-icon-bar.png" alt caption width="96" height="96"}**Apps**.
:::

:::WorkflowBlockItem
Select the **Rates** app, and select **Start**.
:::
::::

Here’s what’s in the project folder:

```text
rates/
├── src/
│   ├── main.ts              the app code: you'll work here
│   └── appmeta/
│       └── manifest.json    app id, name, version
├── shared/                  helpers, including the device client
├── AGENTS.md                instructions for AI coding agents
└── package.json             build and install commands
```



## <font color="#ED0018">Step 5.</font> Replace the app code

::::::WorkflowBlock
::::WorkflowBlockItem
Replace everything in `src/main.ts` with the code below.

:::ExpandableHeading
**src/main.ts**:inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/BnDmvlxd6fDnLVlZlpBnN-20260615-181604.png" alt caption width="96" height="96"}

```typescript
import { device } from "@shared/device";
import { column, render, type Node } from "@busy-app/busy-lib";
import manifest from "./appmeta/manifest.json";

const APP = manifest.id;
const API = "https://api.frankfurter.dev/v1/latest";
const CURRENCIES = ["USD", "EUR", "GBP", "JPY", "CHF", "CNY"];
const SETTINGS_VERSION = 1; // "version" in settings.json
const SAVED_KEY = "quote";

const GREY = "#FFFFFF80";
const WHITE = "#FFFFFFFF";
const GREEN = "#3DDC84FF";

type Settings = { base: string; refreshMinutes: number };
const DEFAULTS: Settings = { base: "EUR", refreshMinutes: 10 };

let settings: Settings = DEFAULTS;
let quotes = CURRENCIES.filter((c) => c !== DEFAULTS.base);
let index = 0;
let rates: Record<string, number> = {};
let status = "Loading";

let refreshTimer: ReturnType<typeof setInterval> | undefined;
let saveTimer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;

const report = (err: unknown) =>
  console.error(`${APP}: ${err instanceof Error ? err.message : String(err)}`);

// ---------- Screen ----------

function frame(): Node {
  const quote = quotes[index];
  const rate = rates[quote];
  const text = rate === undefined ? status : rate.toFixed(rate >= 100 ? 2 : 4);
  // gap: 2 keeps the two lines from touching.
  return column({ justify: "center", align: "center", gap: 2 }, [
    { type: "text", id: "pair", text: `${settings.base}/${quote}`, font: "small", color: GREY },
    { type: "text", id: "rate", text, font: "bold", color: rate === undefined ? WHITE : GREEN },
  ]);
}

let drawing = false; // a frame is on its way
let dirty = false;   // something changed while it was

function draw(): void {
  if (drawing) {
    dirty = true;
    return;
  }
  drawing = true;
  dirty = false;

  const done = () => {
    drawing = false;
    if (dirty) draw(); // send only the latest state
  };

  device
    .DisplayDraw({ application_name: APP, priority: 50, elements: render(frame()) })
    .then(done, (err) => {
      report(err);
      done();
    });
}

// ---------- Data ----------

// Read the values when the app starts. If anything goes wrong, fall back to the defaults.
async function loadSettings(): Promise<Settings> {
  try {
    const { version, values } = await device.AppsSettingsGet({ app_id: APP });
    if (version !== SETTINGS_VERSION) return DEFAULTS;
    return {
      base: typeof values.base === "string" ? values.base : DEFAULTS.base,
      refreshMinutes: typeof values.refresh_minutes === "number" ? values.refresh_minutes : DEFAULTS.refreshMinutes,
    };
  } catch (err) {
    report(err);
    return DEFAULTS;
  }
}

async function fetchRates(): Promise<void> {
  try {
    const res = await fetch(`${API}?base=${settings.base}&symbols=${quotes.join(",")}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = (await res.json()) as { rates: Record<string, number> };
    rates = data.rates;
  } catch (err) {
    // Keep the last good rates on screen and try again on the next refresh.
    report(err);
    if (Object.keys(rates).length === 0) status = "No data";
  }
  if (!stopped) draw();
}

function restoreChoice(): void {
  const i = quotes.indexOf(localStorage.getItem(SAVED_KEY) ?? "");
  index = i >= 0 ? i : 0;
}

function saveChoice(): void {
  saveTimer = undefined;
  localStorage.setItem(SAVED_KEY, quotes[index]);
}

// Writing goes to flash memory, so save a few seconds after the wheel stops.
function saveChoiceSoon(): void {
  if (saveTimer !== undefined) clearTimeout(saveTimer);
  saveTimer = setTimeout(saveChoice, 3000);
}

// ---------- App ----------

function main(): void {
  draw();

  const stop = () => {
    stopped = true;
    if (refreshTimer !== undefined) clearInterval(refreshTimer);
    if (saveTimer !== undefined) {
      clearTimeout(saveTimer);
      saveChoice();
    }
    // Removing the last listener closes the app. Do it after this handler returns.
    setTimeout(unbind, 0);
  };

  const unbind = listen("input", (event) => {
    if (stopped) return;

    if (event.key === "encoder") {
      // One click of the wheel: delta is 1 clockwise, -1 counterclockwise.
      index = (index + event.delta + quotes.length) % quotes.length;
      saveChoiceSoon();
      draw();
      return;
    }

    if (event.key === "ok" && event.action === "short") {
      void fetchRates();
      return;
    }

    if (event.key === "back" && event.action === "short") {
      stop();
    }
  });

  void (async () => {
    settings = await loadSettings();
    quotes = CURRENCIES.filter((c) => c !== settings.base);
    restoreChoice();
    draw();

    await fetchRates();
    if (stopped) return;
    // Refresh on a timer. The timer also keeps the app running.
    refreshTimer = setInterval(() => void fetchRates(), settings.refreshMinutes * 60 * 1000);
  })();
}

main();
```
:::
::::

::::WorkflowBlockItem
Create `src/appmeta/settings.json` with the settings below.

:::ExpandableHeading
**src/appmeta/settings.json**:inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/BnDmvlxd6fDnLVlZlpBnN-20260615-181604.png" alt caption width="96" height="96"}

```json
{
  "format_version": 1,
  "version": 1,
  "fields": {
    "base": {
      "label": "Base currency",
      "type": "enum",
      "default": "EUR",
      "options": [
        { "value": "EUR", "label": "Euro" },
        { "value": "USD", "label": "US dollar" },
        { "value": "GBP", "label": "Pound sterling" }
      ]
    },
    "refresh_minutes": {
      "label": "Refresh, minutes",
      "type": "integer",
      "default": 10,
      "min": 5,
      "max": 60,
      "step": 5
    }
  }
}
```
:::
::::

:::::WorkflowBlockItem
Build, install, and start the app again:

::::Tabs
:::Tab{title="npm"}
```bash
npm run build && npm run app:install && npm run app:run
```
:::

:::Tab{title="pnpm"}
```bash
pnpm build && pnpm app:install && pnpm app:run
```
:::

:::Tab{title="yarn"}
```bash
yarn build && yarn app:install && yarn app:run
```
:::

:::Tab{title="bun"}
```bash
bun run build && bun run app:install && bun run app:run
```
:::
::::
:::::
::::::

BUSY Bar shows “Loading”, then a live exchange rate:

![](https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/01rP7D4B5g9dNjsuHtTmR_create-app-3.jpg)



## <font color="#ED0018">Step 6.</font> Add app icons

Without icons, the Rates app shows a default icon in the **Apps** menu. To give it its own:

::::WorkflowBlock
:::WorkflowBlockItem
Download the example icons:

::File{src="https://api.archbee.com/api/presign/3StCFqarJkJQZV-7N79yY/JcO5arU2EMg4Lwk8ryCRW_icons-for-rates.zip" label="Icons for Rates.zip"}
:::

:::WorkflowBlockItem
Unzip the archive into `src/appmeta/`.
:::

:::WorkflowBlockItem
Build, install, and run the app.
:::
::::

| **File**              | **Size** | **Format**                                                      | **Shown on** |
| --------------------- | -------- | --------------------------------------------------------------- | ------------ |
| `icon_front_8x8.png`  | 8×8 px   | PNG with a transparent background                               | Front screen |
| `icon_back_11x11.png` | 11×11 px | PNG with a transparent background, in a single grey (`#888888`) | Back screen  |

Both icons are optional. If a file is missing, BUSY Bar uses the default icon. The file names must match exactly.

Your `src/appmeta` folder now looks like this:

```text
src/appmeta/
├── manifest.json
├── settings.json
├── icon_front_8x8.png
└── icon_back_11x11.png
```

:::hint{type="info"}
:inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/JzzAMWPfMWluW80KVupgz-20260311-152018.png" alt caption width="76" height="88"}  <font color="#2B7EFF">**Draw your own icons**</font>

Use any pixel art editor and export PNG with transparency. For examples, see the icons of the built-in [Clock](https://github.com/busy-app/clock-app/tree/main/src/appmeta) and [Weather](https://github.com/busy-app/weather-app/tree/main/src/appmeta) apps.
:::



## <font color="#ED0018">Step 7.</font> Try the app

Let’s see how Rate works and how to change its settings:

- Turn the :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/nsAwZD5PgLJbX9tOp0m-D-20260316-105319.png" alt caption width="96" height="96"} **scroll wheel** to switch currencies.
- Press :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/xlWSbh7j8KDYqS-b2YSDD-20260316-105319.png" alt caption width="96" height="96"}**OK** to refresh the rates.
- Press :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/acizC_ktW4Oi3wrSX9lwA-20260316-105319.png" alt caption width="80" height="96"} **Back** to quit.
- On BUSY Bar, go to :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/M6Io6N1BMSKTHyrniD6_Q_apps-icon-bar.png" alt caption width="96" height="96"}**Apps → Rates → Setup** and change **Base currency**. Start the app again to see the new currency pairs.

:::hint{type="success"}
:inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/1aDofAIpvZz_g9fNc-_io_success-icon.png" alt caption width="76" height="88"}  <font color="#00C16A">**ll done!**</font>

Your Rates app is ready. To learn how it works, keep reading.
:::

***

# Inside the Rates app

This section walks through the code you added in [Step 5](docId:68xNbWmB5YW2ohmzMjEuA). The snippets below are excerpts from `src/main.ts` for reference, not code to copy.

## How an app runs

A JavaScript app has no main loop and no exit function. It keeps running while it has something to wait for: a timer, a pending request, or an input listener. When nothing is left, it closes.

The Rates app keeps running because it has an input listener and a refresh timer. To quit, the `stop()` function in Rates clears the timer and removes the listener:

```typescript
const stop = () => {
  stopped = true;
  if (refreshTimer !== undefined) clearInterval(refreshTimer);
  // ...
  // Removing the last listener closes the app. Do it after this handler returns.
  setTimeout(unbind, 0);
};
```



## Drawing on the screen

An app draws with **display elements**: text, images, animations, countdowns, rectangles, and bitmaps. Each element has an `id`. When you send an element with an `id` that’s already on screen, BUSY Bar replaces it. You don’t need to clear the screen and draw everything again.

The front screen is 72×16 pixels and fits two lines of text. Instead of counting pixels, describe the layout with the `row()`, `column()`, and `stack()` functions from `@busy-app/busy-lib`. The `render()` function, also from `@busy-app/busy-lib`, turns the layout into elements, and the `device.DisplayDraw()` method sends them to BUSY Bar.

In the Rates app, `frame()` describes the screen:

```typescript
function frame(): Node {
  const quote = quotes[index];
  const rate = rates[quote];
  const text = rate === undefined ? status : rate.toFixed(rate >= 100 ? 2 : 4);
  // gap: 2 keeps the two lines from touching.
  return column({ justify: "center", align: "center", gap: 2 }, [
    { type: "text", id: "pair", text: `${settings.base}/${quote}`, font: "small", color: GREY },
    { type: "text", id: "rate", text, font: "bold", color: rate === undefined ? WHITE : GREEN },
  ]);
}
```

- Colors are written as `#RRGGBBAA`: four pairs of hex values from `00` to `FF`. `RR` is red, `GG` is green, `BB` is blue, and `AA` is opacity. For example, `#FFFFFFFF` is white, and `#FFFFFF80` is half-transparent white. All 8 digits are required.
- `gap` sets the space between lines in pixels. Without it, lines in a column touch.

:::hint{type="info"}
:inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/JzzAMWPfMWluW80KVupgz-20260311-152018.png" alt caption width="76" height="88"}  <font color="#2B7EFF">**this**</font>

Change `GREEN` to `"#FFB000FF"` and the rate turns amber.
:::



The `device.DisplayDraw()` method in the `draw()` function sends the elements to BUSY Bar:

```typescript
device.DisplayDraw({ application_name: APP, priority: 50, elements: render(frame()) })
```

- `application_name` is your app ID. When the app closes, BUSY Bar removes all the elements the app drew.
- `priority` decides whose drawing is shown when several apps or integrations draw at the same time. It goes from 1 to 100, and the higher number wins. 50 is the default. During an active BUSY or CUSTOM session, the session takes over the screen, and your app’s drawing isn’t shown until the session ends. Details are in the `display/draw` request of the [HTTP API reference](https://api.busy.app/busybar/docs).



## Buttons and the scroll wheel

The built-in `listen()` function subscribes to the buttons and the scroll wheel. It returns a function that removes the listener:

```typescript
const unbind = listen("input", (event) => {
  if (event.key === "encoder") {
    // One click of the wheel: delta is 1 clockwise, -1 counterclockwise.
    index = (index + event.delta + quotes.length) % quotes.length;
    draw();
    return;
  }

  if (event.key === "ok" && event.action === "short") {
    void fetchRates();
    return;
  }
  // ...
});
```

| **Control**                                                                                                                                                                                 | **`event.key`** | **`event.action`**                                                                                        |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------- |
| :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/nsAwZD5PgLJbX9tOp0m-D-20260316-105319.png" alt caption width="96" height="96"} **Scroll wheel** (rotation)   | `encoder`       | `clockwise` or `counterclockwise`, plus `delta`.                                                          |
| :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/xlWSbh7j8KDYqS-b2YSDD-20260316-105319.png" alt caption width="96" height="96"}**&#x20;Scroll wheel** (press) | `ok`            | `short` for a normal press, `long` for a long one. `press`, `release`, and `repeat` are useful for games. |
| :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/9cerr6S5jJu3AvWCAOTer-20260316-105319.png" alt caption width="128" height="96"}**&#x20;Start / Pause**       | `start`         | Same as `ok`.                                                                                             |
| :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/acizC_ktW4Oi3wrSX9lwA-20260316-105319.png" alt caption width="80" height="96"}**&#x20;Back**                 | `back`          | `short`, `press`, and `release`. Holding **Back** closes the app, so the app doesn’t get `long`.          |

Holding :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/acizC_ktW4Oi3wrSX9lwA-20260316-105319.png" alt caption width="80" height="96"}**&#x20;Back** always closes the app, so even an app with a bug can’t get stuck on screen.

:::hint{type="info"}
:inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/JzzAMWPfMWluW80KVupgz-20260311-152018.png" alt caption width="76" height="88"}  <font color="#2B7EFF">**Try this**</font>

Change the OK check to `(event.key === "ok" || event.key === "start")` so :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/9cerr6S5jJu3AvWCAOTer-20260316-105319.png" alt caption width="128" height="96"}**&#x20;Start / Pause** also refreshes the rates.
:::



## Showing live data

When BUSY Bar is connected to Wi-Fi, the built-in `fetch()` function sends HTTP requests from BUSY Bar to web services. The `fetchRates()` function in the Rates app uses it to get the numbers from [Frankfurter](https://frankfurter.dev), a free exchange rate API that needs no key:

```typescript
async function fetchRates(): Promise<void> {
  try {
    const res = await fetch(`${API}?base=${settings.base}&symbols=${quotes.join(",")}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const data = (await res.json()) as { rates: Record<string, number> };
    rates = data.rates;
  } catch (err) {
    // Keep the last good rates on screen and try again on the next refresh.
    report(err);
    if (Object.keys(rates).length === 0) status = "No data";
  }
  if (!stopped) draw();
}
```

The `setInterval()` function calls `fetchRates()` on a timer. The timer also keeps the app running:

```typescript
refreshTimer = setInterval(() => void fetchRates(), settings.refreshMinutes * 60 * 1000);
```

:::hint{type="info"}
:inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/JzzAMWPfMWluW80KVupgz-20260311-152018.png" alt caption width="76" height="88"}  <font color="#2B7EFF">**Get data wisely**</font>

Refresh every few minutes, not every few seconds. Every request takes time and memory on a small device.
:::

Your app can also read the BUSY Bar device status, such as Wi-Fi, time, and battery. See the [list of endpoints apps can use](https://developer.busy.app/javascript-applications.html#:~\:text=HTTP%20Requests,-Requests%20to%20the).

:::hint{type="info"}
:inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/JzzAMWPfMWluW80KVupgz-20260311-152018.png" alt caption width="76" height="88"}  <font color="#2B7EFF">**Try this**</font>

Add `"SEK"` to `CURRENCIES` to show the Swedish krona.
:::



## Settings

Settings let users change how your app works, on BUSY Bar (**Apps → your app → Setup**) or on the **Apps** tab of the local web interface. You list the settings in `src/appmeta/settings.json`, and BUSY Bar creates the settings screens for you.

The `loadSettings()` function in the Rates app reads the settings with the `device.AppsSettingsGet()` method when the app starts. If you change settings in the web interface while Rates is running, the changes apply the next time it starts. If reading the settings fails, Rates uses the defaults, so it still works:

```typescript
async function loadSettings(): Promise<Settings> {
  try {
    const { version, values } = await device.AppsSettingsGet({ app_id: APP });
    if (version !== SETTINGS_VERSION) return DEFAULTS;
    return {
      base: typeof values.base === "string" ? values.base : DEFAULTS.base,
      refreshMinutes: typeof values.refresh_minutes === "number" ? values.refresh_minutes : DEFAULTS.refreshMinutes,
    };
  } catch (err) {
    report(err);
    return DEFAULTS;
  }
}
```

- Apps can read their settings but can’t change them. Users change them, and the new values apply the next time the app starts.
- Change `version` when you change fields in an incompatible way: BUSY Bar then drops the old stored values and uses the new defaults.
- Field types: boolean, integer, string, enum, color, time, geolocation, plus groups. Some types can be viewed but not edited on the device itself; all of them can be edited in the local web interface. All options are in the [settings file schema](https://developer.busy.app/javascript-applications-settings-schema.html).

:::hint{type="info"}
:inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/JzzAMWPfMWluW80KVupgz-20260311-152018.png" alt caption width="76" height="88"}  <font color="#2B7EFF">**Try this**</font>

Add `{ "value": "CHF", "label": "Swiss franc" }` to the `base` options in `settings.json`.
:::



## Saving app data

When you quit an app, its in-memory data is lost. To keep data between launches, use the built-in `localStorage` object. It stores text, separately for each app.

For example, the Rates app remembers the last currency you picked. The `restoreChoice()` function reads it with the `localStorage.getItem()` method, and the `saveChoiceSoon()` function saves it a few seconds after the wheel stops:

```typescript
function restoreChoice(): void {
  const i = quotes.indexOf(localStorage.getItem(SAVED_KEY) ?? "");
  index = i >= 0 ? i : 0;
}

// Writing goes to flash memory, so save a few seconds after the wheel stops.
function saveChoiceSoon(): void {
  if (saveTimer !== undefined) clearTimeout(saveTimer);
  saveTimer = setTimeout(saveChoice, 3000);
}
```

Every call to the `localStorage.setItem()` method rewrites everything the app has saved, so write rarely and keep values small. For anything the user should configure, use settings instead.



## Keeping it fast

Every redraw is an HTTP request, and BUSY Bar handles them one at a time. If the app sends a frame on every click of a fast-spinning wheel, the requests pile up and the screen lags behind. The Rates app sends one frame at a time and skips the ones in between:

```typescript
let drawing = false; // a frame is on its way
let dirty = false;   // something changed while it was

function draw(): void {
  if (drawing) {
    dirty = true;
    return;
  }
  drawing = true;
  dirty = false;

  const done = () => {
    drawing = false;
    if (dirty) draw(); // send only the latest state
  };

  device
    .DisplayDraw({ application_name: APP, priority: 50, elements: render(frame()) })
    .then(done, (err) => {
      report(err);
      done();
    });
}
```

- Redraw only when something visible changes.
- When the app quits, clear its timers. A forgotten timer keeps the app running.
- If your app plays sounds, stop them before quitting with `DELETE /api/audio/play`.
- The default 128 KiB of memory is plenty for most apps. If you get out-of-memory errors, raise `heap_size_kib` in `manifest.json`, up to 512.

***

# How to share your app

::::::WorkflowBlock
:::WorkflowBlockItem
Set the version in `src/appmeta/manifest.json`, for example `1.0.0`.
:::

:::::WorkflowBlockItem
Build, install, and run the app to confirm that it works as expected:

::::Tabs
:::Tab{title="npm"}
```bash
npm run build && npm run app:install && npm run app:run
```
:::

:::Tab{title="pnpm"}
```bash
pnpm build && pnpm app:install && pnpm app:run
```
:::

:::Tab{title="yarn"}
```bash
yarn build && yarn app:install && yarn app:run
```
:::

:::Tab{title="bun"}
```bash
bun run build && bun run app:install && bun run app:run
```
:::
::::

The `build` command saves your app as a `.tgz` file in the project’s `dist/` folder.
:::::

:::WorkflowBlockItem
Share your app with the community on [Reddit](https://www.reddit.com/r/busyapp/) and [Discord](https://discord.com/invite/jXnEQQ4ahf).
:::
::::::

Others can install the app from a file using the **Apps** tab in the web interface. To learn more, visit [Manage apps on BUSY Bar](docId\:pqsePhUA0iLDLeuetPoIF).

:::hint{type="info"}
:inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/JzzAMWPfMWluW80KVupgz-20260311-152018.png" alt caption width="76" height="88"}  <font color="#2B7EFF">**Apps Catalog is coming**</font>

The official BUSY Barr apps catalog is coming by the end of 2026. You’ll be able to publish your app there.
:::

***

# Troubleshooting

| **Problem**                                           | **What to do**                                                                                                                                                                                                                        |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `npm run app:install` can’t reach BUSY Bar            | Check the USB cable and open [http://10.0.4.20](http://10.0.4.20). If the page doesn’t load, see [Local web interface issues](https://docs.busy.app/bar/troubleshooting/local-web-interface).                                         |
| The project setup or the build fails right away       | Run `node -v`. The template needs Node.js 24.                                                                                                                                                                                         |
| The rate shows **No data**                            | Check that BUSY Bar is connected to Wi-Fi, then press :inlineImage[]{src="https://api.archbee.com/api/optimize/3StCFqarJkJQZV-7N79yY/xlWSbh7j8KDYqS-b2YSDD-20260316-105319.png" alt caption width="96" height="96"}**OK** to refresh. |
| BUSY Bar shows **App incompatible, update firmware.** | [Update the BUSY Bar firmware](https://docs.busy.app/bar/basics/firmware-update).                                                                                                                                                     |
| BUSY Bar shows **Syntax error, check script.**        | Run `npm run typecheck`, fix the errors, then build and install again.                                                                                                                                                                |
| BUSY Bar shows **Settings file is invalid.**          | Check `settings.json` against the [settings file schema](https://developer.busy.app/javascript-applications-settings-schema.html).                                                                                                    |
| BUSY Bar shows **App crashed, see device logs.**      | Build with `npm run build -- --no-minify` to get readable code in the logs, then install and run the app again.                                                                                                                       |

For anything else, contact [support](https://support.busy.app) or open an issue on [GitHub](https://github.com/busy-app/busy-tools/issues).

***

# Next steps

- **Work over Wi-Fi** — Once [Wi-Fi access](https://docs.busy.app/bar/dev/http-api#wi-fi-access) is enabled, add the BUSY Bar IP address and HTTP API access password to any `app:` command, for example `npm run app:install -- --addr <BUSY Bar IP address> --password <http api access password>`.
- **[Create apps with AI agents](https://docs.busy.app/bar/dev/js-apps/create-with-ai)** — Let an AI agent write the next app for you.
- **[JS apps reference](https://developer.busy.app/javascript-applications.html)** — The manifest, settings, available endpoints, and input events.
