Synapse — Sensor Ingest
Synapse is the serverless sensor/IoT ingest gateway for tissue.systems. Devices publish telemetry over MQTT; synapse authenticates each device, enforces per-account and per-device topic isolation and rate limits, and dispatches every reading into the owning Cell's sensor handler.
The name fits the tissue metaphor: a synapse is the junction that transmits a signal from one cell to the next. Synapse is that junction for physical devices — it carries each reading from the sensor into your Cell, then gets out of the way. It is stateless: synapse stores no readings. Your Cell owns storage (typically c3).
How it works
device ──MQTT(S):8883──▶ synapse (auth + ACL + rate-limit) ──HTTP POST──▶ Cell.sensor() ──▶ c3
- A device opens an MQTT-over-TLS connection to
ingest.tissue.dev:8883and authenticates with itsdevice_id(username) and token (password). - It publishes a message to
tissue/<account_id>/<device_id>/<metric>. - Synapse checks the device's rate limit, verifies the topic is inside the device's own namespace, and looks up the owning Cell.
- Synapse POSTs the raw payload to the Cell runtime with
x-tissue-event: sensorand identity headers. The router delivers it to your Cell'ssensor(event, env)export. - Your Cell decodes the payload and does whatever it likes with it — usually an
INSERTinto c3.
Dispatch is best-effort, exactly like pulse: if the Cell is unavailable or throws, the reading is not retried. Telemetry is loss-tolerant by design.
Transport
Synapse speaks MQTT 3.1.1 over TLS (MQTTS) on port 8883. The production ingest host is ingest.tissue.dev, geo-routed to the nearest edge.
Devices authenticate at MQTT CONNECT with:
| Field | Value |
|---|---|
| username | the device_id (dev_<8hex>) |
| password | the device token (key_<64hex>) |
Not yet supported
The following are designed but not implemented today:
- CoAP / DTLS ingest transport.
- MQTT over WebSocket.
- mTLS device authentication (client certificates). Auth is token-based today.
Downlink (cloud → device)
Data flows the other way too: a device may subscribe to topics inside its own namespace (by convention tissue/<account>/<device>/cmd/#), and your Cell publishes commands to it through the downlink endpoint — POST https://ingest.tissue.dev/publish with the device's own credentials and a topic relative to its namespace. Downlink cmd/* messages are routed to the device's live subscription and are never dispatched back into your Cell's sensor() handler, so a command can't masquerade as a reading. Full walkthrough with device-side code (LED, servo): Connect Your Own Device → Cloud to device.
Topics
Every reading is published on a canonical topic:
tissue/<account_id>/<device_id>/<metric>
- Account isolation is broker-enforced: a device can only publish under its own account prefix (
tissue/<account_id>/). - Per-device ACL is enforced per message: a device may only publish within its own device prefix (
tissue/<account_id>/<device_id>/); it cannot publish as another device, even inside the same account. - One message per metric. The
<metric>segment is everything after the device id, sotissue/acct_9c/dev_a1b2c3/env/humiditycarries the metricenv/humidity.
Inside the Cell, derive the metric from the topic:
const metric = event.topic.split("/").slice(3).join("/");
Payload formats
Synapse forwards the raw message bytes untouched — it does not parse the payload. Your Cell decides how to decode it. Common choices:
| Format | Example |
|---|---|
| JSON | {"value": 21.4, "unit": "C"} |
| Bare number | 21.4 |
| InfluxDB line protocol | temperature,room=kitchen value=21.4 |
The reference Cell (tissue-sense) tries JSON first and falls back to a bare number.
The sensor handler
Export a sensor function from your Cell module alongside fetch. Synapse calls it once per reading:
export default {
async sensor(event, env) {
const metric = event.topic.split("/").slice(3).join("/");
const body = event.json(); // decode the payload
console.log(`${event.device} ${metric} = ${body.value}${body.unit ?? ""}`);
},
async fetch(request, env) {
return new Response("ok");
},
};
The sensor export is optional. A Cell without it silently ignores any sensor events dispatched to it (synapse gets a 204 and moves on).
The env argument is identical to the one passed to fetch — all declared bindings (c3, g7, text) are fully available.
The event object
| Property | Type | Description |
|---|---|---|
event.device |
string |
The publishing device's id (dev_...) |
event.account |
string |
The owning account id |
event.topic |
string |
Full topic, tissue/<account>/<device>/<metric> |
event.transport |
string |
The ingest transport (mqtt) |
event.bytes |
Uint8Array |
The raw payload bytes |
event.text() |
string |
The payload decoded as UTF-8 text |
event.json() |
object |
The payload parsed as JSON (throws if not valid JSON) |
event.receivedTime |
Date |
When the runtime received the reading |
Example: store readings in c3
A complete Cell that writes each reading into a c3 table and prunes old rows hourly with pulse.
ribo.toml
[cell]
name = "tissue-sense"
js = "./cell.js"
[[pulse]]
schedule = "0 * * * *" # hourly retention prune
[[bindings]]
type = "c3"
binding = "DB"
database = "tissue-sense"
cell.js
const SCHEMA = `
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL,
device_id TEXT NOT NULL,
metric TEXT NOT NULL,
value REAL NOT NULL,
unit TEXT,
recorded_at TEXT NOT NULL
)
`;
export default {
// Fired by synapse, once per reading.
async sensor(event, env) {
await env.DB.exec(SCHEMA);
// topic = tissue/<account>/<device>/<metric...>
const metric = event.topic.split("/").slice(3).join("/") || "unknown";
// Decode JSON {value, unit}, falling back to a bare number.
let value, unit;
try {
const body = event.json();
value = Number(body.value);
unit = body.unit ?? null;
} catch {
value = Number(event.text());
unit = null;
}
if (Number.isNaN(value)) {
console.log(`dropped non-numeric reading device=${event.device} metric=${metric}`);
return;
}
await env.DB.prepare(
"INSERT INTO readings (account_id, device_id, metric, value, unit, recorded_at) VALUES (?, ?, ?, ?, ?, ?)"
).bind(event.account, event.device, metric, value, unit, event.receivedTime.toISOString()).run();
},
// Hourly retention prune (> 14 days).
async pulse(event, env) {
await env.DB.exec(SCHEMA);
const cutoff = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString();
await env.DB.prepare("DELETE FROM readings WHERE recorded_at < ?").bind(cutoff).run();
},
// Render the latest readings as JSON.
async fetch(request, env) {
await env.DB.exec(SCHEMA);
const { results } = await env.DB.prepare(
"SELECT device_id, metric, value, unit, recorded_at FROM readings ORDER BY id DESC LIMIT 200"
).all();
return Response.json(results);
},
};
Getting started
- Register a device under your Cell — with the CLI (
ribo sensor add <cell>) or the dashboard's Synapse section. Save the token it prints; it is shown only once. - Configure the device's MQTT client: host
ingest.tissue.dev, port8883(TLS), username = thedevice_id, password = the token, publish topictissue/<account>/<device>/<metric>. - Deploy a Cell that exports
sensor(event, env)withribo deploy(see the example above). - Watch readings arrive — the device publishes, synapse dispatches, and your
sensorhandler runs. Query your c3 table (or the Cell'sfetchendpoint) to confirm.
See also
- Sensor API Reference — the
/v1/sensors/*device-registry endpoints - Managing Devices — the device credential model and lifecycle
- ribo CLI Reference —
ribo sensorcommands - c3 Overview — structured storage for readings