Seventeen markdown files at the repo root, twelve of them dated one-shot reports sitting next to CLAUDE.md. That is why stale docs read as current: nothing in the path said which was which. Root now keeps CLAUDE.md and AGENTS.md. Living docs move under docs/ and carry a Last verified line. Dated measurements move to docs/evals/ ISO-prefixed, and are never edited after the day, so a newer number is a new file. The senior review moves to docs/archive/. Every reference was rewritten across markdown, Go comments, the Makefile and the recall fixture. The touched Go packages still build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7.4 KiB
Maven Voice Protocol
Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.
Auto-generated from
internal/voice/wire.go,internal/voice/errors.go,internal/voice/frame.go,internal/voice/client.go. If this file and those files disagree, the code wins.
Transport
TCP, length-prefixed JSON. Each frame is:
[4 bytes big-endian uint32 length][JSON payload]
The length is the number of bytes of the JSON payload that follows
(excludes the 4-byte length prefix itself). Maximum frame size is 64 MiB
(maxFrame = 64 << 20), enough for ~33 minutes of 16k mono int16 PCM audio.
The server binds a TCP address inside the WireGuard tunnel (production) or
127.0.0.1:9100 (local smoke test). The reference server port is configured
via voice.bind in mavend.json.
Frame types
Three frame shapes share the same length-prefixed envelope. A reader distinguishes them by shape rather than a type tag:
| Frame | Has id? |
Has kind? |
Direction |
|---|---|---|---|
| Request | yes (id + m) |
no | client → server |
| Response | yes (id) |
no | server → client |
| Push | no | yes (kind) |
server → client (async) |
Request (client → server)
{
"id": 1,
"m": "push_to_talk",
"p": { ... }
}
| Field | Type | Description |
|---|---|---|
id |
uint64 | Chosen by client, monotonically increasing per connection. Server echoes it back in the matching Response |
m |
string | Method name (see Methods below) |
p |
object | Method-specific params (omitempty) |
Response (server → client)
{
"id": 1,
"r": { ... },
"e": null
}
| Field | Type | Description |
|---|---|---|
id |
uint64 | Matches the Request this replies to |
r |
object | Result payload (omitempty). Set exactly when e is null |
e |
object | Error payload (omitempty). See Error codes |
A Response always matches a prior Request. It is written on the same connection immediately after handling. The client can block reading — there is exactly one Response per Request for today's synchronous methods.
Push (server → client, async)
{
"kind": "audio_nudge",
"p": { ... }
}
| Field | Type | Description |
|---|---|---|
kind |
string | Push kind (see Push kinds below) |
p |
object | Push-specific params (omitempty) |
A Push is server-initiated. It can arrive on a connection that is also
awaiting a Response. The client distinguishes them by checking whether id
is present (Response) or kind is present (Push).
Methods
push_to_talk
The reactive round-trip: client sends captured audio, server replies with synthesised audio + reply text.
Request params (PushToTalkReq):
{
"audio": {
"fmt": "pcm_16k_mono",
"data": "<base64-encoded PCM bytes>"
},
"lang": "ru",
"surface": "pc_client"
}
| Field | Type | Description |
|---|---|---|
audio |
Audio | Captured PCM. fmt is one of pcm_16k_mono, pcm_44k_stereo, etc. (see internal/audio). data is base64-encoded raw PCM bytes |
lang |
string | Recognition language hint: "ru", "en", "mixed", or unset for daemon default |
surface |
string | Client's auth surface. Today the floor always sets "pc_client"; future mTLS/passkey handshakes populate this |
Response result (PushToTalkResp):
{
"reply_audio": {
"fmt": "pcm_16k_mono",
"data": "<base64>"
},
"reply_text": "тихий режим включён. буду реже напоминать.",
"transcript": "тихий режим",
"routed_channels": null
}
| Field | Type | Description |
|---|---|---|
reply_audio |
Audio | TTS-synthesised reply audio (always present on success) |
reply_text |
string | Same reply in plain text |
transcript |
string | STT transcription of the input audio (omitempty) |
routed_channels |
[string] | Away channels the dispatcher also delivered to (empty for today's reactive-only path) |
pong
Liveness response. The client sends this in reply to a ping Push to
refresh its last-active timestamp on the server.
Request params: none (empty "p": null or omitted).
Response result: null.
Push kinds
audio_nudge
Server has a proactive nudge to deliver. The client should play the audio.
Params (AudioNudgePush):
{
"rule_name": "water",
"severity": 2,
"audio": {
"fmt": "pcm_16k_mono",
"data": "<base64>"
},
"text": "кажется, ты давно не пил воду.",
"ts": "2026-07-03T12:00:00Z"
}
| Field | Type | Description |
|---|---|---|
rule_name |
string | The proactive rule that fired |
severity |
int | 1–4 where 4 = most urgent |
audio |
Audio | TTS-synthesised nudge body |
text |
string | Same body as plain text |
ts |
datetime | When the nudge was sent (RFC3339) |
ping
Liveness probe from the server. The client should respond with a pong
Request to keep its session alive.
Params: none ("p": null or omitted).
Audio format
The Audio type is always:
{
"fmt": "pcm_16k_mono",
"data": "<base64>"
}
- Sample rate: 16000 Hz
- Channels: 1 (mono)
- Sample format: signed 16-bit little-endian int (int16)
- Encoding: raw PCM, no header
- Wire encoding: base64 inside the JSON frame
The reference client wraps/unwraps WAV headers at the file edge
(audio.WAVFromPCM, audio.PCMFromWAV). The wire never carries WAV.
Error codes
Errors are returned as an object in the Response e field:
{
"id": 1,
"r": null,
"e": {
"c": "unknown_method",
"m": "optional diagnostic"
}
}
| Code | Meaning | Has message? |
|---|---|---|
unknown_method |
The method name is not recognised | yes (the method name) |
bad_params |
Params failed to parse / validate | yes (parse error text) |
forbidden |
The surface is not authorised for this method | no |
internal |
Server-side error (transient, retryable) | yes (diagnostic only) |
The message field is never authority-bearing. Auth refusals carry
forbidden with no message.
Auth surface
The server enforces capability scopes per surface. Today's floor assigns
SurfacePCClient to every connection (full L3 access). Future passkey
handshakes will set the surface from mTLS metadata / WebAuthn
enrollment.
Known surface values:
voice— voice/chat channel (structurally capped below destructive acts)pc_client— reference desktop clientauthed_page— mavweb /tools pagetelegram— Telegram inbound
Session lifecycle
- Client connects via TCP to the voice address.
- Server registers a Session (assigns an opaque ID, records
lastActive). - Client sends Requests and receives Responses + Pushes on the same conn.
- Proactive delivery routes to the most-recently-active session by
lastActivetimestamp. If no session is live, the dispatcher falls through to away channels. - On disconnect (EOF / read error / shutdown), the session is removed.
Reference client
cmd/mavenclient implements this protocol. Use it to smoke-test:
# one-shot: send audio.wav → get reply.wav
mavenclient -addr 127.0.0.1:9100 -in audio.wav -out reply.wav
# listen mode: stay connected, write incoming Pushes to disk
mavenclient -addr 127.0.0.1:9100 -listen -out-prefix /tmp/nudge-