The CoAP runlet implements a lightweight CoAP server over UDP, allowing constrained IoT devices to interact with Tinkwell services using a RESTful request/response model.
Resources are defined in .tw configuration files with a pluggable binding architecture.
Each coap block defines a UDP server listening on a port.
Within a server, resource blocks define URL path patterns.
Each resource contains on <verb> blocks that group bindings by HTTP-like method (GET, POST, PUT, DELETE).
Bindings are loaded from assemblies at runtime and execute sequentially within an on block.
CoAP UDP Request
→ Match resource path pattern
→ Execute matching on-verb blocks (with optional when filter)
→ Execute bindings (with optional when filter)
→ Return last non-null binding result as response
The from clause is optional for built-in bindings (measure, event, store); they default to Tinkwell.Integrations.
Use from "<assembly>" for custom or external bindings.
coap <name> {
port = 5683 # UDP port (default 5683)
resource "<pattern>" {
on <verb> [when (<expression>)] {
bind <name> [from "<assembly>"] [when (<expression>)] {
# binding-specific parameters
with <label> {
# nested parameters (e.g. event payload)
}
}
}
}
}
/sensor/temperature— exact match/sensor/+—+matches exactly one segment/sensor/#—#matches zero or more trailing segments
CoAP methods: get, post, put, delete.
Multiple on blocks for the same verb are allowed — all matching blocks execute in order.
Block-level: on post when (expression) { ... } — skips the entire block if the expression is falsy.
Binding-level: bind event when (expression) { ... } — skips this specific binding if the expression is falsy.
Other bindings in the same block still execute.
Both levels compose: the on filter runs first, then each bind filter individually.
The optional on error block configures what happens when a binding fails.
It can appear at the on verb level (default for all bindings in that block) or at the individual bind level (overrides the verb default).
Policies:
| Syntax | Behavior |
|---|---|
on error resume next; |
Log warning, skip this binding, continue. This is the implicit default. |
on error stop this; |
Log error, disable this binding for future invocations. |
on error stop application; |
Log critical, shut down the application. |
Retry modifiers (optional, on any policy):
| Modifier | Description |
|---|---|
retry N |
Max retry attempts before applying the policy. |
delay N |
Base delay in milliseconds between retries (default: 1000). |
backoff N |
Multiplier per attempt (default: 1 = fixed delay; 2 = exponential). |
coap sensors {
resource "/sensor/+" {
on post {
on error resume next retry 2 delay 500;
bind measure {
name = (segment(path, -1))
on error stop this;
}
}
}
}
| Variable | Description |
|---|---|
path |
Request URI path (e.g. /sensor/temp) |
query |
Query string if present |
payload |
Request body as string (empty for GET) |
Use segment(path, N) for path parsing (negative indices count from end), json_value(payload, "$.key") for JSON extraction.
Reads and writes measure values via the measures gRPC service.
| Method | Behavior | Output |
|---|---|---|
| GET | Reads current value | text/plain (default) or application/octet-stream (4-byte IEEE 754 float) |
| POST/PUT | Sets value from payload | None |
| DELETE | No-op | None |
Parameters:
name(required) — measure name (expression or literal)
Publishes an event to the event bus. Never produces output.
Parameters:
source(required) — event source identifierverb(required) — event verb (e.g.changed,created,fired, orcustom:xxx)name(required) — event nameobject(optional) — event object value
Nested blocks: with <label> { ... } — properties become EventEnvelope.Payload entries.
CRUD operations on the state store via gRPC.
| Method | Behavior | Output |
|---|---|---|
| GET | Reads entry | text/plain (default) or application/json |
| POST | Creates entry | None |
| PUT | Creates or updates (upsert) | None |
| DELETE | Removes entry | None |
Parameters:
bucket(required) — bucket IDkey(required) — entry key (expression or literal)namespace(optional) — key namespace (default empty)value(optional) — value for POST/PUT (defaults to request payload)ttl(optional) — TTL in seconds
| Code | Meaning |
|---|---|
| 2.01 Created | POST with no binding output |
| 2.02 Deleted | DELETE with no binding output |
| 2.04 Changed | PUT with no binding output |
| 2.05 Content | Binding returned output body |
| 4.00 Bad Request | ArgumentException from a binding |
| 4.04 Not Found | No matching resource pattern |
| 4.05 Method Not Allowed | No on block for this verb |
| 5.00 Internal Server Error | Unhandled exception |
POST sensor readings, set the measure value, and publish a changed event:
coap sensors {
port = 5683
resource "/sensor/+" {
on post {
bind measure {
name = (segment(path, -1))
}
bind event {
source = "coap"
verb = changed
name = (segment(path, -1))
with payload {
device = (segment(path, 1))
raw = (payload)
}
}
}
}
}
A POST to /sensor/temperature with body 23.5 will:
- Set measure
temperatureto23.5 - Publish event
{source: "coap", verb: changed, name: "temperature", payload: {device: "sensor", raw: "23.5"}} - Respond with
2.01 Created(no body)
Read current measure values, supporting both text and binary formats:
coap sensors {
resource "/sensor/+" {
on get {
bind measure {
name = (segment(path, -1))
}
}
}
}
- GET
/sensor/temperaturewith Accept: text/plain →"23.5"(UTF-8 string) - GET
/sensor/temperaturewith Accept: application/octet-stream → 4-byte IEEE 754 float
Single path pattern serving GET (read) and POST (write + event):
coap sensors {
resource "/sensor/+" {
on get {
bind measure {
name = (segment(path, -1))
}
}
on post {
bind measure {
name = (segment(path, -1))
}
bind event {
source = "coap"
verb = changed
name = (segment(path, -1))
}
}
}
}
Route critical alerts to a separate event while publishing normal telemetry:
coap devices {
resource "/device/+" {
on post {
bind measure {
name = (segment(path, -1))
}
bind event {
source = "coap"
verb = changed
name = (segment(path, -1))
}
bind event when (json_value(payload, "$.severity") == "critical") {
source = "coap"
verb = alert
name = (segment(path, -1))
with details {
severity = (json_value(payload, "$.severity"))
message = (json_value(payload, "$.message"))
}
}
}
}
}
Only the third binding (alert event) has a when filter — the measure update and normal event always execute.
Full CRUD operations on the state store:
coap storage {
port = 5684
resource "/store/+" {
on get {
bind store {
bucket = "default"
key = (segment(path, -1))
}
}
on post {
bind store {
bucket = "default"
key = (segment(path, -1))
ttl = 3600
}
}
on put {
bind store {
bucket = "default"
key = (segment(path, -1))
}
}
on delete {
bind store {
bucket = "default"
key = (segment(path, -1))
}
}
}
}
Skip an entire verb block based on a condition:
coap restricted {
resource "/admin/+" {
on post when (query == "auth=secret") {
bind store {
bucket = "admin"
key = (segment(path, -1))
}
}
}
}
POST requests without ?auth=secret in the query will receive 4.05 Method Not Allowed.
Run two CoAP servers on different ports:
coap sensors {
port = 5683
resource "/sensor/+" {
on post {
bind measure {
name = (segment(path, -1))
}
}
}
}
coap admin {
port = 5684
resource "/store/+" {
on get {
bind store {
bucket = "admin"
key = (segment(path, -1))
}
}
}
}
runner coap-host from "Tinkwell.Runner.Headless.dll" {
runlet coap from "Tinkwell.Runlet.Coap.dll";
}
The CoAP runlet discovers required services (measures, events, store) via IServiceDiscovery.
Ensure those runners are started before the CoAP runner.