kodeOS internals

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

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:

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).

ExampleWhat it teachesRadio
buttons_ledBUTTON_EVT_SIG in, kode_led_rgb() out, kode_exit_to_os(). The reference for the othersNone
console_actionsAn app's own console verbs: one variable and two functions (internals/variables-and-functions)None
storagekode_nvs_* for a boot counter, and requests to AO_Storage for a file on /storageNone
audioA tone, and a two-second recording into a WAV file on /storageNone
imuThe accelerometer, gyroscope and magnetometer read every 200 ms: tilt and headingNone
hapticA short tick and a long buzz from the LRANone
nfc_readThe type and UID of a tag, then its first NDEF recordNone
rfid_readAn EM4100 ID from the 125 kHz readerNone
ir_remoteLearn a NEC code from a remote and send it back, through the C5Link only
expansion_ioA blinking output, a PWM output dimmed by an ADC knob and an input with an interrupt, on J3 with kode_exp_*None
powerThe battery reading, and turning the board off with kode_power_off(), arm-then-confirmNone
networkA ping and an HTTP GET through AO_Net, and a ping with kode_net_ping() in a kode_worker jobWi-Fi and network
mqttAn MQTT session: publish button presses, receive MQTT_MSG_SIGWi-Fi, network and MQTT
display_lvglLVGL on the glass through kscreen: the display state, the flush by event, a counterNone
arduino_helloAn Arduino sketch (internals/arduino). A .ino, not an ESP-IDF projectNone

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.

SettingInWhy
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="managed_components/kodediy__kbsp/partitions.csv"Every exampleEach 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=yEvery exampleAO_Boot never writes otadata
CONFIG_KODEOS_CONSOLE=yEvery exampleSomewhere to watch the Active Object from; it selects KODEOS_APPMGR
CONFIG_KODEOS_SCREENSHOT=nAll but display_lvglThe 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=nAll but network, mqtt, ir_remoteWi-Fi, Bluetooth, the network and MQTT depend on the radio
CONFIG_KODEOS_RADIO=y, _WIFI=n, _NET=n, _BLE=nir_remoteThe link to the C5 only
CONFIG_KODEOS_WIFI=y, _NET=y, _MQTT=nnetworkPing and HTTP
CONFIG_KODEOS_MQTT=y, CONFIG_KODEOS_CHANNEL=nmqttThe Kode channel holds the one MQTT client
CONFIG_LV_FONT_MONTSERRAT_28=y, _48=ydisplay_lvglThe 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

ThingValue
QueueQEvtPtr queue[KQP_AO_QUEUE_LEN], 16 events by default. A published event reaches every subscriber, and a full queue stops QP with an assert
StackStackType_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 nameQActive_setAttr(..., TASK_NAME_ATTR, "<name>") before QACTIVE_START. Without it every AO is called "AO" in asserts
ButtonsAn AO that reads buttons adds kdrv to PRIV_REQUIRES: ButtonEvt.id is a bare uint8_t documented as a kdrv_button_t
ManifestKODE_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 stackIn 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 jobMust 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 argumentValid 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:

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

WayCommandNotes
The console, over the cablepython3 tools/kcli.py -p <port> --settle 5 install <app>.bin --dst appThe 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 FATCopy the .bin into storage_image/, run idf.py -p <port> storage-flash, then install from the launcher or the consolestorage-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 mailboxpython -m esptool --chip esp32p4 -p <port> write-flash 0x1A00000 <app>.binkodeOS 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

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.

/*
 * 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