Utter's stable integration boundary is the utter command. Install it once as
a user-scoped tool, download the model once, and call it from any project that
can launch a subprocess. The caller does not need Python or MLX dependencies in
its own environment.
The Python distribution is named utter-mlx because the name utter is owned
by an unrelated project on PyPI. The installed executable remains utter.
Until a registry release is published, install from Git:
uv tool install --python 3.12 \
"utter-mlx @ git+https://github.com/maskedsyntax/utter.git@master"
uv tool update-shellFor reproducible automation, replace master with a release tag or commit SHA.
Restart the shell after uv tool update-shell, then verify and install the
pinned model:
utter --version
utter models pull
utter models status --jsonThe model is stored in ~/Library/Caches/utter/. All projects and agents under
the same macOS user share that installation.
For local Utter development, install the current checkout in editable mode:
uv tool install --force --python 3.12 --editable \
/absolute/path/to/utterReinstall a newer Git revision with:
uv tool install --force --python 3.12 \
"utter-mlx @ git+https://github.com/maskedsyntax/utter.git@NEW_REVISION"Agents should use --json, parse stdout as exactly one JSON document, and keep
stderr for progress or diagnostics. Successful synthesis returns an absolute
output path. Failures return nonzero status and, when --json is active, a
stable symbolic error in stdout.
Before synthesis:
- Run
utter models status --jsonand requiremodel.readyto betrue. - Run
utter voices --jsonwhen voice selection is not already known. - Create the intended output directory.
- Use an absolute
.wavoutput path inside the current project. - Do not use
--forceunless replacing an existing file was requested.
Example:
mkdir -p "$PWD/artifacts/audio"
utter speak "Your build completed successfully." \
--voice Ryan \
--language en \
--output "$PWD/artifacts/audio/build-complete.wav" \
--jsonFor multiline or dynamically supplied text, avoid shell interpolation and use stdin:
printf '%s' "$TEXT" | utter speak \
--stdin \
--voice Aiden \
--style "Speak calmly and warmly." \
--output "$PWD/artifacts/audio/message.wav" \
--json| Code | Meaning |
|---|---|
| 0 | success |
| 1 | unexpected runtime failure |
| 2 | invalid input or command arguments |
| 3 | model missing, incomplete, or pull failure |
| 4 | invalid voice or language |
| 5 | synthesis or waveform failure |
| 6 | output path or write failure |
| 130 | interrupted with Ctrl+C |
Add this to a consuming project's AGENTS.md or equivalent instructions:
## Local Text-to-Speech
Use the globally installed `utter` command for local speech generation.
- Run `utter models status --json` before synthesis and require
`model.ready == true`.
- Run `utter voices --json` to discover exact, case-sensitive voice IDs.
- Put generated files under `artifacts/audio/` unless the task specifies
another project-local directory.
- Create the parent directory before invoking Utter.
- Always pass an absolute `.wav` output path and `--json`.
- Parse stdout as JSON. Treat stderr as progress and diagnostics.
- Do not use `--force` unless replacement was explicitly requested.
- Do not run `utter models pull` automatically unless model installation was
requested; it downloads approximately 3.1 GB.
- Prefer `--stdin` for user-provided, multiline, or shell-sensitive text.
Example:
```bash
mkdir -p "$PWD/artifacts/audio"
utter speak "Text to synthesize." \
--voice Ryan \
--language en \
--output "$PWD/artifacts/audio/output.wav" \
--json
## Python Subprocess Example
This example has no Utter Python dependency. It requires only the globally
installed command. A runnable copy is available at
`examples/python_subprocess.py`:
```python
import json
import subprocess
from pathlib import Path
def synthesize(text: str, output: Path, voice: str = "Ryan") -> Path:
output = output.expanduser().resolve()
output.parent.mkdir(parents=True, exist_ok=True)
result = subprocess.run(
[
"utter",
"speak",
"--stdin",
"--voice",
voice,
"--language",
"en",
"--output",
str(output),
"--json",
],
input=text,
text=True,
capture_output=True,
check=False,
)
payload = json.loads(result.stdout)
if result.returncode != 0:
error = payload.get("error", {})
raise RuntimeError(
f"Utter failed ({error.get('code')}): {error.get('message')}\n"
f"{result.stderr.strip()}"
)
return Path(payload["output"])
A reusable ES module is available at examples/node_subprocess.mjs.
import { mkdir } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { spawn } from "node:child_process";
export async function synthesize(text, output, voice = "Ryan") {
const absoluteOutput = resolve(output);
await mkdir(dirname(absoluteOutput), { recursive: true });
return await new Promise((resolvePromise, reject) => {
const child = spawn("utter", [
"speak", "--stdin", "--voice", voice, "--language", "en",
"--output", absoluteOutput, "--json",
]);
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", chunk => stdout += chunk);
child.stderr.setEncoding("utf8").on("data", chunk => stderr += chunk);
child.on("error", reject);
child.on("close", code => {
let payload;
try {
payload = JSON.parse(stdout);
} catch (error) {
reject(new Error(`Invalid Utter response: ${stdout}\n${stderr}`));
return;
}
if (code !== 0) {
reject(new Error(
`Utter failed (${payload.error?.code}): ` +
`${payload.error?.message}\n${stderr.trim()}`
));
return;
}
resolvePromise(payload.output);
});
child.stdin.end(text, "utf8");
});
}CLI subprocess execution should remain the default. Add an optional local MCP stdio server only when at least one of these is true:
- the target host cannot run shell commands;
- typed tool discovery materially improves the agent workflow;
- repeated requests need a warm resident model;
- the host needs WAV bytes returned as MCP audio content.
An MCP implementation should expose list_voices, model_status, and
synthesize_speech, call the same internal Utter service as the CLI, restrict
file output to a configured project root, and keep model installation as an
explicit administrative CLI action. It should use local stdio transport rather
than open an HTTP port.