An embedded AI agent runtime for constrained targets. EmbedClaw can run on FreeRTOS, bare-metal firmware, or POSIX host builds: it accepts user input over a serial/network transport, 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 - Provider adapter boundary — the agent loop talks to
ec_model, so model/provider backends can evolve without rewriting the core loop - TLS/HTTPS — mbedTLS integration on POSIX/FreeRTOS, or board-provided TLS through the bare-metal socket HAL
- Agentic loop — dispatches tool calls from the LLM, feeds results back, and loops until a final text response
- Capability bundles — compile-time capability groups with explicit policy boundaries; each bundle contributes tools and LLM system context
- Host simulation profile — explicit POSIX simulation mode for exercising almost all of the runtime on a desktop host
- 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 or foreground loop
- Bare-metal port — reusable UART, socket, and direct-MMIO HAL boundaries with no FreeRTOS dependency
- 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
└──────┬──────────┬───────┘
│ │
▼ ▼
┌────────────┐ ┌─────────────────────────────────────┐
│ Model Adapter │ │ Skill System (ec_skill) │
│ (ec_model) │ │ ┌───────────────────────────────┐ │
│ provider │ │ │ hw_register_control │ │
│ selection │ │ │ hw_register_read/write │ │
└─────┬─────────┘ │ ├───────────────────────────────┤ │
│ │ │ web_browsing │ │
│ │ │ web_search, web_fetch │ │
│ │ └───────────────────────────────┘ │
│ └──────────────┬──────────────────────┘
▼ │ (web tools also use HTTP)
┌─────────────────────────┐ │
│ Chat API Backend │◀─────┘
│ (ec_api) │
│ JSON + HTTP POST │
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ HTTP Client (ec_http) │ HTTP/1.1, chunked transfer encoding
└────────────┬────────────┘
│
▼
┌─────────────────────────┐
│ Socket (ec_socket) │ TCP + optional TLS
│ │ POSIX / FreeRTOS+TCP / bare-metal HAL
└─────────────────────────┘
The source tree follows the same boundary:
src/core/ Agent, model, HTTP, JSON, session, tools, skills
src/platform/posix/ POSIX socket, stdin/stdout UART shim, Telnet, mock MMIO
src/platform/freertos/ FreeRTOS+TCP, UART HAL bridge, Telnet, direct MMIO
src/platform/baremetal/ UART HAL bridge, socket HAL bridge, direct MMIO
src/app/ Host demo entry points
Code under src/core/ is shared by every target and should not include
platform headers. Platform-specific files own OS, board, socket, and MMIO
integration.
- CMake ≥ 3.10
- C99 compiler (GCC or Clang)
- mbedTLS (included as a git submodule for POSIX/FreeRTOS TLS/HTTPS support)
- For FreeRTOS builds: FreeRTOS+TCP 10.2.1 and a cross-compiler toolchain
- For bare-metal builds: board HAL callbacks for UART and socket/network access
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, embedclaw_tests, and the
POSIX Telnet smoke-test target embedclaw_telnet_tests.
mkdir build && cd build
cmake -DEC_PLATFORM=POSIX -DEC_HOST_SIM=ON ..
makeIf third_party/mbedtls is not initialized, use the same no-TLS fallback as the
normal POSIX build:
cmake -DEC_PLATFORM=POSIX -DEC_HOST_SIM=ON -DEC_ENABLE_TLS=OFF ..This produces the explicit simulation executable embedclaw_sim_demo and the
simulation-only validation target embedclaw_host_sim_tests, making the
simulation profile visible at build time.
Host simulation keeps the core runtime shared, but swaps hardware register access onto a bookkeeping-backed MMIO layer. The host-sim tool path still enforces the datasheet-backed access policy, so only modeled registers are readable or writable through the agent.
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.
The POSIX validation suite also includes a Telnet smoke test that exercises the real socket-based I/O path:
ctest --verbose -R 'e2e|telnet-io'When built with -DEC_HOST_SIM=ON, the simulation profile also adds a host-sim
test target that validates:
- bookkeeping-backed MMIO through the real tool path,
- deterministic mocked-model simulation mode, and
- the real-provider path running against mocked HTTP.
ctest --verbose -R 'e2e|host-sim|telnet-io'mkdir build && cd build
cmake -DEC_PLATFORM=FREERTOS -DFREERTOS_PATH=/path/to/freertos ..
makemkdir build && cd build
cmake -DEC_PLATFORM=BAREMETAL -DEC_ENABLE_TLS=OFF ..
makeBare-metal builds produce libembedclaw.a and no demo executable. Board code
owns startup, calls ec_io_uart_set_hal() for the UART byte transport, calls
ec_socket_baremetal_set_hal() for network/TLS access, then initializes
ec_io_uart_ops and runs the same ec_agent loop used by the other ports.
EC_ENABLE_TLS=ON is allowed for bare-metal builds, but TLS is delegated to the
registered socket HAL instead of linking mbedTLS directly.
Set 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 2323To run the explicit host simulation profile:
./build/embedclaw_sim_demoThe host simulator defaults to the deterministic mock-model mode, so a plain local run works without provider credentials:
./build/embedclaw_sim_demoHost simulation supports two model modes:
EC_SIM_MODEL_MODE=mock(default) uses a deterministic local model shim that still drives the real tool loopEC_SIM_MODEL_MODE=realkeeps the normal provider-backed path
Examples:
./build/embedclaw_sim_demo
EC_SIM_MODEL_MODE=real EC_API_KEY=sk-... ./build/embedclaw_sim_demoThe simulator runs as a simple line-oriented shell. After startup it prints a
ready banner and a > prompt. Type one request per line and press Enter.
In the default EC_SIM_MODEL_MODE=mock, the local mock model is best suited for direct
register operations. For example:
> read register 0x40001000
Simulated register 0x40001000 contains 0x00000000.
> write register 0x40001000 to 0x00000001
Simulated register write completed.
> read register 0x40001000
Simulated register 0x40001000 contains 0x00000001.
In EC_SIM_MODEL_MODE=real, the shell works the same way, but responses come
from the configured provider-backed model instead of the deterministic local
shim.
| 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 capability bundles |
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 |
EC_CONFIG_UART_RX_TIMEOUT_MS |
100 |
FreeRTOS UART read poll timeout (ms) |
EC_CONFIG_UART_TX_TIMEOUT_MS |
1000 |
FreeRTOS UART write timeout (ms) |
| 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);For the built-in FreeRTOS and bare-metal UART backends, board code provides the
actual UART transport hooks through ec_io_uart_set_hal(), then selects
ec_io_uart_ops.
Bare-metal firmware registers a socket HAL before the model or web tools make network requests:
#include "ec_socket.h"
static int board_connect(void *ctx, const char *host, uint16_t port, int use_tls);
static int board_send(void *ctx, const void *data, size_t len);
static int board_recv(void *ctx, void *buf, size_t len, uint32_t timeout_ms);
static void board_close(void *ctx);
static const ec_socket_baremetal_hal_t socket_hal = {
.ctx = NULL,
.connect = board_connect,
.send = board_send,
.recv = board_recv,
.close = board_close,
};
ec_socket_baremetal_set_hal(&socket_hal);The HAL may wrap a vendor TCP/IP stack, Wi-Fi module, cellular modem, or TLS
offload engine. The core HTTP client continues to call ec_socket_connect(),
ec_socket_send(), ec_socket_recv(), and ec_socket_close() regardless of
platform.
Two tools are registered via the hw_datasheet capability bundle, 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 capability bundle:
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 capability bundle:
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 embedded builds, hardware register access is restricted to registers declared in the compiled-in datasheet. Unknown addresses and policy-forbidden accesses are rejected. POSIX builds still use the mock register bank.
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 and bare-metal targets, 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) ===
When validating a target build, use this minimum checklist:
- Network path — verify the device obtains network connectivity and can open a TCP connection to the configured model/API host.
- UART path — verify the board-specific
ec_io_uart_set_hal()hooks can read a full line and write a reply without truncation or lockup. - Telnet path — on FreeRTOS, verify a client can connect, send fragmented lines, and receive responses over the Telnet backend.
- Bare-metal socket HAL — on bare-metal targets, verify connect/send/recv/close callbacks handle timeout and close semantics expected by
ec_socket. - Safety path — verify datasheet-backed register policy rejects unknown or forbidden accesses on target hardware.
- Agent path — run one full prompt/tool/result turn against the configured model provider with debug logging enabled.
- TLS/HTTPS via mbedTLS (POSIX, with embedded CA bundle)
- Debug logging (
EC_DEBUG=1) for LLM request/response inspection - FreeRTOS+TCP socket backend
- FreeRTOS UART backend
- FreeRTOS Telnet I/O backend
- Bare-metal UART/socket HAL port
- POSIX Telnet smoke validation coverage
- 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