# Apps and examples

What a kodeOS app is, the fifteen SDK examples that show each thing an app does, and how an app project, its build files and its install paths are set up.

## What it is

A kodeOS app is a complete firmware. It is its own ESP-IDF project and it builds its own `.bin`. The build puts the SDK components it uses into that file, kodeOS installs the file into the `apps` region (`internals/apps-region`), and the bootloader override starts it in place. kodeOS loads nothing into itself while it runs, so an app is not a plugin.

An app starts the whole system inside its own image. Its `app_main()` calls `kode_init()`, which starts the log, QP/C, the event pools and every Active Object the build configured. While the app runs, kodeOS does not, so the app controls every device with the same code kodeOS uses. `kode_led_rgb()` in an app publishes `LED_RGB_SIG` and AO_LedMgr acts on it, exactly as it does for kodeOS. AO_LedMgr cannot tell the two callers apart.

An app never writes `otadata`, so it does not choose what starts after a reset. The bootloader does, from the boot request (`internals/boot-model`). Any reset of an app started from the launcher or the console returns to kodeOS.

The SDK holds fifteen examples, one for each thing an app does. Copy the closest one instead of writing the CMake files from nothing. `build/setup`, `build/first-build` and `build/flash` cover the toolchain and the flash loop.

## How it is connected

```mermaid
flowchart TD
  subgraph image["One app image"]
    main["app_main()"]
    ours["Your Active Object"]
    subgraph sdk["The SDK"]
      init["kode_init()"]
      aos["SDK Active Objects<br/>AO_Boot, AO_LedMgr, ..."]
    end
    main -- "starts" --> init
    init --> aos
    main -- "starts" --> ours
    ours -- "kode_led_rgb()<br/>*_request()" --> aos
    aos -. "published events" .-> ours
  end
  class main,ours app
  class aos ao
```

*What an app image contains*

Every example has the same shape. `app_main()` calls `kode_init()` and starts one Active Object of its own. That Active Object asks the owner of a device with a `*_request()` function from `kaos.h`, or with a call of the facade such as `kode_led_rgb()`, and handles the answer when it arrives as a signal.

The expansion header J3 has no owner, so `expansion_io` calls `kode_exp_*()` from its own step, and none of those calls blocks.

### Rules for your Active Objects

**Never block in an Active Object.** An AO never blocks and a task of your own can. `kode.h` has blocking calls (`kode_net_ping()`, `kode_http_get()`, `kode_imu_read()`, the NFC and RFID reads, `kode_delay_ms()`). Use them in `app_main()`, in a FreeRTOS task of your own or in a worker job.

Inside a step of an Active Object the same call stops that object from handling events, its queue fills, and a full queue stops the system with an assert. The supervisor logs each step longer than a second (`watch: <ao> step <ms> ms (signal 0x<sig>)`).

**A worker runs the blocking call for the Active Object.** `kode_worker.h` gives an Active Object a task that runs one function at a time and posts the result back as an event. The object starts the worker once with `kode_worker_start(&me->worker, &me->super, "name", stack_bytes, prio, depth)`, hands it a job with `kode_worker_submit(&me->worker, fn, arg, DONE_SIG, &seq)`, and goes on.

The job has the form `kode_status_t fn(void *arg, uint32_t *value)`. When it returns, one `KqpWorkerDoneEvt` arrives with the sequence number, the status and the word the function wrote. The `network` example shows it with `kode_net_ping()`.

**Subscribe first, then ask.** An owner publishes an event when its device starts, and every Active Object that `kode_init()` starts is already running when it returns, so its first events are past. A latecomer first subscribes to the signal and then sends the request that asks for the current state, for example `display_state_request()` after `DISPLAY_STATE_SIG`.

**The signals of your app are private.** Yours start at `MAX_SIG`. Post them and never publish them: `QF_psInit` sizes the publish bus from `MAX_PUB_SIG` and `QActive_publish_` asserts above it. Subscribe to the SDK's signals, publish the SDK's, and post your own.

### Where the board facts come from

Everything about the Kode Dot (flash, PSRAM, cache, the console, the SDIO wiring of the C5) is in `components/kbsp/sdkconfig.board`, and every project reads it first. Two settings cannot come from that file, because ESP-IDF needs them before it can fetch it:

- `set(IDF_TARGET esp32p4)` before `project()`.
- `CONFIG_PARTITION_TABLE_CUSTOM_FILENAME` in the project's `sdkconfig.defaults`.

A clean checkout configures twice and you see one build. The example's `CMakeLists.txt` runs a throwaway configure in `build/kbsp_fetch` purely so the component manager fetches `kbsp` into `managed_components/`. It then deletes that folder, because its `sdkconfig` was made with ESP-IDF defaults (no PSRAM, 2 MB of flash) and would win in every later build.

