Two weeks ago I wrote about putting a guardrail in front of our voice agent, on the input, where a caller had talked the model out of its own refund policy. This is the other half of that job, and it is the harder half. Everything below is about the output side, and about one number I had never measured.
Here is the shape of it. Our output rail worked. It fired, it logged, it named the rule, and the log has a timestamp on it. Then I put that timestamp next to the rest of the turn. The sentence went to TTS at 900 milliseconds, the caller's handset started playing it at 1,100, and the rail fired at 1,400. Three hundred milliseconds behind the ear it was supposed to protect.
Two days arguing with a trace
I spent most of a Wednesday convinced I had a bug in the rail. The rule was correct, the scanner was correct, and the block was in the log where a block should be. What I could not explain was why the call recording had the agent saying the thing anyway.
The recording is the part that settles arguments. You can read a trace ten times and talk yourself into a story. Then you play sixteen seconds of audio and hear your agent say a sentence, and hear the caller react to it, and the story stops working.
The rail had not failed. It had run late, and late on a phone call is a different failure from the one I was looking for. I had been using "blocked" to mean two things for months.
Input rails have time, output rails do not
The asymmetry took me a while to state cleanly, so let me state it cleanly here.
When you scan an input, you have the whole duration of the caller's utterance to work with. They are still talking. Every millisecond they spend finishing their sentence is a millisecond your scanner spends for free. Input safety is a scheduling problem with a generous budget.
Output is the opposite. The model produces text, the text becomes audio, the audio plays, and every one of those steps is moving away from you. There is no point after which you get to reconsider, because audio is irreversible. Once a sample has played there is no call you can make that unsends it, and the correction you play afterwards is a second thing the caller hears rather than a replacement for the first. The best your rail can do, once it is late, is apologise on your behalf.
Which reframes the question I should have been asking all along: how much did the caller hear before the rail was allowed to have an opinion?
Where a voice pipeline actually commits
Voice stacks commit earlier than most people picture, and they commit somewhere upstream of the speaker.
The usual arrangement: the model streams tokens, an aggregator buffers them until it has something worth speaking, and in every implementation I have worked on that unit is a sentence. That sentence goes to TTS. TTS returns audio. The audio goes out.
The commitment happens at the aggregator. The moment a sentence is handed to the synthesiser you have spent it, because everything downstream is a pipeline you can stop but not rewind. You can cut the audio mid-word, and we do, and stopping halfway through "your balance is forty-two thousand" is not a save.
Which means there is a number sitting in every voice stack that nobody I have asked has measured: the size of the text block your output rail waits for, compared against one sentence. If the first is larger than the second, the first sentence goes out unchecked. That is not an occasional failure. It happens on every turn, by construction.
And the first sentence is where a voice agent puts the answer. It is where it confirms the appointment, states the balance, or repeats the thing from the record. Our incident was not in some rambling fourth paragraph.
The number I should have been logging
Here is the instrumentation, because this is the part I would actually hand someone.
We already had a timestamp for when the rail fired, because the rail wrote one. We had nothing for when the audio reached the caller, and you cannot get that from the server. Server-side, everything looks fine: we stopped generating, we cancelled, the log is clean. The event I needed was on the far end.
So we made the client emit two things per turn: the moment its playout buffer started on a given sentence, and the moment it drained. Then one derived field per rail trigger:
fired_minus_played_ms = rail_fired_at - audio_started_playing_at
Positive means the caller heard it first. That is the whole metric. It took an afternoon.
Our first week of data was not comfortable reading. A meaningful share of triggers came back positive, which is to say a meaningful share of the blocks on my guardrail dashboard had prevented nothing at all. Before that field existed, every one of them had been counted as the system working.
I would take a dashboard with a smaller, honest block count over one that quietly counts arrivals.
What the tooling does and does not decide for you
I went back through the options I weighed in the earlier post, this time reading their streaming behavior rather than their feature lists. Capabilities below are as of July 2026, read from source or from the vendor's own docs.
NVIDIA NeMo Guardrails applies output rails over token chunks, defaulting to 200 tokens with 50 carried for context. Two details matter more than the size: streaming output rails are off unless you enable them, and stream_first defaults to true, meaning chunks are streamed before the rails are applied. Guardrails AI takes a sentence-shaped approach instead, accumulating text in validate_stream and validating once more than one sentence has arrived. Future AGI's gateway checks accumulated text every 100 characters and can either stop the stream or append a disclaimer; like NeMo's it is opt-in, and in its case at two levels, since both the guardrail engine and the streaming checker default to disabled in the gateway config. Llama Guard 4 is a model rather than a policy, a fine-tuned Llama 4 that scores input and output against MLCommons categories, so the granularity is whatever you hand it. Meta ships the orchestration separately, in LlamaFirewall, which describes itself as a policy engine that coordinates several scanners and is built for low-latency environments.
All of that is readable in about ten minutes if you want to check me: OutputRailsStreamingConfig in rails/llm/config.py in github.com/NVIDIA/NeMo-Guardrails, validate_stream in validator_base.py in github.com/guardrails-ai/guardrails, stream_checker.go alongside DefaultConfig() in github.com/future-agi/future-agi, and the LlamaFirewall README in github.com/meta-llama/PurpleLlama.
Lakera is the one that made me feel slow. Their Guard docs have a section on screening streamed output that recommends sentence-level chunking for accuracy, a ten-token minimum for incremental snapshots, and a delay buffer that screens a chunk before showing it, which they say costs latency and is the right default when safety outranks speed. That is the conclusion I arrived at over an incident and two days of trace-reading, and they had already written it down. The only thing voice adds is that the display in "screen before display" is a speaker, so the deadline is harder and the buffer costs you barge-in budget rather than a flicker.
None of that picks your block size for you. The tools give you a dial and a default. The default assumes a user who is reading. Only you know whether yours is listening.
What shipped
We moved the rail in front of the TTS handoff and paid the latency, which on our stack ran 120 to 300 milliseconds depending on which scanner was in the path. We covered most of that with a fixed, hardcoded opener while the first real sentence gets checked, which is the same filler trick voice teams already use for model latency, pointed at a safety budget instead. The coarse end-of-stream check stayed, because it catches things a sentence-at-a-time view misses, but it now writes to the incident log rather than to the prevention count. And the fired-minus-played field ships on every trigger.
The engineer who reviewed that guardrail config and closed the ticket was me, and here is what he had wrong. He read the config as a promise about what the caller would hear. It was a promise about what the model would finish generating. In every system I had built before this one those were the same sentence, so I never learned to tell them apart. On a phone call they come apart by about three hundred milliseconds, and that gap is the only part of the conversation the caller remembers.
If you run a voice agent, go and find out how much of its first sentence has ever been checked. I was six months in before I asked, and the honest answer was none of it.

Top comments (0)