Prd/docs/device-api.md
Lucy Doupalů be9f14ce34 Helpdesk - operator console + patched GrapheneOS Dialer for call handling
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.
2026-07-27 18:50:32 +02:00

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.