App SDK
Everything you need is in app_sdk/. Copy that directory into your project, or reference it from a shared location. No separate installation step.

App SDK overview

The App SDK consists of two things:

Your app does not link against ESP-IDF, FreeRTOS, or the C standard library. All system services are accessed through the syscall table that the kernel exports at runtime. This keeps your ELF small and independent of the firmware version.

App entry point: thistle_app_t

Every app defines one thistle_app_t struct and passes it to the THISTLE_APP() macro. The macro places a pointer to your struct in a special ELF section (.thistle_app) that the kernel reads during loading.

typedef struct {
    const char *id;               // reverse-DNS, e.g. "com.example.myapp"
    const char *name;             // display name shown in launcher
    const char *version;          // semver string "1.0.0"
    bool        allow_background;  // keep running when another app is foreground
    int        (*on_create)(void);   // allocate resources; return 0 = OK
    void       (*on_start)(void);    // build UI, start tasks
    void       (*on_pause)(void);    // another app came to foreground
    void       (*on_resume)(void);   // back in foreground
    void       (*on_destroy)(void);  // user closed the app; free everything
} thistle_app_t;

Lifecycle

App Lifecycle State Machine
install (copy .app.elf to /sdcard/apps/) │ ▼ on_create() ── allocate resources, return 0 on success │ ▼ on_start() ── build LVGL UI, register input callback │ ├──── user switches away ─► on_pause() │ ├──── user switches back ─► on_resume() │ ▼ on_destroy() ── user closed app; free LVGL objects, free memory

Available syscalls

System

void     thistle_log(const char *tag, const char *fmt, ...);
uint32_t thistle_millis(void);
void     thistle_delay(uint32_t ms);
void    *thistle_malloc(size_t size);  // allocates from PSRAM
void     thistle_free(void *ptr);

Display

Apps use LVGL directly — all LVGL symbols are exported through the syscall table. The display syscalls are convenience helpers for reading screen dimensions.

uint16_t thistle_display_get_width(void);
uint16_t thistle_display_get_height(void);

Input

typedef void (*thistle_input_cb_t)(int event_type, int keycode,
                                       int x, int y);
void thistle_input_register_cb(thistle_input_cb_t cb);

Radio (requires PERM_RADIO)

int thistle_radio_send(const uint8_t *data, size_t len);
int thistle_radio_set_freq(uint32_t freq_hz);

GPS (requires PERM_GPS)

int thistle_gps_enable(void);
int thistle_gps_get_lat_lon(double *lat, double *lon);

Storage (requires PERM_STORAGE)

App paths are relative to /spiffs/data/apps/<manifest-id>/. Absolute paths and paths containing . or .. are rejected. File handles are private to the app that opened them; shared storage requires a future explicit broker API.

void *thistle_fs_open (const char *relative_path, const char *mode);
int   thistle_fs_read (void *buf, size_t size, size_t count, void *stream);
int   thistle_fs_write(const void *buf, size_t size, size_t count, void *stream);
int   thistle_fs_close(void *stream);

Migration: remove legacy /spiffs/ or /sdcard/ prefixes from app file paths. Existing data must be copied once into the app's manifest-ID root by a trusted system migration; apps cannot import arbitrary legacy paths themselves.

IPC (requires PERM_IPC)

int thistle_msg_send(uint32_t dst_app, uint32_t type,
                     const void *data, size_t len);
int thistle_msg_recv(uint32_t *src_app, uint32_t *type,
                     void *data, size_t *len, uint32_t timeout_ms);

Power

uint16_t thistle_power_get_battery_mv(void);
uint8_t  thistle_power_get_battery_pct(void);

Building with CMake

Create a directory for your app and write a CMakeLists.txt:

cmake_minimum_required(VERSION 3.16)
project(my_app)

# Point to the App SDK directory
set(THISTLE_SDK_PATH "/path/to/esp32-os/app_sdk")
include(${THISTLE_SDK_PATH}/CMakeLists.txt)

# List all your .c source files
thistle_app(my_app SRCS main.c ui.c)

Build with standard CMake + the Xtensa toolchain that ships with ESP-IDF:

export IDF_PATH=~/esp/esp-idf
source $IDF_PATH/export.sh

cmake -B build -DCMAKE_TOOLCHAIN_FILE=$IDF_PATH/tools/cmake/toolchain-esp32s3.cmake
cmake --build build

# Output: build/my_app.app.elf

LVGL UI patterns

LVGL runs in the kernel's UI task. All LVGL calls from your app must happen on that same task — the easiest way is to do everything in on_start() and LVGL event callbacks (which also run on the UI task).

Create a label

static void my_start(void)
{
    lv_obj_t *screen = lv_scr_act();

    lv_obj_t *label = lv_label_create(screen);
    lv_label_set_text(label, "Hello from my app!");
    lv_obj_center(label);
}

Handle button press

