Skip to content

cap_lua and Lua overview

Source: cap_lua.c · header: cap_lua.h

ESP-Claw uses Lua as the primary on-device language for programmable automation. It plays three roles:

The LLM can author scripts (lua_write_script) and run them (lua_run_script). Lua bridges natural-language plans to hardware: GPIO, display, audio, etc.

LLM decides → emits Lua → lua_write_script saves → lua_run_script runs → hardware reacts

claw_event_router supports a run_script action so rules can fire Lua without going through an LLM:

{
  // Some fields (for example, id) are omitted
  "match": { "event_type": "button_pressed" },
  "actions": [{ "type": "run_script", "input": { "path": "on_press.lua" } }]
}

That yields fully local, low-latency responses without network or model cost.

Users who do not ship C firmware can still extend behavior via Lua. Native modules registered as lua_module_* expose peripherals through small Lua APIs.

ESP-Claw runs Lua via cap_lua_runtime_* helpers. The runtime initializes in cap_lua_group_init, locks module registration, then starts.

cap_lua runtime files must live under the Script Base Dir. In basic_demo, the default Script Base Dir is /fatfs/scripts/ (apps can override it via cap_lua_set_base_dir).

  • path must target a .lua file under Script Base Dir.
  • path supports two forms:
  • Absolute path: e.g. /fatfs/scripts/hello.lua (must stay under Base Dir and must not contain ..).
  • Short relative name: e.g. hello.lua (auto-expanded to ${base_dir}/hello.lua, and / is not allowed).
  • lua_list_scripts currently scans only the Base Dir top level and returns absolute paths (non-recursive).
  • lua_list_scripts.prefix also uses an absolute-path prefix and must stay under Base Dir.

cap_lua_register_module is only valid before cap_lua_group_init. After init, s_module_registration_locked = true and further registration returns ESP_ERR_INVALID_STATE, freezing the module set at boot.

// During app init (before cap_lua_register_group)
lua_module_display_register();   // register display
lua_module_gpio_register();      // register gpio
// ...
cap_lua_register_group();        // then lock

cap_lua currently registers eight Callables:

Tool IDPurpose
lua_list_scriptsList .lua files under the managed tree
lua_write_scriptWrite (create or overwrite) a script
lua_run_scriptSync run; block until output
lua_run_script_asyncAsync enqueue; returns job_id immediately
lua_list_async_jobsList async jobs (status filter)
lua_get_async_jobFetch status/output for one job (supports lookup by job_id or name)
lua_stop_async_jobStop one async job (by job_id or name)
lua_stop_all_async_jobsStop async jobs in bulk (optionally filter by exclusive group)
ModeWhen to useTimeoutReturns
lua_run_scriptQuick work / state readsoptional timeout_msscript output string
lua_run_script_asyncLong work (animation, waiting on sensors)timeout_ms=0 means run until stopped (default)job_id

lua_run_script_async supports name, exclusive, and replace controls:

  • name: assign a logical name for later lua_get_async_job / lua_stop_async_job operations.
  • exclusive: mutual-exclusion group (for example, "display"), commonly used for single-slot resources.
  • replace: true: when an active job with the same name or exclusive group exists, attempt to preempt and replace it.

Typical flow:

// LLM call (start a long-running job):
{"path": "animation.lua", "name": "clock_anim", "exclusive": "display", "timeout_ms": 0}

// Immediate return:
"Started Lua job 8a72f3c1 (name=clock_anim, exclusive=display, timeout_ms=0 [until cancelled], status=running) for animation.lua"

// Later query:
{"name": "clock_anim"}
// Response: includes job_id / status / summary, etc.

// Stop the job:
{"name":"clock_anim"}

Optional JSON args becomes the global args inside the script:

-- read parameters in-script
local input = args  -- mirrors caller args object
local speed = input.speed or 100

When lua_run_script / lua_run_script_async is triggered by the Agent inside an IM conversation, if the tool call does not explicitly provide args.channel, args.chat_id, or args.session_id, the runtime auto-merges the current session context into args. This lets scripts read conversation context directly (for example, for replies) without passing these fields every time.

{"path": "hello.lua", "content": "print('hello')", "overwrite": false}

With overwrite: false, an existing file errors instead of being clobbered. Default is true (overwrite).

  • Lua timeout detection combines an instruction-hook check (triggered every fixed instruction count) with wall-clock timeout; when timed out, it throws execution timed out.
  • The hook callback proactively calls taskYIELD() to avoid tight loops monopolizing CPU and accidentally triggering the task watchdog.
  • Integer-like values in JSON args are preserved as Lua integer type where possible, reducing type ambiguity for GPIO values, pixel coordinates, and similar parameters.

Besides tools, cap_lua exposes C helpers for firmware code:

// write script
cap_lua_write_script("startup.lua", lua_code, true, output, sizeof(output));

// sync run
cap_lua_run_script("startup.lua", NULL, 3000, output, sizeof(output));

// async run (optional name / exclusive / replace)
cap_lua_run_script_async("startup.lua",
                         NULL,
                         0,
                         "startup_loop",
                         "display",
                         false,
                         output,
                         sizeof(output));

// stop one async job (job_id or name)
cap_lua_stop_job("startup_loop", 2000, output, sizeof(output));

// stop all async jobs in a group
cap_lua_stop_all_jobs("display", 2000, output, sizeof(output));

// register module (must be before cap_lua_register_group)
cap_lua_register_module("my_module", luaopen_my_module);

Jobs move through:

Diagram

lua_list_async_jobs filters with "all" / "queued" / "running" / "done" / "failed" / "timeout" / "stopped".

Coordination with Skill deactivation guards

Section titled “Coordination with Skill deactivation guards”

In basic_demo, the cap_lua_run Skill has a deactivation guard: if Lua async jobs are still running, deactivate_skill is rejected with a structured reason (prompting you to stop jobs first via lua_stop_async_job or lua_stop_all_async_jobs).

That guard is registered in main.c’s app_main, after app_claw_start() returns (claw_skill_register_deactivate_guard("cap_lua_run", cap_lua_run_deactivate_guard)), so the guard attaches only after the Skill subsystem is fully ready.