{
  "openapi": "3.1.0",
  "info": {
    "title": "NewvOice API",
    "version": "v1",
    "description": "The NewvOice API turns speech into finished text. The phone app records the user, sends the audio here, and inserts the text that comes back into whatever app the user is typing in. The same API holds the user's profile, their own custom words and their dictation history.\n\nEvery route, field and error below is generated from the server's code, so this page always matches what is deployed from `main`. The raw spec is [openapi.json](openapi.json) (for Postman or code generators).\n\n## Changelog\n\n**2026-10-25**\n\n- New: `GET /v1/me`, `PATCH /v1/me/preferences`, `POST /v1/me/trial` (*Profile*).\n- New: `GET /v1/history`, `DELETE /v1/history/{dictation_id}`, `GET /v1/history/{dictation_id}/audio` (*History*): the user's own dictations, with search.\n- Changed: `/v1/words` keeps a signed-in user's words per user (`source: \"user\"`, at most 500), used for that user's dictations only. With the shared server token nothing changed (`source: \"phone\"`).\n- Changed: a signed-in user's recordings are kept for 30 days, unless they switch the `keep_audio` preference off.\n- This reference: every field, value, limit and error is now described, with examples.\n\n## Not done yet\n\nSo nobody builds on an assumption:\n\n- **Preferences are stored, not applied.** `language`, `default_mode` and `default_persona` are saved by `PATCH /v1/me/preferences` but the server does not use them as dictation defaults. Keep sending `mode`, `language` and `field.persona` with every dictation. (`keep_audio` *is* applied.)\n- **Plan and trial are not enforced.** Every user can dictate without limits whatever `plan` says.\n- **Phone verification is not enabled.** Phone OTP sign-in is off in the Supabase project, so `POST /v1/me/trial` currently always answers `403 phone_required`.\n- **No per-user rate limit** on HTTP requests yet (the limits below are the only ones).\n\n## Base URL\n\n| Environment | Base URL |\n|---|---|\n| Dev | `https://dev.speaknewvoice.com` (WebSocket: `wss://dev.speaknewvoice.com/v1/stream`) |\n\nDev runs 09:00 to 23:00 IST; outside those hours requests fail to connect. After it is switched on, it takes 1 to 3 minutes before `GET /health` answers `ok`.\n\n## Authentication\n\nSend the signed-in user's **Supabase access token** on every request:\n\n```\nAuthorization: Bearer <access token>\n```\n\nThe app gets it from Supabase Auth (Google sign-in today): with `supabase-js`, `(await supabase.auth.getSession()).data.session.access_token`. Access tokens expire after about an hour; `supabase-js` refreshes them on its own, so read the session before each request rather than keeping a copy. The WebSocket takes the same header on the upgrade request; a `?token=` query parameter is not accepted.\n\n- **401 `unauthorised`**: the token is missing, expired or invalid. Refresh the session and retry once; if it is still 401, send the user to sign in.\n- Routes about a user (`/v1/me*`, `/v1/history*`) need a user's token. The others also accept the server's own **shared token**, which exists for local development and self-hosting (`make token` prints it); never ship it in the app.\n- Only `GET /health` and `GET /v1/health` work without a token.\n\n## Errors\n\nEvery error has the same body, on every route:\n\n```json\n{\"error\": {\"code\": \"unknown_mode\", \"message\": \"unknown mode 'fast'\"}}\n```\n\nBranch on `code`, which is stable; `message` is for logs and may change. An invalid request is always `400 bad_request`, never `422`.\n\n| HTTP | `code` | Meaning | What the app should do |\n|---|---|---|---|\n| 400 | `bad_request` | Missing or invalid field, parameter or body | Fix the request; do not retry |\n| 400 | `bad_audio` | The upload is empty, not WAV/OGG/FLAC, or cannot be decoded | Do not retry this file |\n| 400 | `unknown_mode` | `mode` is not one of `GET /v1/modes` | Refresh the modes list |\n| 401 | `unauthorised` | Token missing, expired or wrong | Refresh the session, retry once, else sign in |\n| 403 | `phone_required` | `POST /v1/me/trial` before a phone is verified | Open phone verification |\n| 403 | `forbidden` | Admin route without an operator or owner account (admin console only) | Not for the app |\n| 404 | `not_found` | No such item, or sign-in / the database is not configured on this server | Treat as gone |\n| 409 | `duplicate` | `POST /v1/dictate`: this `client_id` already finished; the result is in `final` | Insert `final.text` |\n| 409 | `conflict` | Shared word cannot be removed; 500-word limit; phone already had a trial | Show the reason |\n| 413 | `too_large` | Upload over 25 MB | Do not retry |\n| 429 | `rate_limited` | Too many admin model tests in a minute (admin console only) | Wait a minute |\n| 500 | `stt_failed` | Speech recognition failed after retries | Keep the recording, retry later |\n| 500 | `internal` | Unexpected server error | Retry later |\n| 502 | `internal` | The database refused or did not answer | Retry later |\n| 503 | `starting` | `GET /health` only: the server is still starting | Wait and retry |\n\nThe WebSocket has its own error message and codes; see *Live streaming (WebSocket)*.\n\n## Idempotency\n\nEach dictation has a `client_id`: a UUID the app creates when recording starts and reuses for that dictation everywhere (the stream's `start`, `resume`, and every upload retry). A second `POST /v1/dictate` with a `client_id` that already finished does no work and is not billed; it answers `409 duplicate` with the stored result. So retrying an upload is always safe. `POST /v1/words`, `PATCH /v1/me/preferences` and `POST /v1/me/trial` are also safe to repeat.\n\n## Limits\n\n| What | Limit |\n|---|---|\n| Upload size (`POST /v1/dictate`) | 25 MB; WAV, OGG/Opus or FLAC |\n| Streamed dictation length | 180 s, then stopped with warning `truncated_max_duration` |\n| Live streams at once, per server | 50 (`too_many_sessions` above) |\n| Custom words per user | 500, each 1 to 50 characters |\n| History page size | 1 to 100 (default 30); search text up to 200 characters |\n| Recording link | works 5 minutes; recordings kept 30 days |\n\nWhen the speech provider is busy, dictations slow down rather than fail: live partial text is skipped first, and the final text arrives a little later.\n\n## Typical flow\n\n1. **Sign in** with Supabase Auth (Google) and get the access token.\n2. **`GET /v1/me`**: the user and their preferences (to fill settings, and to choose the mode and persona the app sends).\n3. **`GET /v1/modes`** and **`GET /v1/personas`**: build the pickers from these lists; they change without app updates. Optionally **`GET /v1/words`** for the Words screen.\n4. **Dictate**: open **`wss://…/v1/stream`** and stream audio for live text (see *Live streaming*). If the stream cannot finish, keep the recording and **`POST /v1/dictate`** it with the same `client_id`.\n5. Insert `final.text`.\n6. **`GET /v1/history`** for the history screen; `DELETE` to remove an item, `/audio` to play it.\n\n## Versioning\n\nAll routes are under `/v1`. Within `/v1`, things are only **added**: new fields in responses, new optional request fields, new modes and personas, new `warnings`, new error codes. Ignore fields and values you do not know rather than failing on them. A change that would break the app would get a new prefix (`/v2`). The server build is in `version` from `GET /v1/health`.\n"
  },
  "paths": {
    "/v1/health": {
      "get": {
        "tags": [
          "health"
        ],
        "summary": "Is the deployment up?",
        "description": "Liveness only: answers 200 as soon as the process runs, whatever the providers are doing. Needs no token. Use it to check the base URL is right; use `GET /health` to know whether dictation will work.",
        "operationId": "liveness_v1_health_get",
        "responses": {
          "200": {
            "description": "The server is running",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Liveness"
                },
                "examples": {
                  "up": {
                    "summary": "Up",
                    "value": {
                      "status": "ok",
                      "version": "0.1.0+abc1234"
                    }
                  }
                }
              }
            }
          }
        },
        "security": [],
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://dev.speaknewvoice.com/v1/health"
          }
        ]
      }
    },
    "/health": {
      "get": {
        "tags": [
          "health"
        ],
        "summary": "Can this server dictate right now?",
        "description": "Readiness: `200` once the speech provider and the clean-up provider have each answered a probe since start and the voice detector is loaded; `503` with the same body and `status: \"starting\"` until then. Probes repeat every 60 s, so the `*_reachable` fields reflect the last minute. Needs no token. The app's \"Test connection\" button reads this.\n\n**Operators only:** `?reload=1` re-reads the server's configuration and answers `{\"reloaded\": true}`. It needs the server's own shared token (not a user's access token); a broken file keeps the previous configuration and answers `400 bad_request`.",
        "operationId": "health_health_get",
        "parameters": [
          {
            "name": "reload",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "maximum": 1,
              "minimum": 0,
              "description": "Operators only. `1` re-reads the server configuration (needs the server's shared token). Leave it out for a normal health check",
              "examples": [
                0
              ],
              "default": 0
            },
            "description": "Operators only. `1` re-reads the server configuration (needs the server's shared token). Leave it out for a normal health check"
          }
        ],
        "responses": {
          "200": {
            "description": "Ready to dictate (or, with `reload=1`, the configuration was reloaded)",
            "content": {
              "application/json": {
                "schema": {
                  "anyOf": [
                    {
                      "$ref": "#/components/schemas/HealthStatus"
                    },
                    {
                      "$ref": "#/components/schemas/Reloaded"
                    }
                  ],
                  "title": "Response Health Health Get"
                },
                "examples": {
                  "ready": {
                    "summary": "Ready",
                    "value": {
                      "status": "ok",
                      "version": "0.1.0+abc1234",
                      "stt_provider": "groq",
                      "stt_reachable": true,
                      "cleanup_reachable": true,
                      "vad_loaded": true,
                      "sessions_active": 0,
                      "uptime_s": 123.4
                    }
                  },
                  "reloaded": {
                    "summary": "With reload=1",
                    "value": {
                      "reloaded": true
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "Still starting: a provider has not answered yet. Same body as 200",
            "content": {
              "application/json": {
                "examples": {
                  "starting": {
                    "summary": "starting",
                    "value": {
                      "status": "starting",
                      "version": "0.1.0+abc1234",
                      "stt_provider": "groq",
                      "stt_reachable": false,
                      "cleanup_reachable": true,
                      "vad_loaded": true,
                      "sessions_active": 0,
                      "uptime_s": 123.4
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/HealthStatus"
                }
              }
            }
          },
          "400": {
            "description": "`reload=1` only: the new configuration is invalid; the previous one is kept (`bad_request`)",
            "content": {
              "application/json": {
                "examples": {
                  "bad_request": {
                    "summary": "bad_request",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "invalid body.client_id: Field required"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "401": {
            "description": "`reload=1` only: the server's shared token is missing or wrong (`unauthorised`)",
            "content": {
              "application/json": {
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          }
        },
        "security": [],
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl -i https://dev.speaknewvoice.com/health"
          }
        ]
      }
    },
    "/v1/modes": {
      "get": {
        "tags": [
          "configuration"
        ],
        "summary": "List the dictation modes",
        "description": "A mode decides what happens to the transcript: `clean` removes fillers and fixes punctuation and spelling while keeping every word; `raw` returns the transcript with only custom words applied. Modes are server configuration: new ones appear here without an app update, so build the mode picker from this list rather than hard-coding ids. Fetch it at start-up and after sign-in; it changes rarely. Read-only, no side effects.",
        "operationId": "modes_v1_modes_get",
        "responses": {
          "200": {
            "description": "The modes and the default",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ModeList"
                },
                "examples": {
                  "current": {
                    "summary": "Today",
                    "value": {
                      "modes": [
                        {
                          "id": "clean",
                          "name": "Clean",
                          "description": "Remove fillers, fix punctuation and spelling; keeps every word",
                          "cleanup_model": "gpt-4.1-nano"
                        },
                        {
                          "id": "raw",
                          "name": "Raw",
                          "description": "Transcript with custom words only, no clean-up"
                        }
                      ],
                      "default": "clean"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid access token (`unauthorised`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://dev.speaknewvoice.com/v1/modes -H \"Authorization: Bearer $ACCESS_TOKEN\""
          }
        ]
      }
    },
    "/v1/personas": {
      "get": {
        "tags": [
          "configuration"
        ],
        "summary": "List the personas",
        "description": "A persona is an optional second pass that rewrites the cleaned text in a voice (LinkedIn, romantic, poetic). The app picks one per dictation and sends its id as `field.persona`. It applies only in a mode with clean-up; the clean text comes back beside the rewrite as `plain_text`, and is used instead when the rewrite fails or changes a fact. Personas are server configuration: new ones go live here with no app update. Read-only.",
        "operationId": "personas_v1_personas_get",
        "responses": {
          "200": {
            "description": "The personas",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PersonaList"
                },
                "examples": {
                  "current": {
                    "summary": "Example",
                    "value": {
                      "personas": [
                        {
                          "id": "linkedin",
                          "name": "LinkedIn Bro",
                          "description": "Any language in, inspirational LinkedIn English out"
                        },
                        {
                          "id": "poetic",
                          "name": "Poetic",
                          "description": "Your message as a short poem"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid access token (`unauthorised`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://dev.speaknewvoice.com/v1/personas -H \"Authorization: Bearer $ACCESS_TOKEN\""
          }
        ]
      }
    },
    "/v1/dictate": {
      "post": {
        "tags": [
          "dictation"
        ],
        "summary": "Transcribe an uploaded recording",
        "description": "Upload a complete recording and get the finished text back in one request.\n\n**When the app calls it.** Live dictation goes over the WebSocket (`/v1/stream`, see *Live streaming*). This route is the fallback: when a stream could not finish (Wi-Fi lost, `unknown_session`, a fatal stream error) the app keeps the recording on disk and posts it here, now or later from the history screen. It is also the easiest way to try the pipeline with `curl`.\n\n**What happens.** The audio is decoded to 16 kHz mono, cut at pauses, transcribed, custom words are applied (the shared list plus the signed-in user's own words), then the mode's clean-up runs, then the persona rewrite if `field.persona` names one. The request returns when the text is ready: typically 1 to 5 s, longer for long recordings.\n\n**Idempotency.** `client_id` identifies the dictation. Posting the same `client_id` again after it succeeded does **no new work and bills nothing**: the answer is `409 duplicate` with the stored result in `final`, so a retry after a lost response still gets the text. Treat 409 as success. A `client_id` whose earlier attempt failed is processed again.\n\n**Side effects.** A dictation record is written. For a signed-in user it is added to their history (`GET /v1/history`) and, unless they switched `keep_audio` off, the uploaded file is stored for 30 days (`GET /v1/history/{dictation_id}/audio`). That upload runs after the response and never delays it.\n\n**Limits.** Audio up to 25 MB: WAV (any sample rate, mono or stereo), OGG/Opus or FLAC, recognised from the file's first bytes (the file name and MIME type are ignored). There is no separate length limit on this route; 25 MB of 16 kHz 16-bit mono WAV is about 13 minutes.",
        "operationId": "dictate_v1_dictate_post",
        "requestBody": {
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/DictateUpload"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "The finished dictation. Insert `text`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FinalResponse"
                },
                "examples": {
                  "clean": {
                    "summary": "Clean mode",
                    "value": {
                      "type": "final",
                      "client_id": "3f2a9c1e-8b7d-4e6f-9a01-2b3c4d5e6f70",
                      "mode": "clean",
                      "text": "Send the PostgreSQL report by Friday, and copy Priya.",
                      "raw_text": "um send the postgres q l report by friday and uh copy priya",
                      "language": "en",
                      "segments": 1,
                      "timings": {
                        "stt_ms": 410,
                        "post_ms": 950,
                        "stop_to_final_ms": 1420,
                        "billed_audio_s": 10.0
                      },
                      "warnings": []
                    }
                  },
                  "persona": {
                    "summary": "With a persona rewrite",
                    "value": {
                      "type": "final",
                      "client_id": "3f2a9c1e-8b7d-4e6f-9a01-2b3c4d5e6f70",
                      "mode": "clean",
                      "text": "Shipped the migration today. Small steps, big wins.",
                      "raw_text": "i finished the migration today",
                      "language": "en",
                      "segments": 1,
                      "timings": {
                        "stt_ms": 410,
                        "post_ms": 950,
                        "stop_to_final_ms": 1420,
                        "billed_audio_s": 10.0
                      },
                      "warnings": [],
                      "persona": "linkedin",
                      "plain_text": "I finished the migration today."
                    }
                  },
                  "fallback": {
                    "summary": "Clean-up timed out: raw text returned",
                    "value": {
                      "type": "final",
                      "client_id": "3f2a9c1e-8b7d-4e6f-9a01-2b3c4d5e6f70",
                      "mode": "clean",
                      "text": "um send the postgres q l report by friday and uh copy priya",
                      "raw_text": "um send the postgres q l report by friday and uh copy priya",
                      "language": "en",
                      "segments": 1,
                      "timings": {
                        "stt_ms": 410,
                        "post_ms": 10003,
                        "stop_to_final_ms": 10450,
                        "billed_audio_s": 10.0
                      },
                      "warnings": [
                        "cleanup_timeout"
                      ]
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "This `client_id` already completed (`duplicate`). Not an error for the app: insert `final.text`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DuplicateBody"
                },
                "examples": {
                  "duplicate": {
                    "summary": "duplicate",
                    "value": {
                      "error": {
                        "code": "duplicate",
                        "message": "client_id already completed; stored result attached"
                      },
                      "final": {
                        "type": "final",
                        "client_id": "3f2a9c1e-8b7d-4e6f-9a01-2b3c4d5e6f70",
                        "mode": "clean",
                        "text": "Send the PostgreSQL report by Friday, and copy Priya.",
                        "raw_text": "um send the postgres q l report by friday and uh copy priya",
                        "language": "en",
                        "segments": 1,
                        "timings": {
                          "stt_ms": 410,
                          "post_ms": 950,
                          "stop_to_final_ms": 1420,
                          "billed_audio_s": 10.0
                        },
                        "warnings": []
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The request can never succeed as sent: `bad_request` (missing `audio` or `client_id`, `client_id` not a UUID, `field` not a JSON object), `bad_audio` (empty, not WAV/OGG/FLAC, or undecodable), `unknown_mode`. Do not retry",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "bad_request": {
                    "summary": "bad_request",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "invalid body.client_id: Field required"
                      }
                    }
                  },
                  "bad_audio": {
                    "summary": "bad_audio",
                    "value": {
                      "error": {
                        "code": "bad_audio",
                        "message": "unsupported audio container: expected WAV, OGG/Opus or FLAC"
                      }
                    }
                  },
                  "unknown_mode": {
                    "summary": "unknown_mode",
                    "value": {
                      "error": {
                        "code": "unknown_mode",
                        "message": "unknown mode 'fast'"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid access token (`unauthorised`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                }
              }
            }
          },
          "413": {
            "description": "The audio is over 25 MB (`too_large`). Do not retry",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "too_large": {
                    "summary": "too_large",
                    "value": {
                      "error": {
                        "code": "too_large",
                        "message": "audio exceeds 25 MB"
                      }
                    }
                  }
                }
              }
            }
          },
          "500": {
            "description": "Speech recognition failed after retries (`stt_failed`), or an unexpected error (`internal`). Keep the recording and retry later with the same `client_id`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "stt_failed": {
                    "summary": "stt_failed",
                    "value": {
                      "error": {
                        "code": "stt_failed",
                        "message": "speech recognition failed; try again"
                      }
                    }
                  },
                  "internal": {
                    "summary": "internal",
                    "value": {
                      "error": {
                        "code": "internal",
                        "message": "the database refused the request"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl -X POST https://dev.speaknewvoice.com/v1/dictate \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\" \\\n  -F audio=@recording.wav \\\n  -F client_id=3f2a9c1e-8b7d-4e6f-9a01-2b3c4d5e6f70 \\\n  -F mode=clean \\\n  -F 'field={\"app\":\"com.whatsapp\",\"single_line\":false,\"style\":\"casual\"}'"
          },
          {
            "lang": "TypeScript",
            "label": "TypeScript",
            "source": "// React Native: the file part is {uri, name, type}, so the recording never passes through JS.\nconst form = new FormData();\nform.append('audio', {uri: `file://${path}`, name: `${clientId}.wav`, type: 'audio/wav'} as any);\nform.append('client_id', clientId);          // the same UUID on every retry\nform.append('mode', 'clean');\nform.append('field', JSON.stringify({app: 'com.whatsapp', single_line: false}));\n\nconst res = await fetch('https://dev.speaknewvoice.com/v1/dictate', {\n  method: 'POST',\n  headers: {Authorization: `Bearer ${accessToken}`},\n  body: form,\n});\nconst body = await res.json();\nif (res.status === 200) insert(body.text);\nelse if (res.status === 409) insert(body.final.text);   // already done earlier: stored result\nelse if (res.status === 401) refreshSignIn();\nelse if (res.status >= 500) retryLater();                 // keep the file; same client_id\nelse discard(body.error.code);                            // 400 / 413: will never succeed"
          }
        ]
      }
    },
    "/v1/words": {
      "get": {
        "tags": [
          "words"
        ],
        "summary": "List the custom words",
        "description": "Custom words are names and terms that speech recognition should hear and spell the caller's way (\"PostgreSQL\", not \"postgres q l\"). They are given to the recogniser as hints and used to fix its spelling afterwards. This lists the shared words, then the caller's own, for the app's Words screen. Read-only.\n\n**Who is calling decides where words live.** With a signed-in user's access token the words are that user's own (`source: \"user\"`, at most 500, applied to their dictations only, within about a minute of a change). With the server's shared token (local development) they are server-wide (`source: \"phone\"`). Either way the shared `config` words come first and apply to everyone.",
        "operationId": "list_words_v1_words_get",
        "responses": {
          "200": {
            "description": "All the words that apply",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WordList"
                },
                "examples": {
                  "words": {
                    "summary": "Signed-in user",
                    "value": {
                      "words": [
                        {
                          "word": "PostgreSQL",
                          "source": "config"
                        },
                        {
                          "word": "Kubernetes",
                          "source": "user"
                        },
                        {
                          "word": "Priya Raman",
                          "source": "user"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid access token (`unauthorised`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Signed in, but the database is not configured on this server (`not_found`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "not_found": {
                    "summary": "not_found",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "the database is not configured on this server"
                      }
                    }
                  }
                }
              }
            }
          },
          "502": {
            "description": "The database is unreachable or refused the request (`internal`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "internal": {
                    "summary": "internal",
                    "value": {
                      "error": {
                        "code": "internal",
                        "message": "the database refused the request"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://dev.speaknewvoice.com/v1/words -H \"Authorization: Bearer $ACCESS_TOKEN\""
          }
        ]
      },
      "post": {
        "tags": [
          "words"
        ],
        "summary": "Add a custom word",
        "description": "Adds one word or short phrase and returns the whole list. **Idempotent**: adding a word that is already there (in any casing, for a signed-in user) or that is a shared `config` word changes nothing and answers 200. A signed-in user holds at most **500** words (`409 conflict` beyond that).\n\n**Who is calling decides where words live.** With a signed-in user's access token the words are that user's own (`source: \"user\"`, at most 500, applied to their dictations only, within about a minute of a change). With the server's shared token (local development) they are server-wide (`source: \"phone\"`). Either way the shared `config` words come first and apply to everyone.",
        "operationId": "add_word_v1_words_post",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/NewWord"
              },
              "examples": {
                "word": {
                  "summary": "A word",
                  "value": {
                    "word": "Kubernetes"
                  }
                },
                "phrase": {
                  "summary": "A short phrase",
                  "value": {
                    "word": "Priya Raman"
                  }
                }
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "The whole list after adding",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WordList"
                },
                "examples": {
                  "words": {
                    "summary": "After adding",
                    "value": {
                      "words": [
                        {
                          "word": "PostgreSQL",
                          "source": "config"
                        },
                        {
                          "word": "Kubernetes",
                          "source": "user"
                        },
                        {
                          "word": "Priya Raman",
                          "source": "user"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`word` missing, not a string, empty, over 50 characters, or more than one line (`bad_request`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "bad_request": {
                    "summary": "bad_request",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "invalid body.client_id: Field required"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid access token (`unauthorised`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Signed in, but the database is not configured on this server (`not_found`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "not_found": {
                    "summary": "not_found",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "the database is not configured on this server"
                      }
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "The dictionary already holds 500 words (`conflict`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "conflict": {
                    "summary": "conflict",
                    "value": {
                      "error": {
                        "code": "conflict",
                        "message": "this word is in config/custom_words.txt; edit it there"
                      }
                    }
                  }
                }
              }
            }
          },
          "502": {
            "description": "The database is unreachable or refused the request (`internal`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "internal": {
                    "summary": "internal",
                    "value": {
                      "error": {
                        "code": "internal",
                        "message": "the database refused the request"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl -X POST https://dev.speaknewvoice.com/v1/words \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"word\": \"Kubernetes\"}'"
          }
        ]
      }
    },
    "/v1/words/{word}": {
      "delete": {
        "tags": [
          "words"
        ],
        "summary": "Remove a custom word",
        "description": "Removes one of the caller's own words (`user` or `phone`) and returns the list. For a signed-in user the match ignores case. Shared `config` words cannot be removed from the app (`409 conflict`). URL-encode the word (`Priya%20Raman`).\n\n**Who is calling decides where words live.** With a signed-in user's access token the words are that user's own (`source: \"user\"`, at most 500, applied to their dictations only, within about a minute of a change). With the server's shared token (local development) they are server-wide (`source: \"phone\"`). Either way the shared `config` words come first and apply to everyone.",
        "operationId": "delete_word_v1_words__word__delete",
        "parameters": [
          {
            "name": "word",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "description": "The word to remove, URL-encoded, exactly as listed",
              "examples": [
                "Kubernetes"
              ]
            },
            "description": "The word to remove, URL-encoded, exactly as listed"
          }
        ],
        "responses": {
          "200": {
            "description": "The whole list after removing",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WordList"
                },
                "examples": {
                  "words": {
                    "summary": "After removing",
                    "value": {
                      "words": [
                        {
                          "word": "PostgreSQL",
                          "source": "config"
                        },
                        {
                          "word": "Kubernetes",
                          "source": "user"
                        },
                        {
                          "word": "Priya Raman",
                          "source": "user"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid access token (`unauthorised`)",
            "content": {
              "application/json": {
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "404": {
            "description": "No such word, or the database is not configured (`not_found`)",
            "content": {
              "application/json": {
                "examples": {
                  "not_found": {
                    "summary": "not_found",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "the database is not configured on this server"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "409": {
            "description": "The word is a shared `config` word (`conflict`)",
            "content": {
              "application/json": {
                "examples": {
                  "conflict": {
                    "summary": "conflict",
                    "value": {
                      "error": {
                        "code": "conflict",
                        "message": "this word is in config/custom_words.txt; edit it there"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "502": {
            "description": "The database is unreachable or refused the request (`internal`)",
            "content": {
              "application/json": {
                "examples": {
                  "internal": {
                    "summary": "internal",
                    "value": {
                      "error": {
                        "code": "internal",
                        "message": "the database refused the request"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request (`bad_request`, `unknown_mode`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "bad_request": {
                    "summary": "bad_request",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "invalid body.client_id: Field required"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl -X DELETE https://dev.speaknewvoice.com/v1/words/Kubernetes -H \"Authorization: Bearer $ACCESS_TOKEN\""
          }
        ]
      }
    },
    "/v1/me": {
      "get": {
        "tags": [
          "me"
        ],
        "summary": "Who is signed in, with their profile",
        "description": "Who the access token belongs to (id, email and phone, from the token) and their profile: preferences, plan and trial. Call it after sign-in to greet the user and fill the settings screen. Read-only. `profile` is null when the server has no database configured or the row does not exist yet (it is created at sign-up).\n\nNeeds a signed-in user's access token; the server's shared token is refused (`401`).",
        "operationId": "me_v1_me_get",
        "responses": {
          "200": {
            "description": "The user and their profile",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MeResponse"
                },
                "examples": {
                  "signed_in": {
                    "summary": "Signed in",
                    "value": {
                      "id": "8d0f6c2e-1b9a-4c3d-9e7f-5a6b7c8d9e0f",
                      "email": "priya@example.com",
                      "profile": {
                        "id": "8d0f6c2e-1b9a-4c3d-9e7f-5a6b7c8d9e0f",
                        "display_name": "Priya",
                        "language": "auto",
                        "default_mode": "clean",
                        "keep_audio": true,
                        "plan": "free",
                        "created_at": "2026-10-25T09:14:03.120456+00:00",
                        "updated_at": "2026-10-25T09:14:03.120456+00:00"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid access token (`unauthorised`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Sign-in is not configured on this server (`not_found`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "not_found": {
                    "summary": "not_found",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "the database is not configured on this server"
                      }
                    }
                  }
                }
              }
            }
          },
          "502": {
            "description": "The database is unreachable or refused the request (`internal`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "internal": {
                    "summary": "internal",
                    "value": {
                      "error": {
                        "code": "internal",
                        "message": "the database refused the request"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://dev.speaknewvoice.com/v1/me -H \"Authorization: Bearer $ACCESS_TOKEN\""
          }
        ]
      }
    },
    "/v1/me/preferences": {
      "patch": {
        "tags": [
          "me"
        ],
        "summary": "Change the user's preferences",
        "description": "Partial update: send any of `language`, `default_mode`, `default_persona`, `keep_audio`, `display_name`; fields not sent stay as they are. Returns the whole updated profile. Safe to repeat.\n\n**What each preference does today.** `keep_audio` is applied at once: `false` means new dictations' recordings are not stored. `display_name` is the name shown for the user. `language`, `default_mode` and `default_persona` are **stored only**: the server does not use them as dictation defaults yet, so keep sending `mode`, `language` and `field.persona` with each dictation (read them from `GET /v1/me` on the phone). Plan and trial cannot be changed here.\n\nNeeds a signed-in user's access token; the server's shared token is refused (`401`).",
        "operationId": "update_preferences_v1_me_preferences_patch",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PreferencesPatch"
              },
              "examples": {
                "keep_audio": {
                  "summary": "Stop keeping recordings",
                  "value": {
                    "keep_audio": false
                  }
                },
                "language": {
                  "summary": "Mixed Tamil and English",
                  "value": {
                    "language": "ta-en"
                  }
                },
                "clear": {
                  "summary": "Clear the default persona",
                  "value": {}
                }
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "The updated profile",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Profile"
                },
                "examples": {
                  "updated": {
                    "summary": "After the change",
                    "value": {
                      "id": "8d0f6c2e-1b9a-4c3d-9e7f-5a6b7c8d9e0f",
                      "display_name": "Priya",
                      "language": "auto",
                      "default_mode": "clean",
                      "keep_audio": true,
                      "plan": "free",
                      "created_at": "2026-10-25T09:14:03.120456+00:00",
                      "updated_at": "2026-10-25T09:14:03.120456+00:00"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Nothing sent, an unknown field, a wrong type, or an invalid value (`bad_request`); an unknown mode id (`unknown_mode`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "bad_request": {
                    "summary": "bad_request",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "invalid body.client_id: Field required"
                      }
                    }
                  },
                  "unknown_mode": {
                    "summary": "unknown_mode",
                    "value": {
                      "error": {
                        "code": "unknown_mode",
                        "message": "unknown mode 'fast'"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid access token (`unauthorised`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No profile for this user yet, or no database on this server (`not_found`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "not_found": {
                    "summary": "not_found",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "the database is not configured on this server"
                      }
                    }
                  }
                }
              }
            }
          },
          "502": {
            "description": "The database is unreachable or refused the request (`internal`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "internal": {
                    "summary": "internal",
                    "value": {
                      "error": {
                        "code": "internal",
                        "message": "the database refused the request"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl -X PATCH https://dev.speaknewvoice.com/v1/me/preferences \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"keep_audio\": false, \"language\": \"ta-en\"}'"
          }
        ]
      }
    },
    "/v1/me/trial": {
      "post": {
        "tags": [
          "me"
        ],
        "summary": "Start the 30-day trial",
        "description": "Starts the user's 30-day trial and returns when it ends. No request body.\n\n**Needs a verified phone number** on the user's Supabase account. Without one the answer is `403 phone_required`: open the phone verification screen. Each phone number gets **one trial ever**, even across deleted accounts (`409 conflict`). Idempotent: once started, calling it again returns the same end date. Sets `plan` to `trial`.\n\n**Not live yet:** phone sign-in (SMS OTP) is not enabled in the Supabase project, so no user can verify a phone today and every call ends in `403 phone_required`. The plan is not enforced either: nothing is limited by plan yet.\n\nNeeds a signed-in user's access token; the server's shared token is refused (`401`).",
        "operationId": "start_trial_v1_me_trial_post",
        "responses": {
          "200": {
            "description": "The trial is running (just started, or started earlier)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TrialResponse"
                },
                "examples": {
                  "started": {
                    "summary": "Started",
                    "value": {
                      "trial_ends_at": "2026-11-24T09:14:03.120456+00:00"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid access token, or the account is disabled (`unauthorised`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Verify a phone number first (`phone_required`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "phone_required": {
                    "summary": "phone_required",
                    "value": {
                      "error": {
                        "code": "phone_required",
                        "message": "verify a phone number first"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Sign-in or the database is not configured on this server (`not_found`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "not_found": {
                    "summary": "not_found",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "the database is not configured on this server"
                      }
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "This phone number has already had a trial (`conflict`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "conflict": {
                    "summary": "conflict",
                    "value": {
                      "error": {
                        "code": "conflict",
                        "message": "this word is in config/custom_words.txt; edit it there"
                      }
                    }
                  }
                }
              }
            }
          },
          "502": {
            "description": "The database is unreachable or refused the request (`internal`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "internal": {
                    "summary": "internal",
                    "value": {
                      "error": {
                        "code": "internal",
                        "message": "the database refused the request"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl -X POST https://dev.speaknewvoice.com/v1/me/trial -H \"Authorization: Bearer $ACCESS_TOKEN\""
          }
        ]
      }
    },
    "/v1/history": {
      "get": {
        "tags": [
          "history"
        ],
        "summary": "List and search the user's dictations",
        "description": "The signed-in user's own dictations, **newest first**, for the history screen. Both streamed and uploaded dictations appear once they have finished; a dictation made with the server's shared token (development) does not.\n\n**Search.** `q` matches whole words in the final text (or the raw text), in any language and script, ignoring case. Web-search syntax: `invoice friday` (both words), `\"quoted phrase\"` (in that order), `-word` (exclude), `invoice or bill` (either). There is no prefix matching: `inv` does not find `invoice`. Leave `q` out or empty for everything.\n\n**Paging.** Pages start at `0`; `page_size` is 1 to 100 (default 30). Ask for pages until `items` is shorter than `page_size`, or use `total` to show a count. Read-only.\n\nNeeds a signed-in user's access token; the server's shared token is refused (`401`).",
        "operationId": "list_history_v1_history_get",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ],
              "maxLength": 200,
              "description": "Search words, web-search syntax (`\"phrase\"`, `-exclude`, `or`). Whole words only; up to 200 characters. Leave out for all dictations",
              "examples": [
                "\"quarterly report\" -draft"
              ]
            },
            "description": "Search words, web-search syntax (`\"phrase\"`, `-exclude`, `or`). Whole words only; up to 200 characters. Leave out for all dictations"
          },
          {
            "name": "page",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "maximum": 100000,
              "minimum": 0,
              "description": "Page number, from 0",
              "examples": [
                0
              ],
              "default": 0
            },
            "description": "Page number, from 0"
          },
          {
            "name": "page_size",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "maximum": 100,
              "minimum": 1,
              "description": "Dictations per page, 1 to 100",
              "examples": [
                30
              ],
              "default": 30
            },
            "description": "Dictations per page, 1 to 100"
          }
        ],
        "responses": {
          "200": {
            "description": "One page of dictations",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HistoryPage"
                },
                "examples": {
                  "page": {
                    "summary": "First page",
                    "value": {
                      "items": [
                        {
                          "id": 1042,
                          "created_at": "2026-10-25T09:14:03.120456+00:00",
                          "mode": "clean",
                          "app_package": "com.whatsapp",
                          "language": "en",
                          "audio_s": 6.4,
                          "text": "Send the PostgreSQL report by Friday, and copy Priya.",
                          "has_audio": true
                        }
                      ],
                      "total": 57,
                      "page": 0,
                      "page_size": 30
                    }
                  },
                  "empty": {
                    "summary": "No matches",
                    "value": {
                      "items": [],
                      "total": 0,
                      "page": 0,
                      "page_size": 30
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`page` or `page_size` out of range, or `q` over 200 characters (`bad_request`)",
            "content": {
              "application/json": {
                "examples": {
                  "bad_request": {
                    "summary": "bad_request",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "invalid body.client_id: Field required"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid access token (`unauthorised`)",
            "content": {
              "application/json": {
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "404": {
            "description": "Sign-in or the database is not configured on this server (`not_found`)",
            "content": {
              "application/json": {
                "examples": {
                  "not_found": {
                    "summary": "not_found",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "the database is not configured on this server"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "502": {
            "description": "The database is unreachable or refused the request (`internal`)",
            "content": {
              "application/json": {
                "examples": {
                  "internal": {
                    "summary": "internal",
                    "value": {
                      "error": {
                        "code": "internal",
                        "message": "the database refused the request"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl -G https://dev.speaknewvoice.com/v1/history \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\" \\\n  --data-urlencode 'q=\"quarterly report\" -draft' \\\n  -d page=0 -d page_size=30"
          }
        ]
      }
    },
    "/v1/history/{dictation_id}": {
      "delete": {
        "tags": [
          "history"
        ],
        "summary": "Delete one dictation",
        "description": "Permanently removes one of the user's dictations: its text, its statistics, and its stored recording. **It cannot be undone**; ask the user to confirm. Only the user's own dictations can be deleted: someone else's id answers `404`, exactly like a missing one. Deleting the same id twice answers `404` the second time.\n\nThe recording is removed from storage right away when possible; if that step fails, the dictation is still deleted (`audio_deleted: false`) and the recording expires within 30 days.\n\nNeeds a signed-in user's access token; the server's shared token is refused (`401`).",
        "operationId": "delete_history_v1_history__dictation_id__delete",
        "parameters": [
          {
            "name": "dictation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "description": "The dictation's `id` from `GET /v1/history`",
              "examples": [
                1042
              ]
            },
            "description": "The dictation's `id` from `GET /v1/history`"
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Deleted"
                },
                "examples": {
                  "with_audio": {
                    "summary": "Had a recording",
                    "value": {
                      "deleted": 1042,
                      "audio_deleted": true
                    }
                  },
                  "text_only": {
                    "summary": "No recording",
                    "value": {
                      "deleted": 1042,
                      "audio_deleted": false
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`dictation_id` is not a positive integer (`bad_request`)",
            "content": {
              "application/json": {
                "examples": {
                  "bad_request": {
                    "summary": "bad_request",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "invalid body.client_id: Field required"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid access token (`unauthorised`)",
            "content": {
              "application/json": {
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "404": {
            "description": "No such dictation, or the database is not configured (`not_found`)",
            "content": {
              "application/json": {
                "examples": {
                  "not_found": {
                    "summary": "not_found",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "the database is not configured on this server"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "502": {
            "description": "The database is unreachable or refused the request (`internal`)",
            "content": {
              "application/json": {
                "examples": {
                  "internal": {
                    "summary": "internal",
                    "value": {
                      "error": {
                        "code": "internal",
                        "message": "the database refused the request"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl -X DELETE https://dev.speaknewvoice.com/v1/history/1042 -H \"Authorization: Bearer $ACCESS_TOKEN\""
          }
        ]
      }
    },
    "/v1/history/{dictation_id}/audio": {
      "get": {
        "tags": [
          "history"
        ],
        "summary": "Get a short-lived link to a dictation's recording",
        "description": "For a \"play\" button on a history item with `has_audio: true`. Returns a pre-signed link that works for **5 minutes**; ask again when it expires rather than caching it. Each call makes a new link; nothing is changed.\n\nRecordings are kept 30 days after the dictation, and only while the user's `keep_audio` preference was on when they dictated. After that the answer is `404 not_found`.\n\nNeeds a signed-in user's access token; the server's shared token is refused (`401`).",
        "operationId": "history_audio_v1_history__dictation_id__audio_get",
        "parameters": [
          {
            "name": "dictation_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "description": "The dictation's `id` from `GET /v1/history`",
              "examples": [
                1042
              ]
            },
            "description": "The dictation's `id` from `GET /v1/history`"
          }
        ],
        "responses": {
          "200": {
            "description": "A link to the recording",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AudioLink"
                },
                "examples": {
                  "streamed": {
                    "summary": "Streamed dictation",
                    "value": {
                      "url": "https://<bucket>.s3.amazonaws.com/<user id>/2026/10/<client id>.flac?X-Amz-Signature=…",
                      "content_type": "audio/flac",
                      "expires_in_s": 300
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "`dictation_id` is not a positive integer (`bad_request`)",
            "content": {
              "application/json": {
                "examples": {
                  "bad_request": {
                    "summary": "bad_request",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "invalid body.client_id: Field required"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid access token (`unauthorised`)",
            "content": {
              "application/json": {
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "404": {
            "description": "No recording for this dictation (never stored, expired, deleted, not the user's), or recordings are not kept on this server (`not_found`)",
            "content": {
              "application/json": {
                "examples": {
                  "not_found": {
                    "summary": "not_found",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "the database is not configured on this server"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          },
          "502": {
            "description": "The database is unreachable or refused the request (`internal`)",
            "content": {
              "application/json": {
                "examples": {
                  "internal": {
                    "summary": "internal",
                    "value": {
                      "error": {
                        "code": "internal",
                        "message": "the database refused the request"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                }
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl https://dev.speaknewvoice.com/v1/history/1042/audio -H \"Authorization: Bearer $ACCESS_TOKEN\""
          }
        ]
      }
    },
    "/v1/admin/models/test": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "Test a model with a real call (admin console)",
        "description": "Makes one real call with the server's API key: speech-to-text transcribes a short bundled clip, clean-up and persona ask for a one-line rewrite (20 s limit). A failure is `200` with `ok: false` and a plain reason. A pass is recorded, and the console may then save that model for that step for 24 hours. Operators and owners only; 10 tests a minute per person.",
        "operationId": "check_model_v1_admin_models_test_post",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ModelTestRequest"
              },
              "examples": {
                "stt": {
                  "summary": "Speech-to-text",
                  "value": {
                    "step": "stt",
                    "model_id": "openai/whisper-large-v3"
                  }
                },
                "cleanup": {
                  "summary": "Clean-up",
                  "value": {
                    "step": "cleanup",
                    "model_id": "openrouter/anthropic/claude-haiku-4.5"
                  }
                }
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "The test ran; `ok` says whether the model works",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ModelTestResult"
                },
                "examples": {
                  "passed": {
                    "summary": "Works",
                    "value": {
                      "ok": true,
                      "latency_ms": 840,
                      "sample": "Can you send me the report by Friday?"
                    }
                  },
                  "failed": {
                    "summary": "Unknown model id",
                    "value": {
                      "ok": false,
                      "latency_ms": 310,
                      "error_code": "unknown_model",
                      "message": "The provider doesn't know this model id. Check the spelling."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid request (`bad_request`, `unknown_mode`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "bad_request": {
                    "summary": "bad_request",
                    "value": {
                      "error": {
                        "code": "bad_request",
                        "message": "invalid body.client_id: Field required"
                      }
                    }
                  },
                  "unknown_mode": {
                    "summary": "unknown_mode",
                    "value": {
                      "error": {
                        "code": "unknown_mode",
                        "message": "unknown mode 'fast'"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid access token (`unauthorised`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "unauthorised": {
                    "summary": "unauthorised",
                    "value": {
                      "error": {
                        "code": "unauthorised",
                        "message": "missing or invalid bearer token"
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "description": "Not an operator or owner (`forbidden`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "forbidden": {
                    "summary": "forbidden",
                    "value": {
                      "error": {
                        "code": "forbidden",
                        "message": "operators and owners only"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Not found, or sign-in is not configured on this server (`not_found`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "not_found": {
                    "summary": "not_found",
                    "value": {
                      "error": {
                        "code": "not_found",
                        "message": "the database is not configured on this server"
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "More than 10 tests in a minute (`rate_limited`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "rate_limited": {
                    "summary": "rate_limited",
                    "value": {
                      "error": {
                        "code": "rate_limited",
                        "message": "too many tests; wait a minute and try again"
                      }
                    }
                  }
                }
              }
            }
          },
          "502": {
            "description": "The database is unreachable or refused the request (`internal`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorBody"
                },
                "examples": {
                  "internal": {
                    "summary": "internal",
                    "value": {
                      "error": {
                        "code": "internal",
                        "message": "the database refused the request"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "x-codeSamples": [
          {
            "lang": "Shell",
            "label": "curl",
            "source": "curl -X POST https://dev.speaknewvoice.com/v1/admin/models/test \\\n  -H \"Authorization: Bearer $ACCESS_TOKEN\" \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"step\": \"cleanup\", \"model_id\": \"openrouter/anthropic/claude-haiku-4.5\"}'"
          }
        ]
      }
    }
  },
  "components": {
    "schemas": {
      "AudioLink": {
        "properties": {
          "url": {
            "type": "string",
            "description": "A pre-signed HTTPS link to the recording. Fetch or stream it with a plain GET and **no** `Authorization` header (the signature is in the URL). Do not store it",
            "examples": [
              "https://<bucket>.s3.amazonaws.com/<user id>/2026/10/<client id>.flac?X-Amz-Signature=…"
            ]
          },
          "content_type": {
            "type": "string",
            "description": "`audio/flac` for a streamed dictation (16 kHz mono); for an upload, the format that was uploaded (`audio/wav`, `audio/ogg` for OGG/Opus, `audio/flac`)",
            "examples": [
              "audio/flac"
            ]
          },
          "expires_in_s": {
            "type": "integer",
            "description": "Seconds the link works for (300, i.e. 5 minutes)",
            "examples": [
              300
            ]
          }
        },
        "type": "object",
        "required": [
          "url",
          "content_type",
          "expires_in_s"
        ],
        "title": "AudioLink"
      },
      "Deleted": {
        "properties": {
          "deleted": {
            "type": "integer",
            "description": "The id of the deleted dictation",
            "examples": [
              1042
            ]
          },
          "audio_deleted": {
            "type": "boolean",
            "description": "A recording was found and removed from storage. False when there was none, or when removing it failed (it then expires within 30 days anyway)",
            "examples": [
              true
            ]
          }
        },
        "type": "object",
        "required": [
          "deleted",
          "audio_deleted"
        ],
        "title": "Deleted"
      },
      "DuplicateBody": {
        "properties": {
          "error": {
            "$ref": "#/components/schemas/ErrorDetail",
            "description": "Always code `duplicate`"
          },
          "final": {
            "$ref": "#/components/schemas/FinalResponse",
            "description": "The stored result of the earlier request. Insert this as if it were a 200"
          }
        },
        "type": "object",
        "required": [
          "error",
          "final"
        ],
        "title": "DuplicateBody",
        "description": "`409 duplicate`: this `client_id` already finished. The stored result comes with it."
      },
      "ErrorBody": {
        "description": "The body of every error response.",
        "properties": {
          "error": {
            "$ref": "#/components/schemas/ErrorDetail",
            "description": "The error. Always present on a 4xx or 5xx answer."
          }
        },
        "required": [
          "error"
        ],
        "title": "ErrorBody",
        "type": "object"
      },
      "ErrorDetail": {
        "description": "What went wrong: a stable code for the app to branch on, and a message for people.",
        "properties": {
          "code": {
            "description": "Stable, machine-readable code. Branch on this, never on `message`. One of `unauthorised`, `bad_request`, `bad_audio`, `duplicate`, `too_large`, `unknown_mode`, `stt_failed`, `starting`, `internal`, `conflict`, `not_found`, `phone_required`, `forbidden`, `rate_limited` (see the error table in the overview).",
            "examples": [
              "unauthorised"
            ],
            "type": "string"
          },
          "message": {
            "description": "Short English explanation for logs and developers. It may change between versions; do not parse it or show it to users as is.",
            "examples": [
              "missing or invalid bearer token"
            ],
            "type": "string"
          }
        },
        "required": [
          "code",
          "message"
        ],
        "title": "ErrorDetail",
        "type": "object"
      },
      "FinalResponse": {
        "properties": {
          "type": {
            "type": "string",
            "const": "final",
            "description": "Always `final`",
            "examples": [
              "final"
            ]
          },
          "client_id": {
            "type": "string",
            "description": "The `client_id` you sent, normalised to lower-case UUID form",
            "examples": [
              "3f2a9c1e-8b7d-4e6f-9a01-2b3c4d5e6f70"
            ]
          },
          "mode": {
            "type": "string",
            "description": "The mode that was applied (the default mode when none was sent) Configured on the server, so the live list is `GET /v1/modes`; today: `clean`, `raw`.",
            "examples": [
              "clean",
              "raw"
            ]
          },
          "text": {
            "type": "string",
            "description": "**The text to insert.** Cleaned up (and rewritten when a persona was applied). Falls back to `raw_text` when clean-up fails, so it is never worse than the transcript. Empty when no speech was found",
            "examples": [
              "Send the PostgreSQL report by Friday."
            ]
          },
          "raw_text": {
            "type": "string",
            "description": "The transcript before clean-up, with custom words already applied. Equal to `text` in a mode without clean-up (`raw`)",
            "examples": [
              "send the postgres q l report by friday"
            ]
          },
          "language": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO 639-1 code of the language used or detected (`en`, `ta`, `hi`). Null when it could not be determined (for example, no speech)",
            "examples": [
              "en"
            ]
          },
          "segments": {
            "type": "integer",
            "description": "How many pieces the audio was cut into for speech recognition (at pauses, at most about 25 s each)",
            "examples": [
              3
            ]
          },
          "timings": {
            "$ref": "#/components/schemas/Timings",
            "description": "Where the time went"
          },
          "warnings": {
            "items": {
              "type": "string",
              "enum": [
                "cleanup_timeout",
                "cleanup_error",
                "cleanup_dropped_content",
                "cleanup_skipped_empty",
                "segment_dropped_no_speech",
                "segment_dropped_hallucination",
                "segment_failed_retried",
                "truncated_max_duration",
                "persona_timeout",
                "persona_error",
                "persona_facts"
              ]
            },
            "type": "array",
            "description": "Things that went less than perfectly. The text is still usable; show a hint at most.\n\n| Warning | Meaning |\n|---|---|\n| `cleanup_timeout` | Clean-up took longer than its limit (10 s); `text` is the raw transcript |\n| `cleanup_error` | Clean-up failed or returned nothing usable; `text` is the raw transcript |\n| `cleanup_dropped_content` | The clean-up model answered the dictation instead of cleaning it; raw text kept (always with `cleanup_error`) |\n| `cleanup_skipped_empty` | Nothing was transcribed, so clean-up was skipped |\n| `segment_dropped_no_speech` | A piece of audio held no speech (silence, noise) and was left out |\n| `segment_dropped_hallucination` | A piece's transcript looked invented (repetition, prompt echo) and was left out |\n| `segment_failed_retried` | Speech recognition needed a retry, or missing stream frames never arrived |\n| `truncated_max_duration` | Streaming only: the dictation hit the 180 s limit and was stopped there |\n| `persona_timeout` / `persona_error` | The persona rewrite failed; `text` is the clean text |\n| `persona_facts` | The rewrite changed a number or fact, so the clean text was kept |\n\nNew warnings may be added; treat an unknown one as informational.",
            "examples": [
              []
            ]
          },
          "persona": {
            "type": [
              "string",
              "null"
            ],
            "description": "**Present only when a persona rewrite was applied**: the persona id. Absent (not null) otherwise. Configured on the server, so the live list is `GET /v1/personas`; today: `linkedin`, `romantic-sweet`, `romantic-flirty`, `poetic`.",
            "examples": [
              "linkedin",
              "romantic-sweet",
              "romantic-flirty",
              "poetic"
            ]
          },
          "plain_text": {
            "type": [
              "string",
              "null"
            ],
            "description": "**Present only with `persona`**: the clean text before the rewrite, so the app can offer \"use the plain version\"",
            "examples": [
              "I finished the migration today."
            ]
          }
        },
        "type": "object",
        "required": [
          "type",
          "client_id",
          "mode",
          "text",
          "raw_text",
          "language",
          "segments",
          "timings",
          "warnings"
        ],
        "title": "Final",
        "description": "The finished dictation. The same object is the stream's `final` message."
      },
      "HealthStatus": {
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok",
              "starting"
            ],
            "description": "`ok` (HTTP 200): ready to dictate. `starting` (HTTP 503): a provider has not answered its first probe yet, or the voice detector is still loading"
          },
          "version": {
            "type": "string",
            "description": "Server build, e.g. `0.1.0+abc1234`",
            "examples": [
              "0.1.0+abc1234"
            ]
          },
          "stt_provider": {
            "type": "string",
            "enum": [
              "groq",
              "openrouter",
              "fake"
            ],
            "description": "Which speech-recognition provider this server uses",
            "examples": [
              "groq"
            ]
          },
          "stt_reachable": {
            "type": "boolean",
            "description": "The speech provider answered the most recent probe (every 60 s)"
          },
          "cleanup_reachable": {
            "type": "boolean",
            "description": "The clean-up provider answered the most recent probe (every 60 s)"
          },
          "vad_loaded": {
            "type": "boolean",
            "description": "The voice-activity detector model is loaded"
          },
          "sessions_active": {
            "type": "integer",
            "description": "Live streaming sessions right now (updated every few seconds)",
            "examples": [
              0
            ]
          },
          "uptime_s": {
            "type": "number",
            "description": "Seconds since the server started",
            "examples": [
              123.4
            ]
          }
        },
        "type": "object",
        "required": [
          "status",
          "version",
          "stt_provider",
          "stt_reachable",
          "cleanup_reachable",
          "vad_loaded",
          "sessions_active",
          "uptime_s"
        ],
        "title": "HealthStatus",
        "description": "Whether this server can dictate right now."
      },
      "HistoryItem": {
        "properties": {
          "id": {
            "type": "integer",
            "description": "The dictation's id, for `DELETE /v1/history/{dictation_id}` and `/audio`",
            "examples": [
              1042
            ]
          },
          "created_at": {
            "type": "string",
            "description": "When it was recorded (ISO 8601, UTC offset)",
            "examples": [
              "2026-10-25T09:14:03.120456+00:00"
            ]
          },
          "mode": {
            "type": [
              "string",
              "null"
            ],
            "description": "The mode used. Configured on the server, so the live list is `GET /v1/modes`; today: `clean`, `raw`.",
            "examples": [
              "clean",
              "raw"
            ]
          },
          "persona": {
            "type": [
              "string",
              "null"
            ],
            "description": "The persona whose rewrite is in `text`; null when none was applied. Configured on the server, so the live list is `GET /v1/personas`; today: `linkedin`, `romantic-sweet`, `romantic-flirty`, `poetic`.",
            "examples": [
              "linkedin",
              "romantic-sweet",
              "romantic-flirty",
              "poetic"
            ]
          },
          "app_package": {
            "type": [
              "string",
              "null"
            ],
            "description": "The app the text went into (`field.app`), e.g. `com.whatsapp`; may be null",
            "examples": [
              "com.whatsapp"
            ]
          },
          "language": {
            "type": [
              "string",
              "null"
            ],
            "description": "Language code used or detected, e.g. `en`",
            "examples": [
              "en"
            ]
          },
          "audio_s": {
            "type": [
              "number",
              "null"
            ],
            "description": "Length of the recording in seconds",
            "examples": [
              6.4
            ]
          },
          "text": {
            "type": [
              "string",
              "null"
            ],
            "description": "The final text that was inserted, or the raw transcript if there was none",
            "examples": [
              "Send the PostgreSQL report by Friday, and copy Priya."
            ]
          },
          "has_audio": {
            "type": "boolean",
            "description": "The recording is still stored and `GET /v1/history/{dictation_id}/audio` will work. False after 30 days, or when `keep_audio` was off",
            "default": false,
            "examples": [
              true
            ]
          }
        },
        "type": "object",
        "required": [
          "id",
          "created_at"
        ],
        "title": "HistoryItem",
        "description": "One dictation in the user's history."
      },
      "HistoryPage": {
        "properties": {
          "items": {
            "items": {
              "$ref": "#/components/schemas/HistoryItem"
            },
            "type": "array",
            "description": "This page's dictations, newest first. Empty past the last page"
          },
          "total": {
            "type": "integer",
            "description": "Matching dictations across all pages (0 when this page is empty, even past the end)",
            "examples": [
              57
            ]
          },
          "page": {
            "type": "integer",
            "description": "The page you asked for, from 0",
            "examples": [
              0
            ]
          },
          "page_size": {
            "type": "integer",
            "description": "The page size you asked for",
            "examples": [
              30
            ]
          }
        },
        "type": "object",
        "required": [
          "items",
          "total",
          "page",
          "page_size"
        ],
        "title": "HistoryPage"
      },
      "Liveness": {
        "properties": {
          "status": {
            "type": "string",
            "const": "ok",
            "description": "Always `ok`: the process is answering"
          },
          "version": {
            "type": "string",
            "description": "Server build: package version plus the git commit, e.g. `0.1.0+abc1234`",
            "examples": [
              "0.1.0+abc1234"
            ]
          }
        },
        "type": "object",
        "required": [
          "status",
          "version"
        ],
        "title": "Liveness"
      },
      "MeResponse": {
        "properties": {
          "id": {
            "type": "string",
            "description": "The user's id, from the access token",
            "examples": [
              "8d0f6c2e-1b9a-4c3d-9e7f-5a6b7c8d9e0f"
            ]
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "description": "Email from the access token (Google sign-in); null if none",
            "examples": [
              "priya@example.com"
            ]
          },
          "phone": {
            "type": [
              "string",
              "null"
            ],
            "description": "Phone number from the access token; null if none",
            "examples": [
              null
            ]
          },
          "profile": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/Profile"
              },
              {
                "type": "null"
              }
            ],
            "description": "Null when the row does not exist yet, or the server has no database key"
          }
        },
        "type": "object",
        "required": [
          "id",
          "email",
          "phone",
          "profile"
        ],
        "title": "MeResponse"
      },
      "ModeInfo": {
        "properties": {
          "id": {
            "type": "string",
            "description": "Send this as `mode` (upload) or `mode` in the stream's `start`. Configured on the server, so the live list is `GET /v1/modes`; today: `clean`, `raw`.",
            "examples": [
              "clean",
              "raw"
            ]
          },
          "name": {
            "type": "string",
            "description": "Short label to show the user",
            "examples": [
              "Clean"
            ]
          },
          "description": {
            "type": "string",
            "description": "One line explaining the mode, to show under the label",
            "examples": [
              "Remove fillers, fix punctuation and spelling; keeps every word"
            ]
          },
          "cleanup_model": {
            "type": [
              "string",
              "null"
            ],
            "description": "The clean-up model the mode is configured with, for display. Null when the mode does no clean-up (`raw`). An admin may override it at runtime, so treat it as informational",
            "examples": [
              "gpt-4.1-nano"
            ]
          }
        },
        "type": "object",
        "required": [
          "id",
          "name",
          "description",
          "cleanup_model"
        ],
        "title": "ModeInfo"
      },
      "ModeList": {
        "properties": {
          "modes": {
            "items": {
              "$ref": "#/components/schemas/ModeInfo"
            },
            "type": "array",
            "description": "Every mode, in the order to show them"
          },
          "default": {
            "type": "string",
            "description": "The mode used when a request names none (the first in the list) Configured on the server, so the live list is `GET /v1/modes`; today: `clean`, `raw`.",
            "examples": [
              "clean",
              "raw"
            ]
          }
        },
        "type": "object",
        "required": [
          "modes",
          "default"
        ],
        "title": "ModeList"
      },
      "ModelTestRequest": {
        "properties": {
          "step": {
            "type": "string",
            "enum": [
              "stt",
              "cleanup",
              "persona"
            ],
            "description": "`stt` speech-to-text, `cleanup` clean-up, `persona` persona rewrites"
          },
          "model_id": {
            "type": "string",
            "maxLength": 200,
            "minLength": 1,
            "description": "Exactly what the server is given: an OpenRouter id for `stt` (`openai/whisper-large-v3`), a LiteLLM id for the others (`openrouter/anthropic/claude-haiku-4.5`)"
          }
        },
        "type": "object",
        "required": [
          "step",
          "model_id"
        ],
        "title": "ModelTestRequest"
      },
      "ModelTestResult": {
        "properties": {
          "ok": {
            "type": "boolean",
            "description": "The provider, the server's key and the model all worked"
          },
          "latency_ms": {
            "type": [
              "integer",
              "null"
            ],
            "description": "How long the test call took"
          },
          "sample": {
            "type": [
              "string",
              "null"
            ],
            "description": "The start of what the model returned"
          },
          "error_code": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "unknown_model",
              "auth_failed",
              "no_credit",
              "provider_unreachable",
              "timeout",
              "bad_output",
              "not_configured"
            ],
            "description": "Why it failed: `unknown_model`, `auth_failed` (the key was refused), `no_credit`, `provider_unreachable`, `timeout`, `bad_output`, `not_configured`"
          },
          "message": {
            "type": [
              "string",
              "null"
            ],
            "description": "The reason in plain words, with what to do"
          }
        },
        "type": "object",
        "required": [
          "ok"
        ],
        "title": "ModelTestResult"
      },
      "NewWord": {
        "properties": {
          "word": {
            "type": "string",
            "maxLength": 50,
            "minLength": 1,
            "description": "The word or short phrase to recognise and spell this way, e.g. a name or product term. A string of 1 to 50 characters after trimming, on one line",
            "examples": [
              "Kubernetes"
            ]
          }
        },
        "type": "object",
        "title": "NewWord"
      },
      "PersonaInfo": {
        "properties": {
          "id": {
            "type": "string",
            "description": "Send this as `field.persona`. Configured on the server, so the live list is `GET /v1/personas`; today: `linkedin`, `romantic-sweet`, `romantic-flirty`, `poetic`.",
            "examples": [
              "linkedin",
              "romantic-sweet",
              "romantic-flirty",
              "poetic"
            ]
          },
          "name": {
            "type": "string",
            "description": "Label to show the user",
            "examples": [
              "LinkedIn Bro"
            ]
          },
          "description": {
            "type": "string",
            "description": "One line explaining the voice",
            "examples": [
              "Any language in, inspirational LinkedIn English out"
            ]
          }
        },
        "type": "object",
        "required": [
          "id",
          "name",
          "description"
        ],
        "title": "PersonaInfo"
      },
      "PersonaList": {
        "properties": {
          "personas": {
            "items": {
              "$ref": "#/components/schemas/PersonaInfo"
            },
            "type": "array",
            "description": "Every persona, in the order to show them. May be empty"
          }
        },
        "type": "object",
        "required": [
          "personas"
        ],
        "title": "PersonaList"
      },
      "PreferencesPatch": {
        "properties": {
          "language": {
            "type": [
              "string",
              "null"
            ],
            "description": "`auto`, a 2-3 letter language code (`en`, `ta`), or two joined with `-` for mixed speech (`ta-en`). Lower-cased before saving; anything else is `400 bad_request`",
            "examples": [
              "ta-en"
            ]
          },
          "default_mode": {
            "type": [
              "string",
              "null"
            ],
            "description": "A mode id from `GET /v1/modes`; an unknown id is `400 unknown_mode`. Configured on the server, so the live list is `GET /v1/modes`; today: `clean`, `raw`.",
            "examples": [
              "clean",
              "raw"
            ]
          },
          "default_persona": {
            "type": [
              "string",
              "null"
            ],
            "description": "A persona id from `GET /v1/personas`; an unknown id is `400 bad_request`. Configured on the server, so the live list is `GET /v1/personas`; today: `linkedin`, `romantic-sweet`, `romantic-flirty`, `poetic`.",
            "examples": [
              "linkedin",
              "romantic-sweet",
              "romantic-flirty",
              "poetic"
            ]
          },
          "keep_audio": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "`true` or `false` (null is refused). `false` stops storing new recordings",
            "examples": [
              false
            ]
          },
          "display_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "One line, at most 80 characters after trimming spaces",
            "examples": [
              "Priya"
            ]
          }
        },
        "additionalProperties": false,
        "type": "object",
        "title": "PreferencesPatch",
        "description": "Only the fields sent are changed; send at least one. `null` or `\"\"` clears a text field.\n\nUnknown fields are refused (`400 bad_request`), so a typo is never silently ignored."
      },
      "Profile": {
        "properties": {
          "id": {
            "type": "string",
            "description": "The user's id (Supabase Auth user id, a UUID)",
            "examples": [
              "8d0f6c2e-1b9a-4c3d-9e7f-5a6b7c8d9e0f"
            ]
          },
          "display_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Name to greet the user with. Filled from the Google account at sign-up; the user may change it (one line, at most 80 characters)",
            "examples": [
              "Priya"
            ]
          },
          "language": {
            "type": [
              "string",
              "null"
            ],
            "description": "Preferred spoken language: `auto`, a language code (`en`, `ta`), or two joined for mixed speech (`ta-en`). **Stored only**: the server does not apply it to dictations yet, so send `language` with each dictation",
            "examples": [
              "ta-en"
            ]
          },
          "default_mode": {
            "type": [
              "string",
              "null"
            ],
            "description": "A mode id from `GET /v1/modes`. **Stored only**: the server does not apply it yet, so send `mode` with each dictation. Configured on the server, so the live list is `GET /v1/modes`; today: `clean`, `raw`.",
            "examples": [
              "clean",
              "raw"
            ]
          },
          "default_persona": {
            "type": [
              "string",
              "null"
            ],
            "description": "A persona id from `GET /v1/personas`. **Stored only**: the server does not apply it yet, so send `field.persona` with each dictation. Configured on the server, so the live list is `GET /v1/personas`; today: `linkedin`, `romantic-sweet`, `romantic-flirty`, `poetic`.",
            "examples": [
              "linkedin",
              "romantic-sweet",
              "romantic-flirty",
              "poetic"
            ]
          },
          "keep_audio": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Keep each dictation's recording for 30 days (default `true`). **Applied**: when `false`, new recordings are not stored. Switching it off does not delete recordings already stored; they expire 30 days after they were made, or delete them one by one with `DELETE /v1/history/{dictation_id}`",
            "examples": [
              true
            ]
          },
          "plan": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "trial",
              "free",
              "pro"
            ],
            "description": "`free` (the default), `trial` after `POST /v1/me/trial`, `pro` (not sold yet). **Not enforced yet**: every plan dictates without limits today",
            "examples": [
              "free"
            ]
          },
          "phone_verified_at": {
            "type": [
              "string",
              "null"
            ],
            "description": "Set when the trial is started with a verified phone number; null before",
            "examples": [
              null
            ]
          },
          "trial_started_at": {
            "type": [
              "string",
              "null"
            ],
            "description": "When the 30-day trial started; null if never",
            "examples": [
              null
            ]
          },
          "trial_ends_at": {
            "type": [
              "string",
              "null"
            ],
            "description": "When the trial ends (start + 30 days); null if never started. Stays set after",
            "examples": [
              null
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ],
            "description": "When the account was created",
            "examples": [
              "2026-10-25T09:14:03.120456+00:00"
            ]
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ],
            "description": "When the profile last changed",
            "examples": [
              "2026-10-25T09:14:03.120456+00:00"
            ]
          }
        },
        "type": "object",
        "required": [
          "id"
        ],
        "title": "Profile",
        "description": "The user's profile: preferences they set, and plan and trial set by the server.\n\nTimes are ISO 8601 strings with a UTC offset."
      },
      "Reloaded": {
        "properties": {
          "reloaded": {
            "type": "boolean",
            "const": true,
            "description": "The configuration was re-read successfully"
          }
        },
        "type": "object",
        "required": [
          "reloaded"
        ],
        "title": "Reloaded"
      },
      "Timings": {
        "properties": {
          "stt_ms": {
            "type": "integer",
            "description": "Total time spent waiting on speech recognition, in milliseconds",
            "examples": [
              640
            ]
          },
          "post_ms": {
            "type": "integer",
            "description": "Time spent on clean-up and the persona rewrite, in milliseconds",
            "examples": [
              1820
            ]
          },
          "stop_to_final_ms": {
            "type": "integer",
            "description": "From the end of the audio to the final text, in milliseconds. Streaming: from `stop`; upload: from the moment the file was decoded",
            "examples": [
              2510
            ]
          },
          "billed_audio_s": {
            "type": "number",
            "description": "Seconds of audio the speech provider bills for, rounded to 0.1. Each request is billed at least 10 s, so this is usually more than the audio length",
            "examples": [
              40.0
            ]
          }
        },
        "type": "object",
        "required": [
          "stt_ms",
          "post_ms",
          "stop_to_final_ms",
          "billed_audio_s"
        ],
        "title": "Timings",
        "description": "Where the time went, for diagnostics and the history screen."
      },
      "TrialResponse": {
        "properties": {
          "trial_ends_at": {
            "type": "string",
            "description": "When the 30-day trial ends (ISO 8601)",
            "examples": [
              "2026-11-24T09:14:03.120456+00:00"
            ]
          }
        },
        "type": "object",
        "required": [
          "trial_ends_at"
        ],
        "title": "TrialResponse"
      },
      "WordEntry": {
        "properties": {
          "word": {
            "type": "string",
            "description": "The word or phrase, as stored",
            "examples": [
              "Kubernetes"
            ]
          },
          "source": {
            "type": "string",
            "enum": [
              "config",
              "phone",
              "user"
            ],
            "description": "Where the word lives. `config`: the shared list every dictation uses, edited on the server; read-only from the app. `user`: the signed-in user's own word, used for their dictations only; removable. `phone`: added with the server's shared token (local or self-hosted servers), used for every dictation there; removable.",
            "examples": [
              "user"
            ]
          }
        },
        "type": "object",
        "required": [
          "word",
          "source"
        ],
        "title": "WordEntry"
      },
      "WordList": {
        "properties": {
          "words": {
            "items": {
              "$ref": "#/components/schemas/WordEntry"
            },
            "type": "array",
            "description": "All words that apply to the caller: the `config` words first, then their own"
          }
        },
        "type": "object",
        "required": [
          "words"
        ],
        "title": "WordList"
      },
      "DictateUpload": {
        "properties": {
          "audio": {
            "type": "string",
            "contentMediaType": "application/octet-stream",
            "description": "The recording: WAV (any rate, mono or stereo), OGG/Opus or FLAC, at most 25 MB. The format is detected from the content"
          },
          "client_id": {
            "type": "string",
            "format": "uuid",
            "description": "A UUID the app generates once per dictation (v4 recommended) and reuses on every retry of it, and for the stream of the same dictation. The idempotency key",
            "examples": [
              "3f2a9c1e-8b7d-4e6f-9a01-2b3c4d5e6f70"
            ]
          },
          "mode": {
            "type": [
              "string",
              "null"
            ],
            "description": "What to do with the transcript, by mode id from `GET /v1/modes`. Leave it out for the server's default mode (the `default` in `GET /v1/modes`). An unknown id is `400 unknown_mode`. Configured on the server, so the live list is `GET /v1/modes`; today: `clean`, `raw`.",
            "examples": [
              "clean",
              "raw"
            ]
          },
          "field": {
            "type": [
              "string",
              "null"
            ],
            "description": "Optional JSON object, **sent as a string** in the form, describing where the text is going. Every key is optional; send `{}` or leave the field out when you know nothing.\n\n| Key | Type | Purpose |\n|---|---|---|\n| `app` | string | Android package of the app being typed into, e.g. `com.whatsapp`. Clean-up is told the target app, and the history shows it |\n| `single_line` | boolean, default `false` | The target field is one line (a search box, a subject line): clean-up returns one line with no line breaks |\n| `style` | `casual` \\| `clear` \\| `formal` | Tone for clean-up, from the app's Styles screen. Unknown values are ignored |\n| `persona` | string | A persona id from `GET /v1/personas`: the clean text is then rewritten in that voice (only in a mode with clean-up). Unknown ids are ignored |\n| `speaker` | `male` \\| `female` | Grammatical gender of the speaker, for languages that mark it (Hindi: \"aa raha hoon\" / \"aa rahi hoon\"), used by the persona step. Other values are ignored |\n\n`app` must be a string and `single_line` a boolean, otherwise `400 bad_request`. Unknown keys are ignored. Configured on the server, so the live list is `GET /v1/personas`; today: `linkedin`, `romantic-sweet`, `romantic-flirty`, `poetic`.",
            "examples": [
              "{\"app\":\"com.whatsapp\",\"single_line\":false,\"style\":\"casual\"}"
            ]
          },
          "language": {
            "type": [
              "string",
              "null"
            ],
            "description": "Force the spoken language: an ISO 639-1 code such as `en`, `ta`, `hi`. Leave it out (or send `auto`) to let the server detect it, which also handles a language change mid-dictation. When left out, the mode's language and then the server's setting apply (both `auto` today)",
            "examples": [
              "en"
            ]
          }
        },
        "type": "object",
        "required": [
          "audio",
          "client_id"
        ],
        "title": "DictateUpload"
      }
    },
    "securitySchemes": {
      "bearer": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "The signed-in user's Supabase access token (or, for local development only, the server's shared token). See *Authentication* in the overview."
      }
    }
  },
  "servers": [
    {
      "url": "https://dev.speaknewvoice.com",
      "description": "Dev"
    }
  ],
  "security": [
    {
      "bearer": []
    }
  ],
  "tags": [
    {
      "name": "dictation",
      "x-displayName": "Dictation",
      "description": "Turn a recording into text. Live dictation streams over the WebSocket (next section); the upload here is its fallback and retry path."
    },
    {
      "name": "stream",
      "x-displayName": "Live streaming (WebSocket)",
      "description": "Live dictation streams audio over one WebSocket while the user speaks, and gets text back as it is recognised. It is how the app dictates normally; `POST /v1/dictate` is the fallback.\n\nA dictation is a **session**, identified by the app's `client_id`, not a connection: the connection may drop and come back (`resume`) and the session waits for it, keeping every frame and every piece of text, for 60 s.\n\n### Connect\n\n```\nGET wss://dev.speaknewvoice.com/v1/stream\nAuthorization: Bearer <access token>\n```\n\nIn React Native the third constructor argument carries the header:\n\n```ts\nconst ws = new WebSocket('wss://dev.speaknewvoice.com/v1/stream', null,\n  {headers: {Authorization: 'Bearer ' + accessToken}});\nws.binaryType = 'arraybuffer';\nws.onopen = () => ws.send(JSON.stringify({type: 'start', client_id: clientId, mode: 'clean'}));\n```\n\nThe connection is always accepted; a bad token is answered with an `error` (`unauthorised`) and close code 1008. Text frames carry JSON objects with a `type`; binary frames carry audio. The server never sends binary.\n\n### Lifecycle\n\n1. Open the socket and send **`start`** at once. Record from the first tap: buffer frames while the socket opens, never wait for the server.\n2. The server answers **`ready`**. Send audio as **binary frames** of 100 ms, numbered from 0, including silence (the server decides what a pause is).\n3. The server sends **`ack`** every 10 frames, **`committed`** text as each piece is recognised (at pauses after about 8 s of speech), sometimes a **`partial`** during long unbroken speech, and **`idle`** after 8 s of silence.\n4. When the user stops (or on `idle`), send **`stop`** with the last frame's number. If frames are missing the server asks for them with **`need_frames`**.\n5. The server sends **`final`**: insert `final.text`, then close the socket (code 1000).\n\nTo abandon a dictation send **`cancel`**: nothing is stored and the server closes the socket.\n\n**Dropped connection.** Keep recording and keep every frame until `final`. Reconnect (back off 0.5, 1, 2, 4, 8 s), send **`resume`** with the same `client_id`, and the server answers `ready` with `last_seq`, the highest frame it holds; resend from `last_seq + 1`. It then re-sends every `committed` message so far, the latest `partial`, and `final` if it is already done (it is kept 60 s). Ignore a `committed.index` you already have. If the session is gone (`unknown_session`), or a fatal error arrives, **post the whole recording to `POST /v1/dictate`** with the same `client_id`.\n\n**Liveness.** While the server expects audio, 15 s without any message ends the connection (the session waits for `resume`). The server also sends WebSocket pings every 5 s. After `stop` the app may be silent while it waits for `final`.\n\n### Audio frames (app → server, binary)\n\n```\noffset  size  field\n0       4     seq      uint32 big-endian, 0 for the first frame, +1 per frame\n4       1     codec    0 = pcm16, 1 = opus  (the codec named in start)\n5       …     payload  100 ms of 16 kHz mono audio\n```\n\n- **pcm16**: exactly 3,200 bytes = 1,600 samples, signed 16-bit little-endian.\n- **opus**: a count byte (normally 5), then for each packet a uint16 big-endian length and the packet bytes. Each packet is one 20 ms Opus frame at 16 kHz mono (Android `MediaCodec` `audio/opus`). The last frame before `stop` may hold fewer packets.\n\nA frame number the server already holds is ignored (safe to resend). A frame that skips a number is fatal (`bad_sequence`).\n\n### Messages from the app\n\n| `type` | Fields | When |\n|---|---|---|\n| `start` | see below | First message of a new dictation |\n| `stop` | `last_seq` (integer, required): the last frame's `seq` | The user stopped, or on `idle` |\n| `resume` | `client_id` (the session's), `last_acked_seq` (the last `ack` seen; informational) | First message after a reconnect |\n| `cancel` | none | Abandon the dictation; nothing is stored |\n| `ping` | none | Optional; answered with `pong` |\n\n**`start` fields**\n\n| Field | Type | Required | Meaning |\n|---|---|---|---|\n| `client_id` | string (UUID) | yes | The dictation's id, also used for any upload retry |\n| `mode` | string | no | A mode id from `GET /v1/modes` (`clean`, `raw`); default `clean`. Unknown: `bad_start` |\n| `codec` | `opus` \\| `pcm16` | no | Audio codec of the frames; default `opus` |\n| `sample_rate` | integer | no | Must be `16000` |\n| `field` | object | no | Where the text goes, as a JSON **object** (not a string): `app`, `single_line`, `style`, `persona`, `speaker`, exactly as in `POST /v1/dictate`. Personas today: `linkedin`, `romantic-sweet`, `romantic-flirty`, `poetic` |\n| `language` | string or null | no | Force a language (`en`, `ta`); null or absent to detect |\n| `codec_config` | string or null | no | Base64 Opus header from the encoder. Accepted and not needed by the server |\n| `app_version`, `device` | string | no | Stored with the dictation for support, e.g. `0.1.0`, `Pixel 8 / Android 15` |\n\n```json\n{\"type\": \"start\", \"client_id\": \"3f2a9c1e-8b7d-4e6f-9a01-2b3c4d5e6f70\", \"mode\": \"clean\",\n \"field\": {\"app\": \"com.whatsapp\", \"single_line\": false, \"style\": \"casual\"},\n \"codec\": \"opus\", \"sample_rate\": 16000, \"language\": null,\n \"app_version\": \"0.1.0\", \"device\": \"Pixel 8 / Android 15\"}\n```\n\nSending `start` again for a session that is still running acts as `resume`.\n\n### Messages from the server\n\n| `type` | Fields | Meaning |\n|---|---|---|\n| `ready` | `session_id`, `last_seq`, `resumed`, `partial_interval_s`, `server_version` | The session is attached. `last_seq`: highest frame held (-1 for a new session); resend from the next. `resumed`: true after `resume`. `partial_interval_s`: seconds of unbroken speech between partials (0 = none) |\n| `ack` | `seq` | Frames up to `seq` are held. Sent every 10 frames and on `stop`. Marks the resume point only: keep all frames until `final` |\n| `partial` | `text`, `since_seq` | Provisional text for the audio after the last `committed`, from frame `since_seq`. Replace the previous partial; obsolete once a `committed` arrives |\n| `committed` | `index`, `text`, `from_seq`, `to_seq` | A finished piece of text for frames `from_seq` to `to_seq`. `index` increases from 0 and may skip numbers (a piece with no speech sends nothing). Never changes once sent |\n| `idle` | none | 8 s of silence after speech: treat it as the user tapping stop |\n| `need_frames` | `from_seq`, `to_seq` | After `stop`, these frames are missing: resend them. After 5 s the server finishes with what it has (warning `segment_failed_retried`) |\n| `final` | the `POST /v1/dictate` response | The finished dictation. **`final.text` is the only text to insert** |\n| `error` | `code`, `message`, `fatal` | See below |\n| `pong` | none | Answer to `ping` |\n\n```json\n{\"type\": \"ready\", \"session_id\": \"srv-6b1f…\", \"last_seq\": -1, \"resumed\": false,\n \"partial_interval_s\": 10, \"server_version\": \"0.1.0+abc1234\"}\n{\"type\": \"ack\", \"seq\": 9}\n{\"type\": \"committed\", \"index\": 0, \"text\": \"So the first thing we need to do is fix the firewall.\",\n \"from_seq\": 0, \"to_seq\": 95}\n{\"type\": \"partial\", \"text\": \"and then we can\", \"since_seq\": 96}\n{\"type\": \"error\", \"code\": \"bad_sequence\", \"message\": \"expected seq 12, got 14\", \"fatal\": true}\n```\n\n### Errors\n\n`fatal: true` means the session is over and the app should post its saved recording to `POST /v1/dictate`. The server then closes the socket with the close code below, so the app can tell errors apart without parsing; an error raised while finishing (`stt_failed`, `internal` after `stop`) leaves the socket open, so close it yourself. `fatal: false` means the message was ignored and the session goes on.\n\n| `code` | Close | Fatal | Cause |\n|---|---|---|---|\n| `starting` | 4000 | yes | The server is still starting (`start` too early) |\n| `unauthorised` | 1008 | yes | Missing or invalid token on the upgrade request |\n| `bad_start` | 4002 | before `ready`: yes; after: no | Before `ready`: the first message is not `start`/`resume`, `start` is invalid (bad UUID, unknown mode or codec, sample rate not 16000, `field` not an object), audio came before `start`, or a message is not JSON. After `ready` (ignored, session goes on): `stop` without `last_seq`, a second `start`/`resume`, an unknown `type` |\n| `bad_audio` | 4003 | yes | A frame cannot be parsed or decoded (wrong size, bad Opus packet) |\n| `bad_sequence` | 4004 | yes | A frame skipped a number within one connection |\n| `unknown_session` | 4005 | yes | `resume` for a session that expired, was cancelled or never existed |\n| `session_busy` | 4006 | yes | `resume` while another connection holds the session |\n| `too_many_sessions` | 4007 | yes | The server is at its limit of 50 live streams |\n| `too_long` | 4008 | yes | Reserved; not sent today (long dictations are stopped with a warning instead) |\n| `stt_failed` | 4009 | yes | Speech recognition failed for part of the audio after retries |\n| `internal` | 1011 | yes | Unexpected error, or the server is shutting down |\n\n### Example\n\n```\napp → {\"type\":\"start\",\"client_id\":\"3f2a…\",\"mode\":\"clean\",\"codec\":\"opus\",\"sample_rate\":16000}\nsrv ← {\"type\":\"ready\",\"session_id\":\"srv-6b1f…\",\"last_seq\":-1,\"resumed\":false,...}\napp → [binary seq 0] [binary seq 1] … [binary seq 9]\nsrv ← {\"type\":\"ack\",\"seq\":9}\napp → … [binary seq 120]          (Wi-Fi drops)\n      reconnect, then:\napp → {\"type\":\"resume\",\"client_id\":\"3f2a…\",\"last_acked_seq\":119}\nsrv ← {\"type\":\"ready\",\"last_seq\":120,\"resumed\":true,...}\nsrv ← {\"type\":\"committed\",\"index\":0,\"text\":\"So the first thing…\",\"from_seq\":0,\"to_seq\":95}\napp → [binary seq 121] … [binary seq 299]\napp → {\"type\":\"stop\",\"last_seq\":299}\nsrv ← {\"type\":\"ack\",\"seq\":299}\nsrv ← {\"type\":\"committed\",\"index\":1,...}\nsrv ← {\"type\":\"final\",\"client_id\":\"3f2a…\",\"text\":\"So the first thing we need…\",...}\napp → close (1000)\n```\n"
    },
    {
      "name": "configuration",
      "x-displayName": "Modes and personas",
      "description": "What the app may offer the user, from the server's live configuration. Build the pickers from these; new modes and personas need no app update."
    },
    {
      "name": "words",
      "x-displayName": "Custom words",
      "description": "Names and terms speech recognition should hear and spell the user's way. Shared words apply to everyone; a signed-in user also has their own."
    },
    {
      "name": "me",
      "x-displayName": "Profile",
      "description": "The signed-in user: who they are, their preferences, plan and trial."
    },
    {
      "name": "history",
      "x-displayName": "History",
      "description": "The signed-in user's own dictations: list and search, delete, and play the recording."
    },
    {
      "name": "health",
      "x-displayName": "Health",
      "description": "Whether the server is up, and whether it can dictate right now."
    },
    {
      "name": "admin",
      "x-displayName": "Admin console",
      "description": "Used by the admin console only, not the phone app. Needs an operator or owner account."
    }
  ],
  "x-tagGroups": [
    {
      "name": "Dictate",
      "tags": [
        "dictation",
        "stream",
        "configuration",
        "words"
      ]
    },
    {
      "name": "User",
      "tags": [
        "me",
        "history"
      ]
    },
    {
      "name": "Operations",
      "tags": [
        "health",
        "admin"
      ]
    }
  ]
}
