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 aoEvery 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)beforeproject().CONFIG_PARTITION_TABLE_CUSTOM_FILENAMEin the project'ssdkconfig.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:
dependencies:
kodediy/kode:
git: https://github.com/kodediy/kode-sdk.git
path: components/kode
version: v0.1.0kaos, 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
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.
- Build it:
idf.py -C sdk/examples/buttons_led build. The first build fetches the SDK and takes several minutes. It ends withProject build completeand the filesdk/examples/buttons_led/build/buttons_led.bin. - Install and start it:
python3 tools/kcli.py -p <port> --settle 5 -v install sdk/examples/buttons_led/build/buttons_led.bin. The tool printsinstalled and booting, and a few seconds later the LED is green. - Press TOP. The LED turns red. Press TOP again and it turns green.
- Press BOT. The board restarts and Apps is on the glass, in the folder
exampleswith the rowButtons + LEDin focus. - 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
examples/: the fifteen examples, one folder each.components/kode/include/kode.h: the facade an app includes.storage_image/README.md: whatstorage-flashwrites to the board.