# sag gives you macOS say style speech from ElevenLabs or 60db on any platform

> sag is a Go command line tool that turns text into speech through ElevenLabs or 60db, streaming to speakers by default and saving audio files when asked. It auto-selects a provider from whichever API key you configured, refuses to guess when both are set, and it documents the places where the two providers behave differently instead of hiding them.

**steipete/sag** — Like the macOS say command, but with a modern voice.

- Repository: https://github.com/steipete/sag
- Website: https://sag.sh
- Stars: 584 · Forks: 62
- Language: Go
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/steipete-sag

## The one-line command is the whole interface

sag describes itself as one-liner TTS that works like say, and the resemblance is literal rather than aspirational. The command routing is the first feature in the list: the macOS say style default means sag "Hello" routes to speak automatically, so the subcommand is optional in the common case.

```bash
sag speak -v Roger "Hello world"
```

```bash
sag "Hello world"
```

Both forms are in the README, and the second is the one the design is built around. The say compatibility shortcuts make the mapping explicit:

```bash
sag -v Roger -r 200 "Faster speech"
sag -o out.mp3 "Save to file"
sag -v ?      # list voices
```

The voice flag takes a name or an ID, and a question mark lists the available voices. The rate flag is words per minute, and the README says it maps to the ElevenLabs speed with a default of 175, which is close to the pace of the macOS say command. The output flag writes an audio file, and the format is inferred from the extension, so .wav gives PCM and .mp3 gives MP3.

There is a streaming default too, and the flag list includes --stream/--no-stream where streaming is on by default. So the common path is text in, sound out of the speakers immediately, with a file written only if you ask for one.

The project is written in Go, with the module path github.com/steipete/sag, and the go.mod requires Go 1.25.0 with a toolchain line of go1.27.2.

## Provider selection is a rule, and two keys is an error

sag supports two TTS providers and auto-selects one from your configured credentials. The credentials are environment variables, and there are file based alternatives for each.

For ElevenLabs, the key is ELEVENLABS_API_KEY, and you can also pass it with --api-key, or point at a file with --api-key-file, ELEVENLABS_API_KEY_FILE or SAG_API_KEY_FILE. For 60db at api.60db.ai, the key is SIXTYDB_API_KEY, or SIXTYDB_API_KEY_FILE for the file variant.

The selection rules are stated in three lines and they are worth reading closely. Only one key set means that provider is used. Both keys set is an error, and the instruction is to unset one provider key and retry. Neither set is an error.

Refusing to pick when both are configured is the right behaviour, since a silent preference order would send your text to whichever provider happened to be checked first, and you would have no idea which one was billed. There is an optional ElevenLabs default of ELEVENLABS_VOICE_ID or SAG_VOICE_ID, and the active provider host can be overridden with --base-url.

The file based key options exist for a reason that is easy to guess and good to have: putting a key in a shell profile or a CI environment is a common source of accidental commits, and pointing at a file lets you keep it outside the repository with restrictive permissions.

The same file based pattern is used for the player and the timeout, both of which can also be set as environment variables, SAG_PLAYER and SAG_TIMEOUT, alongside their command line flags.

## 60db support is narrow on purpose, and the README says where

The README is unusually explicit that 60db support is intentionally narrow and follows the live API behavior, and then it lists four consequences.

Voice discovery merges the results of GET /default-voices and GET /myvoices. Synthesis uses POST /tts-synthesize, validates every NDJSON frame, and wraps the returned 48 kHz PCM as WAV. Incomplete or malformed provider responses fail rather than producing a truncated file. And 60db currently supports WAV playback and output only, with its live synthesis response buffered rather than streamed.

That last point changes the user experience, so it is good that it is stated up front. On ElevenLabs, streaming is the default and the whole point of a one-liner TTS tool. On 60db you wait for the whole utterance.

The frame validation is worth a sentence of its own, because it is the difference between a tool you can trust and one that quietly gives you a truncated file. Validating every NDJSON frame means a dropped or malformed frame is an error, and the buffered response is then wrapped as a proper WAV.

The two providers also differ in the flags they accept, and the tool is strict about it rather than quietly ignoring what it cannot do. Shared speak flags that work on both include --voice, --speed and --rate, --stability, --similarity, --play, --output, --timeout and --metrics. On 60db, --format accepts only wav and an explicit --stream is rejected.

