A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer answers technician calls, records both call legs as separate channels, and a Ruby backend transcribes them through Whisper and files an AI summary against the caller. Squashed to a single commit for sharing. No credentials are included; secrets live outside the repo in /etc/helpdesk/env on the server or a gitignored .claude/env.local locally. See .claude/env.local.example for the shape. Start at README.md, then docs/architecture.md.
8.8 KiB
Device API - the phone-to-backend contract
This is the wire contract between the patched Dialer and the backend. Two codebases implement it -
components/dialer-patch (Java) and components/backend/server.rb + lib/helpdesk/domain.rb (Ruby) -
so when you change one side, this page and the other side change with it. How the phone decides where
and when to send these requests is a separate page, docs/phone-connectivity.md.
Transport
In production the phone posts to the device door: https://moje.al.army:8443/api/v1. That nginx vhost
speaks TLS without a client certificate (the phone can't hold one), allowlists only
/api/v1/calls/, /api/v1/device/ and /api/v1/recordings/ (everything else is a 404), blanks the
operator-identity headers, rate-limits at 10 requests/s, and caps bodies at 64 KB - except recordings,
which get 64 MB. The backend adds its own 80 MiB cap as a backstop (413 body_too_large). In local dev
the phone or simulator talks plain HTTP to :4000 with the built-in dev credentials.
Correlation
call_id- UUIDv4, minted by the phone the moment a call is detected, sent on every request about that call. It is the one key that stitches the webhooks, the recording upload, the transcript, and the event together.recording_uuid- UUIDv4, minted by the phone when a recording file is finalized. One recording per call. Retrying an upload reuses the same uuid, which is what makes retries safe.ts- the device's own UTC ISO-8601 timestamp for the moment the thing happened. The backend stores it as sent; a delayed retry still reports the original time.
Delivery is at-least-once everywhere. Retries and out-of-order arrival are normal, not errors: every
webhook is an idempotent upsert keyed on call_id, and a duplicate returns 200 with the event's
current state.
Authentication
Every request carries four headers, built by DeviceAuthSigner.java and checked by authed? in
server.rb:
| Header | Value |
|---|---|
Authorization |
Bearer <device_token> |
X-Request-Ts |
unix seconds, now |
X-Request-Nonce |
fresh random string per request |
X-Signature |
lowercase-hex HMAC-SHA256(device_secret, "<ts>.<nonce>.<body-bytes>") |
The signature covers the exact body bytes, so UTF-8 Czech text can't diverge between the two sides. The backend rejects a timestamp more than 300 seconds off its own clock, and a nonce it has seen in the last 600 seconds. Everything compares constant-time. Failures are a plain 401; malformed JSON is a 422 that the phone must not retry.
In production the server refuses to boot with the repo-public dev credentials
(dev-device-token/dev-device-secret); real values come from /etc/helpdesk/env.
Endpoints, device side
All paths relative to /api/v1. All device-authed.
POST /calls/incoming - fired at ring, before anyone answers. The phone never waits for the response;
ringing must not block on the network.
{ "call_id": "uuid", "ts": "2026-07-24T09:14:03Z",
"number": "+420601234567", // caller, E.164 or null when withheld
"presentation": "allowed", // allowed | restricted | unknown | payphone
"dialed_did": "+420800111222", // which of our numbers was called; may be omitted
"device_id": "pixel10a-office-1" }
→ 200 { "event_id": 123, "state": "ringing" }
The backend resolves the caller to a person and the dialed number to an operator queue, then pushes the screen-pop to that operator's console.
POST /calls/outgoing - the same, for a call the phone places (usually because the operator clicked
dial in the console). Body is call_id, ts, number, device_id; there is no dialed_did on a
call we originate. The event is marked direction: outbound and everything downstream works the same.
POST /calls/answered - { "call_id", "ts" } → 200 { event_id, state }.
POST /calls/ended:
{ "call_id": "uuid", "ts": "…", "duration_s": 213,
"disconnect_cause": "remote", // local | remote | missed | rejected | error | unknown
"recording_uuid": "uuid|null" } // null = no recording ever started
→ 200 { "event_id": 123, "state": "ended" }
If duration_s is missing the backend derives it from the answered/ended timestamps. An ended
without a prior answered is fine for missed and rejected calls; for anything else the event gets a
timeline_incomplete flag, because a webhook evidently got lost.
PUT /recordings/{recording_uuid} - the audio bytes as the body (the phone records .m4a), with the
X-Call-Id header linking it to the call. Missing header → 400, because audio without a call id could
never be attached. Normally → 200 { event_id, state: "recording_uploaded" } and transcription starts.
If the webhooks lost the race and no event exists yet, the audio is quarantined and the response is
200 { "quarantined": true, "call_id": … }; when /calls/incoming finally lands, the recording is
reattached automatically. Quarantined audio nobody claims is dropped after 24 hours.
GET /device/commands?device_id=…&wait=25 - the reverse channel. Long-poll: the backend holds the
request up to wait seconds (capped at 30) and answers the moment an operator queues a command, so a
console click reaches the phone in about one round trip. Response:
{ "commands": [ { "id": "uuid", "call_id": "uuid|null", "verb": "answer",
"arg": null, "issued_at": "…", "status": "pending" } ] }
Draining is destructive - returned commands are gone from the queue, so the phone must act on them. The phone applies each verb through the same Telecom paths as the on-screen buttons, and the result comes back through the ordinary webhooks. There is no separate acknowledgement.
GET /device/heartbeat?device_id=… - { "ok": true }. Marks the device as present, nothing else. A
device counts as connected if it polled commands or heartbeated within the last 300 seconds; that
feeds the console's "phone connected" badge and gates click-to-dial.
POST /device/contacts - the phone pushes its address book, at most every 30 minutes:
{ "contacts": [ { "name": "Jan Novák", "numbers": ["+420601234567", "601234567"],
"emails": ["jan@example.cz"] } ] }
→ 200 { "upserted": 12 }
Matching is by E.164 number. The phone owns name and email; the console owns everything else about a person, and a contact deleted on the phone is never deleted server-side - it still has call history.
The command verbs
answer reject hangup hold resume mute unmute dtmf route dial, defined once as COMMAND_VERBS in
domain.rb and mirrored by the phone's CommandGate.java. Operators queue call-scoped verbs with
POST /api/v1/calls/{call_id}/command (operator-authed, { "verb": …, "arg": … }, → 202) and the one
device-scoped verb with POST /api/v1/dial ({ "number": … }). Dial only accepts numbers that
normalize to E.164 - anything else (*21*…# style MMI codes, short codes) is rejected - and only
targets a device that is currently connected, with a queue cap of 20 pending commands.
The event state machine
States rank ringing(0) < answered(1) < ended(2) < recording_uploaded(3) < transcribed(4) < summarised(5), with three side states that carry no rank: recording_missing, no_speech, failed.
The one rule: state only moves forward. An arriving webhook may fill in its timestamps at any time,
but the state becomes the higher of current and incoming - so a retried ended landing after the
upload changes nothing, and out-of-order delivery is harmless by construction. advance! in
domain.rb is the whole implementation.
After the upload the backend runs the pipeline on its own: transcription (hosted Whisper), then the
summary. A failed summary leaves the event at transcribed - the transcript is the valuable part and
the summary can be retried later. A transcription with no speech ends at no_speech; exhausted
retries end at failed with a reason on the event.
Reconciliation
A watchdog runs every 60 seconds (reconcile! in domain.rb) and closes whatever the webhooks left
hanging:
| Situation | After | Result |
|---|---|---|
ringing, nothing else ever arrived |
15 min | closed as missed |
answered, no ended |
6 h | closed, no duration, flagged for review |
ended said a recording exists, upload never came |
30 min | recording_missing - still recoverable, a late upload advances it |
| quarantined upload nobody claimed | 24 h | dropped |
Every terminal-but-incomplete path still leaves a countable event with an explicit state and flags; calls never silently vanish.
Errors, uniformly
401 unauthorized (bad token, signature, skew, or replay) · 404 unknown path or call · 413 body too
large · 422 malformed JSON or bad verb/argument - do not retry · 500 with {"error":"server_error"}
and no detail, because the detail may contain caller numbers; the real message is in the server log.