{"openapi":"3.0.3","info":{"title":"Tendra Voice API","version":"0.1.0","description":"Voice channels for pilots, the battlecap and tech support. Your BACKEND calls POST /v1/join with its API key on behalf of a user and hands the returned token to the user's client, which connects with the Tendra Voice SDK/widget or any LiveKit SDK. See /integration for copy-paste examples."},"components":{"securitySchemes":{"bearer":{"type":"http","scheme":"bearer","description":"API key (server-to-server) or a dev-mode session token"}},"schemas":{}},"paths":{"/healthz":{"get":{"summary":"Liveness + LiveKit reachability","tags":["meta"],"responses":{"200":{"description":"Default Response"}}}},"/v1/meta":{"get":{"summary":"Public info for clients (no auth)","tags":["meta"],"responses":{"200":{"description":"Default Response"}}}},"/v1/dev/login":{"post":{"summary":"DEV MODE ONLY: log the demo page in as {user, role} with the shared demo password","tags":["dev"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["user","role","password"],"properties":{"user":{"type":"object","required":["id","name"],"properties":{"id":{"type":"string","pattern":"^[A-Za-z0-9._-]{1,64}$"},"name":{"type":"string","minLength":1,"maxLength":80}}},"role":{"type":"string","description":"A role from policy.yaml: pilot | battlecap | support"},"password":{"type":"string"}}}}}},"responses":{"200":{"description":"Default Response"}}}},"/v1/join":{"post":{"summary":"Get a token to join a channel","tags":["voice"],"description":"Policy (policy.yaml) decides whether the role may join, speak and listen. A vehicle mapped to a group returns the group room (channel.via). Mic/speaker are client-side: see the SDK.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["channel"],"properties":{"user":{"type":"object","required":["id","name"],"properties":{"id":{"type":"string","pattern":"^[A-Za-z0-9._-]{1,64}$"},"name":{"type":"string","minLength":1,"maxLength":80}}},"role":{"type":"string","description":"A role from policy.yaml: pilot | battlecap | support"},"channel":{"type":"object","required":["type","id"],"properties":{"type":{"type":"string","enum":["group","vehicle","support"]},"id":{"type":"string","pattern":"^[A-Za-z0-9._-]{1,64}$"}}},"ttlSeconds":{"type":"integer","minimum":1,"description":"Token lifetime (default TOKEN_TTL_SECONDS, capped at TOKEN_TTL_MAX_SECONDS)"}}}}}},"security":[{"bearer":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"livekitUrl":{"type":"string"},"token":{"type":"string","description":"LiveKit access token (JWT). Pass to TendraVoice.join / <tendra-voice> / any LiveKit SDK."},"expiresAt":{"type":"string","description":"Token must be USED (connected) before this. A live connection is not cut at expiry."},"identity":{"type":"string"},"channel":{"type":"object","properties":{"type":{"type":"string"},"id":{"type":"string"},"room":{"type":"string","description":"LiveKit room name; a mapped vehicle shares its group room"},"mode":{"type":"string","nullable":true},"via":{"type":"object","additionalProperties":true,"description":"The channel actually joined, when it differs from the one requested (vehicle -> group mapping)","nullable":true}}},"permissions":{"type":"object","properties":{"canSpeak":{"type":"boolean"},"canListen":{"type":"boolean"}}}}}}}},"401":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"}}}}}}}},"403":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"object","properties":{"code":{"type":"string"},"message":{"type":"string"}}}}}}}}}}},"/v1/support/requests":{"post":{"summary":"Call tech support","tags":["support"],"description":"Creates (or returns the open) support request for the user, emits support.requested, and returns a join token for support:<userId>.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"user":{"type":"object","required":["id","name"],"properties":{"id":{"type":"string","pattern":"^[A-Za-z0-9._-]{1,64}$"},"name":{"type":"string","minLength":1,"maxLength":80}}},"role":{"type":"string","description":"A role from policy.yaml: pilot | battlecap | support"},"note":{"type":"string","maxLength":500}}}}}},"security":[{"bearer":[]}],"responses":{"200":{"description":"Default Response"}}},"get":{"summary":"Support queue (support agents / API keys)","tags":["support"],"parameters":[{"schema":{"type":"boolean"},"in":"query","name":"all","required":false}],"security":[{"bearer":[]}],"responses":{"200":{"description":"Default Response"}}}},"/v1/support/requests/{id}/close":{"post":{"summary":"Close a support request","tags":["support"],"parameters":[{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearer":[]}],"responses":{"200":{"description":"Default Response"}}}},"/v1/channels":{"get":{"summary":"Active channels","tags":["channels"],"security":[{"bearer":[]}],"responses":{"200":{"description":"Default Response"}}}},"/v1/channels/{type}/{id}":{"get":{"summary":"Channel + participants","tags":["channels"],"parameters":[{"schema":{"type":"string","enum":["group","vehicle","support"]},"in":"path","name":"type","required":true},{"schema":{"type":"string"},"in":"path","name":"id","required":true}],"security":[{"bearer":[]}],"responses":{"200":{"description":"Default Response","content":{"application/json":{"schema":{"type":"object","properties":{"channel":{"type":"object","properties":{"type":{"type":"string"},"id":{"type":"string"},"room":{"type":"string","description":"LiveKit room name; a mapped vehicle shares its group room"},"mode":{"type":"string","nullable":true},"via":{"type":"object","additionalProperties":true,"description":"The channel actually joined, when it differs from the one requested (vehicle -> group mapping)","nullable":true}}},"participants":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"role":{"type":"string"},"joinedAt":{"type":"string"},"canSpeak":{"type":"boolean"},"serverMuted":{"type":"boolean"},"micOn":{"type":"boolean"},"muted":{"type":"boolean"},"speaking":{"type":"boolean","description":"Not known server-side; use the SDK \"speaking\" event","nullable":true},"tracks":{"type":"array","items":{"type":"object","additionalProperties":true}}}}}}}}}}}}},"/v1/channels/{type}/{id}/participants/{pid}/mute":{"post":{"summary":"Server-side mute/unmute (enforced: the client cannot unmute itself)","tags":["moderation"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["muted"],"properties":{"muted":{"type":"boolean"}}}}}},"parameters":[{"schema":{"type":"string","enum":["group","vehicle","support"]},"in":"path","name":"type","required":true},{"schema":{"type":"string"},"in":"path","name":"id","required":true},{"schema":{"type":"string"},"in":"path","name":"pid","required":true}],"security":[{"bearer":[]}],"responses":{"200":{"description":"Default Response"}}}},"/v1/channels/{type}/{id}/participants/{pid}/remove":{"post":{"summary":"Remove a participant (its current token stops working)","tags":["moderation"],"parameters":[{"schema":{"type":"string","enum":["group","vehicle","support"]},"in":"path","name":"type","required":true},{"schema":{"type":"string"},"in":"path","name":"id","required":true},{"schema":{"type":"string"},"in":"path","name":"pid","required":true}],"security":[{"bearer":[]}],"responses":{"200":{"description":"Default Response"}}}},"/v1/events":{"get":{"summary":"Server-Sent Events stream","tags":["events"],"description":"text/event-stream. Each message: `id`, `event: <type>`, `data: <json VoiceEvent>`. Resume with the Last-Event-ID header or ?since=<id>. Browsers: EventSource cannot set headers, pass ?access_token=<token>. Filter with ?channel=<type>:<id>.","parameters":[{"schema":{"type":"string"},"in":"query","name":"access_token","required":false},{"schema":{"type":"integer"},"in":"query","name":"since","required":false},{"schema":{"type":"string"},"in":"query","name":"channel","required":false}],"security":[{"bearer":[]}],"responses":{"200":{"description":"Default Response"}}}}}}