Dispatcher developer guide
For a developer who wants to build a dispatcher console: a desktop or web program that talks and listens on talkgroups, shows who is talking, handles emergencies, shows locations, makes private calls and call alerts, sends messages, monitors radios remotely and plays recordings. Read README.md first for the system overview and glossary.
Status: MCPTT does not ship a dispatcher console yet. Radios and phones run in production on the voice protocol a console uses. This guide says what you can build on today and where you will hit gaps. Anything marked "unverified" could not be confirmed and may change.
1. The recommended path
Embed the MCPTT client SDK (the Rust client core every radio and phone app uses) in a desktop app, for example with Tauri. Use the event stream (events-websocket.md) for the fleet-wide view and for dispatcher actions (clear an emergency, request a location, send a call alert, remote monitoring). Use the management REST API (management-rest.md) for lookups and remote monitoring.
Do not implement the voice protocol yourself unless you must. It is large (two sites at once, de-duplication, forward error correction, gap repair, catch-up hold), and every radio and phone already uses the SDK's implementation. A browser-only console that runs the SDK is not possible yet: there is no WebAssembly or WebTransport build. A web UI with a small local service that runs the SDK and serves it on localhost works the same way as a desktop shell.
2. Architecture
+------------------------------- desktop app ------------------------------+
| UI (any framework) <-------------------------> native backend |
| client SDK (per seat) |
| audio I/O |
| event stream client |
| REST client |
+--------------------------------------------------------------------------+
| QUIC, both sites | wss events.locN | https /api/v1
v v v
loc1 site <-------------------------> loc2 site
- From Rust you link the SDK as a normal library. Kotlin and Swift bindings exist for mobile apps.
- The SDK does not own audio devices. The app captures PCM and calls
RadioCore::push_capture(pcm), and pulls playout withRadioCore::pull_playout()on its audio clock (16 kHz mono, 20 ms frames, PCM16). Device selection, outputs and mixing across seats are yours.
3. Voice
3.1 Connecting
RadioCore::new(settings, listener, device_key) starts the runtime and connects to both sites. RadioSettings carries the server addresses, the pinned server certificate SHA-256 (clients pin the MCPTT certificate authority, not the public web roots), the device identity, and one of the sign-in forms: badge and PIN, account email and password, or a one-time enrollment code. device_key is the TLS client key. Events reach you through the RadioListener you pass in, plus optional listeners for calls, emergencies, messaging, transcripts and pictures (set_*_listener).
On the wire this is QUIC v1, TLS 1.3, a hello exchange that agrees optional features (capability bits), then a login.
3.2 Codec
Opus, wideband, 16 kHz mono, 20 ms frames on normal links (40 or 60 ms on constrained links). Every frame arrives over both sites and the SDK de-duplicates them.
3.3 Floor
| Step | SDK |
|---|---|
| Choose the talkgroup | set_talkgroup(id) |
| Press PTT | ptt_down(); capture starts at the press and is buffered until the grant |
| Granted, denied or queued | listener callback (a denial carries a cause; a queue carries a position) |
| Release | ptt_up() |
| Someone else talks | listener callback with the talker's callsign and unit number, then idle |
| Pre-empted | listener callback |
| Catch-up hold ("Rx pending") | listener callback |
Priority: an emergency talker outranks everyone; otherwise the callsign's talk level (-10 to +10) decides, and a higher priority pre-empts after a short grace period (200 ms by default, none for an emergency). Console-only floor rights (all-call, broadcast, emergency override) need a dispatcher sign-in, which is not offered yet, so a console today has a radio's floor rights.
3.4 Emergency
- A voice session that supports it receives emergency alerts for its talkgroups through
set_emergency_listenerandemergency_alerts(). - Fleet-wide, the event stream publishes
emergency.*events (scopeevents:emergency; see the event stream guide, section 5). - A console clears an emergency with the event-stream command
emergency.clear(scopeemergency:clear, a reason of at least 10 characters). It is cleared asdispatcher, as from the portal. There is no acknowledge step yet;emergency.ackis refused as unavailable. - The SDK's
clear_emergencyclears the session's own callsign's emergency, which is the radio case, not the dispatcher case. - For the position of the unit in emergency, use the event stream's
location.position(section 5).
3.5 Private calls and call alerts
- SDK:
start_private_call(target, confirmed),answer_private_call(accept),end_private_call(),private_call(),send_call_alert(target, text),ack_call_alert(id). - The SDK holds one private call at a time, as a radio does. Several calls at once, calls to dispatch positions, radio check and break-in are not offered.
- A console or CAD can also send a call alert without a voice session: the event-stream command
call_alert.send(scopecalls:alert). - A call or call alert to a unit in a covert or silent emergency is refused.
3.6 Listening to many talkgroups
The SDK models one radio: one selected talkgroup plus scan (set_scan, set_scan_overlap(Mix)), with one playout stream. Until there is a console core, the options are:
- One
RadioCoreper audio "seat" (for example per talkgroup or per output), each a separate device identity with its own callsign sign-in, mixed by your app. Costs one device record and two connections per seat (unverified at 20 seats). - One
RadioCorewith scan in mix mode, accepting one mixed output and no per-group routing.
3.7 Patch, multi-select, all-call, tone-out
Not offered to consoles.
3.8 Messages
The SDK implements messaging (conversations, send_message, attachments) for sessions that support it. There is no event-stream command to send a message.
3.9 Recordings and replay
- Every burst is recorded on the server; the raw stream is the record.
- From an SDK session:
download(transmission_id)andtranscript(transmission_id), plus local history of what the session heard (list_history,play_history,seek_history). - Instant recall of recent bursts is yours to build on top of
pull_playoutor history. - Recording search, playback links and export are not in the REST API yet. The portal's transmissions page is the current way to find and play a recording.
4. Who is talking, fleet-wide
Inside a voice session you only learn about talkgroups the session is affiliated to. For the whole agency, subscribe to the event stream: floor.granted (talkgroup, callsign, device, transmission_id), floor.released, floor.idle, with last_value: true to get the current talker per talkgroup on connect.
5. Locations
Event stream topic location (location.position, the newest fix per batch), with a token that holds both events:location and locations:read. Each connection that can receive positions is audited. To ask units for a position now, send the event-stream command location.request (scope locations:request, one to 16 callsigns, a reason); the fixes arrive as location.position. Location history and geofences are not offered to integrations.
6. Remote monitoring
A console can listen to, and with a camera watch, a radio without its user pressing PTT:
- Start a session with
POST /api/v1/monitoring/sessions(scopemonitoring:control, a reason of at least 10 characters) or the event-stream commandmonitoring.start. - Watch it: ask for a viewer token (
POST /api/v1/monitoring/sessions/{id}/viewer-token, scopemonitoring:view, or the commandmonitoring.viewer_token) and open the viewer WebSocket from your backend; or embed theview_urlpage that the REST start answer returns. - Follow its state with the
monitoring.*events (scopeevents:monitoring). - Stop it with
POST /api/v1/monitoring/sessions/{id}/stopor the commandmonitoring.stop. Recorded segments stay available throughGET /api/v1/monitoring/sessions/{id}/segments.
Every start, stop and viewer is audited. Details and the viewer protocol: management-rest.md, Monitoring.
7. Authentication for a console
What exists today:
- Voice sessions authenticate as devices: an enrolled device key, then a login with a badge and PIN or an account email and password. A console today is enrolled like a phone or radio and signs in to a callsign. It then has a radio's rights, not a dispatcher's.
- Event stream: per-site event tokens created by an agency admin in the portal.
- Management API: OAuth 2.0 client credentials at
/oauth/token. An agency administrator or the operator creates an API client and gives you its client id and secret.
Planned, not offered: dispatcher sign-in with OpenID Connect and MFA, with dispatcher scopes and floor rights.
Never log tokens, PINs, passwords, audio, or location and recording content.
8. Available today and planned
"Today" means a console can use it now through the SDK, the event stream or the REST API.
| Feature | Today | Notes |
|---|---|---|
| Group PTT, grant, deny, queue, pre-emption | Yes, through the SDK (radio rights) | Dispatcher floor rights not offered |
| Talker ID | Yes | SDK for affiliated talkgroups; event stream floor.granted for all |
| Catch-up hold indicator | Yes, through the SDK | |
| Multi-select PTT, all-call, announcement, tone-out | No | |
| 20 talkgroups audible with per-group routing | No | The SDK has one output; see section 3.6 |
| Emergency list and alerts | Yes | Event stream emergency.*; SDK emergency alerts for the session's talkgroups |
| Emergency clear by a dispatcher | Yes | Event-stream command emergency.clear |
| Emergency acknowledge | No | |
| Private call (one at a time), call alert | Yes, through the SDK | Call alert also by event-stream command |
| Several private calls, dispatch positions, radio check, break-in | No | |
| Remote monitoring (audio, camera, covert) | Yes | REST /monitoring/sessions and event-stream commands; section 6 |
| Presence: battery, accessory, channel, signal | Yes | Event stream presence.*, network.signal |
| Lock a user | Yes | REST PUT /users/{id}/lock |
| Set a status | No | |
| Stun and revive a device | Portal only | |
| Live call list | Build it | From floor.* events |
| Instant recall | Build it | From SDK history or your own buffer |
| Recording download, transcript of a burst | Yes, through the SDK | Live captions through set_caption_listener |
| Recording search, playback link, export | No | Portal only |
| Live positions on a map | Yes | Event stream location.position |
| Location request | Yes | Event-stream command location.request |
| Track playback, geofences | No | Portal map meanwhile |
| Patches, dynamic regroup, force select | No | |
| Messaging | Yes, through the SDK | No event-stream command |
| Dispatcher sign-in with OIDC and MFA | No | |
| Two sites at once | Yes, through the SDK | |
| Browser-only build | No | |
| Keyboard, USB or footswitch PTT | Yours to build | Call ptt_down / ptt_up |
9. First console in a day
Goal: a desktop window that signs in, talks and listens on one talkgroup, shows who is talking across the agency, shows units on a map, and sends a call alert.
- Get access (before the day). From the agency admin: a device record for your console with a callsign that is a member of a test talkgroup (with talk right); its sign-in credentials or an enrollment code; the pinned server certificate hash and the voice address of both sites; an event token from each site with
events:floor,events:presence,events:location,locations:read,events:emergency,emergency:clear,calls:alert; an API client withtalkgroups:read users:read. - Scaffold. A desktop shell (for example Tauri), the client SDK, an audio library, a WebSocket client for the event stream and an HTTP client for REST.
- Audio loop. Open a 16 kHz mono input and output (or 48 kHz and resample). Every 20 ms: feed captured PCM16 to
push_capturewhileis_capturing(); writepull_playout()to the output. - Voice session. Build
RadioSettings(both sites, pin, device identity, sign-in), implementRadioListenerto forward state to the UI, callRadioCore::new, thenset_talkgroup. Map a key and an on-screen button toptt_down/ptt_up. Show grant, deny, and the current talker. - Talkgroup and callsign names.
GET /api/v1/talkgroupsandGET /api/v1/users?kind=callsignwith the client-credentials token; cache them. - Who is talking, everywhere. Connect to
wss://events.loc1.server.mcptt.com/v1/stream, sendhellowith the token,subscribetofloorwithlast_value: true; answer heartbeats. Render a talkgroup list with the current talker. On disconnect, reconnect withresume_from_seq; onsystem.resync_required, take the snapshot again. - Map. Subscribe to
location; drawlocation.positionmarkers; grey out fixes older than 60 s. - Call alert and private call. Add a callsign picker (
search_contacts/lookup_directoryfrom the SDK) and buttons forsend_call_alertandstart_private_call(target, false); show call state from the call listener. - Emergencies. Subscribe to
emergency(scopeevents:emergency) and set the SDK's emergency listener; show emergencies in a list with a repeating tone. Clear them with theemergency.clearcommand (token scopeemergency:clear). - Recordings. For a burst in your call list,
download(transmission_id)and play the file;transcript(transmission_id)for the text.
What you will not have at the end of the day: dispatcher floor rights, multi-group audio routing, emergency acknowledge, patches, location history. See section 8.
