Files
Maven/docs/protocol.md
claude 93987f2dfc docs: tier the tree by lifetime, so staleness shows in the path (V-446)
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>
2026-08-02 03:28:49 +04:00

7.4 KiB
Raw Permalink Blame History

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 14 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 client
  • authed_page — mavweb /tools page
  • telegram — Telegram inbound

Session lifecycle

  1. Client connects via TCP to the voice address.
  2. Server registers a Session (assigns an opaque ID, records lastActive).
  3. Client sends Requests and receives Responses + Pushes on the same conn.
  4. Proactive delivery routes to the most-recently-active session by lastActive timestamp. If no session is live, the dispatcher falls through to away channels.
  5. 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-