The complete loop for "I want the LLM to be able to do X":
- Declare the intent in the manifest
- Implement the handler in firmware
- Test with the round-trip script
- Re-flash
- LLM picks it up automatically — the Bridge re-reads the manifest
The Bridge and MCP server require no code change. The manifest is the
single source of truth: change it, regenerate optional artifacts, the LLM
sees the new tool. This document walks through a complete worked example
adding a blink(times, period) intent to the lamp.
Edit examples/lamp_manifest.yaml. Append to intents::
- name: blink
params:
times: { type: int, range: [1, 20], default: 3 }
period: { type: duration, unit: ms,
range: [50, 2000], default: 200 }
capability: lamp.write
idempotent: false # calling twice blinks twice
dry_run: true # the LLM can ask "what would this do?"What each field buys you, automatically:
range→ the Bridge rejects out-of-range calls before they reach the wiredefault→ omitting the parameter in the LLM call uses the defaultunit→ the LLM seesunit: msin the tool schema and stops sending secondscapability→ callers must holdlamp.write(token-gated)dry_run: true→ the LLM gets a__dry_run__boolean parameter for freeidempotent: false→ the LLM is hinted that retries are unsafe
That's it for declaration. The Bridge will load this on next start and
expose a tool named blink to the LLM, with a full JSON Schema generated
from the param table.
Edit firmware/esp32/examples/lamp/lamp.ino. Add the handler:
static dcp::Status handle_blink(uint8_t kind,
dcp::CborReader& params,
dcp::CborMap& reply,
void*) {
int64_t times = 3; // defaults match the manifest
double period = 200.0;
// Parse CBOR params. Keys arrive in the order the caller sent them.
while (params.remaining() > 0) {
const char* key; size_t key_len;
if (!params.next_key(&key, &key_len)) return dcp::STATUS_DENIED;
if (key_len == 5 && memcmp(key, "times", 5) == 0) {
if (!params.read_int(×)) return dcp::STATUS_RANGE;
} else if (key_len == 6 && memcmp(key, "period", 6) == 0) {
if (!params.read_float(&period)) return dcp::STATUS_RANGE;
} else {
params.skip(); // forward-compat with new manifest fields
}
}
// Belt-and-suspenders: re-check ranges. The Bridge already did this,
// but defending here means a misbehaving Bridge can't drive the LED
// outside the safe envelope.
if (times < 1 || times > 20) return dcp::STATUS_RANGE;
if (period < 50 || period > 2000) return dcp::STATUS_RANGE;
// Dry-run: report what we would do, no side effects.
if (kind == dcp::KIND_DRY_RUN) {
reply.add_int("would_blink", times);
reply.add_int("at_period_ms", (int64_t)period);
return dcp::STATUS_OK;
}
// Actually blink. Restore the previous brightness when done.
uint32_t saved = (uint32_t)(g_brightness * 2.55f);
uint32_t half = (uint32_t)(period / 2.0);
for (int i = 0; i < times; ++i) {
ledcWrite(LED_PIN, 255); delay(half);
ledcWrite(LED_PIN, 0); delay(half);
}
ledcWrite(LED_PIN, saved);
return dcp::STATUS_OK;
}Register it in the binding table — the array right above setup():
static dcp::IntentBinding bindings[] = {
{ DCP_ID("set_brightness"), handle_set_brightness, nullptr },
{ DCP_ID("set_color"), handle_set_color, nullptr },
{ DCP_ID("read_brightness"), handle_read_brightness, nullptr },
{ DCP_ID("blink"), handle_blink, nullptr }, // ← added
};DCP_ID("blink") is a constexpr — the CRC-16 of the string is computed
at compile time and matched against the wire intent_id. Same string in
manifest and firmware means same id. No code generation step required.
Append a few cases to tools/test_uart_roundtrip.py's tests list:
("blink 3x default", "blink", {"times": 3, "period": 200}, False, "ok"),
("blink dry-run 5x", "blink", {"times": 5}, True, "ok"),
("blink times out of range","blink",{"times": 9999}, False, "range"),
("blink with only defaults","blink", None, False, "ok"),The third case verifies the Bridge rejects bad input before any byte
reaches the wire (status comes back as range from Python, not from the
firmware). The fourth case verifies defaults work end-to-end: the LLM
can just say blink() and get 3 blinks at 200ms.
arduino-cli compile --fqbn esp32:esp32:esp32 firmware\esp32\examples\lamp\lamp.ino
arduino-cli upload --port COM5 --fqbn esp32:esp32:esp32 firmware\esp32\examples\lamp\lamp.inoThen re-run the round-trip test — all rows should be [OK]. You should
see the LED actually blink during the "blink 3x default" row.
Restart any LLM client that has the dcp-lamp MCP server connected
(Claude Code, Claude Desktop, etc). On the next conversation the new
tool is automatically listed:
blink(times: int = 3, period: number = 200, __dry_run__: bool = false)
DCP intent: blink · capability: lamp.write
Ask the LLM "flash the lamp 5 times quickly" — it'll call
blink({times: 5, period: 100}) and the LED will obey.
- name: read_temperature
returns: { type: float, unit: celsius }
capability: lamp.readstatic dcp::Status handle_read_temperature(uint8_t,
dcp::CborReader&,
dcp::CborMap& reply,
void*) {
double t_celsius = temperatureRead(); // ESP32 internal sensor
reply.add_float("value", t_celsius);
return dcp::STATUS_OK;
}Single returned value, always shape {"value": ...}.
CBOR has no native enum; pass a string and validate. The manifest hints
with a description (DCP v0.1 doesn't yet have an enum: schema field —
roadmap):
- name: set_mode
params:
mode: { type: string, default: "normal" } # one of: normal, reading, sleep, movie
capability: lamp.write
idempotent: trueconst char* mode_str; size_t mode_len;
if (!params.read_string(&mode_str, &mode_len)) return dcp::STATUS_RANGE;
if (mode_len == 6 && memcmp(mode_str, "normal", 6) == 0) { /* ... */ }
else if (mode_len == 7 && memcmp(mode_str, "reading", 7) == 0) { /* ... */ }
// etc.
else return dcp::STATUS_RANGE;events:
- name: motion_detected
payload:
confidence: { type: float, unit: ratio, range: [0, 1] }
capability: lamp.read// In loop(), when your sensor fires:
uint8_t buf[32];
dcp::CborMap m(buf, sizeof(buf));
m.begin();
m.add_float("confidence", 0.94);
dcp_instance->send_event("motion_detected", m);The Bridge fans this out to any LLM session subscribed to lamp.read.
| Symptom | Cause | Fix |
|---|---|---|
denied with empty data from device |
error reply buffer too small in firmware | bump the uint8_t buf[N] in your handler |
denied after Bridge says ok for the same call shape on dry-run |
CBOR type mismatch — handler used read_int on a duration/float param; the reader wedges, next next_key returns false |
use read_float for any param whose manifest type is float or duration; read_int only for int. See "CBOR is type-strict" note below |
unknown_intent for an intent you swore is in the manifest |
spelling mismatch — DCP_ID("foo") vs manifest name: Foo |
strings are byte-exact; rename one to match |
| LLM keeps sending out-of-range values | you forgot range: in the manifest |
add it; the Bridge picks it up after restart |
Long handler causes busy from the Bridge |
delay() exceeded the Bridge timeout (default 2s) |
shorten, or run async via FreeRTOS task and ack-then-event |
set_color works but no light changes |
you don't have an RGB LED wired up | that's by design — the example saves state and flashes the brightness LED to acknowledge |
Capability check fails with capability_required |
the LLM session's token doesn't include that capability | re-issue a token: dcp token mint --caps lamp.write,lamp.read,... |
CBOR distinguishes integer and float at the major-type level. A 42
and a 42.0 are not the same item. The Bridge encodes manifest
parameter values like this:
manifest type |
wire encoding |
|---|---|
int, bool |
CBOR int |
string |
CBOR text string |
float, duration |
CBOR float (double) |
So a duration param like period: { type: duration, unit: ms }
arrives on the device as a CBOR float, even if the caller wrote 200
(no decimal). Your firmware handler must decode it with
params.read_float(&period), not params.read_int(&period). A
mismatched read returns false and leaves the parser positioned wrong
for the next field — the next next_key() call will then also fail,
and the handler returns STATUS_DENIED.
If you see "Bridge accepts the call (status is not range or
unknown_intent) but the device returns denied for both dry-run
and real calls, while a sibling intent with only int/bool params
works fine," check this first. The fix is one word per affected
param (read_int → read_float).
The Python Bridge, the MCP server wrapper, the CLI, and the conformance suite all work off the manifest. You never edit Bridge code to add an intent. That's the protocol's load-bearing property: device authors own the manifest + firmware; the Bridge is generic.
If you ever find yourself patching the Bridge for a specific device, you've probably encoded device-specific knowledge in the wrong layer. Move it to the manifest schema first, then the Bridge change.