An embedded AI agent runtime for FreeRTOS. EmbedClaw runs on constrained hardware and acts as an intelligent automation layer: it accepts user input over a serial interface (UART, Telnet, or similar), forwards it to a remote OpenAI-compatible LLM, executes tool calls returned by the LLM (such as reading and writing hardware registers), and returns the final answer to the user.
It is the embedded counterpart to OpenClaw — same agentic loop, same OpenAI tool-call protocol, different execution environment.
- OpenAI-compatible — uses the standard
/v1/chat/completionsAPI withtool_callsJSON format; no custom protocol - TLS/HTTPS — mbedTLS integration with embedded CA bundle; no filesystem required
- Agentic loop — dispatches tool calls from the LLM, feeds results back, and loops until a final text response
- Skill system — compile-time capability bundles; each skill contributes tools and LLM system context
- Built-in tools — hardware register read/write, register map lookup, web search (Brave API), and web page fetch
- Extensible tool framework — register new tools with a name, JSON Schema, and a C handler function
- Persistent conversation — session history survives UART/Telnet reconnects across the device lifetime
- Transport-agnostic I/O — swap between UART and Telnet (or add new transports) without touching the agent logic
- No dynamic allocation in hot paths — all buffers are statically sized; predictable memory usage on embedded targets
- Single-threaded — no RTOS threading required; the entire agent loop runs to completion in one task
- POSIX build — full host build for development and testing (no hardware required)
User
│
│ UART / Telnet / (extensible)
▼
┌─────────────────────────┐
│ I/O Layer (ec_io) │ read_line / write ops
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Session (ec_session) │ conversation history, persistent across reconnects
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Agent Loop (ec_agent) │ user → LLM → tool dispatch → loop → response
└──────┬──────────┬───────┘
│ │
▼ ▼
┌────────────┐ ┌─────────────────────────────────────┐
│ Chat API │ │ Skill System (ec_skill) │
│ (ec_api) │ │ ┌───────────────────────────────┐ │
│ JSON + │ │ │ hw_register_control │ │
│ HTTP POST │ │ │ hw_register_read/write │ │
│ │ │ ├───────────────────────────────┤ │
│ │ │ │ web_browsing │ │
│ │ │ │ web_search, web_fetch │ │
│ │ │ └───────────────────────────────┘ │
└─────┬──────┘ └──────────────┬──────────────────────┘
│ │ (web tools also use HTTP)
├────────────────────────┘
▼
┌─────────────────────────┐
│ HTTP Client (ec_http) │ HTTP/1.1, chunked transfer encoding
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Socket (ec_socket) │ TCP + optional TLS (mbedTLS)
│ │ FreeRTOS+TCP / POSIX
└─────────────────────────┘
- CMake ≥ 3.10
- C99 compiler (GCC or Clang)
- mbedTLS (included as a git submodule for TLS/HTTPS support)
- For FreeRTOS builds: FreeRTOS+TCP and a cross-compiler toolchain
git clone --recurse-submodules https://github.com/user/embedclaw.git
# Or if already cloned:
git submodule update --initmkdir build && cd build
cmake -DEC_PLATFORM=POSIX ..
makeTo build without TLS (no mbedTLS dependency):
cmake -DEC_PLATFORM=POSIX -DEC_ENABLE_TLS=OFF ..This produces embedclaw_demo, libembedclaw.a, and embedclaw_tests.
From the build directory (POSIX only):
make embedclaw_tests
ctest --verboseThe test suite (tests/test_e2e.c) runs the full agent stack with a mock HTTP layer instead of real networking.
mkdir build && cd build
cmake -DEC_PLATFORM=FREERTOS -DFREERTOS_PATH=/path/to/freertos ..
makeSet your API credentials and start the agent:
export EC_API_KEY=sk-...
export EC_API_HOST=api.openai.com
export EC_API_PORT=443
export EC_MODEL=gpt-4o
export EC_BRAVE_API_KEY=BSA-... # optional, for web_search
./build/embedclaw_demoThe demo defaults to stdin/stdout (UART mode). To use the Telnet backend instead, connect on port 2323:
EC_IO=telnet ./build/embedclaw_demo &
telnet localhost 2323| Command | Effect |
|---|---|
/reset |
Clear conversation history |
/quit |
Exit the agent loop |
All limits are compile-time constants in include/ec_config.h:
| Constant | Default | Description |
|---|---|---|
| API endpoint | ||
EC_CONFIG_API_HOST |
api.openai.com |
LLM API hostname |
EC_CONFIG_API_PORT |
443 |
LLM API port |
EC_CONFIG_USE_TLS |
1 |
Enable TLS (set to 0 for plain HTTP) |
EC_CONFIG_MODEL |
gpt-4o |
Model name |
| HTTP / buffers | ||
EC_CONFIG_REQUEST_BUF |
8192 |
Outgoing JSON request body (bytes) |
EC_CONFIG_RESPONSE_BUF |
8192 |
Raw HTTP response body (bytes) |
EC_CONFIG_CONTENT_BUF |
2048 |
Extracted LLM text response (bytes) |
EC_CONFIG_TOOL_ARG_BUF |
256 |
Per-tool-call arguments JSON (bytes) |
EC_CONFIG_TOOL_RESULT_BUF |
4096 |
Per-tool result buffer (bytes) |
| Session | ||
EC_CONFIG_SESSION_CONTENT_BUF |
512 |
Per-message content in history |
EC_CONFIG_MAX_HISTORY |
64 |
Max messages in conversation history |
EC_CONFIG_MAX_TOOL_CALLS |
4 |
Max tool calls per LLM response |
EC_CONFIG_MAX_AGENT_ITERS |
8 |
Max tool-call iterations per turn |
| Tool / skill framework | ||
EC_CONFIG_MAX_TOOLS |
16 |
Max registered tools |
EC_CONFIG_MAX_SKILLS |
16 |
Max registered skills |
EC_CONFIG_SYSTEM_PROMPT_BUF |
2048 |
Combined system prompt buffer (bytes) |
| I/O layer | ||
EC_CONFIG_IO_LINE_BUF |
256 |
User input line buffer (bytes) |
EC_CONFIG_TELNET_PORT |
2323 |
Telnet listen port |
| Web browsing skill | ||
EC_CONFIG_BRAVE_API_HOST |
api.search.brave.com |
Brave Search API hostname |
EC_CONFIG_BRAVE_API_PORT |
443 |
Brave Search API port |
EC_CONFIG_BRAVE_API_KEY |
BSA-CHANGE-ME |
Brave Search subscription token |
EC_CONFIG_WEB_FETCH_MAX |
4096 |
Max bytes returned by web_fetch |
EC_CONFIG_WEB_SEARCH_COUNT |
5 |
Number of search results to return |
| Debug | ||
EC_CONFIG_DEBUG_LOG |
0 |
Enable debug logging (FreeRTOS; POSIX uses EC_DEBUG env) |
Implement ec_tool_fn_t, fill in an ec_tool_def_t, and call ec_tool_register() at startup:
#include "ec_tool.h"
static int my_tool_fn(const char *args_json, char *out_json, size_t out_size)
{
/* parse args_json with ec_json_find_string(), do work, write result */
snprintf(out_json, out_size, "{\"status\":\"ok\"}");
return 0;
}
static const ec_tool_def_t my_tool = {
.name = "my_tool",
.description = "Does something useful on the device.",
.parameters_schema =
"{"
"\"type\":\"object\","
"\"properties\":{"
"\"param\":{\"type\":\"string\",\"description\":\"A parameter\"}"
"},"
"\"required\":[\"param\"]"
"}",
.fn = my_tool_fn,
};
/* Call once at startup, before the agent loop */
ec_tool_register(&my_tool);The tool is automatically advertised to the LLM and dispatched when called.
Implement ec_io_ops_t and call ec_io_init():
#include "ec_io.h"
static int my_read_line(char *buf, size_t size) { /* ... */ }
static int my_write(const char *str) { /* ... */ }
static const ec_io_ops_t my_io_ops = {
.read_line = my_read_line,
.write = my_write,
};
ec_io_init(&my_io_ops);Two tools are registered via the hw_datasheet skill, giving the LLM on-demand access to the device's register map without bloating the system prompt:
hw_module_list — list all hardware modules on the device.
arguments: {}
result: { "modules": [ { "name": "uart0", "base_addr": "0x40001000",
"description": "UART serial port 0." }, ... ] }
hw_register_lookup — look up registers and bit-field definitions for a module.
arguments: { "module": "uart0" }
— or — { "module": "uart0", "register": "CTRL" }
result: { "module": "uart0", "base_addr": "0x40001000",
"registers": [ { "name": "CTRL", "offset": "0x00",
"address": "0x40001000", "reset_value": "0x00000000",
"fields": [ { "name": "EN", "bits": "0", "access": "RW",
"description": "UART enable." }, ... ] } ] }
Create a header file using the ec_hw_datasheet.h data structures. See include/ec_hw_example_asic.h for the pattern:
#include "ec_hw_datasheet.h"
static const ec_hw_bitfield_t s_my_ctrl_fields[] = {
{ "EN", 0, 0, "RW", "Module enable" },
/* ... */
};
static const ec_hw_register_t s_my_regs[] = {
{ .name = "CTRL", .offset = 0x00, .reset_value = 0,
.description = "Control register",
.fields = s_my_ctrl_fields,
.num_fields = sizeof(s_my_ctrl_fields) / sizeof(s_my_ctrl_fields[0]) },
};
static const ec_hw_module_t s_my_modules[] = {
{ .name = "my_periph", .base_addr = 0x40010000,
.description = "My peripheral",
.notes = "Programming sequence: ...",
.registers = s_my_regs,
.num_registers = sizeof(s_my_regs) / sizeof(s_my_regs[0]) },
};
const ec_hw_module_t *EC_HW_MODULES = s_my_modules;
const size_t EC_HW_MODULE_COUNT = sizeof(s_my_modules) / sizeof(s_my_modules[0]);Then include your header from ec_skill_table.c in place of ec_hw_example_asic.h.
Two tools provide web access, registered via the web_browsing skill:
web_search — search the web using the Brave Search API.
arguments: { "query": "FreeRTOS task priorities" }
result: { "results": [ { "title": "...", "url": "...", "description": "..." }, ... ] }
web_fetch — fetch the content of a URL via HTTP GET.
arguments: { "url": "http://example.com/data.json" }
result: { "status": 200, "body": "<page content, truncated>" }
The Brave Search API key is configured via EC_BRAVE_API_KEY (environment variable on POSIX, or EC_CONFIG_BRAVE_API_KEY at compile time). Response bodies from web_fetch are truncated to EC_CONFIG_WEB_FETCH_MAX (default 4096 bytes).
Two tools are registered via the hw_register_control skill:
hw_register_read — read a 32-bit memory-mapped register.
arguments: { "address": "0x40000000" }
result: { "address": "0x40000000", "value": "0x00000001" }
hw_register_write — write a 32-bit value to a register.
arguments: { "address": "0x40000000", "value": "0x00000001" }
result: { "ok": true }
On POSIX builds, a 16-register mock array at base 0x40000000 is used instead of real hardware.
Security note: On production builds, restrict valid address ranges in
ec_tool.cbefore deploying. The LLM controls the address argument.
Set EC_DEBUG=1 to trace the full agent loop — every LLM request/response JSON body, tool dispatches with arguments and results, and iteration counts. All output goes to stderr:
EC_DEBUG=1 ./build/embedclaw_demo 2>debug.logOn FreeRTOS, enable at compile time by setting EC_CONFIG_DEBUG_LOG=1 in ec_config.h.
Example output:
[EC_DEBUG] === agent turn start: "read register 0x40000000"
[EC_DEBUG] --- iteration 1/8 ---
[EC_DEBUG] sending 2 messages to LLM
[EC_DEBUG] >>> LLM request: POST api.openai.com:443/v1/chat/completions
[EC_DEBUG] --- request body (1468 bytes) ---
{"model":"gpt-4o","messages":[...], "tools":[...]}
[EC_DEBUG] --- end request body ---
[EC_DEBUG] <<< LLM response: HTTP 200
[EC_DEBUG] --- response body (223 bytes) ---
{"choices":[{"message":{"tool_calls":[...]},"finish_reason":"tool_calls"}]}
[EC_DEBUG] --- end response body ---
[EC_DEBUG] LLM requested 1 tool call(s)
[EC_DEBUG] dispatching tool [0]: hw_register_read (id=call_001)
[EC_DEBUG] args: {"address":"0x40000000"}
[EC_DEBUG] result: {"address":"0x40000000","value":"0x00000000"}
[EC_DEBUG] --- iteration 2/8 ---
[EC_DEBUG] ...
[EC_DEBUG] === agent turn complete (iter 2) ===
- TLS/HTTPS via mbedTLS (POSIX, with embedded CA bundle)
- Debug logging (
EC_DEBUG=1) for LLM request/response inspection - FreeRTOS+TCP socket backend (
ec_socket.c) - FreeRTOS UART and Telnet I/O backends
- FreeRTOS TLS support (socket layer already TLS-aware)
- Flash/NVS persistence for conversation history across power cycles
- Hardware register address allowlist for production safety
- Minimal mbedTLS config for reduced binary size on embedded targets
See plan.md for the detailed implementation plan and spec.md for the full design specification.
MIT