## Specifications

### The examples

Each ESP-IDF example is a folder in `examples/` of the SDK with seven files: `CMakeLists.txt`, `sdkconfig.defaults`, `main/CMakeLists.txt`, `main/idf_component.yml`, `main/<name>_main.c`, `main/<name>_ao.h` and `main/<name>_ao.c`.

The Active Object has the name of its folder (`AO_ButtonsLed`). Five add `Demo` because the plain name is an SDK Active Object that a second definition would clash with: `audio`, `imu`, `haptic`, `power`, `storage`. `mqtt` adds it too (`AO_MqttDemo`).

| Example | What it teaches | Radio |
| --- | --- | --- |
| `buttons_led` | `BUTTON_EVT_SIG` in, `kode_led_rgb()` out, `kode_exit_to_os()`. The reference for the others | None |
| `console_actions` | An app's own console verbs: one variable and two functions (`internals/variables-and-functions`) | None |
| `storage` | `kode_nvs_*` for a boot counter, and requests to AO_Storage for a file on `/storage` | None |
| `audio` | A tone, and a two-second recording into a WAV file on `/storage` | None |
| `imu` | The accelerometer, gyroscope and magnetometer read every 200 ms: tilt and heading | None |
| `haptic` | A short tick and a long buzz from the LRA | None |
| `nfc_read` | The type and UID of a tag, then its first NDEF record | None |
| `rfid_read` | An EM4100 ID from the 125 kHz reader | None |
| `ir_remote` | Learn a NEC code from a remote and send it back, through the C5 | Link only |
| `expansion_io` | A blinking output, a PWM output dimmed by an ADC knob and an input with an interrupt, on J3 with `kode_exp_*` | None |
| `power` | The battery reading, and turning the board off with `kode_power_off()`, arm-then-confirm | None |
| `network` | A ping and an HTTP GET through AO_Net, and a ping with `kode_net_ping()` in a `kode_worker` job | Wi-Fi and network |
| `mqtt` | An MQTT session: publish button presses, receive `MQTT_MSG_SIG` | Wi-Fi, network and MQTT |
| `display_lvgl` | LVGL on the glass through kscreen: the display state, the flush by event, a counter | None |
| `arduino_hello` | An Arduino sketch (`internals/arduino`). A `.ino`, not an ESP-IDF project | None |

### The build options the examples set

`sdkconfig.defaults` is the same in every example except for the lines the example is about. Never copy a board fact into a project: a private copy that declares PSRAM this chip does not have hangs the boot with no message.

| Setting | In | Why |
| --- | --- | --- |
| `CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="managed_components/kodediy__kbsp/partitions.csv"` | Every example | Each kind of project writes its own path: kodeOS root `sdk/components/kbsp/partitions.csv`, an app beside its SDK submodule `../sdk/components/kbsp/partitions.csv`, an app that fetches the SDK the line shown |
| `CONFIG_KODEOS_BOOT_ROLE_APP=y` | Every example | AO_Boot never writes `otadata` |
| `CONFIG_KODEOS_CONSOLE=y` | Every example | Somewhere to watch the Active Object from; it selects `KODEOS_APPMGR` |
| `CONFIG_KODEOS_SCREENSHOT=n` | All but `display_lvgl` | The panel's shadow copy is 402 KB of PSRAM with nothing to show. The mirror needs it |
| `CONFIG_KODEOS_RADIO=n`, `CONFIG_ESP_HOSTED_ENABLED=n`, `CONFIG_BT_ENABLED=n` | All but `network`, `mqtt`, `ir_remote` | Wi-Fi, Bluetooth, the network and MQTT depend on the radio |
| `CONFIG_KODEOS_RADIO=y`, `_WIFI=n`, `_NET=n`, `_BLE=n` | `ir_remote` | The link to the C5 only |
| `CONFIG_KODEOS_WIFI=y`, `_NET=y`, `_MQTT=n` | `network` | Ping and HTTP |
| `CONFIG_KODEOS_MQTT=y`, `CONFIG_KODEOS_CHANNEL=n` | `mqtt` | The Kode channel holds the one MQTT client |
| `CONFIG_LV_FONT_MONTSERRAT_28=y`, `_48=y` | `display_lvgl` | The two fonts it uses |

`CONFIG_ESP_HOSTED_ENABLED=n` is there on purpose. `KODEOS_RADIO=n` alone is not enough: `kdrv` depends on `esp_wifi_remote`, whose presence makes `ESP_HOSTED_ENABLED` default to `y`, and esp-hosted then installs a constructor that claims the SDMMC controller before `app_main`. Kconfig cannot express "deselect", so a firmware without a radio says the opposite out loud.

