QQQ
QQQ is a single-process Rust server for a small, facilitated conversation. It serves its browser UI, keeps sessions and credentials in memory, and uses an OpenAI-compatible provider for chat, optional transcription, and optional speech synthesis. Restarting the process discards all state.
Install and run
Install the release from crates.io, configure it, then run:
To build and run a local checkout instead:
The embedded UI is at http://127.0.0.1:8787 by default.
Docker and HTTPS
Build and run the repository image with the same .env configuration:
Published images are available from GHCR (pin a version tag in production):
Put a same-origin HTTPS reverse proxy in front of QQQ in production; browser microphone access normally requires a secure context, and QQQ does not terminate TLS itself. For nginx:
location / {
proxy_pass http://127.0.0.1:8787;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
Architecture and lifecycle
The server is deliberately one Rust source file and has no database, queue service, or accounts. The global session map only locates a session; each session has its own async lock. Provider requests occur outside that lock. Inputs are batched into one serial model loop per session and delivery is FIFO with at most one active delivery.
Creating a session starts private creator onboarding. The provider may continue the private conversation, or explicitly call start_group_session after the creator asks for a concrete group purpose. Only then is the session active and joinable. The generated group prompt is retained as the first history message. Every participant has a stable API name (user_1, user_2, …); display names are optional.
Configuration
| Variable | Required | Default | Meaning |
|---|---|---|---|
OPENAI_API_KEY |
yes | — | Provider bearer credential |
CHAT_MODEL |
yes | — | Chat-completions model |
OPENAI_BASE_URL |
no | https://openrouter.ai/api/v1 |
OpenAI-compatible API base URL |
QQQ_BIND |
no | 127.0.0.1:8787 |
Listener address |
STT_MODEL |
no | empty | Transcription model |
TTS_MODEL |
no | empty | Speech model; must be paired with TTS_VOICE |
TTS_VOICE |
no | empty | Speech voice; must be paired with TTS_MODEL |
TTS_FORMAT |
no | mp3 |
Requested speech response format |
OPENAI_HTTP_REFERER |
no | empty | Provider HTTP referer header |
OPENAI_APP_TITLE |
no | empty | Provider application title header |
MAX_SESSIONS |
no | 100 |
In-memory session cap |
MAX_PARTICIPANTS_PER_SESSION |
no | 8 |
Participant cap |
SESSION_TTL_SECONDS |
no | 14400 |
Idle, socket-free retention period |
MAX_HISTORY_MESSAGES |
no | 512 |
Provider-history cap |
MAX_AUDIO_BYTES |
no | 10000000 |
Multipart audio-file cap |
INPUT_BATCH_MS |
no | 350 |
Input debounce interval |
DELIVERY_TIMEOUT_SECONDS |
no | 45 |
Delivery acknowledgement deadline |
DEFAULT_LANGUAGE |
no | en |
Default browser/provider language |
PRIVATE_CONVERSATION_PROMPT |
no | built in | Creator onboarding instructions |
GROUP_SESSION_PROTOCOL_PROMPT |
no | built in | Appended group delivery protocol |
Required strings are trimmed and non-empty. Numeric limits must be nonzero. No provider setting is logged.
Audio modes
Browser recording is independent of configured speech services.
STT_MODEL |
TTS pair | Browser audio input | Delivery output |
|---|---|---|---|
| unset | unset | typed input_audio sent to CHAT_MODEL |
text |
| set | unset | audio/transcriptions |
text |
| unset | set | typed input_audio sent to CHAT_MODEL |
audio plus text |
| set | set | audio/transcriptions |
audio plus text |
Direct chat audio includes raw-byte base64 and a required audio format, with a text content part preserving the participant identity. Multipart input accepts only the audio field and supported browser audio MIME types. The server authenticates and validates session/onboarding state before reading multipart bytes or contacting STT.
Authentication and HTTP API
Participant tokens are bearer credentials. They are returned only from creation/join and are never included in public session views. Except creation, every session HTTP route requires Authorization: Bearer <participant_token>.
| Method and path | Request | Success |
|---|---|---|
POST /api/sessions |
JSON {"browser_language":"en"} |
{"session_id","participant_token","api_name"} |
POST /api/sessions/{id}/join |
JSON language object | same creation shape |
GET /api/sessions/{id} |
— | public session state |
POST /api/sessions/{id}/bootstrap/answer |
JSON {"text":"…"} or multipart audio |
{"ok":true,"session_created":bool} |
GET /api/sessions/{id}/onboarding/audio |
— | creator-only audio bytes |
POST /api/sessions/{id}/input/text |
JSON {"text":"…"} |
{"ok":true} |
POST /api/sessions/{id}/input/audio |
multipart audio |
{"ok":true} |
POST /api/sessions/{id}/interrupt |
— | {"ok":true} |
POST /api/sessions/{id}/deliveries/{did}/complete |
— | {"ok":true} |
GET /api/sessions/{id}/deliveries/{did}/audio |
— | authorized active-delivery audio bytes |
POST /api/sessions/{id}/leave |
— | {"ok":true} |
JSON input is content-type checked, bounded, strongly typed, and rejects malformed bodies and unknown fields. Text is trimmed and limited to 4,000 characters for both onboarding and active input. Standard API failures use exactly:
Status and code distinguish authorization, missing session, conflicts, provider failures, history limits, and audio-size failures. A stale or unauthorized delivery acknowledgement returns 409 with delivery_conflict and cannot alter a later delivery.
WebSocket and delivery sequencing
Connect to GET /api/sessions/{id}/ws, then send {"type":"hello","token":"…"} within ten seconds. Events are JSON with a type field:
session.readyonboarding.prompt:delivery_id,text,audioparticipant.joined,participant.left,participant.named:api_nameai.thinkingdelivery.preparing:target(user_Norall)delivery.started:delivery_id,text,audiodelivery.completedanddelivery.interrupted:delivery_idinput.accepted:api_namefloor.changed:floor_holder(ornull)error:code
Targeted deliveries are FIFO. A reply_to: "all" tool dispatch creates one delivery ID, synthesizes speech once, and sends the same started event to every currently eligible recipient. Each recipient is authorized to retrieve its audio and must acknowledge the shared ID. It completes after every pending recipient acknowledges, disconnects, or the timeout expires; next_speaker is applied once at completion. Public state exposes its target as all.
New accepted input interrupts an active delivery, clears obsolete queued deliveries, emits delivery.interrupted, changes the floor to the input participant, emits floor.changed, then emits input.accepted. Clients must abort playback/text waiting on interruption and must not acknowledge that delivery. Socket queues are bounded; saturation closes/removes the saturated socket rather than silently retaining a healthy-looking connection. Event serialization failures close the connection rather than emitting replacement data.
An authenticated active participant may also call /interrupt before recording. It interrupts active/queued delivery and changes the floor without submitting a model message; its events are delivery.interrupted (when applicable) followed by floor.changed.
Limits and operations
Multipart audio accepts exactly one audio field and is bounded both by MAX_AUDIO_BYTES and a small framing allowance; JSON has a fixed 16 KiB transport bound; provider dispatch text has a 2,000-character cap. Session memory grows only within configured session, participant, history, and audio limits. This is an in-memory service: it offers no persistence, horizontal coordination, account management, delivery recovery after restart, or TLS termination.
Checks and live tests
Offline checks:
Ignored live tests use configured provider credentials and may spend credits:
; ;
The live suite exercises real chat promotion/group delivery and conditionally skips optional STT/TTS round-trip checks when their independent settings are absent.
License
MIT. See LICENSE.