Field Recorder — Wire Protocol (any language)
The edgegate-runner Python SDK is the easiest way to record on-device AI decisions — see the setup guide. But the SDK is only a reference client of a small, stable HTTP protocol.
If your edge AI runs somewhere Python can't — a native Android/iOS app, a C++/Rust/Go/Java inference process, an RTOS with an HTTP stack — you can implement the same tamper-evident recording in any language by producing the same signed, hash-chained events and POSTing them to the two endpoints below.
Events produced this way are indistinguishable from SDK events: they appear on the dashboard timeline, are replayable, and export into the EU AI Act Article 12 report.
You probably don't need this page. If you can run CPython ≥3.10 on the device (Linux x86_64/aarch64 incl. Jetson & Snapdragon-on-Linux, macOS, Windows), just
pip install edgegate-runnerand follow the setup guide. This page is only for platforms the SDK can't reach.
1. Concepts
- Device chain. Each
device_idowns one append-only hash chain, starting from the genesis hash"0" × 64(64 zeros). Each event'schain_hashbinds it to the previous event, so any later edit to a past event invalidates every subsequent hash — that's what makes the log tamper-evident. - Signing key. Each device holds an Ed25519 key pair. You register the public key once (§3); every event is signed with the private key (§4). The server verifies signatures at ingest using the registered public key.
- Privacy. You never send raw inputs/outputs — only their SHA-256 digests, plus an optional ≤200-char
output_summary.
2. Auth & base URL
- Base URL:
https://edgegateapi.frozo.ai - Every request:
Authorization: Bearer <API_KEY>— a workspace API key from Settings → API Keys (paid plan). The key's workspace must match{workspace_id}in the path, and the caller needs admin role on that workspace.
3. Register the signing key (once per key)
POST /v1/workspaces/{workspace_id}/recorder/keys
Authorization: Bearer <API_KEY>
Content-Type: application/json
{
"device_id": "forklift-07",
"key_id": "key-v1783399162",
"public_key_b64": "<base64 of the 32-byte raw Ed25519 public key>"
}
key_idis your identifier for this key. It MUST be the exact string you stamp on every event (§4). Ingest looks keys up by this value — a mismatch means every signed event is rejected. Keep it ≤64 chars.public_key_b64is base64 of the raw 32-byte Ed25519 public key (not PEM/DER).- Idempotent. Re-POSTing the same
key_id+public_key_b64returns the existing key (200). Re-POSTing the samekey_idwith a different public key is a conflict (409) — rotate by choosing a newkey_id. - Success →
200/201:{ "key_id": "...", "device_id": "...", "provisioned_at": "..." }
Call this before the first ingest. (The Python SDK does it automatically on every sync().)
4. Build one event
For each inference you record, do these steps in order.
4a. Digests
input_digest = hex( SHA256( serialize(input) ) )
output_digest = hex( SHA256( serialize(output) ) )
output_summary = first 200 chars of serialize(output) # or "" if not recording output
serialize is your choice (the SDK uses UTF-8 JSON for structured data, raw bytes otherwise) — it only has to be stable for identical inputs on your device. The server never re-derives it.
4b. The canonical field set — exactly these 13 keys
| key | type | notes |
|---|---|---|
confidence | float | null | model confidence, or null |
device_id | string | stable device id |
event_id | string | a UUID you generate |
input_digest | string | from 4a |
latency_ms | float | inference time |
memory_mb | float | peak memory (0.0 if unknown) |
model_hash | string | SHA-256 of the model file |
model_name | string | human-readable |
output_digest | string | from 4a |
output_summary | string | ≤200 chars, or "" |
quantization | string | "int8", "fp16", "q4_k_m", "unknown", … |
silicon_id | string | chip id, e.g. "Apple M4", "SM8650" |
timestamp | string | ISO-8601 UTC, e.g. "2026-07-07T12:00:00.123456+00:00" |
4c. Canonicalize → chain hash
canonical = JSON of the 13 fields, keys SORTED, NO whitespace
(Python reference: json.dumps(fields, sort_keys=True, separators=(",", ":")))
chain_hash = hex( SHA256( previous_chain_hash + canonical ) )
previous_chain_hash is the previous event's chain_hash, or the genesis "0" × 64 for the device's first event. chain_hash is a lowercase hex SHA-256 string.
⚠️ The one cross-language gotcha: byte-identical canonical JSON
The server recomputes
chain_hashfrom your fields. If your JSON serialization differs from the reference by a single byte, the hash won't match and the event is rejected. You MUST reproduce Python'sjson.dumps(..., sort_keys=True, separators=(",", ":"))byte-for-byte:
- keys sorted lexicographically; separators
,and:with no spaces;- JSON string escaping with
ensure_asciisemantics (non-ASCII →\uXXXX);nullfor absent confidence (do not omit the key);- numbers are the subtle part. Python prints
0.9as0.9,1.0as1.0,128as128— no trailing zeros, no exponent form, integers with no decimal point. Round/formatlatency_msandmemory_mbon-device to values your serializer prints identically to Python's, and keep them stable.De-risk it: unit-test your serializer against the Python reference output (or the SDK's) before shipping — that single test catches virtually all interop bugs.
4d. Sign
signature = base64( Ed25519_sign( private_key, ascii_bytes(chain_hash) ) )
You sign the chain_hash hex string's bytes — not the canonical JSON, not the raw hash bytes.
4e. The full event
The 13 canonical fields plus chain_hash, signature, and key_id:
{
"event_id": "…", "timestamp": "…", "model_hash": "…", "model_name": "…",
"quantization": "int8", "silicon_id": "Apple M4", "device_id": "forklift-07",
"input_digest": "…", "output_digest": "…", "output_summary": "cat",
"confidence": 0.91, "latency_ms": 12.4, "memory_mb": 84.0,
"chain_hash": "…", "signature": "…", "key_id": "key-v1783399162"
}
5. Upload a batch
POST /v1/workspaces/{workspace_id}/recorder/ingest
Authorization: Bearer <API_KEY>
Content-Type: application/json
{
"device_id": "forklift-07",
"events": [ <event>, <event>, … ],
"chain_start_hash": "0000…0000"
}
eventsmust be in chain order.chain_start_hashis thechain_hashof the last event already ingested for this device, or the genesis"0" × 64on the very first batch. It lets the server confirm your batch continues the existing chain with no gaps.- Response
200:{ "ingested": 3, "rejected": 0, "chain_verified": true, "last_chain_hash": "…" }
Reading the response:
rejected > 0with HTTP200→ some events failed signature verification (bad signature, orkey_idnot registered / doesn't match the key you provisioned). Re-check §3.- HTTP
422→ the hash chain didn't verify (achain_hashdidn't match the recomputed value, orchain_start_hashdidn't continue the stored chain). Almost always the canonical-JSON gotcha in §4c. - Persist
last_chain_hashand pass it as the next batch'schain_start_hash.
Ingest is idempotent on event_id — re-uploading an already-ingested event is a no-op, so retry freely.
6. Verify locally before you upload
The server does exactly two checks — run them yourself first:
- Chain: for each event, recompute
SHA256(previous + canonical)wherecanonicalis the JSON of every field exceptchain_hash,signature,key_id(the 13 in §4b), sorted, no whitespace. Compare to the event'schain_hash. - Signature: Ed25519-verify
base64_decode(signature)against the bytes of thechain_hashstring, using the public key you registered forkey_id.
7. Reference client
The Python SDK is the executable spec — when in doubt, match its output:
pip install edgegate-runner
Port the chain math and the event schema first, then unit-test your canonical JSON against the SDK's json.dumps(..., sort_keys=True, separators=(",", ":")) output. Once that test passes, the rest is plumbing.
Questions? Reach out from your dashboard, or see the Integration guide.