static void btn_handler(lv_event_t *e)
{
    thistle_log("myapp", "Button pressed!");
}

lv_obj_t *btn = lv_btn_create(screen);
lv_obj_add_event_cb(btn, btn_handler, LV_EVENT_CLICKED, NULL);
lv_obj_set_size(btn, 120, 40);
lv_obj_center(btn);

Handle keyboard input via syscall

static void input_cb(int event_type, int keycode, int x, int y)
{
    if (event_type == HAL_INPUT_EVENT_KEY_DOWN) {
        thistle_log("myapp", "Key: %d", keycode);
    }
}

// Register in on_create or on_start
thistle_input_register_cb(input_cb);

Example: Hello World walkthrough

The complete hello world app from app_sdk/examples/hello_world/main.c:

#include "thistle_app.h"

static int hello_create(void)
{
    thistle_log("hello", "Hello World app created!");
    return 0;
}

static void hello_start(void)
{
    lv_obj_t *label = lv_label_create(lv_scr_act());
    lv_label_set_text(label, "Hello from ThistleOS!");
    lv_obj_center(label);
}

static void hello_pause(void)   {}
static void hello_resume(void)  {}
static void hello_destroy(void) {}

static const thistle_app_t hello_app = {
    .id               = "com.example.hello",
    .name             = "Hello World",
    .version          = "1.0.0",
    .allow_background = false,
    .on_create        = hello_create,
    .on_start         = hello_start,
    .on_pause         = hello_pause,
    .on_resume        = hello_resume,
    .on_destroy       = hello_destroy,
};

THISTLE_APP(hello_app);

Example: Chat app with LoRa radio

A more realistic app that sends and receives LoRa messages and displays them in an LVGL list:

#include "thistle_app.h"

static lv_obj_t *g_list;
static lv_obj_t *g_input;

/* Called from radio RX callback (runs in radio task) */
static void on_radio_rx(const uint8_t *data, size_t len,
                        int rssi, void *user)
{
    /* Schedule label creation on LVGL task via event */
    /* (simplified — real code needs thread-safe queue) */
    char buf[128];
    snprintf(buf, sizeof(buf), "[%d dBm] %.*s", rssi, (int)len, data);
    lv_list_add_text(g_list, buf);
}

static int chat_create(void)
{
    thistle_radio_set_freq(915000000);
    return 0;
}

static void send_btn_cb(lv_event_t *e)
{
    const char *txt = lv_textarea_get_text(g_input);
    thistle_radio_send((const uint8_t *)txt, strlen(txt));
    lv_textarea_set_text(g_input, "");
}

static void chat_start(void)
{
    lv_obj_t *scr = lv_scr_act();
    g_list  = lv_list_create(scr);
    g_input = lv_textarea_create(scr);
    lv_obj_t *btn = lv_btn_create(scr);
    lv_obj_add_event_cb(btn, send_btn_cb, LV_EVENT_CLICKED, NULL);
    lv_label_set_text(lv_label_create(btn), "Send");
}

static const thistle_app_t chat_app = {
    .id               = "com.example.chat",
    .name             = "LoRa Chat",
    .version          = "1.0.0",
    .allow_background = true,  // keep receiving in background
    .on_create  = chat_create,
    .on_start   = chat_start,
    .on_pause   = NULL,
    .on_resume  = NULL,
    .on_destroy = NULL,
};

THISTLE_APP(chat_app);
Thread safety
LVGL is not thread-safe. Only call LVGL functions from the LVGL task (i.e., inside lifecycle callbacks or LVGL event handlers). If you need to update the UI from a radio RX callback or a FreeRTOS timer, use lv_async_call() or post a message to your own queue and process it in an LVGL timer callback.

Packaging and installing

After building, copy the .app.elf to the SD card:

cp build/my_app.app.elf /path/to/sdcard/apps/
# Reboot the device — the launcher will show the new app

The launcher scans /sdcard/apps/ on startup and adds any .app.elf files it finds. The app's name and ID come from the thistle_app_t struct inside the ELF, not from the filename.

Signing your app

Unsigned apps run in restricted mode — they receive no permissions. To grant permissions, the app must be signed. See Security & Signing for the full signing workflow. The short version:

  1. Build your .app.elf
  2. Run thistle-sign --key your_key.pem my_app.app.elf to produce my_app.app.elf.sig
  3. Include both files in your catalog entry and on the SD card
  4. The kernel verifies the signature at load time and grants the declared permissions

Publishing to the app store

Once your app is built and signed, add it to a catalog.json and host it. See App Store for the full catalog format. The minimum entry looks like:

{
  "id": "com.example.chat",
  "name": "LoRa Chat",
  "version": "1.0.0",
  "type": "app",
  "url": "https://example.com/apps/chat.app.elf",
  "sha256": "a3f1...",
  "sig_url": "https://example.com/apps/chat.app.elf.sig",
  "permissions": ["radio", "ipc"],
  "description": "Send and receive LoRa messages."
}