Resident — Full Architectural Analysis

2026-05-22 · source · source ↗ · raw capture · wiki page

What it is

Resident provides a sandboxed Lua 5.4 runtime for ESP32 devices with network hot-reload. Hardware peripherals are exposed to Lua through a driver interface, so apps can draw to displays, read sensors, and control outputs without touching C++. Apps are pushed over WebSocket as Lua source strings; the sandbox compiles and runs them in-place, replacing any previously running app. An optional Claude Code plugin lets AI coding agents author, validate, and push Lua apps to devices from a chat interface.

Project stats

Architecture

Entry point: Resident::Sandbox (src/ResidentSandbox.h, src/ResidentSandbox.cpp)

The single public class. Combines an optional Courier networking layer with a Lua VM. Constructed with a SandboxConfig, then driven by Arduino's setup()/loop() pattern.

Construction is two-phase: 1. Constructor — applies config, constructs Courier::Client if network is configured, derives device ID from chip MAC 2. setup() — idempotent. Fires user's onConfigureNetwork callback, wires internal Courier hooks (status display/LED updates, reserved-type message routing), calls initialize() to create Lua state and register extensions, then kicks Courier's WiFi+transport setup 3. loop() — drives Courier loop, updates status display, ticks extensions at full rate, fires Lua on_tick at 10 FPS (gated on isConnected() when networked), dispatches one queued event per iteration

Extension system (src/ResidentExtension.h, src/ResidentDriver.h)

Two-tier hierarchy: - Extension — base class. Pure virtual name() returns the Lua global name. Optional begin(), update(), registerModule(), onAppReset(). Uses a static helper beginExtension() for idempotent init. - Driver — extends Extension with sendEvent() (protected) and onAppRunning() hook. Events are queued into the sandbox's ring buffer and delivered to Lua's on_event. Implements RTTI-free downcast via asDriver() returning this — avoids dynamic_cast which requires -frtti (disabled by Arduino/ESP32 builds).

Lua binding system (src/ResidentLuaModule.h)

A builder pattern. Extensions receive a LuaModule& in registerModule() and chain .method<>(), .staticMethod(), .constant() calls. The template machinery (Trampoline<C, &C::fn>) stores the Extension* as a Lua upvalue and casts it back to the derived class pointer on call. This works without RTTI because the cast is static_cast<C*>(void*), which is correct only when Extension is the leftmost base class.

Event system

Fixed-size ring buffer (8 slots) in Sandbox::_events[]. Three event types: - BUTTON (legacy, not used in current code) - DRIVER — from Driver::sendEvent(). Fields serialized as compact JSON into a 256-byte buffer, then parsed at delivery time by flattening onto the Lua event table. - APP_EVENT — from WebSocket messages of type "app_event". Data parsed into an event.data subtable.

Message protocol

Three reserved JSON message types routed internally: - {"type":"app","code":"..."} — compiles and runs Lua source - {"type":"shader","expr":"..."} — converts via user-provided ShaderTemplateFn, then runs - {"type":"app_event","name":"...","data":{...}} — queues an event for Lua's on_event

All other types are forwarded to the user's onMessage callback.

Networking (opt-in via std::optional)

When cfg.network is set, the Sandbox constructs an internal Courier::Client (from the sibling Courier library) which handles WiFi (via WiFiManager captive portal on first boot), NTP time sync, and WebSocket transport. When unset, the sandbox runs standalone — no WiFi code is linked, isConnected() always returns false, and loop() ticks Lua unconditionally.

PSRAM allocator

