Three Characters, One Throat: Picking an ElevenLabs Voice per Call in Hermes


You are dubbing a two-minute short. The script has three speakers: a blacksmith with a voice like a coal scuttle, a narrator who sounds like a museum plaque, and a girl with one line who has to sound eleven years old. You paste the whole thing into Hermes and ask for audio. It comes back in one voice — the same voice for all three, because the speech tool had exactly one to give you: whatever sat in your config. To change it you edited tts.elevenlabs.voice_id, restarted, and tried again. And to know which ID to type, you opened the ElevenLabs site and scrolled a few hundred voice cards, copying strings by hand.

Two new fields close that gap. On the ElevenLabs provider, text_to_speech now accepts a per-call voice_id, and a find_voices object that turns the same tool into a voice search. No new tool, no new provider — two fields on a schema you already have.

The setup this replaces

Text-to-speech is the plain part: you hand a model a string, it hands back audio of that string being read. Hermes has no voices of its own; it calls a hosted provider. ElevenLabs is one of them, selected with tts.provider: elevenlabs and configured in the tts block.

The Voice Library is the messy part: ElevenLabs’ public catalog of shared voices, thousands of entries, each with an ID, a name, descriptive labels, and a short preview clip. Next to it sits your account catalog — the presets plus anything you have saved, cloned, or designed. Before this change neither was reachable from inside a tool call: the model could speak, but it could not look.

A voice as a deployment decision — set once in config, like a default model — fits an assistant reading notifications. It breaks as soon as there is dialogue: a script is many speakers, and nobody restarts a process between two lines.

Two fields on the tool you already have

The prerequisite is the provider switch:

tts:
  provider: elevenlabs
  elevenlabs:
    voice_id: 'YOUR_DEFAULT_VOICE_ID'

voice_id at call time picks the voice for that call only. The rule is strict in both directions: omit it and config is used; pass it and the value applies to this one synthesis. A per-call value never rewrites your config, so the next call returns to the default unless you pass a voice again.

{
  "text": "Coal is hot. Speak your piece while it holds.",
  "voice_id": "YOURCHOSENVOICEID"
}

That is the whole feature for one line. The harder half is finding the ID: a 20-character alphanumeric string is not guessable, and library names are not unique enough to trust.

Searching instead of speaking

find_voices is an object, and passing it flips the tool from speak to search — the one configuration where text becomes optional on this variant, since a search has nothing to say. Two catalogs sit behind the switch:

  • source: library (default) calls GET /v1/shared-voices, the public Voice Library. Those voices work directly by voice_id without saving them to your account first, but the ElevenLabs API only allows paid plans to use them.
  • source: account calls GET /v2/voices and covers the presets plus every voice you have saved, cloned, or designed.
{
  "find_voices": {
    "query": "gravelly old",
    "gender": "male",
    "age": "old",
    "language": "en",
    "use_case": "characters_animation",
    "page_size": 10
  }
}

The filters split by catalog:

Field Where it applies Notes
query both Literal word match on name and description, not semantics
gender both Filter label
age both Filter label
accent both Filter label
language both Filter label
use_case library only For example characters_animation
sort library only Default usage_character_count_1y; also trending, cloned_by_count, created_date
page_size both 1 to 100, default 20
source both library by default, or account

use_case narrows to voices recorded for character animation rather than corporate narration — a different vocal register.

Results page one at a time. next_page returns an opaque cursor that the model passes straight back as page; underneath, the account endpoint uses a next_page_token and the library uses page numbers — which is why the cursor stays opaque to you.

Each row carries the id, a name, traits, a category, a description truncated to 240 characters, and a preview_url. Library voices billing above the default rate also carry credit_rate. The page as a whole reports configured_voice_id, the voice currently in your config — a small detail with real value, since it lets the model avoid recommending the voice you already hear every day.

A dwarf blacksmith, actually picked

