talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.
Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.
Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
talk
Status: WIP, under active development. Specification with the decision addendum:
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 -- <command> |
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 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) |
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 |
Say flags (also for talk "text"): --wait, --now, --priority,
--voice, --lang, --provider.
Installation
Release binary (Linux, macOS, Windows; amd64/arm64): download the
matching talk_<os>_<arch> from the
releases, verify the
checksum against checksums.txt, put it in your PATH as talk. No other
dependencies.
curl -LO https://code.m3ta.dev/m3tam3re/talk/releases/latest/download/talk_linux_amd64
sha256sum -c --ignore-missing <(curl -sL https://code.m3ta.dev/m3tam3re/talk/releases/latest/download/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
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 doctorreports 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 ⚠.--proberuns real synthesis probes (network),--jsonreturns the list for machines. Windows consoles get[OK]/[WARN]/[FAIL]instead of the symbols.talk testspeaks 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 voiceslists the curated voice catalog (ElevenLabs premade, OpenAI, Edge neural voices per locale) as a table;*marks the voice talk uses without--voice(configvoice.<provider>.<lang>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 setupasks 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.qaborts 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.
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:
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, 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:
[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 /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(andtalk "text") detects the daemon viaGET /status. If it runs: enqueue and return at once (non-blocking, stdout gets the item ID);--waitpolls until spoken;--nowjumps the queue; when muted the CLI printsmuted, on a law discarddiscarded: <reason>.talk mute/unmute/status/cancelare daemon calls with plain output. - Mute is persistent (file
mutein the config directory, contenttrue/false) and survives daemon restarts.talk mutewithout 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),
talkspeaks 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=lowis discarded (already on admission; the 202 response saysdiscarded: "rate_limit"); if the window was open on admission, the worker decides later.normalparks in the queue and plays at the next open slot (visible in/statusasrate_limit.next_slot).priority=highmay deliberately break the limit withqueue.allow_high_burst = true(default); without burst rights high waits like normal.now:truebypasses 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, through the system's own tools (Linuxnotify-send, macOS Notification Center, Windows toast), best effort: if the tool is missing, only the log line remains andtalk doctorshows it under "Toast".now:truebreaks 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:truenever 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):
[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:
[server]
allowed_origins = ["https://chat.example.com"] # env TALK_ALLOWED_ORIGINS
OPTIONS /speak(preflight) from a listed origin →204withAccess-Control-Allow-Origin(the origin or*),-Methods: POST, OPTIONS,-Headers: Content-Type,Max-Age: 600.- A real
POST /speakwith a listedOrigincarries 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/plainbody or a form POST without a preflight. Therefore the daemon rejects every request with a foreignOriginheader with403unless it is POST /speak from a listed origin: unlisted origins on /speak, and any browser access to/status,/mute,/unmute,/canceland/mcp, even from listed origins. Only the preflightOPTIONS /speakstays a204without CORS headers for unlisted origins (it has no effect; the browser drops the planned POST). CLI, curl and MCP clients send noOriginheader 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:
{"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: <id>" on admission; muted/discarded: <reason>
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:
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=<name> or provider = "<name>" 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.<name>] 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:
[voice.elevenlabs]
de = "21m00Tcm4TlvDq8ikWAM" # your own voice per language
en = "21m00Tcm4TlvDq8ikWAM"
[voice.edge]
de = "de-DE-KatjaNeural"
Precedence: --voice flag > voice.<provider>.<lang> > 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(orespeak). 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.talktakes 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 -- <command> [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)
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 --barpasses--barto 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.labelin config.toml (envTALK_WRAP_LABELbeats the file) or the command's base name (/usr/bin/make→ "make"). Language detection runs on the label (de/en, falling back to the configuredlang);--langoverrides. - 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;
--waithonoured; autostart), without a daemon the direct path. wrap inherits the say flags (--wait,--now,--priority,--voice,--lang,--provider) and passes them on.
[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 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. The daemon
enforces rate limit, length and age limits, mute and quiet hours regardless
of what the prompt says.
Development
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.
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_<os>_<arch>[.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.