On ESP32 (#ifdef ESP_PLATFORM), a custom Lua allocator routes all Lua allocations to PSRAM via heap_caps_malloc(MALLOC_CAP_SPIRAM). This preserves internal SRAM for the WiFi stack and driver buffers.

Claude Code agent plugin (tools/agent-plugin/)

Five skills at paths like tools/agent-plugin/skills/*/SKILL.md: - create-app — reads DEVICE-SKILL.md + sandbox docs, generates Lua from natural language, validates, retries up to 3 times - validate-app — runs Lua through a local interpreter under a permissive stub harness derived from DEVICE-SKILL.md. Catches syntax errors, missing lifecycle functions, nil dereferences - push-app — sends Lua to the WebSocket relay at resident.inanimate.tech/devices/<deviceId>/send or a self-hosted endpoint - write-device-skill — interactively authors DEVICE-SKILL.md for new hardware - hello-resident — liveness check

The plugin is distributed via a Claude Code marketplace (inanimate-tech/agent-plugins).

Key techniques

  1. Template trampoline for Lua binding without RTTI: Trampoline<C, &C::fn>::call is a static function compatible with lua_CFunction. It pulls the Extension* from an upvalue and casts to C*. The static_assert guarantees C derives from Extension. This avoids dynamic_cast (which requires -frtti, disabled on ESP32) and avoids per-method thunks in the derived class.

  2. void* + fn pointer instead of std::function for Driver event sink: Driver stores EventSinkFn _eventSinkFn (plain function pointer) and void* _eventSinkCtx rather than std::function. This keeps Driver allocation-free for embedded targets — important when drivers may be constructed statically before heap is available.

  3. std::optional as networking opt-in: The entire networking code path is gated on _courier.has_value(). This is a compile-time choice encoded as a runtime check — the linker can't strip the Courier code since it's always compiled, but the pattern means a single Sandbox class serves both networked and standalone use cases without #ifdef soup.

  4. Idempotent init via static helper: Extension::beginExtension(Extension& e) checks a private _begun flag. Users can call it early (e.g., to initialize a display before the sandbox), and the Sandbox's own call is a safe no-op. This avoids the "who calls begin() first" ordering problem.

  5. Runtime error rate limiting: on_tick fires at 10 Hz — a broken app could spam telemetry. The sandbox limits to 3 errors in a burst, then requires a 5-second cooldown before reporting more. init and on_event errors are always reported since they fire less frequently.

  6. Hand-rolled JSON parser for event delivery: Rather than linking ArduinoJson into the hot path of processNextEvent(), the sandbox does a single-pass char-by-char parse of the compact-JSON event data. This avoids allocation in the event dispatch path and keeps the JSON library out of the Lua call stack (where heap pressure matters).

  7. rgb() returns negative packed values: The shader convention is that rgb(r,g,b) returns -(packed_rgb) — a negative integer signals "this is a color, not a number" to shader template code. This is a clever overloading of Lua's number type (all Lua numbers are doubles, but integers up to 2^53 are exact).

Design decisions

Comparison notes

Critical analysis

Strengths: - The C++ template machinery for Lua binding is elegant — minimal boilerplate per driver method, type-safe, zero runtime overhead. Much cleaner than the typical Lua C API boilerplate. - The opt-in networking via std::optional is a clean design. Most embedded frameworks force networking always-on. Resident's "standalone mode" is genuinely useful — a device that just runs a pre-loaded Lua app with no WiFi. - The agent plugin is the real innovation. Resident isn't just a Lua runtime — it's a platform for agent-driven device programming. The four-step bring-up guide (hardware → Resident → drivers → agent skills) is a complete workflow for adding new hardware to an agent's capabilities. - The DEVICE-SKILL.md pattern is smart: a single Markdown file that documents the Lua surface for both humans and agents. The agent reads it to know what Lua functions are available; the local validator reads it to build a stub harness.

Weaknesses: - The 8-extension limit is a reasonable embedded constraint but poorly discoverable — it's a silent overflow in the Extensions initializer_list constructor (if (count >= MAX) break;). - The event ring buffer drops oldest events silently. For button presses this is fine (you want the latest), but for ordered event sequences it could lose state. - The hand-rolled JSON parser in processNextEvent() is ~80 lines of char-by-char parsing that duplicates ArduinoJson's functionality. The justification (avoiding allocation in the hot path) is valid but the code is fragile — it doesn't handle escaped quotes in JSON strings or nested objects. - No authentication beyond device ID. The README acknowledges this ("treat it like an API key for development") but it means Resident devices on the public relay are effectively open to anyone who knows the 8-char hex ID. - The leftmost-base rule is a real footgun. The static_assert catches it at compile time, but the error message won't explain the fix to someone who doesn't already know the rule.

Surprising choices: - Using Lua 5.4 (not 5.1/5.2/LuaJIT which are more common in embedded). The fischer-simon/Esp32Lua port handles the ESP32-specific build flags. - The telemetry format uses a nested JSON string for generationId — it's JSON-inside-JSON that requires double-escaping. This is odd but works with the relay's protocol. - The onTransportsWillConnect callback sets a default WS path of /agents/<type>-agent/<id> but all examples override it to /devices/<id>. This suggests the /agents/ path is the "real" protocol and /devices/ is a backwards-compatible alias.

For AI/agent contexts