# talk > **Status: WIP**, under active development. Specification with the decision > addendum: [`docs/spec.md`](docs/spec.md) **talk** gives scripts and AI agents a voice: one call, `talk "Build done"`, speaks a short announcement through a chain of TTS providers (ElevenLabs → OpenAI → Edge TTS → OS voice), serialized by a background daemon that talk starts itself on first use. One static binary, cross-platform (Windows, macOS, Linux; amd64 and arm64), no runtime dependencies. ## Commands | Command | Purpose | | --- | --- | | `talk "text"` | announce; shorthand for `talk say`, non-blocking | | `talk say "text"` | announce (text as argument or from stdin) | | `talk wrap -- ` | run a command, announce success/failure and duration | | `talk mute` / `talk unmute` | mute all announcements persistently / turn them back on | | `talk status` | daemon status (queue, mute, provider, laws, version) | | `talk cancel` | cancel the playing and all queued announcements | | `talk uri ` | handler behind the `talk:` links of notification buttons (Windows); not called by hand | | `talk daemon run\|start\|stop` | run the daemon in the foreground / start it in the background / stop it (normally not needed, see below) | | `talk voices` | known voices per provider and language, default marked (`--provider`, `--lang`, `--json`) | | `talk test` | speak a test sentence; shows provider and timings (`--lang`, `--provider`, `--voice`); `--toast` sends a test notification instead | | `talk doctor` | self-diagnosis: config, daemon, audio, providers, notifications, mute, laws (`--probe`, `--json`) | | `talk setup` | first-time setup in a few questions: language, voices with preview, quiet hours, config.toml | | `talk version` | version and commit (also `talk --version`) | Say flags (also for `talk "text"`): `--wait`, `--now`, `--priority`, `--voice`, `--lang`, `--provider`. ## Installation **Release binary** (Linux and Windows, amd64/arm64; macOS builds on request, untested so far): download the matching `talk__` from the [releases](https://code.m3ta.dev/m3tam3re/talk/releases), verify the checksum against `checksums.txt`, put it in your PATH as `talk`. No other dependencies. The download URL carries the version (Gitea has no `latest/download` alias): ```console V=v0.1.0 curl -LO https://code.m3ta.dev/m3tam3re/talk/releases/download/$V/talk_linux_amd64 sha256sum -c --ignore-missing <(curl -sL https://code.m3ta.dev/m3tam3re/talk/releases/download/$V/checksums.txt) install -m 755 talk_linux_amd64 ~/.local/bin/talk ``` **Nix** (flake): `nix run git+https://code.m3ta.dev/m3tam3re/talk -- doctor`, or in your configuration `inputs.talk.url = "git+https://code.m3ta.dev/m3tam3re/talk";` and `inputs.talk.packages.${system}.talk` or `inputs.talk.overlays.default`. The package brings espeak-ng (OS voice) and libnotify (toast) along. **From source:** `go install code.m3ta.dev/m3tam3re/talk/cmd/talk@latest`. ## Getting started ```console talk doctor # What is there, what is missing? One line per item. talk test # Do you hear something? Which voice was it? talk setup # Pick a language and voices, set quiet hours, done. talk "Hello world" # From now on scripts and agents call exactly this. ``` talk works without any key: it uses Microsoft's free Edge voice (needs internet) and otherwise your operating system's voice. For a premium voice, set `TALK_ELEVENLABS_KEY` or `TALK_OPENAI_KEY` as an environment variable. Keys never go into config.toml. ### Diagnosis and setup - **`talk doctor`** reports one line per item with ✔ (ok), ⚠ (note), ✘ (error): config file, daemon (running / starts on the next speak command / foreign service on the port), audio output (can oto initialize? `null` = no sound), every provider tier (key present, endpoint, default voice; OS voice: which mechanism was found), notifications, mute, laws including an active quiet window. Exit 0 without ✘, else 1; a daemon that is not running is ⚠. `--probe` runs real synthesis probes (network), `--json` returns the list for machines. Windows consoles get `[OK]`/`[WARN]`/`[FAIL]` instead of the symbols. - **`talk test`** speaks a fixed test sentence directly in this process (not via the daemon; mute and quiet hours do not apply here, an active mute is mentioned) and shows which tier spoke and how long synthesis and playback took. `talk test --toast` shows a test notification with the Cancel button and waits for a click, so you can check the notification setup without speaking anything. - **`talk voices`** lists the curated voice catalog (ElevenLabs premade, OpenAI, Edge neural voices per locale) as a table; `*` marks the voice talk uses without `--voice` (config `voice..` or the built-in default). Config entries outside the catalog appear as their own row. Any voice ID the provider knows works without a catalog entry. - **`talk setup`** asks in the terminal: keys (only a hint; without a key the Edge voice speaks), the fallback language, a voice for each language of the active tier (number from the catalog or a voice ID, preview, accept), quiet hours. Then it writes config.toml, merging an existing file (other settings stay) and backing it up as `.bak`, and speaks a test sentence. `q` aborts at any point; the file is written only at the very end. Without a terminal (pipe, agent) setup asks nothing and only shows the effective configuration. ### Notifications with a Cancel button Every announcement that plays also shows a desktop notification with its text and a **Cancel** button (German: Abbrechen) that stops that announcement without a terminal. The notification disappears when the announcement ends. - **Linux:** talk talks to the freedesktop notification service on the session bus directly (any daemon with the "actions" capability: mako, dunst, GNOME Shell, KDE, quickshell) and receives the click over D-Bus. The notification carries the talk icon. Without a session bus it falls back to notify-send without a button. - **Windows:** a toast from "talk" with its icon and a Cancel button. The button opens a `talk:` link; the daemon registers that URL protocol for the current user (HKCU, no admin rights) so Windows starts `talk uri talk:cancel?id=…`, which tells the daemon to stop the announcement. The toast identity (`AppUserModelId m3ta.talk`, also HKCU) is registered the same way on the first daemon start or `talk test --toast`; before that the toast appears under Windows PowerShell's identity. - **macOS:** Notification Center, no button and no icon yet. The icon file lives next to the config (`talk.png` in the config directory); talk writes it there itself. Turn it off with: ```toml [notify] on_speak = false # env TALK_NOTIFY=false ``` `talk test --toast` shows one such notification, waits a few seconds for a click on Cancel and reports what happened; `talk doctor` names the service and whether it supports the button. ### Language talk detects German and English per announcement (word lists plus umlauts; no ML) and picks the voice accordingly. When it cannot tell (numbers, single words, ties) it uses the configured fallback language, which is also the language of talk's own announcements such as the `wrap` result: ```toml lang = "en" # de | en; default en (env TALK_LANG) ``` ## Daemon There is exactly one binary. The daemon is `talk daemon run`: the same code, in the background. It owns the announcement queue and plays serially, exactly one announcement at a time, in the order `now` > `high` > `normal` > `low` (FIFO per level). The HTTP server listens on `127.0.0.1` only; port: default `7800`, config `[server] port`, env `TALK_PORT`. **Autostart:** when no daemon runs, `talk say` (and `talk wrap`) start it on the first speak command, detached from the terminal (on Windows also broken away from the caller's job object, so a WinRM, SSH or CI session does not take it down), logging to `daemon.log` with its PID in `daemon.pid` in the config directory; stderr says "daemon started" once. The daemon then keeps running (idle costs next to nothing) until `talk daemon stop`, logout or reboot. Parallel first calls are harmless: whoever loses the port race uses the other one's daemon. If you manage the daemon yourself (systemd, launchd, Task Scheduler with `talk daemon run`), turn autostart off: ```toml [daemon] autostart = false # env TALK_AUTOSTART=false ``` Control commands (`mute`, `unmute`, `status`, `cancel`) deliberately do not start the daemon: `talk status` then says "not running" (exit 1), `talk mute` writes the mute file directly. | Endpoint | Effect | | --- | --- | | `POST /speak` | `{text, priority?, provider?, voice?, lang?, now?}` → `202 {"id":"…"}` (+ `"truncated":true` when cut to `max_text`); muted: `202 {"id":"","muted":true}`; law discard: `202 {"id":"","discarded":"rate_limit"\|"quiet_hours"}` (+ `"toast":true` in toast mode); documented behaviour, not an error | | `OPTIONS /speak` | CORS preflight → `204` with allow headers when the `Origin` is listed in `server.allowed_origins` (see below); otherwise `204` without CORS headers | | `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "limits": {"max_text", "max_age"}, "mcp": true, "cors": [origins]}` | | `POST /cancel` | aborts the playing announcement and empties the queue → `200 {"cancelled":n}` | | `POST /cancel-item` | `{"id":"…"}` cancels only that announcement if it is playing (notification Cancel button) → `200 {"cancelled":bool}` | | `POST /mute` / `POST /unmute` | persistent mute state → `200 {"muted":bool}` | | `POST /shutdown` | shuts the daemon down (`talk daemon stop`) → `200 {"ok":true}` | | `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call`; one tool `speak` (see below) | | `GET /mcp` | `405`; the daemon offers no server-initiated streams | - **CLI behaviour:** `talk say` (and `talk "text"`) detects the daemon via `GET /status`. If it runs: enqueue and return at once (non-blocking, stdout gets the item ID); `--wait` polls until spoken; `--now` jumps the queue; when muted the CLI prints `muted`, on a law discard `discarded: `. `talk mute`/`unmute`/`status`/`cancel` are daemon calls with plain output. - **Mute is persistent** (file `mute` in the config directory, content `true`/`false`) and survives daemon restarts. `talk mute` without a running daemon writes the file directly; the daemon re-reads it on every announcement, so the state applies offline too. - **Direct path:** if no daemon is reachable and none can be started (autostart off, port taken by something else, start failed), `talk` speaks directly in its own process: blocking, without a queue (parallel calls may overlap), with a note and the reason on stderr. Mute is honoured here too. Nothing fails silently. ### Rate limit, quiet hours, length and age The daemon enforces the anti-spam laws as a backstop under whatever a prompt does. **Rate limit:** every *started* announcement books a time window, default **1 announcement / 30 s**. Until the window elapses: - `priority=low` is **discarded** (already on admission; the 202 response says `discarded: "rate_limit"`); if the window was open on admission, the worker decides later. - `normal` **parks** in the queue and plays at the next open slot (visible in `/status` as `rate_limit.next_slot`). - `priority=high` may **deliberately break** the limit with `queue.allow_high_burst = true` (default); without burst rights high waits like normal. - `now:true` **bypasses the rate limit too**; the documented escape for urgent matters (it still books a new window). **Quiet hours:** `quiet_hours = "18:00-08:00"` (local time, may span midnight; empty/`off` = disabled; deliberately no default window, quiet hours are opt-in like mute). During quiet hours audio is dropped, also for high (burst is a rate right, not a quiet right): - `quiet_mode = "discard"` (default): drop silently. - `quiet_mode = "toast"`: a desktop notification instead of audio (the notification service or the system's own tool, see "Notifications"), best effort: if nothing is available, only the log line remains and `talk doctor` shows it under "Toast". - `now:true` **breaks out of quiet hours too** (a deliberate interruption). **Length and age:** two more laws against what prompts like to do, reading out the long answer and piling up announcements: - `max_text` (default **400 characters**, two to three sentences): longer texts are **cut** at a word boundary on admission and spoken like that; the response reports `"truncated": true`, the CLI warns on stderr, the MCP result says so in its text. The user hears the beginning instead of a ten-minute lecture, and the API budget stays bounded. - `max_age` (default **2 minutes**): whatever parks longer in the queue (rate limit, backlog) is no longer news and is **discarded** when it comes due (`discarded: "expired"` in the log; the 202 on admission stays a promise, because nothing is stale on admission). `now:true` never waits long and is exempt. **Precedence:** manual mute always wins > `now` > stale (`max_age`) > quiet hours > rate limit; `max_text` applies on admission to everything. Configuration (config.toml; env beats the file): ```toml [queue] rate_limit = "30s" # duration; "0s" = unlimited (TALK_RATE_LIMIT) allow_high_burst = true # high may break the limit (TALK_ALLOW_HIGH_BURST) max_text = 400 # characters; 0 = unlimited (TALK_MAX_TEXT) max_age = "2m" # longest wait; "0s" = unlimited (TALK_MAX_AGE) quiet_hours = "18:00-08:00" # empty = off; env TALK_QUIET_HOURS ("off" = off) quiet_mode = "discard" # discard | toast (TALK_QUIET_MODE) ``` `talk status` shows the law state: `rate-limit: 30s (high burst allowed), next slot …`, `quiet-hours: 18:00-08:00 (mode discard, active until 08:00)` and `limits: max 400 characters, max wait 2m0s`. ### CORS door and MCP endpoint **CORS** applies to `POST /speak` only, the one door a later browser integration (for example a web chat UI) needs; control endpoints stay out of reach of browsers. The allow-list holds exact origins; `"*"` is allowed but not the default; **an empty list (default) means no CORS headers at all**: ```toml [server] allowed_origins = ["https://chat.example.com"] # env TALK_ALLOWED_ORIGINS ``` - `OPTIONS /speak` (preflight) from a listed origin → `204` with `Access-Control-Allow-Origin` (the origin or `*`), `-Methods: POST, OPTIONS`, `-Headers: Content-Type`, `Max-Age: 600`. - A real `POST /speak` with a listed `Origin` carries the same header. - **The door is shut server-side, not only browser-side.** CORS alone only prevents *reading* the response; any web page can send a POST with a `text/plain` body or a form POST without a preflight. Therefore the daemon rejects every request with a foreign `Origin` header with `403` unless it is POST /speak from a listed origin: unlisted origins on /speak, and any browser access to `/status`, `/mute`, `/unmute`, `/cancel` and `/mcp`, even from listed origins. Only the preflight `OPTIONS /speak` stays a `204` without CORS headers for unlisted origins (it has no effect; the browser drops the planned POST). CLI, curl and MCP clients send no `Origin` header and are unaffected. **MCP** (`POST /mcp`, Streamable HTTP): a lean own implementation of the JSON-RPC subset, no SDK: `initialize` (the requested `protocolVersion` is echoed; result `protocolVersion`, `capabilities: {tools:{}}`, `serverInfo`), `notifications/initialized` (notification → `202` without body), `ping`, `tools/list`, `tools/call`. The server is stateless (no session IDs) and pushes nothing (hence `GET /mcp` → `405`). One tool: ```json {"name": "speak", "inputSchema": {"type": "object", "properties": {"text": {"type": "string"}, "priority": {"enum": ["low","normal","high"]}, "provider": {"type": "string"}, "lang": {"enum": ["de","en"]}, "now": {"type": "boolean"}}, "required": ["text"]}} ``` `tools/call` takes exactly the same admission path as `POST /speak` (including mute, rate limit, quiet hours, truncation). The result is always text content: `"queued: "` on admission; `muted`/`discarded: ` with an explanation, deliberately `isError: false`, because a discard is the correct documented result, not a protocol error. Validation errors (missing text, invalid priority, unknown tool) are JSON-RPC errors (`-32602`). Handshake as a curl sequence: ```sh curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}' curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"speak","arguments":{"text":"Build done"}}}' ``` ### Provider chain Without a pin, the four-tier fallback chain runs **ElevenLabs → OpenAI TTS → Edge TTS → OS built-in**: on a missing key, an HTTP error, a timeout or a network problem the chain falls through to the next tier automatically; every fallback is logged to stderr (never silent). Pin with `--provider=` or `provider = ""` in config.toml. | Tier | Key (env) | Default endpoint | Audio | | --- | --- | --- | --- | | `elevenlabs` | `TALK_ELEVENLABS_KEY` | `https://api.elevenlabs.io` | mp3 | | `openai` | `TALK_OPENAI_KEY` | `https://api.openai.com` | mp3 | | `edge` | none (free) | `wss://speech.platform.bing.com/...` | mp3 | | `osbuiltin` | none | local OS TTS | WAV/PCM | Keys come exclusively from environment variables (never config.toml). Endpoints are configurable: file `[providers.] endpoint = "…"` or env `TALK_ELEVENLABS_ENDPOINT` / `TALK_OPENAI_ENDPOINT` / `TALK_EDGE_ENDPOINT` (env beats the file; this is how tests and CI point the chain at fake servers). The OpenAI model is configurable (`[providers.openai] model`, default `tts-1`). Voices: the neutral built-in defaults per provider and language (ElevenLabs "Rachel", OpenAI "alloy", Edge `de-DE-KatjaNeural`/`en-US-AriaNeural`) can be overridden per language in config.toml: ```toml [voice.elevenlabs] de = "21m00Tcm4TlvDq8ikWAM" # your own voice per language en = "21m00Tcm4TlvDq8ikWAM" [voice.edge] de = "de-DE-KatjaNeural" ``` Precedence: `--voice` flag > `voice..` > built-in default. MP3 is decoded by the binary itself (pure Go decoder, no ffmpeg, CGO-free). Edge TTS is an undocumented Microsoft endpoint with a DRM token scheme that Microsoft changes now and then. When that happens, the edge tier fails with a clear "protocol changed?" error and the chain falls through to the OS voice; `talk doctor --probe` makes it visible. ### OS TTS on Linux The `osbuiltin` provider uses two routes on Linux: - **Audio bytes** (playback through the built-in oto sink): needs `espeak-ng` (or `espeak`). On NixOS as a dev dependency: `nix shell nixpkgs#espeak-ng`. - **Direct speech** without espeak: `spd-say` (speech-dispatcher) plays by itself through its daemon but cannot deliver bytes to a file or pipe. `talk` takes this route automatically when espeak is missing. `TALK_AUDIO_SINK=null` (or `audio.sink = "null"` in config.toml) turns all sound off, for CI and servers. ## `talk wrap`: announce commands `talk wrap -- [args …]` runs the command and makes the result audible: stdin/stdout/stderr are passed through; stdout belongs to the command alone, talk's information lines (for example the announcement's item ID) go to stderr so pipes and redirects stay clean. Wall time is measured and announced after the command ends: - success: "sleep succeeded. Duration: 2 seconds." - failure: "sh failed, code 2." (without duration) ```console talk wrap -- sleep 2 talk wrap --wait -- make test ``` - **`--` shields the command completely:** the first standalone `--` ends talk's flag parsing; everything after it goes to the command unchanged. `talk wrap -- foo --bar` passes `--bar` to foo instead of misreading it as a talk flag. talk's flags for the announcement therefore go before it (`talk wrap --wait -- …`); without `--` the usual rules apply (flags anywhere, also after positional arguments). - **Label** of the announcement: `wrap.label` in config.toml (env `TALK_WRAP_LABEL` beats the file) or the command's base name (`/usr/bin/make` → "make"). Language detection runs on the label (de/en, falling back to the configured `lang`); `--lang` overrides. - **Exit code policy:** wrap always exits with the command's code; if the announcement itself fails, the command's code still stands (only a warning on stderr; talk errors are not command errors). Commands that cannot start (binary not found) count as 127 (shell convention), signal deaths portably as 1. Usage errors of talk itself (no command, invalid flags) fail before the command starts. - **Announcement path:** the same as say: daemon (enqueue, non-blocking; `--wait` honoured; autostart), without a daemon the direct path. wrap inherits the say flags (`--wait`, `--now`, `--priority`, `--voice`, `--lang`, `--provider`) and passes them on. ```toml [wrap] label = "Deployment" # announcement label instead of the command's base name (TALK_WRAP_LABEL) ``` ## For agents: skill and standing order [`skills/talk/SKILL.md`](skills/talk/SKILL.md) is the etiquette as a skill artifact: **strict opt-in** (speak only when asked in the session or by a standing order in the project's AGENTS.md, session-sticky until revoked), content = status · next step · what the user is needed for, never the long answer, honour `muted`/`discarded`. The three-line block for project AGENTS.md files is in [`skills/talk/AGENTS-snippet.md`](skills/talk/AGENTS-snippet.md). The daemon enforces rate limit, length and age limits, mute and quiet hours regardless of what the prompt says. ## Development ```console go build ./... # build go test ./... # tests go vet ./... # static analysis gofmt -l . # formatting check (empty output = ok) nix develop # devShell with go, gopls, espeak-ng, libnotify ``` CI: `.gitea/workflows/test.yml` (Gitea Actions) runs vet, the gofmt check, tests and build on every push and pull request. **Icon:** the source is `assets/icon/talk.svg`. `go run assets/icon/gen.go` (needs inkscape) renders the embedded PNG (`internal/icon/talk.png`), the Windows `talk.ico` and the resource objects `cmd/talk/rsrc_windows_*.syso` that give `talk.exe` its icon; all of them are committed, so a normal build needs no resource compiler. **Release:** push a `v*` tag → `.gitea/workflows/release.yml` tests, builds the six targets with `scripts/release.sh` (static, `CGO_ENABLED=0`, version via ldflags) and creates a Gitea release with `talk__[.exe]` and `checksums.txt`. Locally: `scripts/release.sh 1.2.3` → `dist/`. Deliberately without GoReleaser: a shell script around `go build` yields the same artifact locally and in CI without another tool. `nix build .#talk` builds the Nix package (after changes to go.mod, update the `vendorHash` in flake.nix; `nix build` prints the expected value). ## License MIT, see [LICENSE](LICENSE).