The network stack alone adds about 395 KB to an image, so only `network` and `mqtt` turn it on. An app with Wi-Fi, MQTT and the console joins the Kode channel by default (`CONFIG_KODEOS_CHANNEL` defaults to `y`).

### The files of an Active Object

| Thing | Value |
| --- | --- |
| Queue | `QEvtPtr queue[KQP_AO_QUEUE_LEN]`, 16 events by default. A published event reaches every subscriber, and a full queue stops QP with an assert |
| Stack | `StackType_t stack[KQP_AO_STACK_BYTES / sizeof(StackType_t)]`, 8192 bytes by default. A stack overflow hangs the object and does not assert. `KQP_AO_STACK_LARGE`, `_MEDIUM` and `_SMALL` are the sizes SDK objects use after somebody measured them |
| Task name | `QActive_setAttr(..., TASK_NAME_ATTR, "<name>")` before `QACTIVE_START`. Without it every AO is called "AO" in asserts |
| Buttons | An AO that reads buttons adds `kdrv` to `PRIV_REQUIRES`: `ButtonEvt.id` is a bare `uint8_t` documented as a `kdrv_button_t` |
| Manifest | `KODE_APP_MANIFEST("Name", "cat1,cat2");` at file scope, optional, up to four categories. Without it the app is a loose row at the root of Apps, in no folder. With it the app is filed in the folder named by its first category, in the plural |
| Worker stack | In PSRAM, so give it at least 8192 bytes. An overflow in PSRAM corrupts a neighbour silently; `ps` prints each worker's lowest free stack |
| Worker job | Must not call flash, partition or NVS functions, or `kode_nvs_set()`, `kode_nvs_get()`, `kode_nvs_del()`: a task with its stack in PSRAM must not run while the cache is off. `kode_nvs_*` refuses with `KODE_DENIED`. Return the value in the done event and let the Active Object store it |
| Worker argument | Valid until the done event arrives, so a field of the AO or a file-scope object, never a local |

### The one dependency

An app that fetches the SDK from GitHub lists one dependency in `main/idf_component.yml`:

```yaml
dependencies:
  kodediy/kode:
    git: https://github.com/kodediy/kode-sdk.git
    path: components/kode
    version: v0.1.0
```

`kaos`, `kqp`, `khal`, `kdrv`, `klog`, `kbsp`, `kdefs` and the vendored QP/C arrive at the same commit without being named, because each component's manifest declares its siblings with `override_path`. An app that draws also names `kodediy/kscreen` at the same tag.

The version is the SDK release's tag; moving to a newer SDK is that one line. An app that carries the SDK as a submodule skips the pin and sets `EXTRA_COMPONENT_DIRS`. `tools/check_example_pins.sh` fails when the examples' pins disagree or fall behind the newest release.

### Three ways to install

| Way | Command | Notes |
| --- | --- | --- |
| The console, over the cable | `python3 tools/kcli.py -p <port> --settle 5 install <app>.bin --dst app` | The fastest loop. The tool asks for `run=on`; `--no-run` installs only. The host sends one block, waits for the answer and sends the next |
| The `/storage` FAT | Copy the `.bin` into `storage_image/`, run `idf.py -p <port> storage-flash`, then install from the launcher or the console | `storage-flash` replaces the whole partition, and a plain `idf.py flash` never touches it. The folder and Kody's pack must fit about 2,016 KB |
| The mailbox | `python -m esptool --chip esp32p4 -p <port> write-flash 0x1A00000 <app>.bin` | kodeOS adopts it at the next boot, or at once with `install src="pending" dst=app`. The app then runs with a sticky request (`internals/boot-model`) |

`app_run name="<project>"` starts an installed app with a `NEW` request. The name is the `project()` name of the build, and the launcher shows the display name from `KODE_APP_MANIFEST`. If the name ends in `.bin`, kodeOS writes the file to flash first.

### Drawing

Only `display_lvgl` draws. An app that never draws includes no LVGL. An app that does names `kodediy/kscreen` next to `kodediy/kode`, and `PRIV_REQUIRES kdrv kscreen`.

kscreen creates the LVGL display, hands each finished rectangle to AO_Display as an event, and brings `kui` and LVGL with it. `lv_` functions are LVGL's and `kui_` functions are the kit's (`sdk/kui`).

`kscreen_set_rotation()` takes `LV_DISPLAY_ROTATION_0`, `_90`, `_180` or `_270`. Turned, LVGL draws in bands and a full-screen change redraws at about 20 frames a second against 59 unturned. Touch follows by itself, and so does the pad while `CONFIG_KSCREEN_ROTATE_PAD` is on, which is the default.

