# 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)

```json
{
  "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)

```json
{
  "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)

```json
{
  "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`):

```json
{
  "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`):

```json
{
  "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`):

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```shell
# 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-
```