The pull request records a run against the real API: a real ElevenLabs key, eleven_v4, google/gemini-3.8-flash on the Nous inference API, and an actual hermes chat -Q process on the agent side. The task was one character — find a voice for a weathered dwarf blacksmith, then use it to say a line. The first query the model wrote:

{
  "query": "dwarf blacksmith gravelly",
  "age": "old",
  "gender": "male",
  "use_case": "characters_animation"
}

Nothing usable came back. The library matches literal words in names and descriptions, so a role name like dwarf is a guess about metadata that mostly is not there: voice catalogs describe timbre, not characters.

So the model pivoted from describing the person to describing the sound — gravelly, gruff and similar — with the age and gender filters still applied. That produced candidates. It compared traits and the truncated descriptions against the scene, checked configured_voice_id so it would not hand back the voice already in use, then called text_to_speech with the chosen voice_id and the line.

Two habits fall out of that run. Describe the sound, not the role — if a character search comes back empty, do not add more character words; switch to adjectives about the voice and narrow with filters. And the model cannot hear: it reads text about audio, so when the choice matters it hands you preview_url links. A voice picked without listening is a draft, not a decision.

What it deliberately did not touch

Both fields are injected by the existing dynamic schema hook _tts_schema_overrides, which reads your config every time tool definitions are listed. The hook is not new; only its ElevenLabs branch grew.

The boundaries are tight, which is why this is a small change rather than a fork in behaviour:

  • text_to_speech schemas for Edge, OpenAI, xAI and the rest are byte-for-byte identical to main, and the default provider prompt snapshot is unchanged.
  • Sending voice_id to another provider gets you a warning and the argument is ignored — the call still produces audio. That matters in exactly one scenario: you cached a schema, then switched providers.
  • Sending find_voices to another provider fails cleanly with an error saying it needs tts.provider: elevenlabs. No silent fallback to speaking.
  • A malformed ID is rejected before any request leaves the process, against ^[A-Za-z0-9]{8,64}$.

Context cost is where non-ElevenLabs users should relax. Measured with get_tool_definitions, the text_to_speech schema in an ElevenLabs session grows from roughly 410 tokens to roughly 840. In any other session the cost is 0 tokens, because the additions never enter the schema.

Two neighbouring fixes landed in the same batch: find_voices={} stopped falling through to speaking and now means browse, and on the managed audio gateway tts-1 and tts-1-hd are no longer silently downgraded.

Three traps

Library voices are a paid-plan feature. Synthesizing with a library voice_id without saving it to your account is genuinely convenient, but the API path requires a paid ElevenLabs plan. On a free key the search can still return rows the synthesis call cannot use. credit_rate on a row is the tell that the voice bills above the default rate.

A good name is not a good voice. Library names are user-written: two voices called something like “Deep Narrator” can differ wildly in pace, breath, and consonants. Listen to the preview_url — a voice that is lovely for twenty seconds can be unbearable across four minutes of dialogue.

The cursor is not yours to assemble. Page through results by echoing next_page back as page, verbatim. Reconstructing an offset, or reusing a cursor from the account catalog against the library, gets you duplicates or a blank page with no explanation.

What you need to run it

The change was merged on 2026-10-25, after v0.22.5 shipped on 2026-10-23. It is on main and not in a tagged release, so a stock install of the latest release will not have either field. To try it now you are running from main; the installation guide covers the source path.

After that the workflow is short: search by timbre, listen to two or three preview_url clips, keep the IDs you like, pass them per call. Config stays where it was, so one voice for notifications and three for the short film coexist in the same setup — the same principle as per-call model overrides, where a one-off selection never mutates the defaults you rely on.

If latency becomes the next problem once casting is done, the mechanics of keeping a synthesis pipeline warm and streaming at sentence boundaries are in the keep-warm and sentence-streaming guide; picking a voice changes none of that plumbing. And wiring speech into a two-way conversation rather than a one-shot render is a separate decision — see the voice chat model guide.