### Bluetooth in an app

An app that keeps the radio has Bluetooth with no line of its own: the NimBLE lines are in `sdkconfig.board` and `KODEOS_BLE` defaults to `y`. The Kode Dot advertises the same service UUID as in kodeOS. Whether it advertises at boot is the stored `ble` setting (`internals/settings`), and `CONFIG_KODEOS_BLE_AT_BOOT` forces it on. A phone bonded in kodeOS works in the app with no new pairing, because its keys are on the C5 and its token in NVS.

**A new pairing is refused in an app**: no code is shown, and the log says `pairing refused: a new phone pairs in kodeOS; this app serves bonded phones`. An app that wants no Bluetooth sets `CONFIG_KODEOS_BLE=n` and `CONFIG_BT_ENABLED=n`.

## Gotchas

> **WARNING**
>
> **The board reboots in a loop with `assert failed: xQueueGenericSend queue.c:936 (pxQueue)`.** Something posted to an Active Object before `kode_init()` returned, and its queue did not exist yet. Call `kode_init()` first in `app_main()`, always.

> **WARNING**
>
> **An Active Object stops answering after a blocking call.** A blocking `kode_*` call in a step stops that object, its queue fills and QP asserts. Move the call into a `kode_worker` job and handle the result as an event.

> **WARNING**
>
> **You changed `CONFIG_PARTITION_TABLE_CUSTOM_FILENAME` and the old value is still used.** A cached `sdkconfig` value beats a new default every time. Delete the generated `sdkconfig` and build again.

> **NOTE**
>
> **After a change to kode, kaos or kdrv, installed apps still run the old code.** An app is a whole firmware and carries its own copy of everything below `kode.h`. Rebuild the apps the change matters to and install them again.

## Example code

First, run an SDK example. These steps use `buttons_led` from the root of the kodeOS repository, where the SDK is the `sdk/` submodule.

1. Build it: `idf.py -C sdk/examples/buttons_led build`. The first build fetches the SDK and takes several minutes. It ends with `Project build complete` and the file `sdk/examples/buttons_led/build/buttons_led.bin`.
2. Install and start it: `python3 tools/kcli.py -p <port> --settle 5 -v install sdk/examples/buttons_led/build/buttons_led.bin`. The tool prints `installed and booting`, and a few seconds later the LED is green.
3. Press TOP. The LED turns red. Press TOP again and it turns green.
4. Press BOT. The board restarts and Apps is on the glass, in the folder `examples` with the row `Buttons + LED` in focus.
5. Start it again with TOP, or with `python3 tools/kcli.py -p <port> --settle 5 cmd 'app_run name="buttons_led"'`.

To make the app yours, copy the folder out of the tree, run `idf.py -C <copy> fullclean`, change `project(buttons_led)` in `CMakeLists.txt`, change the name in `KODE_APP_MANIFEST` and rebuild.

Then a complete app of your own, with no Active Object: it polls the top button from `app_main()`, which is allowed to block. The LED starts green and flips on each press.

```c
/*
 * top_led.c - 2026-10-28 - from Kode with love
 *
 * A complete app for the Kode Dot. It names itself for the launcher and
 * flips the RGB LED between green and red on each press of the top button.
 * The LED is green at start. Hold the bottom button to go home to kodeOS.
 */
#include "kode.h"

KODE_APP_MANIFEST("Top LED", "example");
#define LED_LEVEL  40U
#define POLL_MS    20U

/* Show green or red on the LED, which AO_LedMgr owns. */
static void show_colour(bool green)
{
    if (green) {
        (void)kode_led_rgb(0U, LED_LEVEL, 0U);
    } else {
        (void)kode_led_rgb(LED_LEVEL, 0U, 0U);
    }
}

/* Poll the top button from app_main's own task; flip the colour on each press. */
void app_main(void)
{
    kode_init();
    bool green = true;
    bool was_down = false;
    show_colour(green);
    for (;;) {
        bool down = (kode_buttons_read() & (1UL << KDRV_BTN_TOP)) != 0U;
        if (down && !was_down) {
            green = !green;
            show_colour(green);
        }
        was_down = down;
        kode_delay_ms(POLL_MS);
    }
}
```

Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. **TBD**: a run on a Kode Dot.

## In the SDK

- [`examples/`](https://github.com/kodediy/kode-sdk/tree/main/examples): the fifteen examples, one folder each.
- [`components/kode/include/kode.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kode/include/kode.h): the facade an app includes.
- [`storage_image/README.md`](https://github.com/kodediy/KodeOS/blob/main/storage_image/README.md): what `storage-flash` writes to the board.