The ElevenLabs-only flags fail fast on 60db: --model-id, --style, --speaker-boost and --no-speaker-boost, --seed, --normalize, --lang and --latency-tier. The one adjustment the tool makes is that --stability and --similarity keep the CLI range of 0 to 1 and are scaled to the 0 to 100 values the 60db API documents. The per provider detail is in docs/providers.md.

## Voice discovery goes further than a name list

Listing voices is a subcommand of its own, and the README shows that the search surface is wider than a plain name filter.

```bash
sag voices --search english --limit 20
sag voices --search english --limit 5 --try
sag voices --query "crazy scientist" --limit 5 --try
sag voices --label accent=british --label use_case=character --limit 10
```

There is a free text search and a separate query mode, and labels let you filter by structured attributes such as accent and use case. The --try flag appears alongside the limit in three of the four examples, and the README does not explain what it does, which is a genuine gap in the documentation.

The feature list also mentions voice discovery via sag voices and -v ?, so the same catalog is reachable from the say style shortcut.

Two other capability notes in the feature list are worth knowing before you script around them. Format inference from the output extension is automatic, so you do not have to pass --format for the common cases. And model selection goes through --model-id, with eleven_v3 as the default and eleven_multilingual_v2 described as the stable baseline to use instead.

## Engine choice changes the prompting vocabulary, not just the latency

The engine table in the README is the part most likely to affect your workflow, because the way you prompt changes with the model.

The v3 engine, at eleven_v3 and marked alpha, is the default and is described as the most expressive option, the one for acting. It uses audio tags like [whispers] and pause tags like [short pause]. The v2 engine at eleven_multilingual_v2 is the reliable baseline with simple prompts and supports SSML breaks. The v2.5 Flash and v2.5 Turbo engines both support SSML breaks as well, and are listed with a latency figure of about 75 ms and about 250 to 300 ms respectively, and 50 percent lower price per character.

The SSML point is the trap. The SSML break element works on v2 and v2.5 but not on v3, and the README repeats this in the notes with the instruction to use pause tags on v3 instead. Since v3 is the default, a script that was written against v2 with break tags will silently stop working after an engine change.

Input limits differ too, and the numbers are in the notes. v3 accepts 5,000 characters, v2 accepts 10,000, and v2.5 Turbo and Flash accept 40,000. When you hit a limit, the advice is to chunk the text and stitch the audio afterwards.

The prompting subcommand exists to print this guidance. Running sag prompting gives the highlights for the current model, and the README notes that --normalize on may not be available on v2.5 Turbo or Flash because of the higher latency, in which case auto or off is the better choice. The source of truth for the engine list is the ElevenLabs models documentation.

## Installation is three routes, and one of them needs a C toolchain

The install section covers Homebrew, prebuilt binaries and a Go toolchain install, which between them cover most cases.

On macOS, Homebrew handles it in one line and taps the author's repository automatically:

```bash
brew install steipete/tap/sag  # auto-taps steipete/tap
```

For prebuilt binaries, you download the Linux, macOS or Windows archives from the latest GitHub release. Two details matter for macOS users, since the direct download is the path that most often fails on that platform. The macOS downloads require macOS 15 or later, and they are Developer ID signed and notarized by Peter Steinberger, so the direct downloads pass Gatekeeper without the manual approval dance. On Linux, you unpack the linux_amd64 archive and put sag on your PATH, for example in /usr/local/bin.

If you would rather build it yourself, the Go route is:

```bash
go install github.com/steipete/sag/cmd/sag@latest
```

And that requires Go 1.25 or newer. The Debian and Ubuntu source build prerequisites are:

```bash
sudo apt install build-essential pkg-config libasound2-dev
```

The presence of libasound2-dev in that list is a hint about the architecture. The go.mod dependencies include github.com/ebitengine/oto/v3, a pure Go audio library, and go-mp3 for MP3 decoding, alongside cobra and pflag for the command line. The ALSA development package is there for the Linux audio path, and oto is what does the playback.

The repository also carries two goreleaser configurations, one for Linux and Windows and one general, which is how the platform specific archives in the release page are produced. The package.json at the root is a private helper that wraps the Go tooling, with scripts for building, linting with golangci-lint, formatting with gofmt and running the tests, plus a docs site builder.

