Teaching a Duplex Voice Model to Call Tools
A full-duplex voice model listens and speaks at the same time — but out of the box it can only talk about what it already knows. It cannot check the date, search the web, or convert a currency. This is how we gave one a hand: grafting a tool-calling action stream onto PersonaPLEX, and the four training attempts it took to make it work.
Base: PersonaPLEX 7B (Moshi/Helium fork) · Method: LoRA + dense action head · Streams: 17 → 18 · Tools: 8
| 18 | 8 | +5e-5 | 4 |
|---|---|---|---|
| frame rows (was 17) | tool types | LoRA rank-32 | training attempts |
01 · What “tool calling” actually is
Text LLMs like ChatGPT don’t reach out to the internet by magic. They are trained to emit special tool-calling tokens — introduced first in Meta’s Toolformer — when a request needs one. The model already predicts a token at every step, so the trick is to add new tokens to the vocabulary that mean “a tool call starts here,” and teach the model to produce them.
A token like
<|toolcall_begin|>signals that the tokens that follow describe a tool and its arguments;<|toolcall_end|>closes it. In between, the model writes something a runtime can parse:<|toolcall_begin|>{"function": "get_weather", "arguments": {"city": "Kathmandu"}}<|toolcall_end|>
The runtime detects that call, executes get_weather deterministically, gets the
result, injects it back into the model, and the model reads it out in natural language.
The model never runs the tool itself — it only learns when and how to ask.
02 · Can a model that thinks in audio tokens do the same?
Yes — with one large caveat we’ll get to. PersonaPLEX (a fork of Kyutai’s Moshi) autoregressively predicts audio tokens, much like an LLM predicts text. Under the hood its temporal transformer, Helium, was originally a text language model — Kyutai trained it on text first, then adapted it to audio tokens produced by the Mimi codec.
Why this matters. Helium still understands text on some level. In fact PersonaPLEX emits an inner monologue at every timestep — a running text transcription of what the agent is about to say. A model that already carries a text channel alongside its audio channels is a model we can teach to emit tool-calling tokens too.
So we can add tool-calling tokens to the vocabulary and fine-tune PersonaPLEX to use them, and the runtime can detect and execute them exactly as it would for a text LLM.
03 · The hard part: you can’t just pause a duplex model
Here is the caveat. A text LLM is turn-based — it can happily stop generating, wait for a tool to run, and resume. A duplex model cannot. It is producing an audio frame every 80 ms, continuously, in lockstep with the incoming user audio. Stepping out to run a tool, wait for a network call, and come back would tear a hole in that stream.
Moshi already solved a related problem — half-duplex to full-duplex — by modelling the user and the agent as two separate token streams, so both can “talk” at once. The natural extension for tool calling is the same move again: add one more stream. We call it the action stream, and the special tool-calling tokens live there, decoupled from the audio the user actually hears.
04 · The four changes that make PersonaPLEX a tool-caller
A PersonaPLEX input frame was a vector of 17 values: index 0 is the text inner monologue, indices 1–8 are the agent’s audio codebooks, and indices 9–16 are the user’s audio codebooks. Each stream has an input head that turns tokens into embeddings; the embeddings are summed into one vector fed to Helium. Adding an action stream means four changes to that picture.
Extend the frame: 17 → 18 rows
A new 18th value, the action token, is appended to every frame
(lm.py action_index = n_q + 1). It carries one
of two things: the action token the model itself sampled last frame (the normal case), or
a token from an external tool result being injected back in.
Two new linear layers
An action_emb input layer embeds the action token and adds it into the summed input
embedding, and an action_linear output head projects Helium’s hidden state to action
logits. The output head is what the runtime watches for tool calls.
A new loss term
The base model was trained with cross-entropy on its text and audio tokens. We add a third
term for the action stream, weighted by a hyper-parameter: total = text + audio + λ·action (λ = 1.0).
Extend the vocabulary
Seven special action tokens are added on top of the 32,000-entry text vocabulary:
| ID | Token | Role |
|---|---|---|
32000 |
<action_pad> |
emitted on every idle frame (the overwhelmingly common case) |
32001 |
<|plan|> |
opens a short natural-language plan (“Let me look that up.”) |
32002 |
<|toolcall_begin|> |
start of the JSON call |
32003 |
<|toolcall_end|> |
end of the JSON call |
32004 |
<|action_end|> |
end of the action burst |
32005 |
<|tool_result_begin|> |
start of an injected tool result |
32006 |
<|tool_result_end|> |
end of an injected tool result |
32007 |
action card / BOS | count of predictable action classes; also the initial embedding row, never predicted |
A subtlety that shows up in the loss curve later. The action input embedding
(action_emb) is warm-started from the text embeddings — the model already knows what
{, ", function look like as text, so we hand it that knowledge. But the action
output head (action_linear) is initialised fresh (small random weights, with a
strong negative bias on the text-control tokens so they don’t leak). That is why, at step
zero, the action stream is effectively babbling — and why its loss starts an order of
magnitude above everything else.
05 · How we fine-tune it
Full fine-tuning a 7B duplex model to add one skill is overkill, so we use LoRA for the bulk of it and train only the genuinely new parts densely.
- Dense (full) fine-tune: the two new action layers,
action_embandaction_linear— they start near-random, so they need real weight updates, on their own higher learning rate (action_lr = 1e-4). - LoRA adapters (rank 32, α 64): Helium’s attention projections and gating FFNs, the depth-transformer’s in/out projections, and the text output head (on a gentler LR to protect the voice).
- Frozen: the Mimi codec, the audio embeddings, and the depth-transformer’s own attention — nothing that would risk the model’s existing voice quality.
Only ~4.2% of the 8.7B parameters are trainable. One more detail that matters at inference: the loss masks out the user’s audio codebooks — the model never generates those, so there is no reason to supervise them — and the training clips add noise to the user channel so the model learns to answer a real, noisy microphone rather than clean TTS.
06 · The dataset
The pipeline is the same one Kyutai ship in moshi-finetune, so the format matches theirs. Each training example is two files.
1. A stereo .wav — left channel is the agent, right channel is the user. Here is one
real training clip (currency conversion). It plays the whole exchange:
Spoken text
USER: What's 75 pounds worth in pesos? → AGENT: Let me look that up. 75 GBP converts to about 1,731.82 MXN.
2. A .json with the word-level transcription (alignments) and — this is the new
part — an actions field. Each action carries the trigger time, the natural-language
planning, the function and arguments the model must generate on the action stream,
and the tool result (plus the result_time it arrives) that the model must speak. That
result is the ground truth for the action-stream cross-entropy during training — the tool
is never actually executed while training.
{
"alignments": [
["What's", [0.0, 0.6222], "SPEAKER_USER"],
["75", [0.6222, 0.8296], "SPEAKER_USER"],
["pounds", [0.8296, 1.4519], "SPEAKER_USER"],
["worth", [1.4519, 1.9704], "SPEAKER_USER"],
["in", [1.9704, 2.1778], "SPEAKER_USER"],
["pesos?", [2.1778, 2.8], "SPEAKER_USER"],
["Let", [3.3, 3.5662], "SPEAKER_MAIN"],
["me", [3.5662, 3.7437], "SPEAKER_MAIN"],
["...", ["...snip..."], "SPEAKER_MAIN"]
],
"actions": [
{
"time": 3.3,
"planning": "Let me convert that amount.",
"function": "convert_currency",
"arguments": { "amount": 75, "from_currency": "GBP", "to_currency": "MXN" },
"result": "{\"converted\": 1731.82, \"to_currency\": \"MXN\"}",
"result_time": 7.9911
}
]
}
The interleaver reads that actions field and builds the 18th (action) row: idle
<action_pad> on every frame, then at frame round(time × frame_rate) it writes the burst
<|plan|> … <|toolcall_begin|> {json} <|toolcall_end|>, and at result_time it writes the
<|tool_result_begin|> … <|tool_result_end|> result (whose positions are excluded from
the loss — they’re observed input, not something the model should predict).
Schema-aware loss weighting. Not all action tokens are equally important. The JSON skeleton — the braces, colons, quote marks, keys, and the function name — has to be exactly right or the runtime can’t parse it. So the data builder tags those tokens as a high-weight “key” tier (×3), while the values (the actual query text, the planning prose) stay at content weight. Malformed structure is penalised harder than a slightly-off value.
07 · Four attempts
We trained this in iterations. Each one fixed the previous failure and surfaced a new one.
Attempt 1 — it answered before the tool ran
Data: 1,000 synthetic single-turn examples across seven tools (get_weather,
web_search, get_time, calculator, convert_currency, set_reminder,
get_current_date). User asks → agent calls the tool → agent speaks the result.
Result: loss (including the action term) dropped smoothly, but live, the model spoke its “result” in a fraction of a second. The logs showed it did fire the tool on the action stream — then immediately hallucinated the answer instead of waiting for it.
Cause. In every training clip, the agent started speaking the moment it called the tool. So the model learned to speak immediately — result available or not. At inference, tools take real time to run, and the model had never seen a reason to wait.
Attempt 2 — teach it to wait, then force it to
Data: the same examples, but now with a random gap (up to ~1 second) between the tool call and the agent’s spoken answer.
Result: after 3 epochs it did pause — but still spoke too early. Comparing the frame timings of “agent starts talking” vs. “tool result injected” showed it was beginning its answer before the result had arrived.
Fix (no retrain needed). The runtime forces silence while a tool is in flight: it
holds the text stream at padding (MOSHI_HOLD_TEXT_UNTIL_RESULT) and feeds silence frames
on the user channel (MOSHI_SILENCE_USER_ON_RESULT) from the tool call until
<|tool_result_end|> is injected. The model literally cannot speak until the answer is in.
With the hold in place, it read back tool results correctly — most reliably
get_current_date, which needs no arguments. Which exposed the next problem: tools that
do need arguments (web_search needs a query built from the user’s question) came out
with broken JSON — wrong braces, wrong keys, wrong values.
Attempt 3 — valid JSON
Data: Attempt 2’s data plus 500 small-talk conversations with no tool calls, so the model learns when not to call a tool.
Change: heavier loss penalty on the JSON structure — extra weight on the {, }, :,
keys, and function name (the “key” tier above).
Result: after 3–4 epochs the model produced valid JSON with the right function name and argument shape. (This run is dissected in §08.) It fixed the format — but not the content of the query.
Attempt 4 — the right query (training now)
The residual bug: the model copied queries verbatim from training instead of building them from the actual question. Asked “Who is the Prime Minister of Nepal?” it would dutifully emit a valid call to search “currency of New Zealand” — a query it had memorised.
Cause. The data didn’t have enough variety to teach query construction — the mapping from “what the user asked” to “what to search for.” So we built a new set of 4,000 quality tool-call examples where the query must be inferred from the user’s prompt, mixed with contiguous daily-talk conversation. This run is training now.
08 · Inside the Attempt 3 run
The rest of this section is Attempt 3’s actual training run (~7,000 steps, 4 epochs, LoRA rank 32). Two things are worth watching: the loss, and what the model said at the eval probe every 100 steps.
The loss: the action head learns almost instantly
■ action (tool-call stream)■ audio (agent speech)■ text (inner monologue)■ total (EMA)
x-axis: training step (0 → 7,000, ~4 epochs). Action loss falls from ~11 to below 0.1 by ~step 140 and stays there; the remaining total loss is dominated by ordinary audio-token cross-entropy.
The action stream (orange) starts near ~11 nats — expected, since its output head is freshly initialised (§04) and has no idea what a tool-call token is. It collapses below 0.1 within ~140 steps and then rides the floor for the rest of training. The action-token grammar is the easy part; the model nails it fast. What’s left in the total loss is almost entirely the ordinary audio term (blue) — i.e. speech quality, not tool calling.
The eval probe: what the model actually said
Every 100 steps we ask the same spoken question and log the answer, the raw action-stream tokens, and whether the JSON parses. The eval question is:
Spoken text
Who is the current Prime Minister of Nepal?
Watching the action stream evolve tells the whole story of the run:
| Step | Action stream | Parses? | What it means |
|---|---|---|---|
| 100 | no action emitted | — | Doesn’t call a tool at all — just hallucinates a name immediately (the Attempt-1 behaviour). |
| 300 | <|plan|> + {date""":""" Let"""time… |
❌ | It learned to fire, but the JSON is token soup. |
| 900 | {"arguments":{"query":"…"},"function":"web_search"} |
✅ | First valid call — correct function, correct shape. |
| 3900–5000 | valid web_search |
✅ | Consistently valid JSON. Format problem solved. |
| 6800 | {"arguments":{"query":"currency of Ukraine"},"function":"web_search"} |
✅ | Valid — but the query is wrong. |
That last row is the punchline. By the end of the run the model emits flawless JSON and
fires web_search every time — but for “Who is the Prime Minister of Nepal?” it searches
“currency of Ukraine.” It has learned the shape of a tool call perfectly and copied a
query it saw in training. Here is a real burst from step 6800:
<|plan|> I'll look this up online.<|toolcall_begin|>
{"arguments":{"query":"currency of Ukraine"},"function":"web_search"}<|tool_result_end|>
-> VALID json, function='web_search'
-> executed 'web_search' -> {"top_result":"Ukrainian hryvnia — ..."} (injected)
09 · End-to-end demo — “What’s today’s date?”
The clearest single demo of the whole system is the one tool that needs no argument and
can’t be faked: asking the model for today’s date, having it call get_current_date, and
hearing it speak the real answer. Below is an actual offline run of the Attempt-3
checkpoint (adapter_epoch04_step6800.pt) — the same weights dissected above — driven by a
real human-voice recording of the question (not the TTS training voice), left to drive
the tool itself. Nothing is scripted; the date you hear is whatever the runtime’s clock
returned at generation time.
Spoken text
User: What's today's date? → Agent: Let's check, today's date. Today is July 17th, 2026.
Here is exactly what happened on the action stream during that clip, straight from the generation log:
[action] <|plan|> emitted; get_current_date detected @frame 83
[toolcall] @frame 83 function='get_current_date' arguments={}
[toolcall] executed 'get_current_date' -> {"date":"2026-07-17"} (injecting tool result)
→ spoken: "Let's check, today's date. Today is July 17th, 2026."
The shape of it matches §08 precisely: a short plan/filler (“Let’s check, today’s
date.”), the tool call fired on the action stream, a silent gap while the runtime
executes get_current_date and injects {"date":"2026-07-17"}, and only then the spoken
answer — with the real date, read out in the cloned voice. This is a tool that needs no
arguments, so there’s no query to get wrong — which is why it works cleanly today, and why
argument-bearing tools like web_search are what Attempt 4 is for.
Honest caveat. Attempt 3 was trained on synthetic TTS user audio (with noise augmentation), so a genuine human recording is out-of-distribution. On this real voice the model does not fire the tool on every attempt — it sometimes hallucinates a date instead. The clip above is a clean successful run; making tool-calling robust to real microphones (not just TTS) is part of what the larger, more varied Attempt-4 dataset is meant to address.
10 · Where it landed
- A duplex model can call tools by adding a fourth stream — an action channel — with its own input embedding, output head, loss term, and vocabulary, and nothing about the audio path has to change.
- Timing is a first-class problem. A streaming model has no natural “wait” state; the runtime enforces one by holding text and feeding silence until the tool result is injected.
- Format is easy, content is hard. The action-token grammar and valid JSON were learned in a few hundred steps. Teaching the model to build the right query from the user’s question is the real work — and the reason for Attempt 4.
Next: finish the Attempt-4 run on the 4,000-example query-construction set, then repeat
the end-to-end pass above on the tools that take arguments (web_search,
convert_currency) — where getting the content right, not just the format, is the whole
game.
PersonaPLEX · tool-calling action stream · Attempt 3 → 4 · © 2026 Oshara AI engineering log. The model learned to call tools perfectly — it just needs to learn what to ask.