# Tendra Voice: integration guide
Voice channels for pilots, the battlecap (commander) and tech support.
Your users join and leave calls, turn the mic and the speaker on and off. Your
app decides who the user is and what role they have; we decide what that role
may do in which channel, using a policy file.
- API reference (OpenAPI 3): `https://<host>/docs`, raw spec `https://<host>/openapi.json`
- Demo and reference client: `https://<host>/`
- Everything below uses `HOST=https://<host>` and `KEY=<your API key>`.
## 1. How auth works
```
your client your backend Tendra voice-api media server (LiveKit)
| "join vehicle 17" | | |
|--------------------->| POST /v1/join | |
| | Authorization: Bearer KEY | |
| | {user, role, channel} ------>| policy.yaml: may join? |
| | | speak? listen? |
| |<---- {livekitUrl, token} ----| signs a short-lived token |
|<--- {livekitUrl, token} | |
|---- connect(livekitUrl, token) ------------------------------------------------>|
|<=================== audio (WebRTC, UDP; TCP fallback) ========================>|
```
- **The API key is a server-to-server secret.** One key per integrating app.
Never ship it to a browser or a mobile app. Your backend calls us with it and
asserts `{user, role}` for its own, already-authenticated users.
- **The token is per user, per channel.** It carries the permissions the policy
gave (`canSpeak`, `canListen`). Its lifetime (`expiresAt`, default 10 min) is
the time the client has to *connect*; a call in progress is not cut when it
passes. Ask for a new token for every join.
- Phase 1 only. If you would rather we verify *your* user tokens (JWT/OIDC)
directly, tell us: it is one module on our side, and your client then calls
`/v1/join` itself.
Errors are always `{"error": {"code": "...", "message": "..."}}`:
`401 unauthorized` (no/bad key), `403 forbidden` (policy says no), `400 bad_request`, `404 not_found`.
## 2. Channels and roles
| channel | who | what for |
|---|---|---|
| `group:<id>` | pilots of the group + battlecap; all speak and listen | UC1 pilots among themselves, UC2 battlecap to a group |
| `vehicle:<id>` | whoever connects to the vehicle's control | UC4: connecting to a vehicle = joining its call. A vehicle can be mapped to a group: then its call *is* the group call (`channel.via` in the response) |
| `support:<userId>` | that user + tech support | UC3: private line to support |
Roles: `pilot`, `battlecap`, `support`. Behaviour (who joins/speaks/listens, a
"command" mode in which only the battlecap speaks, vehicle → group mapping) is
configuration on our side and changes without a release. Ids: `[A-Za-z0-9._-]{1,64}`.
## 3. The API (curl)
```bash
# Join: get a token for a user (UC1/UC2: group, UC4: vehicle)
curl -s -X POST $HOST/v1/join -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
-d '{"user":{"id":"u-42","name":"Pilot 42"},"role":"pilot","channel":{"type":"vehicle","id":"v-17"}}'
# -> {"livekitUrl":"wss://<host>","token":"eyJ...","expiresAt":"...","identity":"u-42",
# "channel":{"type":"vehicle","id":"v-17","room":"vehicle:v-17","mode":null,"via":null},
# "permissions":{"canSpeak":true,"canListen":true}}
# Call support (UC3): creates the request, rings the support console, returns a join token
curl -s -X POST $HOST/v1/support/requests -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
-d '{"user":{"id":"u-42","name":"Pilot 42"},"role":"pilot","note":"no video"}'
# -> 201 {"request":{"id":"...","status":"open","channel":{"room":"support:u-42"},...},"join":{...same as /v1/join...}}
# Support queue (for your support console)
curl -s $HOST/v1/support/requests -H "Authorization: Bearer $KEY"
# A support agent answers = joins that channel:
curl -s -X POST $HOST/v1/join -H "Authorization: Bearer $KEY" -H 'content-type: application/json' \
-d '{"user":{"id":"s-1","name":"Support"},"role":"support","channel":{"type":"support","id":"u-42"}}'
curl -s -X POST $HOST/v1/support/requests/<requestId>/close -H "Authorization: Bearer $KEY"
# Who is talking where
curl -s $HOST/v1/channels -H "Authorization: Bearer $KEY"
curl -s $HOST/v1/channels/group/alpha -H "Authorization: Bearer $KEY"
# -> {"channel":{...},"participants":[{"id":"u-42","name":"Pilot 42","role":"pilot",
# "micOn":true,"muted":false,"serverMuted":false,"canSpeak":true,"speaking":null,...}]}
# Moderation (call it when your battlecap/support presses the button)
curl -s -X POST $HOST/v1/channels/group/alpha/participants/u-42/mute -H "Authorization: Bearer $KEY" \
-H 'content-type: application/json' -d '{"muted":true}' # enforced: the user cannot unmute
curl -s -X POST $HOST/v1/channels/group/alpha/participants/u-42/remove -H "Authorization: Bearer $KEY"
# Events (Server-Sent Events)
curl -N $HOST/v1/events -H "Authorization: Bearer $KEY"
# id: 12
# event: participant.joined
# data: {"id":12,"type":"participant.joined","at":"...","channel":{"type":"group","id":"alpha","room":"group:alpha"},"participant":{"id":"u-42","name":"Pilot 42","role":"pilot"}}
curl -s $HOST/healthz
```
Event types: `participant.joined`, `participant.left`, `participant.mic`
(`data.micOn`), `participant.server_muted` (`data.muted`), `participant.removed`,
`track.published`, `track.unpublished`, `channel.started`, `channel.finished`,
`support.requested`, `support.answered`, `support.closed`.
Resume after a reconnect with the `Last-Event-ID` header (last 500 kept).
Filter with `?channel=group:alpha`. We can also POST every event to a URL of
yours (signed `X-Tendra-Signature: sha256=HMAC(body, your API key)`); give us the URL.
## 4. Client side: pick a level
### Level 1, zero code: the widget
Join/leave, mic, speaker buttons and the participant list with speaking dots.
```html
<!-- web component -->
<script src="https://<host>/voice-widget.js"></script>
<tendra-voice token="TOKEN_FROM_YOUR_BACKEND" autojoin></tendra-voice>
<!-- optional attributes: livekit-url="..." mic="off" speaker="off" -->
<script>
const w = document.querySelector('tendra-voice');
w.addEventListener('tv-joined', e => console.log(e.detail)); // {channel, identity, canSpeak}
w.addEventListener('tv-participants', e => console.log(e.detail)); // [{id,name,role,micOn,speaking,...}]
w.addEventListener('tv-left', e => console.log(e.detail));
// switching vehicles: set a new token and it rejoins
// w.setAttribute('token', newToken);
</script>
<!-- or an iframe (no script from us on your page) -->
<iframe src="https://<host>/embed?token=TOKEN&autojoin=1" allow="microphone; autoplay"
style="width:420px;height:260px;border:0"></iframe>
<!-- the iframe posts {source:'tendra-voice', type:'tv-joined'|..., detail} to window.parent -->
```
### Level 2, your own UI: the JS SDK
ES module at `https://<host>/sdk/voice.js`, types at `https://<host>/sdk/voice.d.ts`.
```js
import TendraVoice from 'https://<host>/sdk/voice.js';
const { token, livekitUrl } = await fetch('/your-backend/voice-token?vehicle=17').then(r => r.json());
const call = await TendraVoice.join({ token, livekitUrl }); // mic on if allowed, speaker on
call.on('participants', list => render(list)); // [{id, name, role, isLocal, micOn, speaking, canSpeak, serverMuted}]
call.on('speaking', ids => highlight(ids)); // who is talking now
call.on('permissions', p => console.log(p)); // {canSpeak, canListen}: moderator mute/unmute
call.on('audioBlocked', () => showEnableAudioButton()); // browser autoplay block -> call.startAudio() on click
call.on('left', reason => console.log('left', reason));
await call.setMic(false); // mic off: our track is muted, others see micOn=false
call.setSpeaker(false); // speaker off: we stop receiving all remote audio
call.canSpeak; // false in a listen-only channel; setMic(true) then throws
await call.leave();
```
Mic off is visible to everyone (events, participant list). Speaker off is local
only: nobody else is told.
### Level 3, native apps: token + any LiveKit SDK
The token is a standard LiveKit access token, so iOS, Android, Flutter, React
Native, Unity and desktop clients can use the official LiveKit SDKs
(https://docs.livekit.io/home/client/connect/) with `livekitUrl` + `token`:
```swift
let room = Room()
try await room.connect(url: livekitUrl, token: token)
try await room.localParticipant.setMicrophone(enabled: true)
```
Use levels 1–2 where you can: at level 3 you depend on LiveKit directly, and
we can no longer swap the media engine without you noticing.
## 5. Browser and network requirements
- The microphone works only on HTTPS pages (or `http://localhost`).
- Media: UDP to the host's media port (fallback: TCP port). See the README for
which ports; the signalling uses the same HTTPS host as the API.