## Where a two provider tool is the wrong shape

The limit that matters most is obvious from the architecture. Both providers are remote HTTP APIs, so sag needs a network connection and a credential to do anything, and it inherits the availability, the pricing and the rate limits of whichever service you point it at. If you need a voice that works on a plane, in an air gapped environment, or at a fixed cost per call, this tool does not cover that case.

The 60db path narrows the problem further. WAV only, buffered rather than streamed, and no support for the ElevenLabs-only flags. That is a sensible design for a provider whose API is narrower, but it means the feature set you have depends on which key you configured.

The alternative to consider is calling the provider API directly from whatever you are already using. The advantage of sag is that it is a complete, tested command line wrapper with voice discovery, model switching, output format handling and playback backends, and it is a single static binary with no runtime to install. The advantage of calling the API yourself is that you get the provider's full parameter set, including the flags sag rejects on 60db, and you avoid a layer between your script and the service.

For scripting purposes, the streaming default is the feature that makes sag worth having. A say compatible one-liner that starts speaking immediately, with a file option when you want the artefact, is a small thing that pipelines use constantly.

The maintenance record is current. The last push was on 2026-10-19, the repository is not archived, and the release history shows v0.4.1 on 2026-07-01, v0.4.2 on 2026-09-14 and v0.4.3 on 2026-09-14. The licence is MIT with a LICENSE file at the root, so no copyleft obligation on anything you ship with it, which is a licensing note rather than legal advice. The topics list names elevenlabs, go and say, and the project ships a docs/ directory alongside the README, including the providers document the flag differences point to.

## Conclusion

Adopt sag if you want say style text to speech from a script or a shell pipeline on Linux, macOS or Windows, and you are comfortable with a tool that depends on a hosted provider's API and its own credentials. Do not adopt it if you need a local or offline voice engine, since both supported providers are remote HTTP APIs, and note the 60db path is deliberately narrow, offering WAV output only with buffered rather than streamed synthesis. Verify first which provider you want and set exactly one key, because configuring both is an error by design, and check the character input limits for your chosen model before feeding it long text.

## FAQ

### How do I install the sag TTS command line tool?

On macOS, run brew install steipete/tap/sag, which taps the tap repository automatically. Elsewhere, download the Linux, macOS or Windows archive from the latest GitHub release, or run go install github.com/steipete/sag/cmd/sag@latest with Go 1.25 or newer. A Debian or Ubuntu source build needs build-essential, pkg-config and libasound2-dev.

### How do I tell sag which TTS provider to use?

Set exactly one provider key: ELEVENLABS_API_KEY for ElevenLabs, or SIXTYDB_API_KEY for 60db, with file based variants available. If only one key is set that provider is used, if both are set sag reports an error and asks you to unset one, and if neither is set it is an error.

### Which ElevenLabs models does sag support and how do they differ?

Any ElevenLabs model_id can be passed through with --model-id, with eleven_v3 as the default. The README's table lists v2 as a reliable baseline and v2.5 Flash and Turbo as low latency options with lower price per character, and notes that SSML break tags work on v2 and v2.5 but not on v3, which uses audio and pause tags instead.

### What are the limits of the 60db provider support in sag?

Support is intentionally narrow and follows the live API behavior. Voice discovery merges GET /default-voices and GET /myvoices, synthesis uses POST /tts-synthesize with every NDJSON frame validated and 48 kHz PCM wrapped as WAV, malformed responses fail rather than producing a truncated file, and 60db supports WAV output only with buffered rather than streamed synthesis. ElevenLabs-only flags are rejected on 60db.

### How do I list the available voices with sag?

Use sag voices with a search term, a query, or label filters such as accent and use case, and combine it with a limit. The shortcut sag -v ? lists the voices too, and --try appears in the README examples alongside --limit without being explained.

## Sources

- [License: MIT](https://github.com/steipete/sag/blob/main/LICENSE)
- [Project website](https://sag.sh)
- [README](https://github.com/steipete/sag/blob/main/README.md)
- [Releases](https://github.com/steipete/sag/releases)
- [steipete/sag on GitHub](https://github.com/steipete/sag)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/steipete-sag
